Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -1553,6 +1553,7 @@ command's integration tests check its output.
| `redirect_bun_default_trust_lost` / `vendor_bun_default_trust_lost` | `warnings[]` / vendor `warnings[]` (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the rewired package is on Bun's built-in default trusted list (better-sqlite3, esbuild, sharp, …) and the project declares no `trustedDependencies` (root `package.json`, or the copy mirrored in `bun.lock`). Bun 1.3.5 and later apply that list only to packages resolved from the npm registry, so on a hosted URL or a local tarball the package's install scripts are skipped with exit 0 (#371). The package is still rewired; the detail tells the user to add it to `trustedDependencies` (which replaces Bun's default list, so other default-trusted dependencies whose scripts matter must be listed too). Repeated on every run while the pin stays and no list is declared. Exit 0. |
| `redirect_bun_non_registry_entry_skipped` / `vendor_non_registry_entry_skipped` | `warnings[]` / vendor warnings (warning) | scan/get `--mode hosted` and vendored mode (bun, `bun.lock` and `bun.lockb`): the lock also installs the granted `name@version` from a user URL or `file:` tarball (its `<name>-<ver>.tgz` leaf names that version). Bun installs it from that spec, so the copy stays unpatched beside any rewired registry copy; it is left untouched, the in-run VEX does not assume the patch, and `vex` attests nothing for that `name@version` (#497). With no registry copy left, vendoring refuses with `vendor_lock_entry_not_rewritable`. A same-name URL / `file:` tarball whose leaf names no version, or a git, folder or `link:` copy, has no version in the lock: these codes do not fire for it, but `vex` attests no ref of that package from that lock (`patched_ref_unattributable`), since the copy may be the wired version. |
| `redirect_npm_patched_dependency_skipped` | `warnings[]` (warning) | scan/get `--mode hosted` (`package-lock.json` / `npm-shrinkwrap.json`): the project patches the granted `name@version` itself with npm ≥ 12.1's native `npm patch` (a root `package.json` `patchedDependencies` key for `name@version` or the bare name, or a `packages` entry carrying npm's `patched` record, which npm writes in a lockfileVersion 4 lock). npm applies the user's diff on top of whatever tarball the lock names and fails the install `EPATCHFAILED` when it no longer applies, so a hosted pin would break every later `npm ci` / `npm install` (or install bytes VEX can never attest); the dep is left on its registry entry in every present npm lock instead (#711). Per-dep; the detail names the key or lock entry and the remedy (fold the Socket fix into the user's patch, or drop the `patchedDependencies` entry and re-run); when an earlier hosted run already pinned the entry, the detail says so and names `socket-patch rollback <uuid>` to restore the registry entry instead of claiming it is unchanged; the in-run VEX never assumes the uuid applied, and no sibling lock confirms it. Vendored mode refuses the lockfileVersion 4 lock `vendor_lockfile_version_unsupported`, its detail naming `npm patch` / `patchedDependencies`. Exit 0. |
| `nested_project_skipped` | `warnings[]` (warning) | scan `--mode hosted` / `--mode vendored` (npm family): the crawl found a patched package only in the `node_modules` of a nested, independent npm project below the scan root (a directory with its own npm-family lock that is not a workspace member whose package manager ignores that lock), the root's lockfiles do not list it, and the root's own install holds no copy. A hosted or vendored run rewires only the root's lockfiles, so the package is left out of the run (no `packages[]` row, no event, no VEX statement) instead of failing `vendor_lock_entry_not_found` or skipping `redirect_unconfirmed`. One warning per nested project; the detail names the purls, the project directory and the PATH to pass (`socket-patch scan --mode <mode> <dir>`). Exit and status unchanged (#1534). |
| `redirect_vlt_lock_unsupported` | `warnings[]` (warning) | scan/get `--mode hosted` (vlt): `vlt-lock.json` has a `lockfileVersion` other than absent, `0` or `1` (decided on the raw JSON token), is not a JSON object, starts with a UTF-8 BOM, or its `nodes` section is not vlt's one-node-per-line layout. Nothing rewritten; also refuses a vendored → hosted takeover of a `flavor: "vlt"` entry before its revert (`skipped` (hosted event)). Exit 0. |
| `redirect_takeover_kept_vendored` | `warnings[]` (warning) | scan/get `--mode hosted`: a vendored → hosted takeover the hosted rewrite would not pin was retracted (see **Staged takeover**): the package keeps its vendored wiring, ledger entry and artifact byte-identical and stays patched. The detail names the cause code, which is also the purl's `skipped` (hosted event). Exit 0. Replaces v5.0-pre `redirect_takeover_unpatched`, which reported a package left unpatched in both modes and is no longer emitted. |
| `redirect_takeover_not_pinned` | `skipped` (hosted event) | scan/get `--mode hosted`: the skip reason of a retracted takeover when no rewriter warning names the cause. |
Expand Down
159 changes: 159 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,9 +577,168 @@ pub(super) fn severity_order(s: &str) -> u8 {
socket_patch_core::api::ranking::severity_order(Some(s))
}

/// Warning code: a hosted or vendored scan left patched npm packages that
/// only a nested, independent npm project installs (#1534).
pub(crate) const NESTED_PROJECT_SKIPPED: &str = "nested_project_skipped";

/// #1534: the patched npm packages a hosted or vendored scan of `cwd`
/// must leave to a nested project. The crawl reads every `node_modules`
/// below the root, including those of a nested project with its own lock
/// (`tools/x/` holding `package-lock.json`), but such a run rewires only
/// the root's locks. A package is left out when the root's lock inventory
/// (`locked`) does not list it, its crawled copy sits in a nested
/// project ([`nested_npm_project`]), and no `node_modules` of the root's
/// own install holds another copy. Returns the purls to drop and one
/// `(code, detail)` warning per nested project, naming the PATH to pass.
///
/// [`nested_npm_project`]: socket_patch_core::hosted::governing_root::nested_npm_project
pub(crate) async fn nested_project_skips(
cwd: &std::path::Path,
mode: &str,
options: &socket_patch_core::crawlers::types::CrawlerOptions,
crawled: &[socket_patch_core::crawlers::types::CrawledPackage],
locked: &[LockfileEntry],
with_patches: &[BatchPackagePatches],
) -> (HashSet<PurlKey>, Vec<(String, String)>) {
use socket_patch_core::hosted::governing_root::nested_npm_project;
use std::collections::BTreeMap;
use std::path::PathBuf;

let locked: HashSet<PurlKey> = locked
.iter()
.filter(|e| e.ecosystem == "npm")
.map(|e| PurlKey::new(&e.purl))
.collect();
let npm = |purl: &str| {
matches!(
socket_patch_core::crawlers::Ecosystem::from_purl(purl),
Some(socket_patch_core::crawlers::Ecosystem::Npm)
)
};
let paths: HashMap<PurlKey, &std::path::Path> = crawled
.iter()
.filter(|p| npm(&p.purl))
.map(|p| (PurlKey::new(&p.purl), p.path.as_path()))
.collect();
// Candidate purl -> the nested project holding its crawled copy.
let mut candidates: BTreeMap<String, PathBuf> = BTreeMap::new();
for pkg in with_patches {
let key = PurlKey::new(&pkg.purl);
if !npm(&pkg.purl) || locked.contains(&key) {
continue;
}
let Some(path) = paths.get(&key) else {
continue;
};
if let Some(project) = nested_npm_project(cwd, path).await {
candidates.insert(pkg.purl.clone(), project);
}
}
if candidates.is_empty() {
return Default::default();
}
// The crawl keeps one copy per purl: a copy in the root's own install
// keeps the package the root's, whatever copy the crawl kept.
// An unreadable tree keeps every candidate the root's (the backend
// then reports it), rather than dropping a package the root installs.
let crawler = socket_patch_core::crawlers::NpmCrawler::new();
let wanted: Vec<String> = candidates.keys().cloned().collect();
let Ok(nm_roots) = crawler.get_node_modules_paths(options).await else {
return Default::default();
};
for nm_root in nm_roots {
if nested_npm_project(cwd, &nm_root).await.is_some() {
continue;
}
let Ok(found) = crawler.find_by_purls(&nm_root, &wanted).await else {
return Default::default();
};
let rooted: HashSet<PurlKey> = found
.into_iter()
.filter(|(_, copies)| !copies.is_empty())
.map(|(purl, _)| PurlKey::new(&purl))
.collect();
candidates.retain(|purl, _| !rooted.contains(&PurlKey::new(purl)));
}
// The PATH as the user's shell sees it: relative to the directory the
// command runs in (a PATH-list leg or `--cwd` scans elsewhere), else
// absolute.
let shell_dir = std::env::current_dir()
.ok()
.map(|d| std::fs::canonicalize(&d).unwrap_or(d));
let mut by_project: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut skipped = HashSet::new();
for (purl, project) in candidates {
let project = std::fs::canonicalize(&project).unwrap_or(project);
let shown = shell_dir
.as_deref()
.and_then(|base| project.strip_prefix(base).ok())
.filter(|rel| !rel.as_os_str().is_empty())
.unwrap_or(&project)
.to_string_lossy()
.replace('\\', "/");
skipped.insert(PurlKey::new(&purl));
by_project.entry(shown).or_default().push(purl);
}
// Spelled out per mode so the remedy-command check can parse each.
let rerun = |dir: &str| {
if mode == "hosted" {
format!("`socket-patch scan --mode hosted {dir}`")
} else {
format!("`socket-patch scan --mode vendored {dir}`")
}
};
let warnings = by_project
.into_iter()
.map(|(dir, purls)| {
let (verb, left) = if purls.len() == 1 {
("is", "it is")
} else {
("are", "they are")
};
(
NESTED_PROJECT_SKIPPED.to_string(),
format!(
"{} {verb} installed only by {dir}, a separate npm project with its own \
lockfile; this run rewires only this directory's lockfile, so {left} left \
alone; run {} to patch that project",
purls.join(", "),
rerun(&shell_word(&dir)),
),
)
})
.collect();
(skipped, warnings)
}

/// `word` as one POSIX shell word: single-quoted unless it is plain.
fn shell_word(word: &str) -> String {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_./-:@%+=,".contains(c));
if plain {
word.to_string()
} else {
format!("'{}'", word.replace('\'', r"'\''"))
}
}

#[cfg(test)]
mod tests {
use super::*;

/// #1534: the PATH in the `nested_project_skipped` remedy survives a
/// copy-paste into a POSIX shell.
#[test]
fn shell_word_quotes_only_what_the_shell_would_split() {
assert_eq!(shell_word("tools/x"), "tools/x");
assert_eq!(shell_word("/repo/@scope/x-1.0"), "/repo/@scope/x-1.0");
assert_eq!(shell_word("tools/my x"), "'tools/my x'");
assert_eq!(shell_word("it's"), r"'it'\''s'");
assert_eq!(shell_word("$HOME"), "'$HOME'");
assert_eq!(shell_word(""), "''");
}
use socket_patch_core::api::types::BatchPatchInfo;
use std::borrow::Cow;

Expand Down
26 changes: 26 additions & 0 deletions crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2503,6 +2503,32 @@ async fn run_scan(
&all_purls,
&mut lockfile_only.purls,
);
// #1534: hosted and vendored runs rewire only this root's locks, so a
// patched copy only a nested npm project installs is left to that
// project's own run, with a warning, not handed to this root's backend.
if (hosted || vendor)
&& !args.common.is_global()
&& args.common.ecosystem_selected(Ecosystem::Npm)
{
let (skipped, warnings) = Box::pin(discovery::nested_project_skips(
&args.common.cwd,
if hosted { "hosted" } else { "vendored" },
&crawler_options,
&filtered_crawled,
&lockfile_only.entries,
&all_packages_with_patches,
))
.await;
if !skipped.is_empty() {
all_packages_with_patches.retain(|p| !skipped.contains(&PurlKey::new(&p.purl)));
if !args.common.json {
for (_, detail) in &warnings {
status.println(format!("Warning: {detail}"));
}
}
layout_refusals.extend(warnings);
}
}

// If every batch errored, surface a full scan failure rather than
// silently reporting zero patches.
Expand Down
1 change: 1 addition & 0 deletions crates/socket-patch-cli/tests/scan/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod hosted_yarn_classic_sha1;
mod scan_batch_sizing_e2e;
mod scan_ecosystems_scope_e2e;
mod scan_invariants;
mod scan_nested_project_e2e;
mod scan_ordered_concurrency_e2e;
mod scan_paths_e2e;
mod scan_sync_e2e;
Expand Down
Loading
Loading