Skip to content
Open
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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1062,9 +1062,13 @@ jobs:
- {os: macos-latest, suite: e2e_vex_build, test_filter: 'pip:: --ignored', pip: '22 26'}
# Real-Maven hosted + vendored capstones, one leg per Maven line:
# 3.6 (pre http-blocker), 3.8 (resolver 1.6: no trusted checksums),
# 3.9 (trusted checksums), 4.0 rc.
# 3.9 (trusted checksums), 4.0 rc. Hosted also runs both sides of
# the trusted-checksums floor: 3.9.3 (last release that ignores
# the .mvn/checksums pin) and 3.9.4 (first that enforces it).
- {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.6.3'}
- {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.8.9'}
- {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.3'}
- {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.4'}
- {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'}
- {os: ubuntu-latest, suite: e2e_redirect_maven_build, maven: '4.0.0-rc-6'}
- {os: macos-latest, suite: e2e_redirect_maven_build, maven: '3.9.16'}
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ limits, and required install commands.

### Fixed

- Hosted Maven warns `redirect_maven_trusted_checksums_unenforced` when
`.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4. Maven
3.9.0–3.9.3 never enforce the Trusted Checksums pin that hosted mode writes;
the 4.0.0 notes and docs wrongly said every 3.9 release does. The version
suffix still fails closed. CI runs the real-Maven hosted capstone on 3.9.3 and
3.9.4 (#258).
- Patch application, reversal, and cleanup handle missing files, release variants,
corrupt state, newer ledger formats, and unsafe manifest paths without silently
dropping protection. File ownership restoration failures produce warnings.
Expand Down
2 changes: 1 addition & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/socket-patch-cli/src/commands/scan/hosted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3551,6 +3551,7 @@ mod tests {
"pom.xml",
".mvn/maven.config",
".mvn/checksums/checksums.sha256",
".mvn/wrapper/maven-wrapper.properties",
"settings.gradle",
"settings.gradle.kts",
"build.gradle",
Expand Down
24 changes: 24 additions & 0 deletions crates/socket-patch-cli/tests/e2e_redirect_maven_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,19 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() {
return;
};
let pristine_pom = std::fs::read_to_string(proj.join("pom.xml")).unwrap();
// The project pins the Maven under test through the Maven Wrapper, so
// the rewriter's "your Maven ignores the Trusted Checksums pin" warning
// is judged against the same release that step 5b drives (#258).
std::fs::create_dir_all(proj.join(".mvn/wrapper")).unwrap();
std::fs::write(
proj.join(".mvn/wrapper/maven-wrapper.properties"),
format!(
"distributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven/\
apache-maven/{v}/apache-maven-{v}-bin.zip\n",
v = mvn.version
),
)
.unwrap();

// 2. Patched jar + served pom + the record (real before/after hashes).
let (_orig, patched) = patched_member(&jar, UUID);
Expand Down Expand Up @@ -353,6 +366,17 @@ fn maven_scan_hosted_fresh_checkout_install_and_manifestless_vex() {
assert_eq!(env["redirect"]["mode"], "hosted", "{env}");
assert_eq!(env["redirect"]["redirected"], 1, "{env}");
assert_eq!(env["vex"]["statements"], 1, "{env}");
let unenforced = env["redirect"]["warnings"]
.as_array()
.into_iter()
.flatten()
.any(|w| w["code"] == "redirect_maven_trusted_checksums_unenforced");
assert_eq!(
unenforced,
!mvn.enforces_trusted_checksums(),
"Maven {}: the unenforced-pin warning must match what step 5b proves: {env}",
mvn.version
);
let embedded: serde_json::Value =
serde_json::from_slice(&std::fs::read(proj.join("embedded.vex.json")).unwrap()).unwrap();
assert_attested(&embedded, &purl(), UUID, Marker::Redirected, &vulns());
Expand Down
14 changes: 8 additions & 6 deletions crates/socket-patch-cli/tests/maven_build_common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,15 @@ impl Mvn {
self.numeric()[0]
}

/// Maven ≥ 3.9 (resolver ≥ 1.9) enforces the trusted-checksums summary
/// file the hosted rewriter commits under `.mvn/checksums/`; older
/// lines ignore the `aether.*` properties and only the transport
/// `checksumPolicy=fail` sidecar check protects the download.
/// Maven ≥ 3.9.4 enforces the trusted-checksums summary file the hosted
/// rewriter commits under `.mvn/checksums/` (#258). 3.9.0 / 3.9.1 leave
/// `${session.rootDirectory}` in `maven.config` uninterpolated, so the
/// file is never found; 3.9.2 / 3.9.3 ignore `checksumAlgorithms=SHA-256`
/// and check SHA-1 only; older lines have no trusted-checksums support.
/// Below 3.9.4 only the transport `checksumPolicy=fail` sidecar check
/// protects the download.
pub fn enforces_trusted_checksums(&self) -> bool {
let v = self.numeric();
v[0] > 3 || (v[0] == 3 && v[1] >= 9)
self.numeric() >= vec![3, 9, 4]
}

/// `mvn -B <args>` in `cwd` against the local repository `m2`, with the
Expand Down
8 changes: 8 additions & 0 deletions crates/socket-patch-core/src/formats/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ const REGISTRY: &[FormatFile] = &[
// into (read so an existing user config / checksum set is preserved).
row(".mvn/maven.config", "maven", HOSTED),
row(".mvn/checksums/checksums.sha256", "maven", HOSTED),
// Never edited: its `distributionUrl` names the project's Maven, which
// the maven planner checks against the Trusted Checksums floor (3.9.4)
// to warn when the `.mvn/*` pin above would be inert.
row(
".mvn/wrapper/maven-wrapper.properties",
"maven",
HOSTED | PRESENCE_ONLY,
),
// Gradle build scripts are never edited — their presence only feeds the
// maven planner's paste-able `exclusiveContent` snippet warning.
row("settings.gradle", "maven", HOSTED | PRESENCE_ONLY),
Expand Down
231 changes: 231 additions & 0 deletions crates/socket-patch-core/src/patch/redirect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5259,6 +5259,56 @@ pub(crate) const TRUSTED_CHECKSUMS_ON: &str =

pub(crate) const MVN_CONFIG: &str = ".mvn/maven.config";
pub(crate) const MVN_CHECKSUMS: &str = ".mvn/checksums/checksums.sha256";
/// The Maven Wrapper's settings file. Read only, never edited: its
/// `distributionUrl` names the Maven release the project builds with, so the
/// rewriter can tell when the Trusted Checksums files it writes are inert.
pub(crate) const MVN_WRAPPER_PROPERTIES: &str = ".mvn/wrapper/maven-wrapper.properties";

/// The first Maven release that enforces the `.mvn/*` Trusted Checksums
/// files (#258). 3.9.0 / 3.9.1 do not interpolate `${session.rootDirectory}`
/// in `maven.config`, so the summary file is never found; 3.9.2 / 3.9.3
/// ignore `checksumAlgorithms=SHA-256` and check SHA-1 only. Older lines have
/// no Trusted Checksums post-processor at all.
const TRUSTED_CHECKSUMS_MIN_MAVEN: [u32; 3] = [3, 9, 4];

/// The Apache Maven release a `maven-wrapper.properties` pins, taken from its
/// `distributionUrl` (`…/apache-maven-<version>-bin.zip` or `.tar.gz`). None
/// for a missing key or any other distribution (e.g. mvnd), which then goes
/// unwarned.
fn maven_wrapper_version(props: &str) -> Option<String> {
props.lines().find_map(|line| {
let line = line.trim();
if line.starts_with('#') || line.starts_with('!') {
return None;
}
let rest = line.strip_prefix("distributionUrl")?;
if !rest.starts_with(|c: char| c == '=' || c == ':' || c.is_whitespace()) {
return None;
}
let value = rest
.trim_start()
.strip_prefix(['=', ':'])
.unwrap_or(rest)
.trim();
let file = value.rsplit('/').next()?;
let version = file
.strip_prefix("apache-maven-")?
.strip_suffix(".zip")
.or_else(|| file.strip_prefix("apache-maven-")?.strip_suffix(".tar.gz"))?
.strip_suffix("-bin")?;
(!version.is_empty()).then(|| version.to_string())
})
}

/// Whether Maven `version` enforces the Trusted Checksums files (≥ 3.9.4;
/// pre-release suffixes such as `-rc-6` compare as their release).
fn maven_enforces_trusted_checksums(version: &str) -> bool {
let mut parts = [0u32; 3];
for (slot, part) in parts.iter_mut().zip(version.split(['.', '-'])) {
*slot = part.parse().unwrap_or(0);
}
parts >= TRUSTED_CHECKSUMS_MIN_MAVEN
}

/// Strip any `sha256-`/`sha256:` SRI-style prefix off a stored hash, leaving the
/// bare lowercase hex Maven's trusted-checksums summary file expects (twin of
Expand Down Expand Up @@ -5358,6 +5408,10 @@ fn rewrite_maven_pom(
let mut checksum_entries: Vec<(String, String)> = vec![];
let gradle_build_present = GRADLE_FILES.iter().any(|f| files.contains_key(*f));
let mut warned_no_pom = false;
// Local-repo paths of the suffixed jars this run's Trusted Checksums pin
// covers, whether the pin lands now or a prior run wrote it: the
// unenforced-pin warning must fire on re-scans too.
let mut pinned_jar_paths: Vec<String> = vec![];

for dep in &maven {
let Some(ov) = registry_override_of_kind(dep, "maven2") else {
Expand Down Expand Up @@ -5606,6 +5660,15 @@ fn rewrite_maven_pom(
});
}

if jar_sha256.is_some() && pom_sha256.is_some() {
pinned_jar_paths.push(local_repo_artifact_path(
&group_id,
&artifact_id,
&suffixed_version,
"jar",
));
}

// A pin landed this run: inject the repository (idempotent via the <id>
// guard) and emit trusted checksums. When the pin was already present
// from a prior run, `pin_landed` stays false and both are skipped,
Expand Down Expand Up @@ -5694,6 +5757,36 @@ fn rewrite_maven_pom(
new: None,
});
}

// The pin is written regardless (a later wrapper upgrade enforces it, and
// the version suffix is fail-closed on its own), but a project whose
// wrapper pins a Maven that ignores it must not read it as client-side
// content pinning. Judged on the files as they stand after this run, so a
// re-scan of an already-pinned project warns as well.
let final_text = |rel: &str| result.files.get(rel).or_else(|| files.get(rel));
let pin_in_place = final_text(MVN_CONFIG)
.is_some_and(|c| c.lines().any(|l| l.trim() == TRUSTED_CHECKSUMS_ON))
&& final_text(MVN_CHECKSUMS).is_some_and(|c| {
pinned_jar_paths.iter().any(|p| {
c.lines()
.any(|l| l.split_whitespace().nth(1) == Some(p.as_str()))
})
});
if let Some(version) = files
.get(MVN_WRAPPER_PROPERTIES)
.and_then(|props| maven_wrapper_version(props))
.filter(|v| pin_in_place && !maven_enforces_trusted_checksums(v))
{
result.warnings.push(RewriteWarning {
code: "redirect_maven_trusted_checksums_unenforced".into(),
detail: format!(
"{MVN_WRAPPER_PROPERTIES} pins Maven {version}, which does not enforce the \
Trusted Checksums pin in {MVN_CHECKSUMS} (Maven enforces it from 3.9.4); \
only the transport .sha1 check guards the Socket-served artifacts. \
Upgrade the Maven Wrapper to 3.9.4 or later"
),
});
}
}

/// Insert the socket-patch `<repository>` block: releases enabled with
Expand Down Expand Up @@ -6574,6 +6667,144 @@ mod tests {

/// A user `.mvn/maven.config` key set to a different value is preserved
/// (never overridden) and a conflict warning is emitted.
/// A Maven Wrapper `maven-wrapper.properties` with the given
/// `distributionUrl` release.
fn maven_wrapper(version: &str) -> String {
format!(
"# Licensed to the Apache Software Foundation (ASF)\nwrapperVersion=3.3.2\ndistributionType=only-script\ndistributionUrl=https\\://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/{version}/apache-maven-{version}-bin.zip\n"
)
}

fn rewrite_with_wrapper(wrapper: &str) -> RewriteResult {
let mut files = BTreeMap::new();
files.insert(
"pom.xml".to_string(),
pom_with_dep("\n <version>1.7.36</version>", ""),
);
files.insert(MVN_WRAPPER_PROPERTIES.to_string(), wrapper.to_string());
rewrite_registry_redirect(&files, &[maven_override()])
}

/// Maven 3.9.0-3.9.3 (and every older line) never enforce the committed
/// `.mvn/checksums` summary (#258). A project whose Maven Wrapper pins
/// such a release gets the pin written AND a warning saying it is inert,
/// instead of a silent claim of client-side content pinning.
#[test]
fn maven_pom_trusted_checksums_warns_when_the_wrapper_maven_ignores_them() {
for version in ["3.9.0", "3.9.3", "3.8.9", "3.6.3"] {
let r = rewrite_with_wrapper(&maven_wrapper(version));
assert!(
r.files.contains_key(MVN_CHECKSUMS),
"{version}: the pin is still written"
);
assert_eq!(
warning_codes(&r),
vec!["redirect_maven_trusted_checksums_unenforced"],
"{version}: {:?}",
r.warnings
);
let detail = &r.warnings[0].detail;
assert!(
detail.contains(&format!("Maven {version}")) && detail.contains("3.9.4"),
"{detail}"
);
assert!(detail.contains(MVN_WRAPPER_PROPERTIES), "{detail}");
}
}

/// A re-scan of a project a prior run already pinned writes nothing, but
/// the wrapper's Maven still ignores the pin, so it still warns. Without
/// the Socket checksum entry (or with Trusted Checksums switched off) there
/// is no pin to call inert, and nothing is said.
#[test]
fn maven_pom_trusted_checksums_unenforced_warning_survives_a_rescan() {
let first = rewrite_with_wrapper(&maven_wrapper("3.9.3"));
let mut again = BTreeMap::new();
for (rel, text) in &first.files {
again.insert(rel.clone(), text.clone());
}
again.insert(MVN_WRAPPER_PROPERTIES.to_string(), maven_wrapper("3.9.3"));
let second = rewrite_registry_redirect(&again, &[maven_override()]);
assert!(
second.files.is_empty() && second.edits.is_empty(),
"re-scan is edit-free: {:?}",
second.edits
);
assert_eq!(
warning_codes(&second),
vec!["redirect_maven_trusted_checksums_unenforced"]
);

let mut no_entry = again.clone();
no_entry.insert(MVN_CHECKSUMS.to_string(), String::new());
let r = rewrite_registry_redirect(&no_entry, &[maven_override()]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);

let mut switched_off = again.clone();
switched_off.remove(MVN_CONFIG);
let r = rewrite_registry_redirect(&switched_off, &[maven_override()]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);

again.insert(MVN_WRAPPER_PROPERTIES.to_string(), maven_wrapper("3.9.4"));
let r = rewrite_registry_redirect(&again, &[maven_override()]);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}

/// 3.9.4 is the first release that rejects a mismatch; newer lines
/// (3.10 / 4.0 release candidates included) enforce too. A wrapper with
/// no recognisable Apache Maven distribution is never warned about.
#[test]
fn maven_pom_trusted_checksums_no_warning_for_enforcing_or_unknown_wrapper() {
for wrapper in [
maven_wrapper("3.9.4"),
maven_wrapper("3.9.16"),
maven_wrapper("3.10.0-rc-1"),
maven_wrapper("4.0.0-rc-6"),
"distributionUrl=https://example.test/maven-mvnd-1.0.2-linux-amd64.zip\n".to_string(),
"# distributionUrl=https\\://x/apache-maven/3.9.3/apache-maven-3.9.3-bin.zip\n"
.to_string(),
String::new(),
] {
let r = rewrite_with_wrapper(&wrapper);
assert!(r.files.contains_key(MVN_CHECKSUMS), "{wrapper}");
assert!(r.warnings.is_empty(), "{wrapper}: {:?}", r.warnings);
}
}

/// The `distributionUrl` parse: escaped or plain `:`, `=`/`:`/space
/// separators, `.tar.gz` distributions, comments and CRLF.
#[test]
fn maven_wrapper_version_parses_distribution_url() {
assert_eq!(
maven_wrapper_version(&maven_wrapper("3.9.3")).as_deref(),
Some("3.9.3")
);
assert_eq!(
maven_wrapper_version(
"distributionUrl : https://archive.apache.org/dist/maven/maven-3/3.9.2/binaries/apache-maven-3.9.2-bin.tar.gz\r\n"
)
.as_deref(),
Some("3.9.2")
);
assert_eq!(
maven_wrapper_version(
"! comment\r\ndistributionUrl=https\\://repo/apache-maven-4.0.0-rc-6-bin.zip\r\n"
)
.as_deref(),
Some("4.0.0-rc-6")
);
assert_eq!(maven_wrapper_version("wrapperVersion=3.3.2\n"), None);
assert_eq!(
maven_wrapper_version("distributionUrlOld=https://x/apache-maven-3.9.3-bin.zip\n"),
None
);
assert!(!maven_enforces_trusted_checksums("3.9.3"));
assert!(maven_enforces_trusted_checksums("3.9.4"));
assert!(maven_enforces_trusted_checksums("3.10.0-rc-1"));
assert!(maven_enforces_trusted_checksums("4.0.0"));
assert!(!maven_enforces_trusted_checksums("3.8.9"));
}

#[test]
fn maven_pom_trusted_checksums_conflict() {
let mut files = BTreeMap::new();
Expand Down
Loading
Loading