[agent] Found by the scheduled NuGet / dotnet bug-hunt routine (ledger #320).
Summary
When a project moves its NuGet global packages folder, either with <add key="globalPackagesFolder" value=".nuget/packages" /> in nuget.config or with <RestorePackagesPath> in Directory.Build.props / the csproj, agent-mode apply patches the wrong copy:
- Default cache is warm (any dev machine or CI image that has restored the same package id/version for another project):
apply patches the user-wide ~/.nuget/packages/<id>/<ver>/ (%USERPROFILE%\.nuget\packages on Windows) and reports 1 of 1 targeted patch applied, exit 0. The project's own folder, the only one dotnet build resolves from (obj/project.assets.json → packageFolders), stays unpatched. socket-patch vex then emits not_affected / inline_mitigations_already_exist for the unpatched product. As a side effect, every other project on the machine that uses the default folder gets its bytes modified.
- Default cache is cold and the project sits two directory levels below the scan root (the common
App.sln + src/App/App.csproj layout): apply fails with "matched no installed package" (exit 1), because discover_paths_from_assets reads obj/project.assets.json only at the root and one level down.
Root cause (suspected)
NuGetCrawler::get_nuget_package_paths (crates/socket-patch-core/src/crawlers/nuget_crawler.rs:74-86) lists source roots in the order <cwd>/packages, then nuget_home() (default ~/.nuget/packages, only NUGET_PACKAGES honoured), then the packageFolders read from obj/project.assets.json. The dispatcher keeps only the first match per PURL (merge_first_wins, crates/socket-patch-cli/src/ecosystem_dispatch.rs:147, deliberate for NuGet). So whenever the default folder also holds the package, the project's real, configured folder is never patched. Separately, discover_paths_from_assets (nuget_crawler.rs:483) searches only one level deep, so a src/<Project>/obj/project.assets.json is never seen. Neither globalPackagesFolder nor RestorePackagesPath is read anywhere.
Impact
A silent false "patched", plus a false VEX not_affected attestation, for any repo that pins a repo-local packages folder. That's a common hermetic-CI pattern. Agent mode also mutates a shared user-wide cache the project doesn't use.
Repro (Linux, dotnet SDK 8.0.131, main f6b7fb9)
SP=/path/to/socket-patch
# 1. warm the default cache with the same package, as any other project would
mkdir warm && cd warm && cat > W.csproj <<'X'
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>
X
dotnet restore && cd ..
# 2. project that relocates its packages folder
mkdir -p proj/src/App && cd proj
cat > nuget.config <<'X'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config><add key="globalPackagesFolder" value=".nuget/packages" /></config>
</configuration>
X
cat > src/App/App.csproj <<'X'
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0</TargetFramework></PropertyGroup><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="13.0.3" /></ItemGroup></Project>
X
echo 'System.Console.WriteLine(1);' > src/App/Program.cs
dotnet new sln -n App && dotnet sln add src/App/App.csproj && dotnet restore
# 3. stage a manifest patching LICENSE.md (sha256 git-blob hashes; blob in .socket/blobs/<afterHash>),
# plus "setup": {"manual": ["nuget"]} so vex runs
$SP apply --offline # -> "1 of 1 targeted patch applied", exit 0
grep -c MARKER .nuget/packages/newtonsoft.json/13.0.3/LICENSE.md # 0 (the copy the build uses)
grep -c MARKER ~/.nuget/packages/newtonsoft.json/13.0.3/LICENSE.md # 1 (wrong copy)
$SP vex --offline --product pkg:nuget/[email protected] # status not_affected
RestorePackagesPath behaves the same: Directory.Build.props with <RestorePackagesPath>$(MSBuildThisFileDirectory)pkgs</RestorePackagesPath> and a root-level csproj. NuGetPackageRoot = <repo>/pkgs, yet apply patches ~/.nuget/packages.
With a cold default cache and the src/App layout, apply exits 1 with The targeted manifest patch matched no installed package. With the project one level down (App/App.csproj) and a cold default cache, it correctly patches .nuget/packages.
Expected vs actual
- Expected: agent
apply patches the copy NuGet actually resolves for the project, which is the packageFolders recorded in obj/project.assets.json / the configured globalPackagesFolder / RestorePackagesPath. Failing that, it refuses. README's apply section and the VEX contract (a statement only for a patch that is actually applied) both require this. docs/ecosystems.md documents NuGet agent mode as in-place patching of the installed package, with no caveat about relocated package folders.
- Actual: it patches an unrelated user-wide cache, reports success, and VEX attests
not_affected.
| OS |
SDK |
root-level project |
src/App project |
| Linux (local) |
8.0.131 |
wrong copy patched, rc 0 |
wrong copy patched, rc 0 (cold default cache: not found, rc 1) |
| Linux |
6.0.428 |
wrong copy patched, VEX not_affected |
same |
| Linux |
9.0.318 |
wrong copy patched, VEX not_affected |
same |
| Linux |
10.0.401 |
wrong copy patched, VEX not_affected |
same |
| macOS |
8.0.425 |
wrong copy patched, VEX not_affected |
same |
| macOS |
9.0.318 |
wrong copy patched, VEX not_affected |
same |
| Windows |
8.0.425 |
wrong copy patched, VEX not_affected |
same |
| Windows |
9.0.318 |
wrong copy patched, VEX not_affected |
same |
Control: an App/ project one level deep with a cold default cache → project copy patched (pass).
First bad version
Not a regression: v4.0.0 (GitHub release binary) behaves identically.
Related but distinct: #338 (the same "wrong copy, success, VEX not_affected" shape for cargo vendor dirs), #352 (the vendored/hosted warm-cache shadowing).
[agent] Found by the scheduled NuGet / dotnet bug-hunt routine (ledger #320).
Summary
When a project moves its NuGet global packages folder, either with
<add key="globalPackagesFolder" value=".nuget/packages" />innuget.configor with<RestorePackagesPath>inDirectory.Build.props/ the csproj, agent-modeapplypatches the wrong copy:applypatches the user-wide~/.nuget/packages/<id>/<ver>/(%USERPROFILE%\.nuget\packageson Windows) and reports1 of 1 targeted patch applied, exit 0. The project's own folder, the only onedotnet buildresolves from (obj/project.assets.json→packageFolders), stays unpatched.socket-patch vexthen emitsnot_affected/inline_mitigations_already_existfor the unpatched product. As a side effect, every other project on the machine that uses the default folder gets its bytes modified.App.sln+src/App/App.csprojlayout):applyfails with "matched no installed package" (exit 1), becausediscover_paths_from_assetsreadsobj/project.assets.jsononly at the root and one level down.Root cause (suspected)
NuGetCrawler::get_nuget_package_paths(crates/socket-patch-core/src/crawlers/nuget_crawler.rs:74-86) lists source roots in the order<cwd>/packages, thennuget_home()(default~/.nuget/packages, onlyNUGET_PACKAGEShonoured), then thepackageFoldersread fromobj/project.assets.json. The dispatcher keeps only the first match per PURL (merge_first_wins,crates/socket-patch-cli/src/ecosystem_dispatch.rs:147, deliberate for NuGet). So whenever the default folder also holds the package, the project's real, configured folder is never patched. Separately,discover_paths_from_assets(nuget_crawler.rs:483) searches only one level deep, so asrc/<Project>/obj/project.assets.jsonis never seen. NeitherglobalPackagesFoldernorRestorePackagesPathis read anywhere.Impact
A silent false "patched", plus a false VEX
not_affectedattestation, for any repo that pins a repo-local packages folder. That's a common hermetic-CI pattern. Agent mode also mutates a shared user-wide cache the project doesn't use.Repro (Linux, dotnet SDK 8.0.131, main
f6b7fb9)RestorePackagesPathbehaves the same:Directory.Build.propswith<RestorePackagesPath>$(MSBuildThisFileDirectory)pkgs</RestorePackagesPath>and a root-level csproj.NuGetPackageRoot=<repo>/pkgs, yetapplypatches~/.nuget/packages.With a cold default cache and the
src/Applayout,applyexits 1 withThe targeted manifest patch matched no installed package. With the project one level down (App/App.csproj) and a cold default cache, it correctly patches.nuget/packages.Expected vs actual
applypatches the copy NuGet actually resolves for the project, which is thepackageFoldersrecorded inobj/project.assets.json/ the configuredglobalPackagesFolder/RestorePackagesPath. Failing that, it refuses. README'sapplysection and the VEX contract (a statement only for a patch that is actually applied) both require this. docs/ecosystems.md documents NuGet agent mode as in-place patching of the installed package, with no caveat about relocated package folders.not_affected.OS × SDK matrix (probe run https://github.com/SocketDev/socket-patch/actions/runs/36791969674)
src/AppprojectControl: an
App/project one level deep with a cold default cache → project copy patched (pass).First bad version
Not a regression: v4.0.0 (GitHub release binary) behaves identically.
Related but distinct: #338 (the same "wrong copy, success, VEX not_affected" shape for cargo
vendordirs), #352 (the vendored/hosted warm-cache shadowing).