diff --git a/crates/socket-patch-cli/src/commands/vex.rs b/crates/socket-patch-cli/src/commands/vex.rs index d0cc8d93d..a74652229 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 { @@ -829,19 +830,22 @@ async fn generate_vex( else { continue; }; - let purl = build_golang_purl(module, version); - let before = outcome.applied.len(); - outcome - .applied - .retain(|p| strip_purl_qualifiers(p) != purl.as_str()); - if outcome.applied.len() == before { - continue; + // PurlKey, not string equality: go.mod spells `+incompatible` + // 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(), - }); } } @@ -859,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)) = @@ -868,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(), + }); + } } } @@ -1659,10 +1671,19 @@ 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, }; + 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) { @@ -1671,15 +1692,6 @@ 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) { - 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 @@ -1687,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 5a98474be..c4235aecc 100644 --- a/crates/socket-patch-cli/tests/covgap_commands_vex.rs +++ b/crates/socket-patch-cli/tests/covgap_commands_vex.rs @@ -1073,6 +1073,109 @@ 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), 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"; + // (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} {required}\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 let Some(reason) = omitted { + assert_eq!(out.status.code(), Some(1), "stderr:\n{stderr}"); + assert!(!stdout.contains("not_affected"), "{stdout}"); + assert!( + stderr.contains(&format!("omitting {encoded} from VEX")) + && stderr.contains(&format!("({reason})")), + "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