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
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ Recognition rules that hold for every ecosystem:
|---|---|---|
| Vendored: a lockfile/config wires a `.socket/vendor` artifact, or a live vendor ledger entry | The **committed artifact** is hashed against the record's `afterHash`. The ledger entry is used when it names the wired artifact (it carries the dir-artifact inventory); otherwise an entry is synthesized from the reference. A present installed tree with different bytes only warns `vendored_tree_out_of_sync`. | `(vendored)` |
| Hosted: a discovered patch-host reference (or a live pre-v5 redirect-ledger record) | The installed copies the build **consumes** through the hosted wiring are hash-verified when any exist: the Go replacement module, never the pristine `M@v` in the module cache; the Socket-registry cargo source dir; maven's suffixed version. Installed evidence wins: `hash_mismatch` / `not_applied` are omitted. With **nothing installed**, a discovered reference whose lock pins the artifact (or whose format's rewriter never writes a pin) attests from that pin, which is the same evidence as in-run `scan --mode hosted --vex`. A pre-v5 ledger-only record, or a reference whose required pin is missing, stays `package_not_found`. So do purls that `--ecosystems` kept out of the crawl, because "not installed" has to mean the crawler looked. | `(redirected)` |
| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged | none |
| Agent: a manifest record with no live hosted/vendored wiring | The installed tree, unchanged. **Every** installed copy the crawler finds for the purl (npm nests duplicates of one `name@version`) must hash to the patched bytes, as `apply` patches every copy. One unpatched copy omits the purl with that copy's tag (`not_applied` / `hash_mismatch`). | none |

**Liveness gates.** These gates run before hashing, and `--no-verify` / `--vex-no-verify` skips only the hashing, never the gates:

Expand Down
19 changes: 9 additions & 10 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use crate::commands::vex_sources::{
self, Plan, Sources, RECORD_MISMATCH, RECORD_UNAVAILABLE, REDIRECT_UNWIRED, VENDOR_UNWIRED,
WIRING_CONFLICT,
};
use crate::ecosystem_dispatch::{collapse_to_first, find_manifest_package_copies_reusing};
use crate::ecosystem_dispatch::find_manifest_package_copies_reusing;
use crate::json_envelope::{Command, Envelope, EnvelopeError, PatchAction, PatchEvent, RunWarning};
use crate::ui::plural;

Expand Down Expand Up @@ -548,12 +548,14 @@ async fn generate_vex(
// mirroring apply/rollback's `silent || json` gating.
let quiet = common.silent || common.json || params.output.is_none();
let purls: Vec<String> = manifest.patches.keys().cloned().collect();
// ONE installed-tree lookup: the first copy of every purl for the
// record check, every copy of the hosted ones below.
// ONE installed-tree lookup: every copy of every purl, for the
// record check and the hosted ones below alike. `apply` patches
// every copy, so an agent record is attested only when EVERY copy
// verifies; the first copy alone would vouch for a later install's
// unpatched nested duplicate.
let copies =
find_manifest_package_copies_reusing(&purls, common, quiet, params.npm_prior.as_ref())
.await;
let package_paths = collapse_to_first(copies.clone());
let go_patches = synthesize_go_patches(common, manifest, &plan.vendor_entries).await;
// Hosted-basis purls are judged by the copies their build CONSUMES
// (the Go replacement module, the Socket registry's cargo src dir,
Expand All @@ -573,12 +575,9 @@ async fn generate_vex(
go_patches,
hosted,
};
let mut outcome = socket_patch_core::vex::applied_patches_with_vendor(
manifest,
&package_paths,
Some(&vendor),
)
.await;
let mut outcome =
socket_patch_core::vex::applied_patches_with_copies(manifest, &copies, Some(&vendor))
.await;
// Hosted lockfile basis: a DISCOVERED Socket-host reference whose
// lock pins the artifact attests from that wiring when no installed
// tree exists yet (a lockfile-only CI checkout) — the evidence the
Expand Down
106 changes: 106 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,112 @@ fn verify_mode_includes_applied_omits_unapplied() {
maybe_validate_with_vexctl(&stdout);
}

/// Lay down `node_modules/<parent>/node_modules/dup-pkg@1.0.0` for every
/// `(parent, index.js bytes)` pair: nested duplicates of ONE `name@version`,
/// the layout a later `npm install` of a new dependent produces.
fn lay_down_nested_dups(cwd: &Path, copies: &[(&str, &[u8])]) {
for (parent, content) in copies {
let parent_dir = cwd.join("node_modules").join(parent);
std::fs::create_dir_all(&parent_dir).unwrap();
std::fs::write(
parent_dir.join("package.json"),
format!(r#"{{"name":"{parent}","version":"1.0.0"}}"#),
)
.unwrap();
let dup = parent_dir.join("node_modules").join("dup-pkg");
std::fs::create_dir_all(&dup).unwrap();
std::fs::write(
dup.join("package.json"),
r#"{"name":"dup-pkg","version":"1.0.0"}"#,
)
.unwrap();
std::fs::write(dup.join("index.js"), content).unwrap();
}
}

/// Regression (#516): an agent record is attested only when EVERY
/// installed copy of its `name@version` is patched. `apply` patches every
/// copy, but `vex` used to hash only the crawler's FIRST copy, so a fresh,
/// unpatched nested copy (added by a later install) was attested
/// `not_affected` whenever the patched copy happened to be crawled first.
/// Both crawl orders must omit the purl; all copies patched attests it.
#[test]
fn verify_mode_requires_every_installed_copy_patched() {
let patched: &[u8] = b"patched dup index";
let pristine: &[u8] = b"pristine dup index";
let after_hash = compute_git_sha256_from_bytes(patched);
let before_hash = compute_git_sha256_from_bytes(pristine);

let run = |copies: &[(&str, &[u8])]| {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
lay_down_nested_dups(cwd, copies);
let mut manifest = PatchManifest::new();
manifest.patches.insert(
"pkg:npm/dup-pkg@1.0.0".to_string(),
make_record(
"44444444-4444-4444-8444-444444444444",
"package/index.js",
before_hash.as_str(),
after_hash.as_str(),
"GHSA-dup",
&["CVE-DUP"],
),
);
write_manifest(cwd, &manifest);
let out = cli()
.args([
"vex",
"--cwd",
cwd.to_str().unwrap(),
"--product",
"pkg:npm/test-app@1.0.0",
])
.output()
.expect("invoke vex");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
};

// One copy patched, the other pristine — in both orders, so the
// verdict cannot depend on which copy the crawler meets first.
for copies in [
[("aaa-parent", patched), ("zzz-parent", pristine)],
[("aaa-parent", pristine), ("zzz-parent", patched)],
] {
let (ok, stdout, stderr) = run(&copies);
assert!(
!ok,
"an unpatched installed copy must keep the purl out of the VEX \
doc (nothing left to attest → non-zero exit). copies: {:?}\n\
stdout:\n{stdout}\nstderr:\n{stderr}",
copies.map(|(p, _)| p)
);
assert!(
!stdout.contains("GHSA-dup"),
"must not attest while a copy is unpatched:\n{stdout}"
);
assert!(
stderr.contains("Warning: omitting pkg:npm/dup-pkg@1.0.0 from VEX")
&& stderr.contains("(not_applied)"),
"the omission must name the unpatched copy's not_applied tag. \
got: {stderr}"
);
}

// Control: every copy patched → attested.
let (ok, stdout, stderr) = run(&[("aaa-parent", patched), ("zzz-parent", patched)]);
assert!(ok, "all copies patched must attest. stderr:\n{stderr}");
let doc: Value = serde_json::from_str(&stdout).unwrap();
let stmts = doc["statements"].as_array().unwrap();
assert_eq!(stmts.len(), 1, "doc:\n{stdout}");
assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-dup");
assert_eq!(stmts[0]["status"], "not_affected");
}

#[test]
fn verify_mode_all_failed_exits_non_zero() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
4 changes: 2 additions & 2 deletions crates/socket-patch-core/src/vex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ pub use schema::{
OPENVEX_CONTEXT_V0_2_0,
};
pub use verify::{
applied_patches, applied_patches_with_vendor, FailedPatch, HostedCopies, VendorContext,
VerifyOutcome,
applied_patches, applied_patches_with_copies, applied_patches_with_vendor, FailedPatch,
HostedCopies, VendorContext, VerifyOutcome,
};

#[cfg(test)]
Expand Down
Loading
Loading