Skip to content

Commit fb8afbd

Browse files
sunnamed434claude
andcommitted
Add BITM0001 analyzer: warn on Context.Module.GetAllTypes() in protections
Walking the module bypasses the [DoNotResolve] member filtering that Context.Parameters.Members applies (reflection / special-runtime / model / BAML excludes), which silently breaks obfuscated output. The analyzer flags it inside any IProtection-derived type and offers a one-click fix to Context.Parameters.Members.OfType<TypeDefinition>(). Ships inside the BitMono.Core package (analyzers/dotnet/cs) so plugin authors get it in their IDE, not just built-in protections. Scoped by IProtection, not namespace, so external plugins are covered. FullRenamer's reference-sync (#220) is the one legit module walk and is suppressed with justification. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 114cf15 commit fb8afbd

8 files changed

Lines changed: 202 additions & 0 deletions

File tree

BitMono.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitMono.Shared.Tests", "tes
6969
EndProject
7070
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitMono.Integration", "src\BitMono.Integration\BitMono.Integration.csproj", "{35FE1CF3-5F61-4943-86BF-4241FF0E4AF8}"
7171
EndProject
72+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BitMono.Analyzers", "src\BitMono.Analyzers\BitMono.Analyzers.csproj", "{084919BD-1003-476B-8109-6A0151E37EEB}"
73+
EndProject
7274
Global
7375
GlobalSection(SolutionConfigurationPlatforms) = preSolution
7476
Debug|Any CPU = Debug|Any CPU
@@ -343,6 +345,18 @@ Global
343345
{35FE1CF3-5F61-4943-86BF-4241FF0E4AF8}.Release|x64.Build.0 = Release|Any CPU
344346
{35FE1CF3-5F61-4943-86BF-4241FF0E4AF8}.Release|x86.ActiveCfg = Release|Any CPU
345347
{35FE1CF3-5F61-4943-86BF-4241FF0E4AF8}.Release|x86.Build.0 = Release|Any CPU
348+
{084919BD-1003-476B-8109-6A0151E37EEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
349+
{084919BD-1003-476B-8109-6A0151E37EEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
350+
{084919BD-1003-476B-8109-6A0151E37EEB}.Debug|x64.ActiveCfg = Debug|Any CPU
351+
{084919BD-1003-476B-8109-6A0151E37EEB}.Debug|x64.Build.0 = Debug|Any CPU
352+
{084919BD-1003-476B-8109-6A0151E37EEB}.Debug|x86.ActiveCfg = Debug|Any CPU
353+
{084919BD-1003-476B-8109-6A0151E37EEB}.Debug|x86.Build.0 = Debug|Any CPU
354+
{084919BD-1003-476B-8109-6A0151E37EEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
355+
{084919BD-1003-476B-8109-6A0151E37EEB}.Release|Any CPU.Build.0 = Release|Any CPU
356+
{084919BD-1003-476B-8109-6A0151E37EEB}.Release|x64.ActiveCfg = Release|Any CPU
357+
{084919BD-1003-476B-8109-6A0151E37EEB}.Release|x64.Build.0 = Release|Any CPU
358+
{084919BD-1003-476B-8109-6A0151E37EEB}.Release|x86.ActiveCfg = Release|Any CPU
359+
{084919BD-1003-476B-8109-6A0151E37EEB}.Release|x86.Build.0 = Release|Any CPU
346360
EndGlobalSection
347361
GlobalSection(SolutionProperties) = preSolution
348362
HideSolutionNode = FALSE
@@ -372,6 +386,7 @@ Global
372386
{81DBFE8C-FE75-49E0-A9AA-B28102063F1D} = {D87066C4-1144-4BD8-96E9-9F4676001397}
373387
{7DDFB773-8DBD-4EFA-8C31-31842C39D79E} = {1EF50257-AFD3-48A3-9E22-03BDC25550A7}
374388
{35FE1CF3-5F61-4943-86BF-4241FF0E4AF8} = {D87066C4-1144-4BD8-96E9-9F4676001397}
389+
{084919BD-1003-476B-8109-6A0151E37EEB} = {D87066C4-1144-4BD8-96E9-9F4676001397}
375390
EndGlobalSection
376391
GlobalSection(ExtensibilityGlobals) = postSolution
377392
SolutionGuid = {7DA0BB43-C1D4-4688-BE43-A9ED2D6F78EE}

docs/source/developers/do-not-resolve-members.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,13 @@ Iterate ``Context.Parameters.Members`` instead and pull out the member kinds you
8787
One catch: the *member list* is sorted, but the members hanging off a type aren't. If you grab a type and
8888
then read ``type.Methods``, those methods skipped the filter. Always go through the list for each kind you
8989
touch, rather than reaching into ``type.Methods``, ``type.Fields``, and so on.
90+
91+
The analyzer has your back
92+
--------------------------
93+
94+
You don't have to keep all this in your head. BitMono ships a small Roslyn analyzer (``BITM0001``) that
95+
spots ``Context.Module.GetAllTypes()`` inside a protection and nudges you toward
96+
``Context.Parameters.Members``, with a one-click fix to swap it over. It rides along with the
97+
``BitMono.Core`` package, so it lights up in :doc:`plugin <plugins>` projects too, not just the built-in
98+
protections. If you really do mean to walk the raw module (collecting references and the like), hit
99+
``Alt+Enter`` / ``Ctrl+.`` on the warning and suppress it - that's a normal thing to do, not a hack.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<!-- Analyzer assembly: ships inside the BitMono.Core package (analyzers/dotnet/cs), it is not
4+
its own package. Must stay netstandard2.0 so Roslyn/the IDE can load it. -->
5+
<PropertyGroup>
6+
<TargetFramework>netstandard2.0</TargetFramework>
7+
<Nullable>enable</Nullable>
8+
<LangVersion>preview</LangVersion>
9+
<IsPackable>false</IsPackable>
10+
<IncludeBuildOutput>false</IncludeBuildOutput>
11+
</PropertyGroup>
12+
13+
<ItemGroup>
14+
<!-- CSharp = the analyzer, CSharp.Workspaces = the code fix. PrivateAssets=all so neither leaks
15+
as a dependency (we ship a single DLL into analyzers/dotnet/cs). -->
16+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
17+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.0.1" PrivateAssets="all" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace BitMono.Analyzers;
8+
9+
/// <summary>
10+
/// Flags <c>Context.Module.GetAllTypes()</c> inside a protection: it walks the whole module and
11+
/// bypasses the <c>[DoNotResolve]</c> filtering BitMono applies to <c>Context.Parameters.Members</c>.
12+
/// </summary>
13+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
14+
public sealed class IterateMembersNotModuleAnalyzer : DiagnosticAnalyzer
15+
{
16+
public const string DiagnosticId = "BITM0001";
17+
18+
private static readonly DiagnosticDescriptor Rule = new(
19+
id: DiagnosticId,
20+
title: "Iterate Context.Parameters.Members, not the module",
21+
messageFormat: "'{0}' walks the whole module and bypasses BitMono's [DoNotResolve] filtering; iterate Context.Parameters.Members.OfType<…>() instead so excluded members are respected",
22+
category: "Usage",
23+
defaultSeverity: DiagnosticSeverity.Warning,
24+
isEnabledByDefault: true,
25+
description: "Inside a protection, Context.Module.GetAllTypes() returns every type and skips the reflection / special-runtime / model / BAML filtering BitMono applies to Context.Parameters.Members. Iterate the sorted member list instead. Suppress this if you genuinely need the raw module (e.g. collecting references to re-sync after renaming).",
26+
helpLinkUri: "https://bitmono.readthedocs.io/en/latest/developers/do-not-resolve-members.html");
27+
28+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
29+
30+
public override void Initialize(AnalysisContext context)
31+
{
32+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
33+
context.EnableConcurrentExecution();
34+
context.RegisterCompilationStartAction(static start =>
35+
{
36+
// Only meaningful in a BitMono protection-authoring compilation. If IProtection isn't
37+
// referenced, this isn't one - do nothing.
38+
var protectionInterface = start.Compilation.GetTypeByMetadataName("BitMono.API.Protections.IProtection");
39+
if (protectionInterface is null)
40+
{
41+
return;
42+
}
43+
start.RegisterSyntaxNodeAction(ctx => Analyze(ctx, protectionInterface), SyntaxKind.InvocationExpression);
44+
});
45+
}
46+
47+
private static void Analyze(SyntaxNodeAnalysisContext context, INamedTypeSymbol protectionInterface)
48+
{
49+
var invocation = (InvocationExpressionSyntax)context.Node;
50+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
51+
{
52+
return;
53+
}
54+
if (memberAccess.Name.Identifier.ValueText != "GetAllTypes")
55+
{
56+
return;
57+
}
58+
// The receiver must be an AsmResolver ModuleDefinition (e.g. Context.Module), so we don't trip
59+
// on some unrelated GetAllTypes() the author happens to define.
60+
var receiverType = context.SemanticModel.GetTypeInfo(memberAccess.Expression, context.CancellationToken).Type;
61+
if (receiverType is not { Name: "ModuleDefinition" } ||
62+
receiverType.ContainingNamespace?.ToDisplayString() != "AsmResolver.DotNet")
63+
{
64+
return;
65+
}
66+
// Only inside a protection. Anchored on IProtection so it covers built-in protections and
67+
// external plugins alike, whatever namespace they live in.
68+
var enclosingType = context.SemanticModel
69+
.GetEnclosingSymbol(invocation.SpanStart, context.CancellationToken)?.ContainingType;
70+
if (enclosingType is null || !ImplementsProtection(enclosingType, protectionInterface))
71+
{
72+
return;
73+
}
74+
context.ReportDiagnostic(Diagnostic.Create(Rule, memberAccess.GetLocation(), memberAccess.ToString()));
75+
}
76+
77+
private static bool ImplementsProtection(INamedTypeSymbol type, INamedTypeSymbol protectionInterface)
78+
{
79+
foreach (var iface in type.AllInterfaces)
80+
{
81+
if (SymbolEqualityComparer.Default.Equals(iface, protectionInterface))
82+
{
83+
return true;
84+
}
85+
}
86+
return false;
87+
}
88+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Collections.Immutable;
2+
using System.Composition;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using Microsoft.CodeAnalysis;
6+
using Microsoft.CodeAnalysis.CodeActions;
7+
using Microsoft.CodeAnalysis.CodeFixes;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.CSharp.Syntax;
10+
using Microsoft.CodeAnalysis.Formatting;
11+
using Microsoft.CodeAnalysis.Simplification;
12+
13+
namespace BitMono.Analyzers;
14+
15+
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(IterateMembersNotModuleCodeFixProvider)), Shared]
16+
public sealed class IterateMembersNotModuleCodeFixProvider : CodeFixProvider
17+
{
18+
private const string Title = "Iterate Context.Parameters.Members.OfType<TypeDefinition>()";
19+
20+
public override ImmutableArray<string> FixableDiagnosticIds =>
21+
ImmutableArray.Create(IterateMembersNotModuleAnalyzer.DiagnosticId);
22+
23+
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
24+
25+
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
26+
{
27+
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
28+
var invocation = root?.FindNode(context.Diagnostics[0].Location.SourceSpan)
29+
.FirstAncestorOrSelf<InvocationExpressionSyntax>();
30+
if (invocation is null)
31+
{
32+
return;
33+
}
34+
context.RegisterCodeFix(
35+
CodeAction.Create(Title, ct => ReplaceAsync(context.Document, invocation, ct), equivalenceKey: Title),
36+
context.Diagnostics[0]);
37+
}
38+
39+
private static async Task<Document> ReplaceAsync(Document document, InvocationExpressionSyntax invocation, CancellationToken cancellationToken)
40+
{
41+
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
42+
// Simplifier.Annotation collapses the global:: name to just TypeDefinition when the using is present.
43+
var replacement = SyntaxFactory
44+
.ParseExpression("Context.Parameters.Members.OfType<global::AsmResolver.DotNet.TypeDefinition>()")
45+
.WithTriviaFrom(invocation)
46+
.WithAdditionalAnnotations(Simplifier.Annotation, Formatter.Annotation);
47+
return document.WithSyntaxRoot(root!.ReplaceNode(invocation, replacement));
48+
}
49+
}

src/BitMono.Core/BitMono.Core.csproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111
<ProjectReference Include="..\BitMono.Utilities\BitMono.Utilities.csproj" />
1212
</ItemGroup>
1313

14+
<ItemGroup>
15+
<!-- Ship the analyzers inside this package (analyzers/dotnet/cs) so plugin authors who reference
16+
BitMono.Core get the protection-authoring warnings in their own IDE. The ProjectReference is
17+
build-order only (no runtime/package dependency). -->
18+
<ProjectReference Include="..\BitMono.Analyzers\BitMono.Analyzers.csproj"
19+
ReferenceOutputAssembly="false" PrivateAssets="all" />
20+
<!-- ponytail: bin-path include is the simple way to pack a sibling analyzer; if the path ever
21+
drifts, switch to a TargetOutputs-from-MSBuild target. -->
22+
<None Include="..\BitMono.Analyzers\bin\$(Configuration)\netstandard2.0\BitMono.Analyzers.dll"
23+
Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
24+
</ItemGroup>
25+
1426
<ItemGroup>
1527
<Reference Include="Echo">
1628
<HintPath>..\..\lib\Echo.dll</HintPath>

src/BitMono.Protections/BitMono.Protections.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,10 @@
1717
<PackageReference Include="NullGuard.Fody" Version="3.1.1" PrivateAssets="All" />
1818
</ItemGroup>
1919

20+
<ItemGroup>
21+
<!-- Run BitMono's own protection-authoring analyzers on the built-in protections. -->
22+
<ProjectReference Include="..\BitMono.Analyzers\BitMono.Analyzers.csproj"
23+
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
24+
</ItemGroup>
25+
2026
</Project>

src/BitMono.Protections/FullRenamer.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ public override Task ExecuteAsync()
156156

157157
// Maps each MemberReference in the module's method bodies to the in-module definition it points
158158
// to, resolved before renaming while names still match.
159+
[SuppressMessage("Usage", "BITM0001:Iterate Context.Parameters.Members, not the module",
160+
Justification = "Builds a MemberReference->definition map to re-sync generic-instance names after renaming (#220); reads the module, doesn't obfuscate, so [DoNotResolve] filtering doesn't apply.")]
159161
private static Dictionary<MemberReference, IMemberDefinition> CollectModuleMemberReferences(ModuleDefinition module)
160162
{
161163
var moduleTypes = new HashSet<TypeDefinition>(module.GetAllTypes());

0 commit comments

Comments
 (0)