Open sandboxFocusImprove this doc

Call telemetry example, step 1: Auditing calls to sensitive APIs

Some APIs require an audit trail. For instance, a security policy may require that every deletion of a file and every start of a process is recorded, with the code that requested it. The audit must cover the calls that the team adds later, and must not depend on each developer remembering to write an audit statement.

In this example, a project fabric redirects every call to File.Delete and Process.Start in the project to static methods of an Audit class. These methods record the call, then call the original API. The calling code does not change in the source code:

Source Code
1using System.Diagnostics;
2
3namespace Metalama.Samples.CallTelemetry1;
4
5public class BackupService
6{
7    // Archives the files of a folder with the tar tool, then deletes them.
8    public void Backup( string folder, string archive )
9    {
10        using var tar = Process.Start( new ProcessStartInfo( "tar", ["-cf", archive, "-C", folder, "."] ) );
11        tar?.WaitForExit();
12
13        foreach ( var file in Directory.GetFiles( folder ) )
14        {
15            File.Delete( file );
16        }
17    }
18}
19
Transformed Code
1using System.Diagnostics;
2
3namespace Metalama.Samples.CallTelemetry1;
4
5public class BackupService
6{
7    // Archives the files of a folder with the tar tool, then deletes them.
8    public void Backup( string folder, string archive )
9    {
10        using var tar = Audit.StartProcess( new ProcessStartInfo( "tar", ["-cf", archive, "-C", folder, "."] ) ,caller: "Backup", file: "/_/src/call-telemetry/call-telemetry-1/BackupService.cs", line: 10);
11        tar?.WaitForExit();
12
13        foreach ( var file in Directory.GetFiles( folder ) )
14        {
15            Audit.DeleteFile( file ,caller: "Backup", file: "/_/src/call-telemetry/call-telemetry-1/BackupService.cs", line: 15);
16        }
17    }
18}
19

When the program runs, the audit methods write the following lines. The paths change from one run to another.

[audit] BackupService.cs, line 10, Backup: Process.Start tar -cf C:\Temp\call-telemetry-xrrp4jza.nqv\reports.tar -C C:\Temp\call-telemetry-xrrp4jza.nqv\reports .
[audit] BackupService.cs, line 15, Backup: File.Delete costs.csv
[audit] BackupService.cs, line 15, Backup: File.Delete sales.csv

The audit methods

The Audit class is plain C# code. You can debug it and unit-test it like any other class:

1using Metalama.Extensions.Interceptors;
2using System.Diagnostics;
3using System.Runtime.CompilerServices;
4
5namespace Metalama.Samples.CallTelemetry1;
6
7// Records the calls to sensitive APIs, then performs them. The calls in this class are not intercepted,
8// so that the audit methods call the original APIs.
9[ExcludeInterceptors]
10public static class Audit
11{
12    public static void DeleteFile(
13        string path,
14        [CallerMemberName] string caller = "",
15        [CallerFilePath] string file = "",
16        [CallerLineNumber] int line = 0 )
17    {
18        Write( caller, file, line, $"File.Delete {Path.GetFileName( path )}" );
19
20        File.Delete( path );
21    }
22
23    public static Process StartProcess(
24        string fileName,
25        string arguments,
26        [CallerMemberName] string caller = "",
27        [CallerFilePath] string file = "",
28        [CallerLineNumber] int line = 0 )
29    {
30        Write( caller, file, line, $"Process.Start {fileName} {arguments}" );
31
32        return Process.Start( fileName, arguments );
33    }
34
35    public static Process? StartProcess(
36        ProcessStartInfo startInfo,
37        [CallerMemberName] string caller = "",
38        [CallerFilePath] string file = "",
39        [CallerLineNumber] int line = 0 )
40    {
41        Write( caller, file, line, $"Process.Start {startInfo.FileName} {string.Join( ' ', startInfo.ArgumentList )}" );
42
43        return Process.Start( startInfo );
44    }
45
46    private static void Write( string caller, string file, int line, string operation )
47        => Console.WriteLine( $"[audit] {Path.GetFileName( file )}, line {line}, {caller}: {operation}" );
48}
49

Each audit method has the same parameters as the API that it audits, followed by parameters with a caller information attribute: [CallerMemberName], [CallerFilePath] and [CallerLineNumber]. Metalama passes the location of the intercepted call site to these parameters, not the location of the call inside the audit method.

The audit methods call File.Delete and Process.Start themselves. Without precaution, these calls would also be intercepted, and the audit methods would call themselves. The [ExcludeInterceptors] attribute on the Audit class prevents this: the calls in this class are never intercepted.

The fabric

The following project fabric registers the interceptors:

1using Metalama.Extensions.Interceptors;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4using Metalama.Framework.Fabrics;
5using System.Diagnostics;
6
7namespace Metalama.Samples.CallTelemetry1;
8
9// Redirects the calls to sensitive APIs to the Audit class, in the whole project.
10internal class AuditFabric : ProjectFabric
11{
12    private static readonly DiagnosticDefinition<IMethod> _callNotAudited = new(
13        "AUDIT01",
14        Severity.Error,
15        "The call to '{0}' cannot be audited because the Audit class has no method for this overload." );
16
17    public override void AmendProject( IProjectAmender amender )
18    {
19        // File.Delete has a single overload, and Audit has a single DeleteFile method.
20        var deleteFile = typeof(Audit).AsINamedType().Methods.OfName( nameof(Audit.DeleteFile) ).Single();
21
22        amender.InterceptMethods(
23            calls => calls
24                .Type( typeof(File) )
25                .Methods( nameof(File.Delete) )
26                .RedirectToExistingMethod( deleteFile ) );
27
28        // Process.Start has several overloads. Each call site is redirected to the StartProcess method that has the
29        // same parameters. A call to an overload without such a method is an error, so that no call escapes the audit.
30        amender.InterceptMethods(
31            calls => calls
32                .Type( typeof(Process) )
33                .Methods( nameof(Process.Start) )
34                .ForEachSite(
35                    site =>
36                    {
37                        var startProcess = FindAuditMethod( nameof(Audit.StartProcess), site.InterceptedMethod );
38
39                        if ( startProcess == null )
40                        {
41                            site.Diagnostics.Report( _callNotAudited.WithArguments( site.InterceptedMethod ) );
42
43                            return site.Skip();
44                        }
45
46                        return site.RedirectToExistingMethod( startProcess );
47                    } ) );
48    }
49
50    // Finds the audit method that has a parameter of the same name and type for each parameter of the static
51    // intercepted method, and whose other parameters are optional, like the caller information parameters.
52    private static IMethod? FindAuditMethod( string name, IMethod interceptedMethod )
53    {
54        if ( !interceptedMethod.IsStatic )
55        {
56            return null;
57        }
58
59        return typeof(Audit).AsINamedType()
60            .Methods.OfName( name )
61            .SingleOrDefault(
62                auditMethod => interceptedMethod.Parameters.All(
63                                   p => auditMethod.Parameters.Any( a => a.Name == p.Name && a.Type.Equals( p.Type ) ) )
64                               && auditMethod.Parameters.All(
65                                   a => interceptedMethod.Parameters.Any( p => p.Name == a.Name )
66                                        || a.DefaultValue != null ) );
67    }
68}
69

File.Delete has a single overload, so the first registration redirects all its calls to Audit.DeleteFile with RedirectToExistingMethod.

Process.Start has several overloads, and an existing method can intercept only an overload whose arguments it receives. The second registration therefore decides for each call site with ForEachSite. It looks for the StartProcess method that receives every argument of the called overload, by name and type. When no such method exists, for instance for Process.Start( string ), the fabric reports the error AUDIT01 instead of leaving the call unchanged. A call that the audit cannot record therefore breaks the build, and the developer adds an audit method for this overload.

Calls to other APIs, such as Directory.Delete or FileInfo.Delete, are not audited. To audit them, add a method to the Audit class and a registration to the fabric.