Central Package Management e Package Source Mapping
Objective
Duas features do NuGet transformam o tratamento de dependências de um hábito de
cada projeto em uma política do repositório. O Central Package Management (CPM)
move todas as versões para um único Directory.Packages.props, então dez
projetos não conseguem divergir em dez versões da mesma biblioteca. O Package
Source Mapping (PSM) declara, no nuget.config, qual feed pode fornecer quais
IDs de pacote, o que fecha a porta para ataques de dependency confusion, em que
um pacote público com o mesmo nome do seu pacote interno ganha o restore. Este
concept mostra como as duas funcionam, os erros exatos que você vai encontrar e
onde cada uma deixa de proteger.
Use Cases
- Uma solution com muitos projetos em que
Newtonsoft.JsonouSerilogsempre acaba em versões diferentes. - Corrigir um pacote transitivo vulnerável uma única vez para o repositório todo.
- Adicionar um analyzer ou pacote de versionamento, só de build, a todos os projetos.
- Usar um feed privado para pacotes
Contoso.*ao lado do nuget.org sem deixar o nuget.org responder por eles. - Auditar de quais feeds cada pacote da solution pode vir.
Deep Dive
Central Package Management
Crie o Directory.Packages.props na raiz do repositório, ligue a feature e
declare as versões como itens PackageVersion. Os projetos passam a usar
PackageReference sem Version:
plaintext<!-- Directory.Packages.props --> <Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> </ItemGroup> </Project> <!-- app.csproj --> <PackageReference Include="Newtonsoft.Json" />
O MSBuild importa só o Directory.Packages.props mais próximo do projeto, então
um arquivo aninhado precisa importar o pai explicitamente com
GetPathOfFileAbove. Os dois primeiros erros que todo mundo comete são pegos no
restore. Escrever a versão no projeto:
texterror NU1008: The following PackageReference items cannot define a value for Version: Newtonsoft.Json. Projects using Central Package Management must define a Version value on a PackageVersion item.
E referenciar um pacote que ninguém declarou:
texterror NU1010: The following PackageReference items do not define a corresponding PackageVersion item: Serilog.
Os dois vieram de um restore real num repositório descartável com o SDK 10.0.100.
A saída de emergência para uma exceção deliberada é o VersionOverride na
referência, que vence a versão central:
plaintext<PackageReference Include="Newtonsoft.Json" VersionOverride="12.0.3" />
Meu restore com esse override funcionou, e ainda imprimiu o warning NU1903 de
audit para a 12.0.3, então overrides não escapam do NuGetAudit. Definir
CentralPackageVersionOverrideEnabled como false transforma override em erro.
Itens GlobalPackageReference no mesmo arquivo adicionam um pacote a todos os
projetos como dependência de desenvolvimento (assets só de build,
PrivateAssets=All), o que serve para ferramentas de versionamento e analyzers.
Transitive pinning
A regra de restore do concept anterior diz que a referência mais próxima vence.
O CPM pode promover um pacote transitivo a top-level por você: ligue
CentralPackageTransitivePinningEnabled e declare um PackageVersion para o
pacote transitivo, sem que nenhum projeto o referencie. No meu teste, o
Microsoft.Extensions.Logging 9.0.0 normalmente traz o Primitives 9.0.0:
plaintext<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.0" /> <PackageVersion Include="Microsoft.Extensions.Primitives" Version="10.0.0" />
text$ dotnet package list --project app/app.csproj --include-transitive > Microsoft.Extensions.Logging 9.0.0 9.0.0 > Microsoft.Extensions.Primitives 10.0.0 <- fixado, transitivo
O pinning só sobe: fixar abaixo do que uma dependência pede gera NU1109. E se
o projeto for empacotado, os pacotes fixados viram dependências explícitas no
.nuspec, então o autor de uma biblioteca pode acabar publicando dependências
que ninguém esperava. Esse é o principal motivo para ter cuidado com essa chave
em bibliotecas.
Package Source Mapping
Por padrão o NuGet procura em todas as fontes configuradas, e quando o mesmo ID
existe em dois feeds o vencedor não é algo que você escolheu. Dependency
confusion explora exatamente isso: o atacante publica o nome do seu pacote
interno com uma versão maior no feed público. Reproduzi com dois feeds de pasta
local (um fazendo o papel do feed interno, outro do público, então nada público
foi envolvido). Contoso.Internal 1.0.0 no primeiro, 9.9.9 no segundo, e uma
referência flutuante:
plaintext<PackageReference Include="Contoso.Internal" Version="*" />
text> Contoso.Internal * 9.9.9 <- veio do feed "público"
Um mapeamento no nuget.config fixa a fonte por ID:
plaintext<packageSourceMapping> <packageSource key="internal"> <package pattern="Contoso.*" /> </packageSource> <packageSource key="public-sim"> <package pattern="*" /> </packageSource> </packageSourceMapping>
text> Contoso.Internal * 1.0.0
Os padrões são um ID exato ou um prefixo terminado em *. Quando vários casam,
o ID exato vence o prefixo, o prefixo mais longo vence o mais curto, e * é o
mais fraco, o que o torna uma fonte padrão conveniente. O ponto central: assim
que existe qualquer mapeamento, todo pacote, transitivos inclusive, precisa
casar com um padrão, senão o restore se recusa a procurar em qualquer lugar:
texterror NU1100: Unable to resolve 'Newtonsoft.Json (>= 13.0.3)' for 'net10.0'. PackageSourceMapping is enabled, the following source(s) were not considered: internal, nuget.org.
Onde a proteção acaba
O mapeamento é aplicado quando o NuGet baixa o pacote. Se o pacote já está na
global packages folder, não há consulta a fonte nenhuma e o mapeamento é
ignorado. Meu primeiro restore do projeto com Newtonsoft.Json sem mapeamento
funcionou porque aquela versão já estava na minha pasta de pacotes; o NU1100
acima só apareceu depois que apontei NUGET_PACKAGES para uma pasta vazia. A
documentação do NuGet recomenda uma global packages folder local ao repositório
quando você quer a garantia completa. Note também que, segundo a documentação, o
mapeamento ainda não filtra comandos de metadados como
dotnet list package --outdated, que continuam consultando todas as fontes
configuradas.
Na minha execução, alterar o nuget.config e restaurar de novo disse "up to
date" e manteve a resolução antiga até eu usar restore --force, então force um
restore depois de editar mapeamentos.
CPM e mapeamento juntos
Com CPM, o NuGet emite um warning (NU1507) quando há mais de uma fonte de
pacotes configurada e nenhum mapeamento definido, que é o empurrão da
documentação para adotar os dois juntos. O texto do warning está documentado; eu
não consegui reproduzi-lo com uma fonte de pasta local, então trate o gatilho
exato como a documentação descreve.
Trade-offs
- Uma versão central significa que uma atualização move todo mundo. Subir a
versão para um projeto sobe para todos, e uma release ruim quebra todos os
consumidores de uma vez.plaintext
<PackageReference Include="Newtonsoft.Json" VersionOverride="12.0.3" /> <!-- a saída de emergência --> VersionOverridereconstrói em silêncio a divergência que o CPM removeu. Revise os overrides, ou desligue-os comCentralPackageVersionOverrideEnabled=false.- Transitive pinning faz bibliotecas carregarem dependências extras. O
pacote fixado é escrito no
.nuspecquando você roda o pack. - O source mapping dá manutenção. Todo prefixo novo de pacote, transitivos
inclusive, precisa de um padrão, senão alguém esbarra num
NU1100numa atualização de rotina. Um default*no feed público troca um pouco de rigor por menos surpresas. - Um cache aquecido de pacotes contorna o mapeamento. O CI deve restaurar
para uma pasta de pacotes limpa e local ao repositório, para realmente testar
a configuração.text
NUGET_PACKAGES=./.nuget/packages dotnet restore - Mapear não substitui reservar o seu prefixo. Se o seu prefixo interno não
está reservado no feed público, alguém ainda pode publicá-lo lá, e um default
*apontando para esse feed confiaria nele.