From 5903819ca5e39a0bcacdbca65aebe990ce349e29 Mon Sep 17 00:00:00 2001 From: Jeppe Fredsgaard Blaabjerg Date: Fri, 23 Oct 2026 13:42:52 +0200 Subject: [PATCH] fix(manifest): materialize the healthy Maven dependencies of a partial graph Maven throws on a collection error before it fetches any artifact, so one dependency whose POM or version range failed left every other dependency of the module without its jar. The facts extension now runs the artifact step itself on the partial graph, leaving the failed dependencies out, and reports each jar it cannot fetch under its full coordinate with an unfetched record. Maven 4's batched failures are unwrapped to their real cause. --- CHANGELOG.md | 1 + .../ext/CoanaFactsLifecycleParticipant.java | 9 +- .../socket/SocketFactsRecordsEngine.java | 94 ++++++++++++++++--- .../test/maven-compat/graph-failure/pom.xml | 11 ++- .../maven-compat/smoke-test-graph-failure.sh | 42 ++++++++- 5 files changed, 135 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 618ea0165..df51df5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - With `--dynamic-sbom-inference`, an sbt build that fails before writing its results is reported as failed instead of reusing another build's results. - `socket scan create --reach` keeps the private libraries and build modules of Socket facts files in the stored SBOM, with their dependencies under them. - JVM dependencies whose jars cannot be fetched, or are gone from disk, no longer reach reachability analysis looking resolved without code. +- One unresolvable Maven dependency no longer strips the jar paths from every other dependency of its build, which made reachability analysis call their vulnerabilities unreachable. Jars that cannot be fetched are reported with their real cause, Maven 4 included. ## [1.10.0](https://github.com/SocketDev/socket-cli/releases/tag/v1.10.0) - 2026-10-22 diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java index d2fbf9217..c8fc4a0f1 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/ext/CoanaFactsLifecycleParticipant.java @@ -5,6 +5,7 @@ import org.apache.maven.execution.MavenSession; import org.apache.maven.project.ProjectDependenciesResolver; import org.apache.maven.rtinfo.RuntimeInformation; +import org.eclipse.aether.RepositorySystem; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tech.coana.socket.SocketFactsRecordsEngine; @@ -28,12 +29,16 @@ public class CoanaFactsLifecycleParticipant extends AbstractMavenLifecyclePartic private static final Logger LOG = LoggerFactory.getLogger("coana"); private final ProjectDependenciesResolver dependenciesResolver; + private final RepositorySystem repositorySystem; private final RuntimeInformation runtimeInformation; @Inject public CoanaFactsLifecycleParticipant( - ProjectDependenciesResolver dependenciesResolver, RuntimeInformation runtimeInformation) { + ProjectDependenciesResolver dependenciesResolver, + RepositorySystem repositorySystem, + RuntimeInformation runtimeInformation) { this.dependenciesResolver = dependenciesResolver; + this.repositorySystem = repositorySystem; this.runtimeInformation = runtimeInformation; } @@ -55,7 +60,7 @@ public void afterSessionEnd(MavenSession session) throws MavenExecutionException opts.excludePaths = opt(session, "socket.excludePaths"); File rootDir = new File(session.getExecutionRootDirectory()); try { - new SocketFactsRecordsEngine(dependenciesResolver, runtimeInformation.getMavenVersion(), LOG) + new SocketFactsRecordsEngine(dependenciesResolver, repositorySystem, runtimeInformation.getMavenVersion(), LOG) .run(session, session.getProjects(), rootDir, opts); } catch (IOException exception) { throw new MavenExecutionException("Cannot write socket facts records", exception); diff --git a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java index b343020e8..6e29aac61 100644 --- a/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java +++ b/src/commands/manifest/scripts/maven-extension/src/main/java/tech/coana/socket/SocketFactsRecordsEngine.java @@ -8,11 +8,14 @@ import org.apache.maven.project.DependencyResolutionResult; import org.apache.maven.project.MavenProject; import org.apache.maven.project.ProjectDependenciesResolver; +import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.graph.DependencyFilter; import org.eclipse.aether.graph.DependencyNode; import org.eclipse.aether.resolution.ArtifactDescriptorException; +import org.eclipse.aether.resolution.ArtifactResult; +import org.eclipse.aether.resolution.DependencyRequest; import org.eclipse.aether.resolution.VersionRangeResolutionException; import org.slf4j.Logger; @@ -72,12 +75,15 @@ public static final class Options { private static final String NODE_DATA_CONFLICT_WINNER = "conflict.winner"; private final ProjectDependenciesResolver dependenciesResolver; + private final RepositorySystem repositorySystem; private final String mavenVersion; private final Logger log; public SocketFactsRecordsEngine( - ProjectDependenciesResolver dependenciesResolver, String mavenVersion, Logger log) { + ProjectDependenciesResolver dependenciesResolver, RepositorySystem repositorySystem, String mavenVersion, + Logger log) { this.dependenciesResolver = dependenciesResolver; + this.repositorySystem = repositorySystem; this.mavenVersion = mavenVersion; this.log = log; } @@ -166,10 +172,11 @@ private void collectModule( String moduleCoord = module.getGroupId() + ":" + module.getArtifactId() + ":" + module.getVersion(); DependencyResolutionResult result; DependencyResolutionException thrown = null; + DependencyFilter resolutionFilter = materializationFilter(opts, passingScopes, reactorGavs, populateGavs); try { DependencyResolutionRequest request = new DefaultDependencyResolutionRequest(module, session.getRepositorySession()); - request.setResolutionFilter(materializationFilter(opts, passingScopes, reactorGavs, populateGavs)); + request.setResolutionFilter(resolutionFilter); result = dependenciesResolver.resolve(request); } catch (DependencyResolutionException e) { // Maven attaches the partial result — graph plus per-dependency errors — to the exception, so @@ -205,13 +212,16 @@ private void collectModule( log.warn("[socket-facts] could not build dependency graph for " + moduleCoord + ": " + rootMessage(e)); reported = true; } + // Maven throws on a collection error before it fetches any artifact, which would leave every + // healthy node of the partial graph without its file. + Set unfetched = new HashSet<>(); + if (!result.getCollectionErrors().isEmpty() && graph != null && opts.withFiles) { + materialize(session, graph, resolutionFilter, unresolved, moduleCoord, failures, unfetched); + } for (Dependency dep : result.getUnresolvedDependencies()) { Artifact artifact = dep.getArtifact(); - if (artifact == null) continue; - List errors = result.getResolutionErrors(dep); - failures.add(new Failure( - gav(artifact), rootMessage(errors.isEmpty() ? null : errors.get(0)), scopeOf(dep), moduleCoord)); - log.debug("[socket-facts] could not materialize " + artifact + " (" + scopeOf(dep) + ")"); + if (artifact == null || unresolved.contains(gav(artifact))) continue; + reportUnfetched(dep, result.getResolutionErrors(dep), moduleCoord, failures, unfetched); reported = true; } // Fail closed: a throw whose result named nothing must still surface, or an unresolved dependency @@ -233,6 +243,10 @@ private void collectModule( if (isProd(scopeOf(child.getDependency()))) directProdIds.add(id); } } + for (String id : unfetched) { + Node node = nodes.get(id); + if (node != null) node.unfetched = true; + } } /** @@ -275,6 +289,47 @@ public boolean accept(DependencyNode node, List parents) { }; } + /** + * The artifact step Maven skipped, run on the partial graph the way the resolver's own + * {@code resolveDependencies} continues past a collection failure. The failed dependencies are left + * out: they are never emitted, and fetching them again would only repeat the failure. + */ + private void materialize( + MavenSession session, + DependencyNode graph, + final DependencyFilter resolutionFilter, + final Set unresolved, + String moduleCoord, + Set failures, + Set unfetched) { + DependencyFilter healthy = new DependencyFilter() { + @Override + public boolean accept(DependencyNode node, List parents) { + return resolutionFilter.accept(node, parents) && !unresolved.contains(gav(node.getArtifact())); + } + }; + List results; + try { + results = repositorySystem + .resolveDependencies(session.getRepositorySession(), new DependencyRequest(graph, healthy)) + .getArtifactResults(); + } catch (org.eclipse.aether.resolution.DependencyResolutionException e) { + results = e.getResult().getArtifactResults(); + } + for (ArtifactResult ar : results) { + if (ar.isResolved()) continue; + reportUnfetched(ar.getRequest().getDependencyNode().getDependency(), ar.getExceptions(), moduleCoord, failures, unfetched); + } + } + + private void reportUnfetched( + Dependency dep, List errors, String moduleCoord, Set failures, Set unfetched) { + String id = externalId(dep.getArtifact()); + failures.add(new Failure(id, rootMessage(errors.isEmpty() ? null : errors.get(0)), scopeOf(dep), moduleCoord)); + unfetched.add(id); + log.debug("[socket-facts] could not materialize " + dep.getArtifact() + " (" + scopeOf(dep) + ")"); + } + private String visit( DependencyNode dn, Set passingScopes, @@ -295,16 +350,12 @@ private String visit( String gav = gav(artifact); if (unresolved.contains(gav)) return null; boolean internal = reactorGavs.contains(gav); - // Maven's `type` rather than aether's file extension, so a test-jar keeps the coordId the - // assembler and the Gradle/SBT scripts already emit. String type = artifact.getProperty(ARTIFACT_PROPERTY_TYPE, artifact.getExtension()); String classifier = artifact.getClassifier(); - // Base version: a resolved remote snapshot's `version` is the timestamped build, which would - // put a coordinate in the records that no manifest ever names. String version = artifact.getBaseVersion(); String id = internal ? SocketSupport.bareId(artifact.getGroupId(), artifact.getArtifactId(), version) - : SocketSupport.coordId(artifact.getGroupId(), artifact.getArtifactId(), type, classifier, version); + : externalId(artifact); // One walk per node per module traversal: a shared subtree reached via another edge is already // fully recorded (node, children, resolved file), so hand back the id without re-descending. @@ -329,6 +380,17 @@ private String visit( return id; } + private static String externalId(Artifact artifact) { + // Maven's `type` rather than aether's file extension, so a test-jar keeps the coordId the + // assembler and the Gradle/SBT scripts already emit. Base version: a resolved remote snapshot's + // `version` is the timestamped build, which would put a coordinate in the records that no + // manifest ever names. + return SocketSupport.coordId( + artifact.getGroupId(), artifact.getArtifactId(), + artifact.getProperty(ARTIFACT_PROPERTY_TYPE, artifact.getExtension()), artifact.getClassifier(), + artifact.getBaseVersion()); + } + private static String gav(Artifact artifact) { return artifact.getGroupId() + ":" + artifact.getArtifactId() + ":" + artifact.getBaseVersion(); } @@ -405,6 +467,7 @@ private int emitRoot( if (nodeMap.containsKey(child)) rec(lines, "edge", rootId, n.id, child); } for (String f : n.files) rec(lines, "file", rootId, n.id, f); + if (n.unfetched) rec(lines, "unfetched", rootId, n.id); } return rootIdx + 1; } @@ -519,7 +582,11 @@ private static String rootMessage(Throwable t) { int guard = 0; while (cur != null && guard++ < 12) { if (cur.getMessage() != null) msg = cur.getMessage(); - cur = cur.getCause(); + Throwable next = cur.getCause(); + // Maven 4 batches requests: BatchRequestException carries each failed request as a suppressed + // exception, not as its cause. + if (next == null && cur.getSuppressed().length > 0) next = cur.getSuppressed()[0]; + cur = next; } return (msg != null ? msg : "unknown resolution failure").trim(); } @@ -623,6 +690,7 @@ private static final class Node { final String project; final TreeSet children = new TreeSet<>(); final TreeSet files = new TreeSet<>(); + boolean unfetched; Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) { this.id = id; diff --git a/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml b/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml index aab459112..b93299123 100644 --- a/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml +++ b/src/commands/manifest/scripts/test/maven-compat/graph-failure/pom.xml @@ -9,15 +9,20 @@ 8 UTF-8 - + demo.graphfail ok 1.0 + + demo.graphfail + nojar + 1.0 + demo.graphfail lib diff --git a/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh b/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh index f100536a8..5eb4c59cc 100755 --- a/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh +++ b/src/commands/manifest/scripts/test/maven-compat/smoke-test-graph-failure.sh @@ -3,7 +3,9 @@ # dependency, in the module it was collected for, and never emitted as a node. Maven keeps a # dependency whose POM failed in the graph (Maven 3.2's resolver leaves it out instead), so it fails # in its own scope, and an excluded scope drops it; a dependency the graph lacks (an unsatisfiable -# range) has no scope. The module itself must never be reported as the failure. +# range) has no scope. The module itself must never be reported as the failure. Under --with-files, +# every healthy dependency must still get its file, and a jar that cannot be fetched is reported under +# its full coordinate, with the cause rather than a wrapper. # # Runs offline: the POMs are seeded straight into the local repository, so their missing parent and # the absent versions of ranged can only fail to resolve. @@ -34,15 +36,18 @@ for lib in lib testlib; do POM done -mkdir -p "$M2/demo/graphfail/ok/1.0" -cat > "$M2/demo/graphfail/ok/1.0/ok-1.0.pom" <<'POM' +for lib in ok nojar; do + mkdir -p "$M2/demo/graphfail/$lib/1.0" + cat > "$M2/demo/graphfail/$lib/1.0/$lib-1.0.pom" < 4.0.0 demo.graphfail - ok + $lib 1.0 POM +done +( cd "$M2/demo/graphfail/ok/1.0" && mkdir -p empty && jar cf ok-1.0.jar -C empty . && rm -r empty ) run() { rm -f "$RECORDS" @@ -100,6 +105,32 @@ print(f"PASS (graph failure): {expected}") PY } +check_files() { + python3 - "$RECORDS" <<'PY' +import sys +rows = [l.rstrip('\n').split('\t') for l in open(sys.argv[1]) if l.strip()] +files = {r[2] for r in rows if r[0] == 'file'} +failures = {(r[1], r[3]) for r in rows if r[0] == 'failure'} +details = {r[2] for r in rows if r[0] == 'failure' and r[1] == 'demo.graphfail:nojar:jar:1.0'} +unfetched = {r[2] for r in rows if r[0] == 'unfetched'} +errors = [] +if 'demo.graphfail:ok:jar:1.0' not in files: + errors.append(f"ok resolved, so its jar must be materialized despite the collection errors; files={sorted(files)}") +if ('demo.graphfail:nojar:jar:1.0', 'compile') not in failures: + errors.append(f"nojar's jar cannot be fetched, so it must be reported; failures={sorted(failures)}") +if any('One or more requests failed' in d for d in details): + errors.append(f"nojar's failure must give the cause, not the batch wrapper; details={sorted(details)}") +if unfetched != {'demo.graphfail:nojar:jar:1.0'}: + errors.append(f"only nojar's jar is unfetched; unfetched={sorted(unfetched)}") +if errors: + print("FAIL:") + for e in errors: + print(" -", e) + sys.exit(1) +print("PASS (graph failure, with files)") +PY +} + VERSION="$("$MVN" -v 2>/dev/null | head -1)" echo "+ $VERSION (graph failure)" case "$VERSION" in @@ -110,3 +141,6 @@ run check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": "test", "demo.graphfail:ranged:[1.0,2.0)": ""}' run -Dsocket.excludeConfigs=test check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": null, "demo.graphfail:ranged:[1.0,2.0)": ""}' +run -Dsocket.withFiles=true +check '{"demo.graphfail:lib:1.0": "compile", "demo.graphfail:testlib:1.0": "test", "demo.graphfail:ranged:[1.0,2.0)": ""}' +check_files