Skip to content
← Tous les articles
· Delta1 Labs Nebula.NETObfuscation.NETGuide

Obfusquer une app .NET qui utilise AutoMapper, MediatR et FluentValidation

Ces trois bibliothèques sont l'ossature d'un service ASP.NET Core moderne, et toutes trois résolvent des choses à l'exécution — par type et par nom de propriété — via la convention et le scan d'assemblys. Elles se comportent donc très différemment sous le renommage. AutoMapper apparie les membres par nom de propriété, donc les DTO renommés cessent silencieusement de mapper ; MediatR trouve les handlers par la forme de l'interface, donc il survit généralement intact ; FluentValidation découvre les validateurs par forme mais rapporte les erreurs par nom de propriété, donc la découverte tient pendant que vos chaînes PropertyName deviennent opaques. Voici exactement ce qui casse dans chacun, la règle keep minimale pour chacun, un nebula.config.json consolidé, et comment le prouver avec des tests d'intégration et Glass.NET.

AutoMapper, MediatR et FluentValidation sont l’ossature d’une multitude de services ASP.NET Core : mapper la requête vers un type de domaine, la dispatcher vers un handler, la valider à l’entrée. Toutes trois relèvent de la « convention plutôt que configuration » : elles découvrent et câblent les choses au démarrage en scannant les assemblys et en appariant sur les types et les noms de membre. C’est exactement la mécanique avec laquelle l’obfuscation par renommage interfère, et l’intéressant est que les trois bibliothèques interfèrent de trois façons différentes. L’une casse bruyamment, l’autre ne casse généralement pas du tout, et la troisième continue de fonctionner tout en corrompant silencieusement les données qu’elle rapporte. Si vous obfusquez tout le service et exécutez les tests non obfusqués, vous ne verrez rien de tout cela.

La cause racine est celle de obfuscation, réflexion et pièges de sérialisation : le renommage est sûr pour tout ce qui est résolu par un token de métadonnées et dangereux pour tout ce qui est résolu par un nom de chaîne à l’exécution. Les trois bibliothèques se situent en des points différents de cette ligne. Prenons chacune à son tour — ce qui casse, la règle keep minimale, comment le confirmer — puis consolidons le tout en une seule configuration Nebula.NET.

AutoMapper : la map survit, l’appariement des membres non

CreateMap<Order, OrderDto>() identifie les deux types par leur token, donc l’enregistrement lui-même survit au renommage intact. La rupture est un cran plus bas, dans la façon dont AutoMapper remplit la destination. Sa convention par défaut apparie un membre source à un membre de destination quand leurs noms sont égaux, et il lit MemberInfo.Name pour faire cette comparaison à l’exécution :

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

Avant obfuscation, la convention apparie "Amount" == "Amount". Après renommage, Order.Amount devient a et OrderDto.Amount devient une autre lettre — ce sont des types différents qui reçoivent des noms indépendants — donc la comparaison est désormais "a" == "x", qui échoue. AutoMapper laisse OrderDto.Amount à sa valeur par défaut et n’y mappe rien. Pas d’exception, juste un 0.00 là où devrait se trouver une valeur.

Ce qui casse : chaque membre mappé par convention de nom — ce qui, dans la plupart des bases de code, représente la plupart des membres. ReverseMap, ProjectTo<T> et l’aplatissement (Customer.Name → CustomerName) reposent tous sur le même appariement par nom et échouent de la même manière.

La règle keep minimale : gardez les noms de propriété publique des types qui apparaissent dans une map — côté source comme côté destination. Gardez les noms seulement ; les champs de stockage, les constructeurs et toute logique de ces types se renomment encore et reçoivent encore la protection complète. L’alternative, si vous voulez aussi renommer ces types, est de rendre chaque mapping explicite, car une lambda d’accès à un membre est un arbre d’expression que le compilateur résout en token :

cfg.CreateMap<Order, OrderDto>()
   .ForMember(dest => dest.Amount, opt => opt.MapFrom(src => src.Amount));   // token-based, survives

C’est robuste mais verbeux pour un DTO large. Garder les noms de propriété est plus simple, et ces noms doivent de toute façon souvent être gardés pour la sérialisation.

Comment le confirmer : appelez configuration.AssertConfigurationIsValid() comme test contre l’assembly protégé. Il lève AutoMapperConfigurationException en listant chaque membre de destination non mappé, ce qui transforme un 0.00 silencieux en test en échec. Appuyez-le d’un aller-retour : mappez une source remplie et affirmez que les champs de la destination sont égaux.

MediatR : résolu par forme, donc il fonctionne généralement sans effort

MediatR est le rassurant. RegisterServicesFromAssembly scanne chaque type implémentant les génériques ouverts IRequestHandler<,> et INotificationHandler<>, et le dispatch apparie une requête à son handler via un type générique fermé — IRequestHandler<CreateOrder, Guid>. Les deux sont des identités par token, pas des chaînes. C’est la même distinction tracée dans obfusquer l’injection de dépendances par scan d’assemblys : un scan qui apparie sur la forme survit, car le renommage réécrit la relation d’implémentation de façon cohérente sur le handler et l’interface.

services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, Guid>
{
    public Task<Guid> Handle(CreateOrder request, CancellationToken ct) { /* ... */ }
}

Nebula est libre de renommer CreateOrderHandler, CreateOrder et le corps de Handle : le scan trouve toujours le handler par son implémentation de IRequestHandler<,>, et Send l’achemine toujours par le générique fermé. Les comportements de pipeline enregistrés comme IPipelineBehavior<,> sont trouvés de la même façon. Les handlers n’ont donc rien à mettre sur la keep-list, et leurs corps — souvent là où vit votre vraie logique de domaine — devraient recevoir le traitement complet, virtualisation comprise.

Ce qui casse quand même : les issues de secours hors forme. Si vous avez remplacé le scan par convention par un enregistrement fait main qui filtre les handlers par t.Name.EndsWith("Handler"), c’est une comparaison de chaînes et elle se vide exactement comme dans l’article sur la DI. Et tout Type.GetType("MyApp.Handlers.CreateOrderHandler") — parfois vu dans des dispatchers personnalisés ou le câblage de sagas — résout une chaîne figée qui ne nomme plus rien et renvoie null.

La règle keep minimale : aucune pour le MediatR idiomatique. Seulement si vous utilisez un enregistrement personnalisé basé sur le nom (passez-le à AssignableTo l’interface du handler) ou une chaîne Type.GetType (gardez le nom de ce seul type, ou mieux, résolvez-le via IMediator et le type de la requête).

Comment le confirmer : un test d’intégration qui fait Send d’une instance de chaque type de requête et Publish de chaque notification contre la build protégée. Un handler que le scan n’a pas réussi à enregistrer lève une exception sur Send ; affirmez un résultat non nul pour chacun.

FluentValidation : la découverte survit, le nom rapporté non

FluentValidation se scinde en deux. La découverte va par forme et est sûre ; les noms de propriété qu’elle rapporte sont dérivés de chaînes et ne le sont pas.

AddValidatorsFromAssembly scanne chaque implémentation de IValidator<T> — un appariement par forme, donc il survit au renommage exactement comme le scan de MediatR — et vos sous-classes d’AbstractValidator<T> peuvent toutes se renommer librement. Le problème est ce qu’une règle sait de la propriété qu’elle garde. RuleFor(x => x.Email) lit le MemberInfo derrière cette lambda quand le validateur est construit et stocke son nom, donc après renommage le nom stocké est a :

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."
    }
}

Ce qui casse : ValidationFailure.PropertyName devient le nom obfusqué, et tout marqueur {PropertyName} dans un message l’affiche. Cela compte dès que quelque chose en aval s’indexe sur ce nom : le model-state d’ASP.NET Core mappe PropertyName aux clés de formulaire/champ, les contrats d’erreur d’API l’exposent aux clients, et un front qui apparie les erreurs aux champs par nom trouve "a" au lieu de "email". La validation s’exécute toujours correctement — elle étiquette seulement le résultat d’un nom que personne hors de l’assembly ne reconnaît.

La règle keep minimale : gardez les noms de propriété du modèle qui affleurent dans les erreurs — les mêmes noms de DTO/modèle qu’AutoMapper et votre sérialiseur doivent déjà garder. Ou, pour continuer à les renommer, découplez le nom rapporté du membre, qui est le motif le plus solide (c’est l’analogue en validation du fait d’épingler un contrat JSON avec [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();

Comment le confirmer : un test qui valide un modèle délibérément invalide contre la build protégée et affirme result.Errors.Single().PropertyName == "email" — pas ce que l’obfuscateur a renommé.

Par quoi chaque bibliothèque résoutpar type / forme — survitpar nom de propriété — à garderMediatR : scan IRequestHandler<,> + générique ferméMediatR : Type.GetType / scans par convention seulementFluentValidation : AddValidatorsFromAssemblyFluentValidation : nom de propriété de RuleFor dans les erreursAutoMapper : CreateMap<TSrc,TDest> + lambda ForMemberAutoMapper : appariement par nom sur les DTO

La keep-list consolidée

Remarquez que toute la colonne de droite se réduit à une seule chose : les noms de propriété des DTO et des modèles. L’ensemble qu’AutoMapper apparie, celui que FluentValidation rapporte et celui que votre sérialiseur écrit sont très majoritairement les mêmes types, donc une seule règle keep sur vos contrats et modèles couvre les trois. Tout le reste — handlers, validateurs, profiles, internes de mapping, votre logique — est trouvé par forme ou appelé directement et se renomme librement. En suivant la discipline de construire une keep-list Nebula, gardez la feuille, pas la branche : members: "public" épingle les noms de propriété et laisse les champs privés se renommer.

{
  "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" }
    ]
  }
}

L’ordre premier-match-gagne compte : les règles keep étroites passent au-dessus du rename large qui ramasse tout, donc les noms de propriété de vos DTO restent pendant que Acme.Api.Handlers, Acme.Api.Validators et tout le reste tombent dans le rename. Il n’y a délibérément aucun keep pour les handlers ni les validateurs — les garder laisserait la carte de votre application en clair pour rien, puisque les deux sont résolus par forme. Pour un unique handler réfléchi que vous ne pouvez pas refactorer, préférez un attribut sur ce type exact plutôt que d’élargir la configuration :

[Obfuscation(Feature = "renaming", Exclude = true)]   // resolved via Type.GetType(string)
public sealed class LegacySagaHandler : IRequestHandler<ResumeSaga, Unit> { /* ... */ }

Réservez vos transformations les plus lourdes aux corps des handlers. La virtualisation de code et le chiffrement de méthode entière — deux fonctionnalités Pro dans Nebula — appartiennent aux méthodes qui portent votre vraie logique de domaine, pas aux DTO, qui n’ont besoin que de garder leurs noms.

Prouvez-le, ne le supposez pas

Aucun de ces échecs n’apparaît à la compilation, et deux des trois sont silencieux à l’exécution, donc la vérification n’est pas optionnelle. Exécutez les trois contrôles ci-dessus comme tests d’intégration contre la build protégée : AssertConfigurationIsValid() pour AutoMapper, un Send/Publish de chaque message pour MediatR, et une assertion de PropertyName avec un modèle invalide pour FluentValidation. Ouvrez ensuite l’assembly protégé dans Glass.NET et confirmez les deux moitiés de la frontière à la fois : vos handlers et validateurs se lisent comme a, b, c et vos internes de mapping ont disparu, pendant que les noms de propriété des DTO par lesquels vos trois bibliothèques résolvent sont toujours intacts. La procédure complète du décompilateur est dans votre obfuscation a-t-elle vraiment fonctionné ?. Quand les tests sont au vert contre la build obfusquée et que le décompilateur montre les noms gardés exactement là où une chaîne les résout et nulle part ailleurs, vous avez une protection qui ne vous coûte pas un pipeline de requêtes cassé.

Essayez Nebula.NET

Renforcez votre code .NET en quelques minutes — commencez avec l'édition gratuite.