Eine .NET-App obfuskieren, die AutoMapper, MediatR und FluentValidation nutzt
Diese drei Bibliotheken sind das Rückgrat eines modernen ASP.NET-Core-Dienstes, und alle drei lösen zur Laufzeit Dinge auf — über Typ und über Eigenschaftsnamen — mittels Konvention und Assembly-Scanning. Deshalb verhalten sie sich unter der Umbenennung sehr unterschiedlich. AutoMapper paart Member über Eigenschaftsnamen, also hören umbenannte DTOs still auf zu mappen; MediatR findet Handler über die Form der Schnittstelle, also überlebt es meist unberührt; FluentValidation entdeckt Validatoren über die Form, meldet Fehler aber über Eigenschaftsnamen, also hält die Entdeckung, während Ihre PropertyName-Strings undurchsichtig werden. Hier steht genau, was in jedem bricht, die minimale Keep-Regel für jedes, eine konsolidierte nebula.config.json und wie Sie es mit Integrationstests und Glass.NET beweisen.
AutoMapper, MediatR und FluentValidation sind das Rückgrat sehr vieler ASP.NET-Core-Dienste: die Anfrage auf einen Domänentyp mappen, sie an einen Handler dispatchen, sie beim Eintreffen validieren. Alle drei folgen „Konvention vor Konfiguration”: sie entdecken und verdrahten die Dinge beim Start, indem sie Assemblys scannen und auf Typen und Member-Namen abgleichen. Genau das ist die Mechanik, mit der die Umbenennungs-Obfuskierung interferiert, und das Interessante ist, dass die drei Bibliotheken auf drei verschiedene Weisen interferieren. Eine bricht laut, eine bricht meist gar nicht, und eine funktioniert weiter, während sie still die Daten verfälscht, die sie meldet. Wenn Sie den ganzen Dienst obfuskieren und die nicht obfuskierten Tests ausführen, sehen Sie nichts davon.
Die Grundursache ist dieselbe wie bei Obfuskierung, Reflexion und Serialisierungs-Fallstricke: die Umbenennung ist sicher für alles, was über einen Metadaten-Token aufgelöst wird, und unsicher für alles, was zur Laufzeit über einen String-Namen aufgelöst wird. Die drei Bibliotheken sitzen an verschiedenen Punkten dieser Linie. Nehmen wir jede der Reihe nach — was bricht, die minimale Keep-Regel, wie man es bestätigt — und konsolidieren es dann zu einer einzigen Nebula.NET-Konfiguration.
AutoMapper: die Map überlebt, der Member-Abgleich nicht
CreateMap<Order, OrderDto>() identifiziert beide Typen über ihren Token, also überlebt die Registrierung selbst die Umbenennung unberührt. Der Bruch liegt eine Ebene tiefer, darin, wie AutoMapper das Ziel füllt. Seine Standardkonvention paart einen Quell-Member mit einem Ziel-Member, wenn ihre Namen gleich sind, und er liest MemberInfo.Name, um diesen Vergleich zur Laufzeit zu machen:
public sealed class Order { public decimal Amount { get; set; } }
public sealed class OrderDto { public decimal Amount { get; set; } }
cfg.CreateMap<Order, OrderDto>();
var dto = mapper.Map<OrderDto>(order); // before: dto.Amount == order.Amount
Vor der Obfuskierung paart die Konvention "Amount" == "Amount". Nach der Umbenennung wird Order.Amount zu a und OrderDto.Amount zu einem anderen Buchstaben — es sind verschiedene Typen und erhalten unabhängige Namen —, also ist der Vergleich nun "a" == "x", der fehlschlägt. AutoMapper lässt OrderDto.Amount auf seinem Standardwert und mappt nichts hinein. Keine Ausnahme, nur ein 0.00, wo ein Wert stehen sollte.
Was bricht: jeder Member, der per Namenskonvention gemappt wird — was in den meisten Codebasen die meisten Member sind. ReverseMap, ProjectTo<T> und das Abflachen (Customer.Name → CustomerName) beruhen alle auf demselben Namensabgleich und scheitern auf dieselbe Weise.
Die minimale Keep-Regel: behalten Sie die öffentlichen Eigenschaftsnamen der Typen, die in einer Map auftauchen — sowohl auf der Quell- als auch auf der Zielseite. Behalten Sie nur die Namen; die Backing-Felder, die Konstruktoren und jede Logik dieser Typen werden weiterhin umbenannt und erhalten weiterhin vollen Schutz. Die Alternative, wenn Sie auch diese Typen umbenennen wollen, ist, jedes Mapping explizit zu machen, da eine Member-Zugriffs-Lambda ein Ausdrucksbaum ist, den der Compiler zu einem Token auflöst:
cfg.CreateMap<Order, OrderDto>()
.ForMember(dest => dest.Amount, opt => opt.MapFrom(src => src.Amount)); // token-based, survives
Das ist robust, aber wortreich für ein breites DTO. Die Eigenschaftsnamen zu behalten ist einfacher, und diese Namen müssen ohnehin oft für die Serialisierung behalten werden.
Wie man es bestätigt: rufen Sie configuration.AssertConfigurationIsValid() als Test gegen das geschützte Assembly auf. Es wirft AutoMapperConfigurationException und listet jeden ungemappten Ziel-Member auf, was ein stilles 0.00 in einen fehlgeschlagenen Test verwandelt. Stützen Sie es mit einem Hin-und-zurück: mappen Sie eine gefüllte Quelle und prüfen Sie, dass die Zielfelder gleich sind.
MediatR: über die Form aufgelöst, funktioniert also meist mühelos
MediatR ist das beruhigende. RegisterServicesFromAssembly scannt jeden Typ, der die offenen Generics IRequestHandler<,> und INotificationHandler<> implementiert, und der Dispatch paart eine Anfrage über einen geschlossenen generischen Typ mit ihrem Handler — IRequestHandler<CreateOrder, Guid>. Beide sind Token-Identitäten, keine Strings. Es ist dieselbe Unterscheidung wie in Dependency Injection per Assembly-Scanning obfuskieren: ein Scan, der auf die Form abgleicht, überlebt, denn die Umbenennung schreibt die Implementierungsbeziehung konsistent auf dem Handler und der Schnittstelle um.
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, Guid>
{
public Task<Guid> Handle(CreateOrder request, CancellationToken ct) { /* ... */ }
}
Nebula darf CreateOrderHandler, CreateOrder und den Handle-Rumpf frei umbenennen — der Scan findet den Handler weiterhin über seine IRequestHandler<,>-Implementierung, und Send leitet ihn weiterhin über das geschlossene Generic. Pipeline-Behaviors, die als IPipelineBehavior<,> registriert sind, werden auf dieselbe Weise gefunden. Handler brauchen also nichts auf der Keep-List, und ihre Rümpfe — oft dort, wo Ihre echte Domänenlogik lebt — sollten die volle Behandlung erhalten, Virtualisierung eingeschlossen.
Was dennoch bricht: die Nicht-Form-Notausgänge. Wenn Sie den Konventions-Scan durch eine selbstgebaute Registrierung ersetzt haben, die Handler über t.Name.EndsWith("Handler") filtert, ist das ein String-Vergleich und läuft genau wie im DI-Beitrag leer. Und jedes Type.GetType("MyApp.Handlers.CreateOrderHandler") — manchmal in benutzerdefinierten Dispatchern oder in der Saga-Verdrahtung zu sehen — löst einen eingefrorenen String auf, der nichts mehr benennt, und gibt null zurück.
Die minimale Keep-Regel: keine für idiomatisches MediatR. Nur wenn Sie eine namensbasierte benutzerdefinierte Registrierung verwenden (stellen Sie sie auf AssignableTo die Handler-Schnittstelle um) oder einen Type.GetType-String (behalten Sie den Namen dieses einen Typs, oder besser, lösen Sie ihn über IMediator und den Anfragetyp auf).
Wie man es bestätigt: ein Integrationstest, der je eine Instanz jedes Anfragetyps Sendt und jede Notification Publisht, gegen den geschützten Build. Ein Handler, den der Scan nicht registrieren konnte, wirft bei Send; prüfen Sie für jeden ein Nicht-null-Ergebnis.
FluentValidation: die Entdeckung überlebt, der gemeldete Name nicht
FluentValidation teilt sich in der Mitte. Die Entdeckung läuft über die Form und ist sicher; die Eigenschaftsnamen, die es meldet, sind String-abgeleitet und sind es nicht.
AddValidatorsFromAssembly scannt jede IValidator<T>-Implementierung — ein Form-Abgleich, also überlebt er die Umbenennung genau wie der Scan von MediatR —, und Ihre AbstractValidator<T>-Unterklassen können alle frei umbenannt werden. Das Problem ist, was eine Regel über die Eigenschaft weiß, die sie bewacht. RuleFor(x => x.Email) liest die MemberInfo hinter dieser Lambda beim Bau des Validators und speichert ihren Namen, sodass der gespeicherte Name nach der Umbenennung a ist:
public sealed class CreateOrderValidator : AbstractValidator<CreateOrder>
{
public CreateOrderValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
// After renaming: failure.PropertyName == "a", message "'a' must not be empty."
}
}
Was bricht: ValidationFailure.PropertyName wird zum obfuskierten Namen, und jeder {PropertyName}-Platzhalter in einer Meldung rendert ihn. Das zählt in dem Moment, in dem etwas weiter unten sich über diesen Namen indiziert: der Model-State von ASP.NET Core mappt PropertyName auf Formular-/Feldschlüssel, API-Fehlerverträge legen ihn Clients offen, und ein Frontend, das Fehler über den Namen auf Eingaben abbildet, findet "a" statt "email". Die Validierung läuft weiterhin korrekt — sie beschriftet das Ergebnis nur mit einem Namen, den niemand außerhalb des Assemblys erkennt.
Die minimale Keep-Regel: behalten Sie die Modell-Eigenschaftsnamen, die in Fehlern auftauchen — dieselben DTO-/Modellnamen, die AutoMapper und Ihr Serializer ohnehin behalten müssen. Oder, um sie weiter umzubenennen, entkoppeln Sie den gemeldeten Namen vom Member, was das stabilere Muster ist (es ist das Validierungs-Pendant zum Festnageln eines JSON-Vertrags mit [JsonPropertyName]):
RuleFor(x => x.Email).NotEmpty().WithName("email"); // per rule
// or, once per property, independent of the lambda:
RuleFor(x => x.Email).OverridePropertyName("email").NotEmpty();
Wie man es bestätigt: ein Test, der ein absichtlich ungültiges Modell gegen den geschützten Build validiert und result.Errors.Single().PropertyName == "email" prüft — nicht das, was der Obfuskator umbenannt hat.
Die konsolidierte Keep-List
Beachten Sie, dass die ganze rechte Spalte sich auf eine Sache reduziert: DTO- und Modell-Eigenschaftsnamen. Die Menge, die AutoMapper paart, die FluentValidation meldet und die Ihr Serializer schreibt, sind überwältigend dieselben Typen, also deckt eine einzige Keep-Regel auf Ihren Contracts und Models alle drei ab. Alles andere — Handler, Validatoren, Profiles, Mapping-Interna, Ihre Logik — wird über die Form gefunden oder direkt aufgerufen und frei umbenannt. Der Disziplin aus eine Nebula-Keep-List aufbauen folgend: behalten Sie das Blatt, nicht den Ast — members: "public" nagelt die Eigenschaftsnamen fest und lässt private Felder umbenennen.
{
"schemaVersion": 1,
"inputs": [ "bin/Release/net8.0/Acme.Api.dll" ],
"outputDirectory": "protected",
"preservePublicApi": true,
"encryptStrings": true,
"controlFlowObfuscation": true,
"renaming": {
"rules": [
{ "pattern": "Acme.Api.Contracts.*", "action": "keep", "members": "public" },
{ "pattern": "Acme.Api.Models.*", "action": "keep", "members": "public" },
{ "pattern": "Acme.Api.*", "action": "rename" }
]
}
}
Die Reihenfolge „erster Treffer gewinnt” zählt: die engen keep-Regeln stehen über dem breiten rename-Auffangnetz, also bleiben die Eigenschaftsnamen Ihrer DTOs, während Acme.Api.Handlers, Acme.Api.Validators und alles andere in das rename fallen. Es gibt bewusst keinen Keep für Handler oder Validatoren — sie zu behalten würde die Karte Ihrer Anwendung für nichts im Klartext hinterlassen, da beide über die Form aufgelöst werden. Für einen einzelnen reflektierten Handler, den Sie nicht refaktorieren können, bevorzugen Sie ein Attribut auf genau diesem Typ statt die Konfiguration zu verbreitern:
[Obfuscation(Feature = "renaming", Exclude = true)] // resolved via Type.GetType(string)
public sealed class LegacySagaHandler : IRequestHandler<ResumeSaga, Unit> { /* ... */ }
Reservieren Sie Ihre schwersten Transformationen für die Handler-Rümpfe. Code-Virtualisierung und Ganz-Methoden-Verschlüsselung — beides Pro-Funktionen in Nebula — gehören auf die Methoden, die Ihre echte Domänenlogik tragen, nicht auf die DTOs, die nur ihre Namen behalten müssen.
Beweisen Sie es, nehmen Sie es nicht an
Keiner dieser Fehler erscheint zur Kompilierzeit, und zwei der drei sind zur Laufzeit still, also ist die Verifikation nicht optional. Führen Sie die drei obigen Prüfungen als Integrationstests gegen den geschützten Build aus: AssertConfigurationIsValid() für AutoMapper, ein Send/Publish jeder Nachricht für MediatR und eine PropertyName-Zusicherung mit einem ungültigen Modell für FluentValidation. Öffnen Sie dann das geschützte Assembly in Glass.NET und bestätigen Sie beide Hälften der Grenze zugleich: Ihre Handler und Validatoren lesen sich als a, b, c und Ihre Mapping-Interna sind weg, während die DTO-Eigenschaftsnamen, über die Ihre drei Bibliotheken auflösen, noch intakt sind. Das vollständige Decompiler-Verfahren steht in hat Ihre Obfuskierung wirklich funktioniert?. Wenn die Tests gegen den obfuskierten Build grün sind und der Decompiler die Namen genau dort behalten zeigt, wo ein String sie auflöst, und nirgendwo sonst, haben Sie einen Schutz, der Sie keine kaputte Anfrage-Pipeline kostet.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.