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

Obfusquer un assembly que vos tests doivent encore réussir : InternalsVisibleTo et assemblies amis

Renommer les membres internes est là où l'obfuscation justifie son existence — mais [InternalsVisibleTo] transforme discrètement certains de ces membres internes en contrat. Un projet de tests, un assembly frère ou un mock généré atteint vos types internes par leur nom, et ce nom n'existe qu'à la compilation. Obfusquez le producteur seul et l'ami cesse de compiler ou, pire, un mock basé sur la réflexion échoue à l'exécution. Voici exactement ce que fait [InternalsVisibleTo] dans les métadonnées, pourquoi Nebula.NET préserve pour vous le cas mono-assembly, et comment garder un véritable assembly ami au vert en obfusquant la paire ensemble.

La chose la plus précieuse que fait un obfuscateur est de renommer ce qu’un consommateur ne peut pas voir : vos types et membres internal et private. C’est là que vit votre véritable implémentation, et rien en dehors de l’assembly n’est censé les référencer par leur nom, si bien que renommer internal class PricingEngine en internal class a ne vous coûte rien et coûte beaucoup à un attaquant.

[InternalsVisibleTo] est le seul attribut qui brise cette hypothèse. Il permet à un assembly ami nommé de voir vos membres internes par leur nom, à la compilation — ce qui promeut discrètement certains de vos membres internes de « détail d’implémentation » à « contrat ». L’ami le plus courant est votre propre projet de tests unitaires, qui vient tester une classe que vous ne vouliez pas rendre publique. Obfusquez le producteur seul et cet ami peut cesser de compiler ou, s’il se lie par réflexion, échouer à l’exécution — sans la moindre erreur de l’étape d’obfuscation elle-même.

Ce qu’est réellement [InternalsVisibleTo]

L’attribut est une métadonnée au niveau de l’assembly. Dans le code source, il s’écrit :

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Acme.Pricing.Tests")]

Dans le module compilé, c’est un unique attribut personnalisé sur l’assembly, portant le nom de l’ami (et, pour un producteur signé, la clé publique de l’ami) :

.assembly Acme.Pricing
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices
      .InternalsVisibleToAttribute::.ctor(string)
      = ( 01 00 11 41 63 6D 65 2E 50 72 69 63 69 6E 67      // ...Acme.Pricing
          2E 54 65 73 74 73 00 00 )                         // .Tests
}

Il ne fait rien à l’exécution — le CLR ne le consulte pas. C’est une instruction pour le compilateur de l’assembly ami : « quand tu compiles Acme.Pricing.Tests, laisse-le se lier aux membres internes de Acme.Pricing ». Dès que vous l’accordez, ceci devient donc légal dans l’ami :

// In Acme.Pricing.Tests — only compiles because of InternalsVisibleTo
[Fact]
public void Discount_is_applied_once()
{
    var engine = new PricingEngine();          // internal type
    Assert.Equal(90m, engine.Apply(100m, 0.1)); // internal method
}

PricingEngine et Apply sont désormais gravés dans les métadonnées de l’assembly de tests comme références externes vers Acme.Pricing. Ces références sont par nom.

Où ça casse — et où ça ne casse pas

Voici le piège, dessiné. Le producteur et l’ami sont deux assemblies distincts, et la sûreté d’un renommage dépend entièrement du fait que l’ami soit lui aussi réécrit.

Producer obfuscated aloneAcme.PricingPricingEngine → aAcme.Pricing.Testscalls PricingEngineunresolved → failsBoth obfuscated togetherAcme.PricingPricingEngine → aAcme.Pricing.Testscalls aresolves → passes

Le cas mono-assembly que Nebula garde fonctionnel. L’attribut [InternalsVisibleTo] et l’identité de l’ami qu’il nomme sont préservés par une exécution — Nebula ne corrompt ni l’attribut ni l’identité propre de l’assembly, de sorte que les relations d’amitié restent structurellement valides (c’est la garantie de portée mono-assembly du moteur). Ce qu’une exécution sur le producteur seul ne peut pas faire, c’est entrer dans un assembly ami qu’on ne lui a jamais fourni et réécrire les références de cet ami vers les membres internes désormais renommés du producteur. Si Acme.Pricing.Tests n’est pas une entrée, il contient toujours une référence à PricingEngine, et une fois que PricingEngine devient a, cette référence ne résout plus rien.

La variante à l’exécution est pire. Un framework de mocking ou un test qui fouille les membres internes par réflexion — Type.GetType("Acme.Pricing.PricingEngine") ou assembly.GetType("...") — compile parfaitement contre le producteur non obfusqué, puis lève null/MissingMethodException à l’exécution une fois le type renommé, parce que la chaîne ne correspond plus. Aucune erreur de build ne vous prévient ; seule l’exécution des tests le fait.

La solution : obfusquer l’ami avec le producteur

Un assembly ami n’est qu’une référence inter-assembly qui se trouve franchir la frontière interne. Traitez-le comme n’importe quelle autre exécution multi-assembly : donnez à Nebula les deux assemblies et laissez-le renommer les membres internes partagés des deux côtés de manière cohérente.

{
  "inputs": [
    "out/Acme.Pricing.dll",
    "out/Acme.Pricing.Tests.dll"
  ],
  "outputDirectory": "out/obf",
  "preservePublicApi": true,
  "crossAssemblyRename": true,
  "encryptStrings": true
}

crossAssemblyRename est la ligne clé : elle renomme les types et membres partagés entre les assemblies d’entrée et réécrit chaque référence inter-assembly, de sorte que lorsque PricingEngine devient a dans le producteur, le site d’appel dans l’assembly de tests est lui aussi réécrit en a. L’attribut continue de nommer un assembly réel, la référence résout, et les tests s’exécutent contre les membres internes obfusqués — ce qui est le test d’acceptation idéal, car vous testez désormais exactement l’IL que vous livrez.

Pour que cela fonctionne, chaque assembly qui référence les membres internes renommés doit être une entrée. C’est la règle générale pour crossAssemblyRename, et [InternalsVisibleTo] est simplement le cas où l’assembly référençant atteint des noms internes plutôt que publics.

Quand vous ne pouvez pas inclure l’ami

Parfois, l’ami n’est vraiment pas à vous pour que vous l’obfusquiez — l’assembly d’un partenaire, ou un outil que vous ne contrôlez pas, à qui l’accès interne a été accordé. Ne sacrifiez pas pour autant toute la surface interne ; préservez uniquement les membres internes exacts qu’il touche. exclude force la préservation d’un type (et de ses membres) ou d’un membre unique par son nom complet :

{
  "inputs": ["out/Acme.Pricing.dll"],
  "preservePublicApi": true,
  "exclude": [
    "Acme.Pricing.PricingEngine",            // whole type the friend constructs
    "Acme.Pricing.Internals.FeeTable.Lookup" // one member a partner tool calls
  ]
}

Tout le reste de l’interne est toujours renommé, aplati et ses chaînes chiffrées — vous avez payé l’accès sur deux types au lieu de le céder pour tout l’assembly. Découvrez exactement quels membres internes un ami lit avec nebula inspect avant de décider ; préserver un type dont vous n’aviez pas besoin, c’est de la protection cédée gratuitement.

Une note sur la signature : si votre producteur a un nom fort, [InternalsVisibleTo] porte la clé publique de l’ami, et une obfuscation qui re-signe la sortie (strongNameKeyFile) n’y change rien — la clause d’ami nomme la clé de l’ami, pas celle du producteur. Re-signer le producteur est orthogonal ; voir re-signer un assembly obfusqué.

La règle à retenir

[InternalsVisibleTo] transforme une tranche de votre implémentation en contrat avec un assembly précis. La protection que vous voulez — renommer les membres internes — est exactement ce à quoi ce contrat est sensible, si bien que les deux doivent être réconciliés, non évités :

  • Par défaut : obfusquez l’ami (généralement votre projet de tests) ensemble avec le produit en CI, avec crossAssemblyRename activé. Les membres internes sont renommés des deux côtés et vos tests se lient à l’IL livré.
  • Quand l’ami n’est pas à vous : utilisez exclude uniquement sur les types ou membres internes précis qu’il atteint, jamais sur toute la surface interne.
  • Jamais : désactiver le renommage des membres internes parce qu’un test lit une classe. Cela échange l’essentiel de votre protection contre un problème qui a une solution précise.

La garantie mono-assembly signifie que Nebula ne corrompra pas silencieusement une relation d’amitié ; garder l’ami au vert à travers un renommage est la seule chose qui vous incombe, et l’inclure dans l’exécution est la façon de l’assumer. Voir Assemblies multiples pour le modèle inter-assembly et Garder votre application fonctionnelle pour la liste de contrôle plus large de « ce qui se résout par nom ».

Téléchargez Nebula.NET gratuitement et exécutez votre suite de tests contre le build obfusqué.

Essayez Nebula.NET

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