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
8 changes: 7 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions crates/socket-patch-cli/src/commands/vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,47 @@ async fn generate_vex(
}
}

// Go keys a replace by module AND version: a Socket replace (agent,
// vendored or hosted) pinned to a version the build list does not select
// is inert, and the build links the unpatched module however intact the
// copy or pin (#391, #392, #509). go.mod + go.sum say which version the
// build selects; never attest a patch for another one.
if !common.is_global() {
use socket_patch_core::utils::purl::build_golang_purl;
use socket_patch_core::utils::purl_key::PurlKey;
use socket_patch_core::vendor::go_build_list::{project_selection, Selection};
for entry in socket_patch_core::vendor::go_mod_edit::read_replace_entries(&common.cwd).await
{
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)) {
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(),
});
}
}

// The plan's gate omissions (record_unavailable / record_mismatch /
// vendor_unwired / redirect_unwired / wiring_conflict) join the omission
// channel so they surface as per-purl `skipped` events like any
Expand Down Expand Up @@ -1599,6 +1640,10 @@ fn join_and(items: &[&str]) -> String {
/// overrides, ignores or rejects (#393, #458, #531).
const GO_WORKSPACE_OVERRIDE: &str = "go_workspace_override";

/// Omission tag for a Go patch whose version-pinned replace names a version
/// the build list does not select (#391, #392, #509).
const GO_VERSION_NOT_SELECTED: &str = "go_version_not_selected";

/// Synthesize go-patches redirect targets for `vex`: for every socket-owned (`.socket/go-patches/`) `replace` in
/// `go.mod` whose module+version maps to a golang PURL of the record view
/// (`manifest` β€” for `vex` the merged manifest + ledger + lockfile view, so
Expand Down Expand Up @@ -1733,6 +1778,10 @@ fn omission_phrase(reason: &str) -> &'static str {
"the go.work in effect overrides, ignores or conflicts with its go.mod replace, \
so the build does not link the patched module"
}
GO_VERSION_NOT_SELECTED => {
"go.mod and go.sum show the build selects another version of the module, so its \
version-pinned replace is ignored and the build links the unpatched module"
}
WIRING_CONFLICT => {
"the lockfiles wire this package to different patches, so which one the build \
installs cannot be determined"
Expand Down Expand Up @@ -1916,6 +1965,7 @@ mod tests {
REDIRECT_UNWIRED,
WIRING_CONFLICT,
GO_WORKSPACE_OVERRIDE,
GO_VERSION_NOT_SELECTED,
] {
assert_ne!(
omission_phrase(tag),
Expand Down
80 changes: 80 additions & 0 deletions crates/socket-patch-cli/tests/covgap_commands_vex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,86 @@ fn go_patches_synthesis_skips_tampered_and_stale_replaces() {
);
}

/// #391 / #392: a go-patches replace pinned to a version the build list no
/// longer selects is inert β€” after `go get M@v1.4.3` (go >= 1.17 require),
/// or in a go 1.16 graph where go.sum shows the build selects another
/// version β€” so vex must omit the patch however intact its copy is.
#[test]
fn go_patches_replace_for_an_unselected_version_is_not_attested() {
let module = "github.com/foo/bar";
let version = "v1.4.2";
let purl = format!("pkg:golang/{module}@{version}");
let replace =
format!("replace {module} {version} => ./.socket/go-patches/{module}@{version}\n");
let selected_v117 =
format!("module example.com/app\n\ngo 1.21\n\nrequire {module} {version}\n\n{replace}");
let bumped_v117 =
format!("module example.com/app\n\ngo 1.21\n\nrequire {module} v1.4.3\n\n{replace}");
let go116 =
format!("module example.com/app\n\ngo 1.16\n\nrequire example.com/mid v1.0.0\n\n{replace}");
let go116_sum = format!(
"{module} {version}/go.mod h1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\
{module} v1.5.0 h1:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=\n"
);
for (go_mod, go_sum, attests) in [
(&selected_v117, None, true),
(&bumped_v117, None, false),
(&go116, Some(go116_sum.as_str()), false),
(&go116, None, true),
] {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
std::fs::write(cwd.join("go.mod"), go_mod).unwrap();
if let Some(sum) = go_sum {
std::fs::write(cwd.join("go.sum"), sum).unwrap();
}
let patched = b"package bar // patched\n";
let copy = cwd.join(format!(".socket/go-patches/{module}@{version}"));
std::fs::create_dir_all(&copy).unwrap();
std::fs::write(copy.join("bar.go"), patched).unwrap();
let mut manifest = PatchManifest::new();
manifest.patches.insert(
purl.clone(),
make_record(
GOOD_UUID,
"bar.go",
"a".repeat(64).as_str(),
&compute_git_sha256_from_bytes(patched),
"GHSA-go-unselected",
&["CVE-2026-391"],
),
);
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)
.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 attests {
assert!(out.status.success(), "{go_mod}\nstderr:\n{stderr}");
assert!(stdout.contains("GHSA-go-unselected"), "{go_mod}\n{stdout}");
} else {
assert_eq!(out.status.code(), Some(1), "{go_mod}\nstderr:\n{stderr}");
assert!(!stdout.contains("not_affected"), "{go_mod}\n{stdout}");
assert!(
stderr.contains("(go_version_not_selected)"),
"{go_mod}\nstderr:\n{stderr}"
);
}
}
}

// ──────────────────────────────────────────────────────────────────────
// 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
17 changes: 11 additions & 6 deletions crates/socket-patch-cli/tests/vendor/e2e_golang_redirect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,13 +253,18 @@ fn check_detects_resolved_version_mismatch() {
);

// apply must NOT silently paper over it: a version bump means the manifest
// is stale (it patches v1.4.2, the build wants v1.5.0). apply re-affirms the
// v1.4.2 redirect but cannot make the build use it, so check STAYS red until
// a human re-scans. (Fail-closed stays closed β€” never a false "in sync".)
// is stale (it patches v1.4.2, the build wants v1.5.0). A v1.4.2 redirect
// can't make the build use it, so apply refuses and names the selected
// version (#392) instead of reporting the patch applied, and check STAYS
// red until a human re-scans. (Fail-closed stays closed.)
let (code, so, se) = apply(root, &gomodcache);
assert_eq!(
apply(root, &gomodcache).0,
0,
"apply itself succeeds (re-affirms v1.4.2)"
code, 1,
"apply must refuse an unselected version.\n{so}\n{se}"
);
assert!(
format!("{so}{se}").contains("v1.5.0"),
"the refusal names the version the build selects.\n{so}\n{se}"
);
assert_eq!(
check(root, &gomodcache),
Expand Down
Loading
Loading