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:
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
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.