From a7741b9a3dc4cb14ffd059d880ca3681c6767bad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:22:38 +0000 Subject: [PATCH 1/3] Start fix for #484 Assisted-by: Claude Code:claude-opus-5-5 From 82c2b93b5ec1b555ba05be1566db4dad76b6fefe Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 09:28:36 +0000 Subject: [PATCH 2/3] Fix vex of +incompatible Go module patches The patch API keys a +incompatible Go module as @v2.0.0%2Bincompatible, but go.mod spells the version with a literal +. vex compared the two spellings as plain strings, so an applied agent-mode Go patch for such a module was never verified: vex said not_applied and exited 1, leaving the patch out of the OpenVEX doc. Match Go purls by release identity (PurlKey) when looking up the manifest record for a go-patches replace, and in the go.work override filter, so a go.work replace that overrides a %2B-keyed patch is still caught and never attested. Fixes #484 Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/vex.rs | 24 +++-- .../tests/covgap_commands_vex.rs | 89 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index d0cc8d93d..33d52917e 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -818,7 +818,8 @@ async fn generate_vex( // conflict with another member's (#393, #458, #531) is not what the // build links, however intact its copy or pin: never attest it. if !common.is_global() { - use socket_patch_core::utils::purl::{build_golang_purl, strip_purl_qualifiers}; + use socket_patch_core::utils::purl::build_golang_purl; + use socket_patch_core::utils::purl_key::PurlKey; for issue in socket_patch_core::vendor::go_consumer_sync::workspace_audit(&common.cwd).await { let socket_patch_core::vendor::go_consumer_sync::GoSyncIssue::Workspace { @@ -831,9 +832,9 @@ async fn generate_vex( }; let purl = build_golang_purl(module, version); let before = outcome.applied.len(); - outcome - .applied - .retain(|p| strip_purl_qualifiers(p) != purl.as_str()); + // PurlKey, not string equality: go.mod spells `+incompatible` + // with a literal `+`, the manifest key with `%2B` (#484). + outcome.applied.retain(|p| !PurlKey::same(p, &purl)); if outcome.applied.len() == before { continue; } @@ -1659,6 +1660,7 @@ async fn synthesize_go_patches( are_safe_redirect_coords, copy_dir_for, }; use socket_patch_core::utils::purl::build_golang_purl; + use socket_patch_core::utils::purl_key::PurlKey; use socket_patch_core::vendor::go_mod_edit::{ read_replace_entries, ReplaceOwner, GO_PATCHES_DIR, }; @@ -1671,10 +1673,18 @@ async fn synthesize_go_patches( let Some(version) = entry.version.as_deref() else { continue; }; - let purl = build_golang_purl(&entry.module, version); - if !manifest.patches.contains_key(&purl) { + // go.mod spells `+incompatible` with a literal `+`; the API keys it + // `%2B` (#484). Match by release and key the copy by the record's + // own spelling, which is what verification looks up. + let wanted = PurlKey::new(&build_golang_purl(&entry.module, version)); + let Some(purl) = manifest + .patches + .keys() + .find(|key| PurlKey::new(key) == wanted) + .cloned() + else { continue; - } + }; // Explicit vendor entries take precedence over the synthesis // (vendor may have taken over an apply redirect). if socket_patch_core::vendor::lookup_entry(entries, &purl).is_some() { diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index 5a98474be..fb491364c 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -1073,6 +1073,95 @@ fn go_patches_replace_for_an_unselected_version_is_not_attested() { } } +/// #484: the patch API keys a `+incompatible` Go module as +/// `@v2.0.0%2Bincompatible`, while go.mod spells the replace's version with +/// a literal `+`. vex must match the two spellings: an applied go-patches +/// redirect attests (it used to be omitted as `not_applied`, exit 1), and a +/// go.work replace that overrides it is still caught and never attested. +#[test] +fn go_patches_replace_of_an_incompatible_module_matches_the_encoded_key() { + let module = "example.com/legacy"; + let version = "v2.0.0+incompatible"; + for overridden in [false, true] { + let tmp = tempfile::tempdir().unwrap(); + let cwd = tmp.path(); + std::fs::write( + cwd.join("go.mod"), + format!( + "module example.com/app\n\ngo 1.21\n\nrequire {module} {version}\n\n\ + replace {module} {version} => ./.socket/go-patches/{module}@{version}\n" + ), + ) + .unwrap(); + let go_work = cwd.join("go.work"); + if overridden { + std::fs::write( + &go_work, + format!("go 1.21\n\nuse .\n\nreplace {module} {version} => ./fork\n"), + ) + .unwrap(); + } + let patched = b"package legacy // patched\n"; + let copy = cwd.join(format!(".socket/go-patches/{module}@{version}")); + std::fs::create_dir_all(©).unwrap(); + std::fs::write(copy.join("lib.go"), patched).unwrap(); + let encoded = "pkg:golang/example.com/legacy@v2.0.0%2Bincompatible"; + let mut manifest = PatchManifest::new(); + manifest.patches.insert( + encoded.to_string(), + make_record( + GOOD_UUID, + "lib.go", + "a".repeat(64).as_str(), + &compute_git_sha256_from_bytes(patched), + "GHSA-go-incompatible", + &["CVE-2026-484"], + ), + ); + write_manifest(cwd, &manifest); + let empty_cache = cwd.join("empty-gomodcache"); + std::fs::create_dir_all(&empty_cache).unwrap(); + + let out = cli() + .env("GOMODCACHE", &empty_cache) + .env( + "GOWORK", + if overridden { + go_work.as_os_str() + } else { + "off".as_ref() + }, + ) + .args([ + "vex", + "--cwd", + cwd.to_str().unwrap(), + "--product", + "pkg:golang/example.com/app@v0.0.1", + ]) + .output() + .expect("invoke vex"); + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if overridden { + assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}"); + assert!(!stdout.contains("not_affected"), "{stdout}"); + assert!( + stderr.contains("(go_workspace_override)"), + "stderr:\n{stderr}" + ); + } else { + assert!(out.status.success(), "stderr:\n{stderr}"); + let doc: Value = serde_json::from_str(&stdout).expect("VEX JSON on stdout"); + let stmts = doc["statements"].as_array().unwrap(); + assert_eq!(stmts.len(), 1, "{stdout}"); + assert_eq!(stmts[0]["vulnerability"]["name"], "GHSA-go-incompatible"); + assert_eq!(stmts[0]["status"], "not_affected"); + assert_eq!(stmts[0]["products"][0]["subcomponents"][0]["@id"], encoded); + } + } +} + // ────────────────────────────────────────────────────────────────────── // Corrupt vendor ledger on a MANIFEST-FREE project (the D2 vendored // posture): the ledger was the only possible source of records, and it From f63372dfb5afe0d926ffaa88bb7fdef31f7e9801 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 10:18:40 +0000 Subject: [PATCH 3/3] Report Go vex omissions under the record's purl Follow-up to the +incompatible fix. When go.work overrides a Go patch, or the build selects another version, vex now names the omitted patch by the manifest's own purl (with %2B), not the go.mod spelling, so --json consumers can join omissions to manifest keys. The copy-dir lookup now maps every record spelling of the release, deterministically. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/src/commands/vex.rs | 115 ++++++++++-------- .../tests/covgap_commands_vex.rs | 26 +++- 2 files changed, 86 insertions(+), 55 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index 33d52917e..a74652229 100644 --- a/crates/socket-patch-cli/src/commands/vex.rs +++ b/crates/socket-patch-cli/src/commands/vex.rs @@ -830,19 +830,22 @@ async fn generate_vex( else { continue; }; - let purl = build_golang_purl(module, version); - let before = outcome.applied.len(); // PurlKey, not string equality: go.mod spells `+incompatible` - // with a literal `+`, the manifest key with `%2B` (#484). - outcome.applied.retain(|p| !PurlKey::same(p, &purl)); - if outcome.applied.len() == before { - continue; + // with a literal `+`, the manifest key with `%2B` (#484). Report + // each omission under the record's own spelling. + let wanted = PurlKey::new(&build_golang_purl(module, version)); + let (overridden, kept): (Vec, Vec) = + std::mem::take(&mut outcome.applied) + .into_iter() + .partition(|p| PurlKey::new(p) == wanted); + outcome.applied = kept; + for purl in overridden { + note_warning(warnings, common, issue.code(), format!("{purl}: {issue}")); + outcome.failed.push(FailedPatch { + purl, + reason: GO_WORKSPACE_OVERRIDE.to_string(), + }); } - note_warning(warnings, common, issue.code(), format!("{purl}: {issue}")); - outcome.failed.push(FailedPatch { - purl, - reason: GO_WORKSPACE_OVERRIDE.to_string(), - }); } } @@ -860,8 +863,8 @@ async fn generate_vex( let Some(version) = entry.version.as_deref().filter(|_| entry.socket_owned()) else { continue; }; - let purl = build_golang_purl(&entry.module, version); - if !outcome.applied.iter().any(|p| PurlKey::same(p, &purl)) { + let wanted = PurlKey::new(&build_golang_purl(&entry.module, version)); + if !outcome.applied.iter().any(|p| PurlKey::new(p) == wanted) { continue; } let Some(Selection::Other(selected)) = @@ -869,21 +872,29 @@ async fn generate_vex( else { continue; }; - outcome.applied.retain(|p| !PurlKey::same(p, &purl)); - note_warning( - warnings, - common, - GO_VERSION_NOT_SELECTED, - format!( - "{purl}: the build selects {} {selected}, so the replace pinned to \ - {version} is ignored and the build links the unpatched module", - entry.module - ), - ); - outcome.failed.push(FailedPatch { - purl, - reason: GO_VERSION_NOT_SELECTED.to_string(), - }); + // Report each omission under the record's own spelling (`%2B` + // for `+incompatible`, #484), not go.mod's. + let (unselected, kept): (Vec, Vec) = + std::mem::take(&mut outcome.applied) + .into_iter() + .partition(|p| PurlKey::new(p) == wanted); + outcome.applied = kept; + for purl in unselected { + note_warning( + warnings, + common, + GO_VERSION_NOT_SELECTED, + format!( + "{purl}: the build selects {} {selected}, so the replace pinned to \ + {version} is ignored and the build links the unpatched module", + entry.module + ), + ); + outcome.failed.push(FailedPatch { + purl, + reason: GO_VERSION_NOT_SELECTED.to_string(), + }); + } } } @@ -1665,6 +1676,14 @@ async fn synthesize_go_patches( read_replace_entries, ReplaceOwner, GO_PATCHES_DIR, }; + let mut by_release: HashMap> = HashMap::new(); + for key in manifest + .patches + .keys() + .filter(|k| k.starts_with("pkg:golang/")) + { + by_release.entry(PurlKey::new(key)).or_default().push(key); + } let mut go_patches = HashMap::new(); for entry in read_replace_entries(&common.cwd).await { if entry.owner != Some(ReplaceOwner::GoPatches) { @@ -1673,23 +1692,6 @@ async fn synthesize_go_patches( let Some(version) = entry.version.as_deref() else { continue; }; - // go.mod spells `+incompatible` with a literal `+`; the API keys it - // `%2B` (#484). Match by release and key the copy by the record's - // own spelling, which is what verification looks up. - let wanted = PurlKey::new(&build_golang_purl(&entry.module, version)); - let Some(purl) = manifest - .patches - .keys() - .find(|key| PurlKey::new(key) == wanted) - .cloned() - else { - continue; - }; - // Explicit vendor entries take precedence over the synthesis - // (vendor may have taken over an apply redirect). - if socket_patch_core::vendor::lookup_entry(entries, &purl).is_some() { - continue; - } // SECURITY: module/version come from a committed (tamper-able) // go.mod and are about to key a path we hash. Apply the same // fail-closed coordinate guard `go_redirect` itself uses before @@ -1697,10 +1699,25 @@ async fn synthesize_go_patches( if !are_safe_redirect_coords(&entry.module, version) { continue; } - go_patches.insert( - purl, - copy_dir_for(&common.cwd, GO_PATCHES_DIR, &entry.module, version), - ); + // go.mod spells `+incompatible` with a literal `+`; the API keys it + // `%2B` (#484). Match by release and key the copy by each record's + // own spelling, which is what verification looks up. + let Some(records) = + by_release.get(&PurlKey::new(&build_golang_purl(&entry.module, version))) + else { + continue; + }; + for purl in records { + // Explicit vendor entries take precedence over the synthesis + // (vendor may have taken over an apply redirect). + if socket_patch_core::vendor::lookup_entry(entries, purl).is_some() { + continue; + } + go_patches.insert( + (*purl).clone(), + copy_dir_for(&common.cwd, GO_PATCHES_DIR, &entry.module, version), + ); + } } go_patches } diff --git a/crates/socket-patch-cli/tests/covgap_commands_vex.rs b/crates/socket-patch-cli/tests/covgap_commands_vex.rs index fb491364c..c4235aecc 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -1076,19 +1076,32 @@ fn go_patches_replace_for_an_unselected_version_is_not_attested() { /// #484: the patch API keys a `+incompatible` Go module as /// `@v2.0.0%2Bincompatible`, while go.mod spells the replace's version with /// a literal `+`. vex must match the two spellings: an applied go-patches -/// redirect attests (it used to be omitted as `not_applied`, exit 1), and a -/// go.work replace that overrides it is still caught and never attested. +/// redirect attests (it used to be omitted as `not_applied`, exit 1), while +/// a go.work replace that overrides it, or a require that selects another +/// version, is still caught, never attested, and reported under the +/// record's own `%2B` spelling. #[test] fn go_patches_replace_of_an_incompatible_module_matches_the_encoded_key() { let module = "example.com/legacy"; let version = "v2.0.0+incompatible"; - for overridden in [false, true] { + // (omission reason, if any) + for omitted in [ + None, + Some("go_workspace_override"), + Some("go_version_not_selected"), + ] { + let overridden = omitted == Some("go_workspace_override"); + let required = if omitted == Some("go_version_not_selected") { + "v2.1.0+incompatible" + } else { + version + }; let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path(); std::fs::write( cwd.join("go.mod"), format!( - "module example.com/app\n\ngo 1.21\n\nrequire {module} {version}\n\n\ + "module example.com/app\n\ngo 1.21\n\nrequire {module} {required}\n\n\ replace {module} {version} => ./.socket/go-patches/{module}@{version}\n" ), ) @@ -1143,11 +1156,12 @@ fn go_patches_replace_of_an_incompatible_module_matches_the_encoded_key() { .expect("invoke vex"); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); - if overridden { + if let Some(reason) = omitted { assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}"); assert!(!stdout.contains("not_affected"), "{stdout}"); assert!( - stderr.contains("(go_workspace_override)"), + stderr.contains(&format!("omitting {encoded} from VEX")) + && stderr.contains(&format!("({reason})")), "stderr:\n{stderr}" ); } else {