Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat(vex): attest detached vendored patches, with or without a manifest
The vex CLI layer now folds detached vendor entries' embedded records
into the manifest view (augment_with_detached; manifest entries win
collisions) before verification and document building — the existing
core machinery then verifies the committed artifact against the
embedded afterHashes and phrases the impact '(vendored)' unchanged. A
missing .socket/manifest.json is no longer terminal when detached
entries exist (PatchManifest::new() + augmentation); the
manifest_not_found / no_patches contract is preserved for projects with
neither. Applies to both the standalone command and the embedded
apply/scan/vendor --vex path, so scan --vendor --detached --vex works
end-to-end manifest-less (pinned in scan_vendor_e2e).

+2 e2e tests: detached attestation with no manifest file; tampered
detached artifact omitted fail-closed with vendor_hash_mismatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
  • Loading branch information
mikolalysenko and claude committed Jun 10, 2026
commit b3de221064448a059d646fd1d56ef4fbe311aaec
68 changes: 51 additions & 17 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,24 @@ pub async fn run(args: VexArgs) -> i32 {

let manifest_path = args.common.resolved_manifest_path();

let manifest = match read_manifest(&manifest_path).await {
Ok(Some(m)) => m,
Ok(None) => {
// `None` ⇒ no manifest file. That is no longer terminal by itself:
// a `scan --vendor --detached` project carries its patch records in
// the vendor ledger instead, and those must still be attestable.
let manifest_file = match read_manifest(&manifest_path).await {
Ok(m) => m,
Err(e) => {
emit_envelope_error_and_track(&args, "manifest_unreadable", &e.to_string()).await;
return 2;
}
};
let had_manifest_file = manifest_file.is_some();
let manifest =
augment_with_detached(&args.common, manifest_file.unwrap_or_else(PatchManifest::new))
.await;

if manifest.patches.is_empty() {
if !had_manifest_file {
// No manifest AND nothing detached — the original contract.
emit_envelope_error_and_track(
&args,
"manifest_not_found",
Expand All @@ -193,13 +208,6 @@ pub async fn run(args: VexArgs) -> i32 {
.await;
return 2;
}
Err(e) => {
emit_envelope_error_and_track(&args, "manifest_unreadable", &e.to_string()).await;
return 2;
}
};

if manifest.patches.is_empty() {
emit_envelope_error_and_track(
&args,
"no_patches",
Expand Down Expand Up @@ -437,19 +445,24 @@ pub(crate) async fn generate_vex_from_manifest_path(
params: &VexBuildParams,
manifest_path: &Path,
) -> Result<VexWriteSummary, VexGenError> {
let manifest = match read_manifest(manifest_path).await {
Ok(Some(m)) => m,
Ok(None) => {
let manifest_file = match read_manifest(manifest_path).await {
Ok(m) => m,
Err(e) => return Err(fail(common, "manifest_unreadable", e.to_string()).await),
};
let had_manifest_file = manifest_file.is_some();
// Detached vendored patches (`scan --vendor --detached`) have no
// manifest record; the ledger's embedded copies must still attest.
let manifest =
augment_with_detached(common, manifest_file.unwrap_or_else(PatchManifest::new)).await;
if manifest.patches.is_empty() {
if !had_manifest_file {
return Err(fail(
common,
"manifest_not_found",
format!("Manifest not found at {}", manifest_path.display()),
)
.await)
.await);
}
Err(e) => return Err(fail(common, "manifest_unreadable", e.to_string()).await),
};
if manifest.patches.is_empty() {
return Err(fail(
common,
"no_patches",
Expand All @@ -460,6 +473,27 @@ pub(crate) async fn generate_vex_from_manifest_path(
generate_vex(common, params, &manifest).await
}

/// Fold detached vendor entries' embedded records into a manifest view so
/// verification and document building see them — `scan --vendor
/// --detached` patches have no manifest record by design. Keyed by the
/// ledger key; an existing manifest entry wins a collision (that purl is
/// manifest-owned and verifies against the manifest's record). An
/// unreadable ledger leaves the manifest unchanged here — verification
/// still fails closed per-entry downstream, and `load_vendor_context`
/// already warns about the unreadable state.
async fn augment_with_detached(common: &GlobalArgs, mut manifest: PatchManifest) -> PatchManifest {
if let Ok(state) = socket_patch_core::patch::vendor::load_state(&common.cwd).await {
for (key, entry) in state.entries {
if !entry.detached {
continue;
}
let Some(record) = entry.record else { continue };
manifest.patches.entry(key).or_insert(record);
}
}
manifest
}

/// Fire `vex_failed` telemetry and build the matching [`VexGenError`].
/// Centralizes the "track then return error" pattern in [`generate_vex`].
async fn fail(common: &GlobalArgs, code: &'static str, message: String) -> VexGenError {
Expand Down
156 changes: 156 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_vendor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,159 @@ fn golang_go_patches_redirect_attested_without_module_cache() {
"a go-patches redirect is not a vendored artifact: {impact}"
);
}

// ──────────────────────────────────────────────────────────────────────
// 5. detached entries (scan --vendor --detached): no manifest at all
// ──────────────────────────────────────────────────────────────────────

/// Ledger writer for the detached shape: `detached: true` plus the
/// embedded record that replaces the manifest as verification source.
fn write_detached_vendor_state(cwd: &Path, purl: &str, rel_path: &str, record: PatchRecord) {
let mut state = VendorState::new();
state.entries.insert(
purl.to_string(),
VendorEntry {
ecosystem: "cargo".to_string(),
base_purl: purl.to_string(),
uuid: UUID.to_string(),
artifact: VendorArtifact {
path: rel_path.to_string(),
sha256: String::new(),
size: None,
platform_locked: None,
},
wiring: Vec::new(),
lock: None,
took_over_go_patches: false,
detached: true,
record: Some(record),
flavor: None,
uv: None,
pnpm: None,
poetry: None,
pdm: None,
pipenv: None,
},
);
let dir = cwd.join(".socket/vendor");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("state.json"),
serde_json::to_string_pretty(&state).unwrap(),
)
.unwrap();
}

/// A detached vendored patch has NO manifest record — `vex` must attest it
/// from the ledger's embedded record + the committed artifact, even when
/// `.socket/manifest.json` does not exist at all. The vendored property-7
/// exemption applies (no setup/manual declaration anywhere).
#[test]
fn detached_entry_attested_without_manifest() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
let purl = "pkg:cargo/serde@1.0.0";

let patched = b"patched detached source\n";
let after_hash = compute_git_sha256_from_bytes(patched);
let rel = write_vendored_dir(cwd, patched);
let record = make_record(
UUID,
"src/lib.rs",
&after_hash,
"GHSA-deta-aaaa",
&["CVE-2026-3"],
);
write_detached_vendor_state(cwd, purl, &rel, record);
assert!(
!cwd.join(".socket/manifest.json").exists(),
"fixture sanity: detached-only project has no manifest"
);

let out = cli()
.args([
"vex",
"--cwd",
cwd.to_str().unwrap(),
"--product",
"pkg:cargo/app@1.0.0",
])
.output()
.expect("invoke vex");
assert!(
out.status.success(),
"detached vendored patch must attest with no manifest. stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let doc: Value = serde_json::from_slice(&out.stdout).expect("VEX JSON on stdout");
let stmts = doc["statements"].as_array().unwrap();
assert_eq!(stmts.len(), 1, "the detached patch must be attested: {doc}");
assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-deta-aaaa");
assert_eq!(stmts[0]["status"], "not_affected");
let subs = stmts[0]["products"][0]["subcomponents"].as_array().unwrap();
assert_eq!(subs[0]["@id"], purl);
assert_eq!(
stmts[0]["impact_statement"].as_str().unwrap(),
format!("Patched via Socket patch {UUID} (vendored)"),
"detached attestation carries the (vendored) marker"
);
}

/// Fail-closed parity with the manifest-tracked flow: a tampered detached
/// artifact is OMITTED (the embedded record's afterHashes are the oracle),
/// and with nothing else to attest the command reports
/// no_applicable_patches.
#[test]
fn tampered_detached_artifact_omitted() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
let purl = "pkg:cargo/serde@1.0.0";

let after_hash = compute_git_sha256_from_bytes(b"what the patch should contain\n");
let rel = write_vendored_dir(cwd, b"tampered detached bytes\n");
let record = make_record(
UUID,
"src/lib.rs",
&after_hash,
"GHSA-deta-bbbb",
&["CVE-2026-4"],
);
write_detached_vendor_state(cwd, purl, &rel, record);

let vex_path = cwd.join("out.vex.json");
let out = cli()
.args([
"vex",
"--cwd",
cwd.to_str().unwrap(),
"--json",
"--output",
vex_path.to_str().unwrap(),
"--product",
"pkg:cargo/app@1.0.0",
])
.output()
.expect("invoke vex");
assert_eq!(
out.status.code(),
Some(1),
"tampered-only ⇒ no_applicable_patches (exit 1). stdout:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let env: Value =
serde_json::from_slice(&out.stdout).expect("vex --json emits an envelope");
assert_eq!(env["status"], "error", "{env}");
assert_eq!(env["error"]["code"], "no_applicable_patches", "{env}");
// Same surfacing shape as the manifest-tracked tamper test: a skipped
// event whose errorCode carries the vendor verification reason.
let events = env["events"].as_array().unwrap();
let skipped = events
.iter()
.find(|e| e["action"] == "skipped" && e["purl"] == purl)
.unwrap_or_else(|| panic!("expected a skipped event for the tampered purl: {env}"));
assert_eq!(
skipped["errorCode"], "vendor_hash_mismatch",
"tamper must surface as vendor_hash_mismatch: {skipped}"
);
assert!(!vex_path.exists(), "no document for an all-failed run");
}
23 changes: 22 additions & 1 deletion crates/socket-patch-cli/tests/scan_vendor_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,34 @@ async fn scan_vendor_detached_mode_writes_no_manifest() {
let tmp = tempfile::tempdir().unwrap();
write_fixture(tmp.path());

let (code, stdout, stderr) = run_scan_vendor(tmp.path(), &mock.uri(), &["--detached"]);
let (code, stdout, stderr) = run_scan_vendor(
tmp.path(),
&mock.uri(),
&["--detached", "--vex", "out.vex.json"],
);
assert_eq!(code, 0, "stdout={stdout}; stderr={stderr}");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert_eq!(v["status"], "success", "envelope={v}");
assert_eq!(v["download"]["detached"], true, "envelope={v}");
assert_eq!(v["vendor"]["summary"]["applied"], 1, "envelope={v}");

// Embedded VEX works manifest-less: the detached entry's embedded
// record is the attestation source.
assert_eq!(v["vex"]["statements"], 1, "envelope={v}");
let doc: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(tmp.path().join("out.vex.json")).unwrap(),
)
.unwrap();
let stmts = doc["statements"].as_array().expect("statements");
assert_eq!(stmts.len(), 1, "doc={doc}");
assert!(
stmts[0]["impact_statement"]
.as_str()
.unwrap()
.contains("(vendored)"),
"doc={doc}"
);

assert!(
!tmp.path().join(".socket/manifest.json").exists(),
"detached mode must not create a manifest"
Expand Down