Skip to content

Commit 0d01c8e

Browse files
mikolalysenkoclaude
andcommitted
Report NuGet patches shadowed by a warm cache
A NuGet patch keeps the upstream id and version, and NuGet restores a package already extracted into its global packages folder without asking any source. On the machine that ran socket-patch (and any CI runner with a restored cache) the upstream copy shadowed the Socket source: restore silently kept the unpatched bytes without a lock, or failed NU1403 with one, while scan reported success, the no-lockfile warning said the feed "forces" the patched copy, and the in-run VEX attested the package not_affected. Hosted and vendored runs now compare the global packages folder's copy (.nupkg.metadata contentHash) with the patched package and warn (redirect_ / vendor_nuget_stale_global_package) with the directory and a working remedy: delete it or clear the folder, then dotnet restore, and drop it from CI caches. Like the gem stale-install guard the probe is read-only (the folder is shared machine-wide), re-fires until the copy is gone, and a flagged hosted purl is withheld from the same-run VEX attestation. Fixes #352. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent cf8864f commit 0d01c8e

5 files changed

Lines changed: 283 additions & 28 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1366,6 +1366,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
13661366
| `redirect_unattributable` | `redirect.skipped[].reason` | scan/get `--mode hosted`: the rewriters would pin the patch, but lockfile discovery over the result reads that pin as contested (another lock or requirements file resolves the same version elsewhere, or the pin is not one the package manager consumes), so `vex`, `rollback`, `remove` and `vendor` would refuse it. The candidate is left out of the rewrite, so nothing is written for it; the detail carries discovery's findings. Exit code unchanged. |
13671367
| `redirect_pin_lockless` | `redirect.warnings[]` (warning) | scan/get `--mode hosted` (nuget, cargo): the pin was written without a lockfile that records its version, so `vex` cannot attest it and `rollback` / `remove` / `vendor` refuse it as unattributable. The detail names the lockfile to create (`dotnet restore --use-lock-file`, `cargo generate-lockfile`) before re-running the hosted scan. |
13681368
| `redirect_pypi_stale_install` | `redirect.warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. |
1369+
| `redirect_nuget_stale_global_package` / `vendor_nuget_stale_global_package` | `redirect.warnings[]` / the vendor result's `warnings` (warning) | scan `--mode hosted` / `vendor` / `scan --mode vendored` (nuget, v5.0 #352): NuGet's global packages folder (`NUGET_PACKAGES`, else `~/.nuget/packages`) already holds the patched package extracted from other bytes (its `.nupkg.metadata` `contentHash` is not the patched one). A patch keeps the upstream id and version and NuGet restores a package already in that folder without asking any source, so `dotnet restore` would keep the upstream bytes (silently without a lock, NU1403 against the re-pinned lock with one). The detail names the directory and the remedy: delete it (or `dotnet nuget locals global-packages --clear`), then `dotnet restore`; CI caches of that folder must drop it too. Read-only like the gem guard (the folder is shared machine-wide), re-fired on every re-run until the copy is gone, skipped on `--dry-run`; a hosted purl it flags is excluded from the same run's `--vex` `assume_applied` and reported stale. A dir without `.nupkg.metadata` (a legacy `packages/` folder) is never judged. |
13691370
| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed archive in bundler's cache dir — `vendor/cache` unless `cache_path` moves it) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. |
13701371
| `redirect_gem_version_not_locked` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): the crawled gem version is installed on the machine but no `GEM` section of the project's lock resolves it (another project's copy in the shared gem home). The gem is skipped and the Gemfile and lock stay byte-identical. |
13711372
| `redirect_pipenv_refused` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). |

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::commands::vex::generate_vex_from_manifest_path;
2121

2222
use super::{discover_selected, ScanArgs};
2323

24+
mod nuget;
2425
mod python;
2526
mod takeover;
2627

@@ -1380,6 +1381,15 @@ pub(crate) async fn run_redirect_selected(
13801381
.await
13811382
};
13821383

1384+
// NuGet global packages folder probe (#352): a copy extracted from the
1385+
// upstream bytes shadows the Socket source. Read-only, like the gem
1386+
// probe; skipped on --dry-run for the same reason.
1387+
let nuget_stale = if common.dry_run {
1388+
StaleInstallOutcome::default()
1389+
} else {
1390+
nuget::stale_install_warnings(common, &confirmed, &done.overrides).await
1391+
};
1392+
13831393
// vlt warm-tree heal: stale installed copies of the Socket-owned nodes
13841394
// are invalidated (classified only on a dry run or
13851395
// with --no-vlt-install-cleanup), and every confirmed vlt purl whose
@@ -1539,6 +1549,7 @@ pub(crate) async fn run_redirect_selected(
15391549
.map(|(purl, _)| purl.clone())
15401550
.filter(|purl| {
15411551
!gem_stale.stale_purls.contains(purl)
1552+
&& !nuget_stale.stale_purls.contains(purl)
15421553
&& !python_stale.stale_purls.contains(purl)
15431554
&& !vlt_stale.stale_purls.contains(purl)
15441555
})
@@ -1549,6 +1560,7 @@ pub(crate) async fn run_redirect_selected(
15491560
params.known_stale = python_stale
15501561
.stale_purls
15511562
.iter()
1563+
.chain(&nuget_stale.stale_purls)
15521564
.chain(&vlt_stale.stale_purls)
15531565
.cloned()
15541566
.collect();
@@ -1578,6 +1590,7 @@ pub(crate) async fn run_redirect_selected(
15781590
let mut warnings: Vec<serde_json::Value> =
15791591
socket_patch_core::hosted::render::rewrite_warnings_json(&engine_warnings);
15801592
warnings.extend(gem_stale.warnings.iter().cloned());
1593+
warnings.extend(nuget_stale.warnings.iter().cloned());
15811594
warnings.extend(python_stale.warnings.iter().cloned());
15821595
warnings.extend(vlt_stale.warnings.iter().cloned());
15831596
warnings.extend(takeover_pre_warnings.iter().cloned());
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//! Read-only check of NuGet's global packages folder for hosted redirects
2+
//! (#352).
3+
//!
4+
//! A hosted NuGet patch keeps the upstream id and version, and NuGet
5+
//! restores a package already extracted into its global packages folder
6+
//! (`NUGET_PACKAGES`, else `~/.nuget/packages`) without asking any source.
7+
//! A copy extracted from the upstream bytes therefore shadows the Socket
8+
//! source: without a lock the restore silently keeps the unpatched bytes,
9+
//! with one it fails NU1403. Like the gem stale-install guard, nothing is
10+
//! deleted: the remedy is prescribed, and the purl is withheld from the
11+
//! same-run VEX attestation.
12+
13+
use socket_patch_core::crawlers::NuGetCrawler;
14+
use socket_patch_core::patch::redirect::DepOverride;
15+
use socket_patch_core::utils::purl::strip_purl_qualifiers;
16+
use socket_patch_core::vendor::nuget_feed::{extracted_content_hash, stale_global_package_detail};
17+
18+
use super::StaleInstallOutcome;
19+
20+
/// Warn for every confirmed NuGet redirect whose package the global
21+
/// packages folder already holds extracted from bytes other than the
22+
/// patched ones. A dir without `.nupkg.metadata` (a legacy `packages/`
23+
/// folder) is never judged: there is no positive evidence.
24+
pub(super) async fn stale_install_warnings(
25+
common: &crate::args::GlobalArgs,
26+
confirmed: &[(String, String)],
27+
overrides: &[DepOverride],
28+
) -> StaleInstallOutcome {
29+
let mut out = StaleInstallOutcome::default();
30+
// (purl, the patched package's NuGet content hash)
31+
let candidates: Vec<(&String, String)> = confirmed
32+
.iter()
33+
.filter(|(purl, _)| purl.starts_with("pkg:nuget/"))
34+
.filter_map(|(purl, uuid)| {
35+
let sha512 = overrides
36+
.iter()
37+
.find(|o| o.ecosystem == "nuget" && &o.patch_uuid == uuid)?
38+
.integrity
39+
.sha512
40+
.as_deref()?;
41+
Some((
42+
purl,
43+
sha512.strip_prefix("sha512-").unwrap_or(sha512).to_string(),
44+
))
45+
})
46+
.collect();
47+
if candidates.is_empty() {
48+
return out;
49+
}
50+
let crawler = NuGetCrawler::new();
51+
let Ok(paths) = crawler
52+
.get_nuget_package_paths(&common.crawler_options())
53+
.await
54+
else {
55+
return out;
56+
};
57+
let purls: Vec<String> = candidates
58+
.iter()
59+
.map(|(purl, _)| strip_purl_qualifiers(purl).to_string())
60+
.collect();
61+
for path in &paths {
62+
let Ok(found) = crawler.find_by_purls(path, &purls).await else {
63+
continue;
64+
};
65+
for (purl, patched) in &candidates {
66+
let Some(pkg) = found.get(strip_purl_qualifiers(purl)) else {
67+
continue;
68+
};
69+
let Some(cached) = extracted_content_hash(&pkg.path).await else {
70+
continue;
71+
};
72+
if cached == *patched || out.stale_purls.contains(*purl) {
73+
continue;
74+
}
75+
out.warnings.push(serde_json::json!({
76+
"code": "redirect_nuget_stale_global_package",
77+
"detail": stale_global_package_detail(
78+
&pkg.name,
79+
&pkg.version,
80+
&pkg.path,
81+
"the Socket source",
82+
),
83+
}));
84+
out.stale_purls.insert((*purl).clone());
85+
}
86+
}
87+
out
88+
}

‎crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs‎

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -773,37 +773,60 @@ fn nuget_hosted_dotnet_restore_then_manifestless_vex() {
773773
let backend = Backend::start(HOSTED_UUID, &pristine, &patched, Some(&nupkg));
774774
let uri = backend.uri();
775775

776-
// `scan --mode hosted --vex`: the real rewriter + the in-run VEX.
777-
let embedded = fixture.join("scan.vex.json");
778-
let (code, env, stderr) = socket_patch(
779-
&fixture,
780-
&store_fx,
781-
&[
782-
"scan",
783-
"--mode",
784-
"hosted",
785-
"--json",
786-
"--yes",
787-
"--api-url",
788-
&uri,
789-
"--org",
790-
ORG,
791-
"--api-token",
792-
"fake-token",
793-
"--patch-server-url",
794-
&uri,
795-
"--vex",
796-
embedded.to_str().unwrap(),
797-
"--vex-product",
798-
PRODUCT,
799-
],
800-
);
776+
// `scan --mode hosted`: the real rewriter. The fixture restore left the
777+
// UPSTREAM copy in the global packages folder, which NuGet would restore
778+
// instead of asking the Socket source (#352): the run says so, names
779+
// the directory to delete, and keeps saying so on re-runs until it is.
780+
let hosted_args = [
781+
"scan",
782+
"--mode",
783+
"hosted",
784+
"--json",
785+
"--yes",
786+
"--api-url",
787+
&uri,
788+
"--org",
789+
ORG,
790+
"--api-token",
791+
"fake-token",
792+
"--patch-server-url",
793+
&uri,
794+
];
795+
let (code, env, stderr) = socket_patch(&fixture, &store_fx, &hosted_args);
801796
assert_eq!(
802797
code,
803798
Some(0),
804799
"SDK {sdk} scan --mode hosted: {env:#}\n{stderr}"
805800
);
806801
assert_eq!(env["redirect"]["redirected"], 1, "{env:#}");
802+
let stale_dir = pkg_dir(&store_fx);
803+
let warned = env.to_string();
804+
assert!(
805+
warned.contains("redirect_nuget_stale_global_package")
806+
&& warned.contains(&stale_dir.display().to_string()),
807+
"SDK {sdk}: the warm global packages folder is reported: {env:#}"
808+
);
809+
// The prescribed remedy, then the idempotent re-run with the in-run VEX.
810+
std::fs::remove_dir_all(&stale_dir).unwrap();
811+
let embedded = fixture.join("scan.vex.json");
812+
let mut vex_args = hosted_args.to_vec();
813+
vex_args.extend([
814+
"--vex",
815+
embedded.to_str().unwrap(),
816+
"--vex-product",
817+
PRODUCT,
818+
]);
819+
let (code, env, stderr) = socket_patch(&fixture, &store_fx, &vex_args);
820+
assert_eq!(
821+
code,
822+
Some(0),
823+
"SDK {sdk} scan --mode hosted --vex: {env:#}\n{stderr}"
824+
);
825+
assert!(
826+
!env.to_string()
827+
.contains("redirect_nuget_stale_global_package"),
828+
"SDK {sdk}: nothing stale once the copy is gone: {env:#}"
829+
);
807830
let doc: Value = serde_json::from_slice(&std::fs::read(&embedded).unwrap()).unwrap();
808831
assert_attested(&doc, PURL, HOSTED_UUID, Marker::Redirected, &vulns());
809832
let config = std::fs::read_to_string(fixture.join("nuget.config")).unwrap();
@@ -916,6 +939,13 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() {
916939
Some(0),
917940
"SDK {sdk} scan --mode vendored: {env:#}\n{stderr}"
918941
);
942+
// The fixture restore's UPSTREAM copy in the global packages folder
943+
// would shadow the vendored feed on this machine (#352): reported.
944+
assert!(
945+
env.to_string()
946+
.contains("vendor_nuget_stale_global_package"),
947+
"SDK {sdk}: the warm global packages folder is reported: {env:#}"
948+
);
919949
let doc: Value = serde_json::from_slice(&std::fs::read(&embedded).unwrap()).unwrap();
920950
assert_attested(&doc, PURL, VENDORED_UUID, Marker::Vendored, &vulns());
921951
let artifact = fixture.join(format!(".socket/vendor/nuget/{VENDORED_UUID}/{NUPKG_NAME}"));

0 commit comments

Comments
 (0)