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
113 changes: 70 additions & 43 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<String>, Vec<String>) =
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(),
});
}
}

Expand All @@ -859,30 +863,38 @@ 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)) =
project_selection(&common.cwd, &entry.module, version).await
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<String>, Vec<String>) =
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(),
});
}
}
}

Expand Down Expand Up @@ -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<PurlKey, Vec<&String>> = 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) {
Expand All @@ -1671,26 +1692,32 @@ 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
// building the copy-dir path.
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
}
Expand Down
103 changes: 103 additions & 0 deletions crates/socket-patch-cli/tests/covgap_commands_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&copy).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
Expand Down
Loading