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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String> 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<Exception> 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
Expand All @@ -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;
}
}

/**
Expand Down Expand Up @@ -275,6 +289,47 @@ public boolean accept(DependencyNode node, List<DependencyNode> 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<String> unresolved,
String moduleCoord,
Set<Failure> failures,
Set<String> unfetched) {
DependencyFilter healthy = new DependencyFilter() {
@Override
public boolean accept(DependencyNode node, List<DependencyNode> parents) {
return resolutionFilter.accept(node, parents) && !unresolved.contains(gav(node.getArtifact()));
}
};
List<ArtifactResult> 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<Exception> errors, String moduleCoord, Set<Failure> failures, Set<String> 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<String> passingScopes,
Expand All @@ -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.
Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -623,6 +690,7 @@ private static final class Node {
final String project;
final TreeSet<String> children = new TreeSet<>();
final TreeSet<String> files = new TreeSet<>();
boolean unfetched;

Node(String id, String groupId, String artifactId, String type, String classifier, String version, String project) {
this.id = id;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,20 @@
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<!-- ok resolves. The rest fail while Maven collects the graph: lib's and testlib's POMs name a parent that cannot be
fetched, which leaves them in the graph with their scope; no version of ranged exists, which
leaves it out of the graph. -->
<!-- ok resolves. nojar's POM resolves but its jar is absent. The rest fail while Maven collects the graph: lib's
and testlib's POMs name a parent that cannot be fetched, which leaves them in the graph with their scope; no
version of ranged exists, which leaves it out of the graph. -->
<dependencies>
<dependency>
<groupId>demo.graphfail</groupId>
<artifactId>ok</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>demo.graphfail</groupId>
<artifactId>nojar</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>demo.graphfail</groupId>
<artifactId>lib</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -34,15 +36,18 @@ for lib in lib testlib; do
</project>
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" <<POM
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo.graphfail</groupId>
<artifactId>ok</artifactId>
<artifactId>$lib</artifactId>
<version>1.0</version>
</project>
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"
Expand Down Expand Up @@ -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
Expand All @@ -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
Loading