Ofuscar una app .NET que usa AutoMapper, MediatR y FluentValidation
Estas tres bibliotecas son la columna vertebral de un servicio ASP.NET Core moderno, y las tres resuelven cosas en tiempo de ejecución —por tipo y por nombre de propiedad— mediante convención y escaneo de ensamblados. Eso hace que se comporten de forma muy distinta bajo el renombrado. AutoMapper empareja miembros por nombre de propiedad, así que los DTOs renombrados dejan de mapear en silencio; MediatR encuentra los handlers por la forma de la interfaz, así que normalmente sobrevive intacto; FluentValidation descubre los validadores por forma pero informa de los errores por nombre de propiedad, así que el descubrimiento se mantiene mientras tus cadenas PropertyName quedan opacas. Esto es exactamente lo que se rompe en cada uno, la regla keep mínima para cada uno, un nebula.config.json consolidado, y cómo demostrarlo con pruebas de integración y Glass.NET.
AutoMapper, MediatR y FluentValidation son la columna vertebral de muchísimos servicios ASP.NET Core: mapea la petición a un tipo de dominio, despáchala a un handler, valídala a la entrada. Las tres son «convención sobre configuración»: descubren y cablean las cosas al arrancar escaneando ensamblados y emparejando por tipos y nombres de miembro. Esa es exactamente la maquinaria con la que interfiere la ofuscación por renombrado, y lo interesante es que las tres bibliotecas interfieren de tres maneras distintas. Una se rompe a gritos, otra normalmente no se rompe en absoluto, y otra sigue funcionando mientras corrompe en silencio los datos que informa. Si ofuscas todo el servicio y ejecutas las pruebas sin ofuscar, no verás nada de esto.
La causa raíz es la misma de ofuscación, reflexión y trampas de serialización: el renombrado es seguro para cualquier cosa resuelta por un token de metadatos e inseguro para cualquier cosa resuelta por un nombre de cadena en tiempo de ejecución. Las tres bibliotecas se sitúan en puntos distintos de esa línea. Tomemos cada una por turno —qué se rompe, la regla keep mínima, cómo confirmarlo— y luego consolidémoslo en una sola configuración de Nebula.NET.
AutoMapper: el mapa sobrevive, el emparejamiento de miembros no
CreateMap<Order, OrderDto>() identifica ambos tipos por su token, así que el registro en sí sobrevive al renombrado intacto. La rotura está un nivel más abajo, en cómo AutoMapper rellena el destino. Su convención por defecto empareja un miembro de origen con uno de destino cuando sus nombres son iguales, y lee MemberInfo.Name para hacer esa comparación en tiempo de ejecución:
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
Antes de ofuscar, la convención empareja "Amount" == "Amount". Tras el renombrado, Order.Amount pasa a ser a y OrderDto.Amount pasa a ser otra letra —son tipos distintos y obtienen nombres independientes—, así que la comparación ahora es "a" == "x", que falla. AutoMapper deja OrderDto.Amount en su valor por defecto y no mapea nada en él. Sin excepción, solo un 0.00 donde debería haber un valor.
Qué se rompe: cada miembro mapeado por convención de nombre, que, en la mayoría de los códigos, es casi todos los miembros. ReverseMap, ProjectTo<T> y el aplanamiento (Customer.Name → CustomerName) dependen todos del mismo emparejamiento por nombre y fallan igual.
La regla keep mínima: mantén los nombres de propiedad pública de los tipos que aparecen en un mapa, tanto en el lado de origen como en el de destino. Mantén solo los nombres; los campos de respaldo, los constructores y cualquier lógica de esos tipos se siguen renombrando y siguen recibiendo protección completa. La alternativa, si quieres renombrar también esos tipos, es hacer explícito cada mapeo, porque una lambda de acceso a miembro es un árbol de expresión que el compilador resuelve a un token:
cfg.CreateMap<Order, OrderDto>()
.ForMember(dest => dest.Amount, opt => opt.MapFrom(src => src.Amount)); // token-based, survives
Eso es robusto pero verboso para un DTO ancho. Mantener los nombres de propiedad es más simple, y esos nombres de todos modos suelen tener que mantenerse para la serialización.
Cómo confirmarlo: llama a configuration.AssertConfigurationIsValid() como prueba contra el ensamblado protegido. Lanza AutoMapperConfigurationException listando cada miembro de destino sin mapear, lo que convierte un 0.00 silencioso en una prueba fallida. Respáldalo con un ida y vuelta: mapea un origen poblado y afirma que los campos del destino son iguales.
MediatR: resuelto por forma, así que normalmente funciona sin más
MediatR es el tranquilizador. RegisterServicesFromAssembly escanea cada tipo que implementa los genéricos abiertos IRequestHandler<,> e INotificationHandler<>, y el despacho empareja una petición con su handler mediante un tipo genérico cerrado —IRequestHandler<CreateOrder, Guid>—. Ambos son identidades por token, no cadenas. Es la misma distinción trazada en ofuscar la inyección de dependencias por escaneo de ensamblados: un escaneo que coincide por forma sobrevive, porque el renombrado reescribe la relación de implementación de forma consistente en el handler y en la interfaz.
services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
public sealed class CreateOrderHandler : IRequestHandler<CreateOrder, Guid>
{
public Task<Guid> Handle(CreateOrder request, CancellationToken ct) { /* ... */ }
}
Nebula es libre de renombrar CreateOrderHandler, CreateOrder y el cuerpo de Handle: el escaneo sigue encontrando el handler por su implementación de IRequestHandler<,>, y Send sigue enrutando hacia él por el genérico cerrado. Los comportamientos de pipeline registrados como IPipelineBehavior<,> se encuentran igual. Así que los handlers no necesitan nada en la keep-list, y sus cuerpos —a menudo donde vive tu lógica de dominio real— deberían recibir el tratamiento completo, virtualización incluida.
Qué sí se rompe: las vías de escape que no van por forma. Si reemplazaste el escaneo por convención con un registro hecho a mano que filtra los handlers por t.Name.EndsWith("Handler"), eso es una comparación de cadenas y se vacía exactamente como en la entrada de DI. Y cualquier Type.GetType("MyApp.Handlers.CreateOrderHandler") —que a veces aparece en despachadores personalizados o en el cableado de sagas— resuelve una cadena congelada que ya no nombra nada y devuelve null.
La regla keep mínima: ninguna para el MediatR idiomático. Solo si usas un registro personalizado basado en nombre (cámbialo a AssignableTo la interfaz del handler) o una cadena Type.GetType (mantén el nombre de ese único tipo, o mejor, resuélvelo mediante IMediator y el tipo de la petición).
Cómo confirmarlo: una prueba de integración que haga Send de una instancia de cada tipo de petición y Publish de cada notificación contra la build protegida. Un handler que el escaneo no logró registrar lanza una excepción en Send; afirma un resultado no nulo para cada uno.
FluentValidation: el descubrimiento sobrevive, el nombre informado no
FluentValidation se parte por la mitad. El descubrimiento va por forma y es seguro; los nombres de propiedad que informa se derivan de cadenas y no lo son.
AddValidatorsFromAssembly escanea cada implementación de IValidator<T> —una coincidencia por forma, así que sobrevive al renombrado exactamente como el escaneo de MediatR—, y tus subclases de AbstractValidator<T> pueden renombrarse todas libremente. El problema es lo que una regla sabe de la propiedad que protege. RuleFor(x => x.Email) lee el MemberInfo detrás de esa lambda cuando se construye el validador y almacena su nombre, así que tras el renombrado el nombre almacenado es 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."
}
}
Qué se rompe: ValidationFailure.PropertyName pasa a ser el nombre ofuscado, y cualquier marcador {PropertyName} en un mensaje lo muestra. Eso importa en el instante en que algo aguas abajo se indexa por ese nombre: el model-state de ASP.NET Core mapea PropertyName a claves de formulario/campo, los contratos de error de API lo exponen a los clientes, y un front end que empareja errores con inputs por nombre encuentra "a" en lugar de "email". La validación sigue ejecutándose correctamente: solo etiqueta el resultado con un nombre que nadie fuera del ensamblado reconoce.
La regla keep mínima: mantén los nombres de propiedad del modelo que afloran en los errores —los mismos nombres de DTO/modelo que AutoMapper y tu serializador ya necesitan—. O, para seguir renombrándolos, desacopla el nombre informado del miembro, que es el patrón más sólido (es el análogo en validación de fijar un contrato JSON con [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();
Cómo confirmarlo: una prueba que valide un modelo deliberadamente inválido contra la build protegida y afirme result.Errors.Single().PropertyName == "email", no como sea que lo haya renombrado el ofuscador.
La keep-list consolidada
Fíjate en que toda la columna de la derecha se reduce a una cosa: nombres de propiedad de DTOs y modelos. El conjunto que AutoMapper empareja, el que FluentValidation informa y el que tu serializador escribe son abrumadoramente los mismos tipos, así que una sola regla keep sobre tus contratos y modelos cubre las tres. Todo lo demás —handlers, validadores, profiles, internos de mapeo, tu lógica— se encuentra por forma o se llama directamente y se renombra libremente. Siguiendo la disciplina de construir una keep-list en Nebula, mantén la hoja, no la rama: members: "public" fija los nombres de propiedad y deja que los campos privados se renombren.
{
"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" }
]
}
}
El orden de primera-coincidencia-gana importa: las reglas keep estrechas van por encima del rename amplio que lo cubre todo, así que los nombres de propiedad de tus DTO se mantienen mientras Acme.Api.Handlers, Acme.Api.Validators y todo lo demás caen al rename. No hay, deliberadamente, ningún keep para handlers ni validadores: mantenerlos dejaría el mapa de tu aplicación en texto claro para nada, ya que ambos se resuelven por forma. Para un único handler reflejado que no puedas refactorizar, prefiere un atributo sobre ese tipo exacto antes que ensanchar la configuración:
[Obfuscation(Feature = "renaming", Exclude = true)] // resolved via Type.GetType(string)
public sealed class LegacySagaHandler : IRequestHandler<ResumeSaga, Unit> { /* ... */ }
Reserva tus transformaciones más pesadas para los cuerpos de los handlers. La virtualización de código y el cifrado de método completo —ambas funciones Pro en Nebula— pertenecen a los métodos que cargan tu lógica de dominio real, no a los DTOs, que solo necesitan que se mantengan sus nombres.
Demuéstralo, no lo asumas
Ninguno de estos fallos aparece en tiempo de compilación, y dos de los tres son silenciosos en tiempo de ejecución, así que la verificación no es opcional. Ejecuta las tres comprobaciones de arriba como pruebas de integración contra la build protegida: AssertConfigurationIsValid() para AutoMapper, un Send/Publish de cada mensaje para MediatR, y una afirmación de PropertyName con un modelo inválido para FluentValidation. Luego abre el ensamblado protegido en Glass.NET y confirma las dos mitades de la frontera a la vez: tus handlers y validadores se leen como a, b, c y tus internos de mapeo desaparecieron, mientras que los nombres de propiedad de los DTO por los que resuelven tus tres bibliotecas siguen intactos. El procedimiento completo del decompilador está en ¿de verdad funcionó tu ofuscación?. Cuando las pruebas están en verde contra la build ofuscada y el decompilador muestra los nombres mantenidos exactamente donde una cadena los resuelve y en ningún otro sitio, tienes una protección que no te cuesta un pipeline de peticiones roto.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.