Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9263005
Start refactor for #594
claude Oct 9, 2026
3f929fd
Wire vendored nuget.config via formats::nuget
claude Oct 9, 2026
5fa2be6
Merge remote-tracking branch 'origin/arch-refactor/594-vendored-nuget…
mikolalysenko Oct 9, 2026
1fec3bb
Merge remote-tracking branch 'origin/arch-refactor/594-vendored-nuget…
mikolalysenko Oct 9, 2026
b586834
Merge remote-tracking branch 'origin/arch-refactor/594-vendored-nuget…
mikolalysenko Oct 9, 2026
f72fc26
Start NuGet fix: nuget-lock-reader
mikolalysenko Oct 9, 2026
84ef4db
Start NuGet fix: nuget-member-locks
mikolalysenko Oct 9, 2026
5dffe23
Start NuGet fix: nuget-gpf-shadow
mikolalysenko Oct 9, 2026
cbd7bcb
Pin only the patched NuGet version; read BOM locks
mikolalysenko Oct 9, 2026
e514fc9
Merge remote-tracking branch 'origin/agent/v5-nuget-lock-reader' into…
mikolalysenko Oct 9, 2026
0168763
Pin every NuGet lock the root config governs
mikolalysenko Oct 9, 2026
cf8864f
Merge remote-tracking branch 'origin/agent/v5-nuget-member-locks' int…
mikolalysenko Oct 9, 2026
0d01c8e
Report NuGet patches shadowed by a warm cache
mikolalysenko Oct 9, 2026
c04be5c
Merge branch 'main' into agent/v5-nuget-lock-reader
mikolalysenko Oct 9, 2026
d99550f
Carry NuGet lock walk in a synthetic key
mikolalysenko Oct 9, 2026
93557ed
Say clearing the NuGet folder empties all of it
mikolalysenko Oct 9, 2026
deeba13
Merge remote-tracking branch 'origin/agent/v5-nuget-member-locks' int…
mikolalysenko Oct 9, 2026
473881a
Walk NuGet projects through the project view
mikolalysenko Oct 9, 2026
2ad0c6e
Merge remote-tracking branch 'origin/agent/v5-nuget-member-locks' int…
mikolalysenko Oct 9, 2026
122bd8d
Merge remote-tracking branch 'origin/main' into agent/v5-nuget-lock-r…
mikolalysenko Oct 9, 2026
6a8cc0b
Merge remote-tracking branch 'origin/agent/v5-nuget-lock-reader' into…
mikolalysenko Oct 9, 2026
78a39c8
Merge remote-tracking branch 'origin/agent/v5-nuget-member-locks' int…
mikolalysenko Oct 9, 2026
0ddeb5a
Collapse the NuGet walk condition for clippy
mikolalysenko Oct 9, 2026
8f5415a
Merge remote-tracking branch 'origin/agent/v5-nuget-member-locks' int…
mikolalysenko Oct 9, 2026
111332f
Merge origin/main into agent/v5-nuget-gpf-shadow
mikolalysenko Oct 10, 2026
51915a1
Merge origin/main into agent/v5-nuget-gpf-shadow
mikolalysenko Oct 10, 2026
5afb32d
Tidy the NuGet stale-cache detail and a garbled doc line
mikolalysenko Oct 10, 2026
aa0a446
Merge main into agent/v5-nuget-gpf-shadow
mikolalysenko Oct 10, 2026
474b30e
Merge main into agent/v5-nuget-gpf-shadow
mikolalysenko Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified
| `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. |
| `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. |
| `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. |
| `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, then `dotnet restore` (`dotnet nuget locals global-packages --clear` also works but empties the whole machine-wide folder, and the detail says so); 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. |
| `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. |
| `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. |
| `redirect_gem_no_lockfile` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): the project has a `Gemfile` / `gems.rb` but no lock, so the version it resolves is unknown (#1125). Every gem is skipped and the Gemfile stays byte-identical; run `bundle lock` (or `bundle install`), commit the lock, and re-run. |
Expand Down
13 changes: 13 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use crate::commands::vex::generate_vex_from_manifest_path;

use super::{discover_selected, ScanArgs};

mod nuget;
mod python;
mod takeover;

Expand Down Expand Up @@ -1420,6 +1421,15 @@ pub(crate) async fn run_redirect_selected(
.await
};

// NuGet global packages folder probe (#352): a copy extracted from the
// upstream bytes shadows the Socket source. Read-only, like the gem
// probe; skipped on --dry-run for the same reason.
let nuget_stale = if common.dry_run {
StaleInstallOutcome::default()
} else {
nuget::stale_install_warnings(common, &confirmed, &done.overrides).await
};

// vlt warm-tree heal: stale installed copies of the Socket-owned nodes
// are invalidated (classified only on a dry run or
// with --no-vlt-install-cleanup), and every confirmed vlt purl whose
Expand Down Expand Up @@ -1579,6 +1589,7 @@ pub(crate) async fn run_redirect_selected(
.map(|(purl, _)| purl.clone())
.filter(|purl| {
!gem_stale.stale_purls.contains(purl)
&& !nuget_stale.stale_purls.contains(purl)
&& !python_stale.stale_purls.contains(purl)
&& !vlt_stale.stale_purls.contains(purl)
})
Expand All @@ -1589,6 +1600,7 @@ pub(crate) async fn run_redirect_selected(
params.known_stale = python_stale
.stale_purls
.iter()
.chain(&nuget_stale.stale_purls)
.chain(&vlt_stale.stale_purls)
.cloned()
.collect();
Expand Down Expand Up @@ -1618,6 +1630,7 @@ pub(crate) async fn run_redirect_selected(
let mut warnings: Vec<serde_json::Value> =
socket_patch_core::hosted::render::rewrite_warnings_json(&engine_warnings);
warnings.extend(gem_stale.warnings.iter().cloned());
warnings.extend(nuget_stale.warnings.iter().cloned());
warnings.extend(python_stale.warnings.iter().cloned());
warnings.extend(vlt_stale.warnings.iter().cloned());
warnings.extend(takeover_pre_warnings.iter().cloned());
Expand Down
88 changes: 88 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted/nuget.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//! Read-only check of NuGet's global packages folder for hosted redirects
//! (#352).
//!
//! A hosted NuGet patch keeps the upstream id and version, and NuGet
//! restores a package already extracted into its global packages folder
//! (`NUGET_PACKAGES`, else `~/.nuget/packages`) without asking any source.
//! A copy extracted from the upstream bytes therefore shadows the Socket
//! source: without a lock the restore silently keeps the unpatched bytes,
//! with one it fails NU1403. Like the gem stale-install guard, nothing is
//! deleted: the remedy is prescribed, and the purl is withheld from the
//! same-run VEX attestation.

use socket_patch_core::crawlers::NuGetCrawler;
use socket_patch_core::patch::redirect::DepOverride;
use socket_patch_core::utils::purl::strip_purl_qualifiers;
use socket_patch_core::vendor::nuget_feed::{extracted_content_hash, stale_global_package_detail};

use super::StaleInstallOutcome;

/// Warn for every confirmed NuGet redirect whose package the global
/// packages folder already holds extracted from bytes other than the
/// patched ones. A dir without `.nupkg.metadata` (a legacy `packages/`
/// folder) is never judged: there is no positive evidence.
pub(super) async fn stale_install_warnings(
common: &crate::args::GlobalArgs,
confirmed: &[(String, String)],
overrides: &[DepOverride],
) -> StaleInstallOutcome {
let mut out = StaleInstallOutcome::default();
// (purl, the patched package's NuGet content hash)
let candidates: Vec<(&String, String)> = confirmed
.iter()
.filter(|(purl, _)| purl.starts_with("pkg:nuget/"))
.filter_map(|(purl, uuid)| {
let sha512 = overrides
.iter()
.find(|o| o.ecosystem == "nuget" && &o.patch_uuid == uuid)?
.integrity
.sha512
.as_deref()?;
Some((
purl,
sha512.strip_prefix("sha512-").unwrap_or(sha512).to_string(),
))
})
.collect();
if candidates.is_empty() {
return out;
}
let crawler = NuGetCrawler::new();
let Ok(paths) = crawler
.get_nuget_package_paths(&common.crawler_options())
.await
else {
return out;
};
let purls: Vec<String> = candidates
.iter()
.map(|(purl, _)| strip_purl_qualifiers(purl).to_string())
.collect();
for path in &paths {
let Ok(found) = crawler.find_by_purls(path, &purls).await else {
continue;
};
for (purl, patched) in &candidates {
let Some(pkg) = found.get(strip_purl_qualifiers(purl)) else {
continue;
};
let Some(cached) = extracted_content_hash(&pkg.path).await else {
continue;
};
if cached == *patched || out.stale_purls.contains(*purl) {
continue;
}
out.warnings.push(serde_json::json!({
"code": "redirect_nuget_stale_global_package",
"detail": stale_global_package_detail(
&pkg.name,
&pkg.version,
&pkg.path,
"the Socket source",
),
}));
out.stale_purls.insert((*purl).clone());
}
}
out
}
80 changes: 55 additions & 25 deletions crates/socket-patch-cli/tests/e2e_nuget_dotnet_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -773,37 +773,60 @@ fn nuget_hosted_dotnet_restore_then_manifestless_vex() {
let backend = Backend::start(HOSTED_UUID, &pristine, &patched, Some(&nupkg));
let uri = backend.uri();

// `scan --mode hosted --vex`: the real rewriter + the in-run VEX.
let embedded = fixture.join("scan.vex.json");
let (code, env, stderr) = socket_patch(
&fixture,
&store_fx,
&[
"scan",
"--mode",
"hosted",
"--json",
"--yes",
"--api-url",
&uri,
"--org",
ORG,
"--api-token",
"fake-token",
"--patch-server-url",
&uri,
"--vex",
embedded.to_str().unwrap(),
"--vex-product",
PRODUCT,
],
);
// `scan --mode hosted`: the real rewriter. The fixture restore left the
// UPSTREAM copy in the global packages folder, which NuGet would restore
// instead of asking the Socket source (#352): the run says so, names
// the directory to delete, and keeps saying so on re-runs until it is.
let hosted_args = [
"scan",
"--mode",
"hosted",
"--json",
"--yes",
"--api-url",
&uri,
"--org",
ORG,
"--api-token",
"fake-token",
"--patch-server-url",
&uri,
];
let (code, env, stderr) = socket_patch(&fixture, &store_fx, &hosted_args);
assert_eq!(
code,
Some(0),
"SDK {sdk} scan --mode hosted: {env:#}\n{stderr}"
);
assert_eq!(env["redirect"]["redirected"], 1, "{env:#}");
let stale_dir = pkg_dir(&store_fx);
let warned = env.to_string();
assert!(
warned.contains("redirect_nuget_stale_global_package")
&& warned.contains(&stale_dir.display().to_string()),
"SDK {sdk}: the warm global packages folder is reported: {env:#}"
);
// The prescribed remedy, then the idempotent re-run with the in-run VEX.
std::fs::remove_dir_all(&stale_dir).unwrap();
let embedded = fixture.join("scan.vex.json");
let mut vex_args = hosted_args.to_vec();
vex_args.extend([
"--vex",
embedded.to_str().unwrap(),
"--vex-product",
PRODUCT,
]);
let (code, env, stderr) = socket_patch(&fixture, &store_fx, &vex_args);
assert_eq!(
code,
Some(0),
"SDK {sdk} scan --mode hosted --vex: {env:#}\n{stderr}"
);
assert!(
!env.to_string()
.contains("redirect_nuget_stale_global_package"),
"SDK {sdk}: nothing stale once the copy is gone: {env:#}"
);
let doc: Value = serde_json::from_slice(&std::fs::read(&embedded).unwrap()).unwrap();
assert_attested(&doc, PURL, HOSTED_UUID, Marker::Redirected, &vulns());
let config = std::fs::read_to_string(fixture.join("nuget.config")).unwrap();
Expand Down Expand Up @@ -916,6 +939,13 @@ fn nuget_vendored_dotnet_restore_then_manifestless_vex() {
Some(0),
"SDK {sdk} scan --mode vendored: {env:#}\n{stderr}"
);
// The fixture restore's UPSTREAM copy in the global packages folder
// would shadow the vendored feed on this machine (#352): reported.
assert!(
env.to_string()
.contains("vendor_nuget_stale_global_package"),
"SDK {sdk}: the warm global packages folder is reported: {env:#}"
);
let doc: Value = serde_json::from_slice(&std::fs::read(&embedded).unwrap()).unwrap();
assert_attested(&doc, PURL, VENDORED_UUID, Marker::Vendored, &vulns());
let artifact = fixture.join(format!(".socket/vendor/nuget/{VENDORED_UUID}/{NUPKG_NAME}"));
Expand Down
Loading
Loading