diff --git a/src/main/java/com/projectlightpath/analysis/java/CallEdge.java b/src/main/java/com/projectlightpath/analysis/java/CallEdge.java index 0c793b5..6635bc5 100644 --- a/src/main/java/com/projectlightpath/analysis/java/CallEdge.java +++ b/src/main/java/com/projectlightpath/analysis/java/CallEdge.java @@ -7,7 +7,62 @@ public record CallEdge( int line, String sourcePath, SemanticCallFacts.Certainty certaintyHint, - String relationshipType) { + String relationshipType, + TargetContext targetContext) { + public enum TargetOrigin { + APPLICATION("application"), + DEPENDENCY("dependency"); + + private final String id; + + TargetOrigin(String id) { + this.id = id; + } + + public String id() { + return id; + } + } + + /** Describes the resolved input that supplied a call target. */ + public record TargetContext( + TargetOrigin origin, + String dependencyClasspathEntry, + String targetBytecodePath, + String targetSourcePath) { + public TargetContext { + origin = origin == null ? TargetOrigin.APPLICATION : origin; + dependencyClasspathEntry = blankIfNull(dependencyClasspathEntry); + targetBytecodePath = blankIfNull(targetBytecodePath); + targetSourcePath = blankIfNull(targetSourcePath); + if (origin == TargetOrigin.APPLICATION) { + dependencyClasspathEntry = ""; + } + if (origin == TargetOrigin.DEPENDENCY && dependencyClasspathEntry.isBlank()) { + throw new IllegalArgumentException("dependency target context requires a classpath entry"); + } + } + + public static TargetContext application() { + return new TargetContext(TargetOrigin.APPLICATION, "", "", ""); + } + + private static String blankIfNull(String value) { + return value == null ? "" : value; + } + } + + /** Compatibility constructor for existing provider graph fixtures. */ + public CallEdge( + MethodSymbol caller, + MethodSymbol callee, + int line, + String sourcePath, + SemanticCallFacts.Certainty certaintyHint, + String relationshipType) { + this(caller, callee, line, sourcePath, certaintyHint, relationshipType, TargetContext.application()); + } + public CallEdge { if (line < -1) { throw new IllegalArgumentException("call line must be -1 or non-negative"); @@ -17,9 +72,17 @@ public record CallEdge( } certaintyHint = certaintyHint == null ? SemanticCallFacts.Certainty.DEFINITE : certaintyHint; relationshipType = relationshipType == null || relationshipType.isBlank() ? "invokes" : relationshipType; + targetContext = targetContext == null ? TargetContext.application() : targetContext; } public static CallEdge definite(MethodSymbol caller, MethodSymbol callee, int line, String sourcePath) { - return new CallEdge(caller, callee, line, sourcePath, SemanticCallFacts.Certainty.DEFINITE, "invokes"); + return new CallEdge( + caller, + callee, + line, + sourcePath, + SemanticCallFacts.Certainty.DEFINITE, + "invokes", + TargetContext.application()); } } diff --git a/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java b/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java index 28c9a71..d1c4e5f 100644 --- a/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java +++ b/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java @@ -4,8 +4,10 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -16,69 +18,136 @@ final class ProviderFactNormalizer { private ProviderFactNormalizer() {} static List normalize(AnalysisContext context, AnalysisGraph graph) { - List facts = new ArrayList<>(); Set ids = new HashSet<>(); - Map baseIdCounts = new HashMap<>(); - for (CallEdge edge : graph.calls()) { - baseIdCounts.merge(legacyCallId(edge), 1, Integer::sum); + CallReferences references = collectCallReferences(graph); + Map callIds = callIds(references, ids); + List facts = callFacts(context, references.edges(), callIds); + facts.addAll(dynamicFacts(context, graph.dynamicFeatures(), ids)); + return List.copyOf(facts); + } + + /** Collects graph edges and the application collision counts used for stable IDs. */ + private static CallReferences collectCallReferences(AnalysisGraph graph) { + List edgeRefs = new ArrayList<>(); + Map applicationBaseIdCounts = new HashMap<>(); + for (int index = 0; index < graph.calls().size(); index++) { + CallEdge edge = graph.calls().get(index); + edgeRefs.add(new EdgeRef(index, edge, edgeIdentity(edge))); + if (isApplicationEdge(edge)) { + applicationBaseIdCounts.merge(legacyCallId(edge), 1, Integer::sum); + } } - for (CallEdge edge : graph.calls()) { - String callIdentity = edge.caller().displayName() + "|" + edge.caller().descriptor() - + "->" + edge.callee().displayName() + "|" + edge.callee().descriptor() - + "@" + edge.line() + "|" + edge.sourcePath() + "|" + edge.relationshipType(); - String baseId = legacyCallId(edge); - String id = baseIdCounts.get(baseId) == 1 - ? uniqueId(ids, baseId, callIdentity) - : uniqueId(ids, baseId + "-" + sha256(callIdentity), callIdentity); - boolean openWorldDynamic = DynamicCertaintyPolicy.isOpenWorldProviderRelationship( - edge.relationshipType()); - SemanticCallFacts.Certainty certainty = openWorldDynamic - ? SemanticCallFacts.Certainty.UNRESOLVED - : edge.certaintyHint(); - String limitation = openWorldDynamic - ? DynamicCertaintyPolicy.LIMITATION - : certainty == SemanticCallFacts.Certainty.POSSIBLE - ? "CHA exposes a bounded implementation/callback candidate; runtime selection is not unique." - : null; - facts.add(SemanticCallFacts.Fact.provider( - id, - context, - edge.caller().displayName(), - edge.callee().displayName(), - edge.relationshipType(), - certainty, - List.of(SemanticCallFacts.Evidence.sootup( - edge.sourcePath(), - edge.line(), - edge.caller(), - edge.callee(), - "SootUp CHA application call")), - limitation)); + return new CallReferences(List.copyOf(edgeRefs), Map.copyOf(applicationBaseIdCounts)); + } + + /** Assigns deterministic IDs without changing the graph's output order. */ + private static Map callIds(CallReferences references, Set ids) { + Map idsByIndex = new HashMap<>(); + references.edges().stream() + .sorted(Comparator.comparing(EdgeRef::identity).thenComparingInt(EdgeRef::index)) + .forEach(ref -> idsByIndex.put( + ref.index(), callId(ref, references.applicationBaseIdCounts(), ids))); + return idsByIndex; + } + + /** Selects the origin-aware ID namespace for one call edge. */ + private static String callId( + EdgeRef ref, Map applicationBaseIdCounts, Set ids) { + if (!isApplicationEdge(ref.edge())) { + return uniqueId(ids, dependencyCallId(ref.edge()), ref.identity()); } - for (DynamicFeature feature : graph.dynamicFeatures()) { - String relationship = feature.kind() == DynamicFeature.Kind.REFLECTION ? "reflection" : "proxy_dispatch"; - String baseId = dynamicFeatureId(feature); - String id = baseId; - int duplicate = 1; - while (!ids.add(id)) { - id = baseId + "-" + duplicate++; - } - facts.add(SemanticCallFacts.Fact.provider( - id, - context, - feature.caller().displayName(), - feature.target(), - relationship, - SemanticCallFacts.Certainty.UNRESOLVED, - List.of(new SemanticCallFacts.Evidence( - feature.kind() == DynamicFeature.Kind.REFLECTION ? "reflection_api" : "dynamic_proxy_call", - feature.sourcePath(), - feature.symbols(), - feature.sourceLine() > 0 ? feature.sourceLine() : null, - dynamicEvidenceAttributes(feature))), - feature.limitation())); + String baseId = legacyCallId(ref.edge()); + if (applicationBaseIdCounts.get(baseId) == 1) { + return uniqueId(ids, baseId, ref.identity()); } - return List.copyOf(facts); + return uniqueId(ids, baseId + "-" + sha256(ref.identity()), ref.identity()); + } + + /** Normalizes ordinary call edges in their original provider order. */ + private static List callFacts( + AnalysisContext context, List edgeRefs, Map callIds) { + List facts = new ArrayList<>(); + for (EdgeRef ref : edgeRefs) { + facts.add(callFact(context, ref.edge(), callIds.get(ref.index()))); + } + return facts; + } + + /** Normalizes one ordinary provider edge with the centralized certainty policy. */ + private static SemanticCallFacts.Fact callFact( + AnalysisContext context, CallEdge edge, String id) { + boolean openWorldDynamic = DynamicCertaintyPolicy.isOpenWorldProviderRelationship( + edge.relationshipType()); + SemanticCallFacts.Certainty certainty = openWorldDynamic + ? SemanticCallFacts.Certainty.UNRESOLVED + : edge.certaintyHint(); + return SemanticCallFacts.Fact.provider( + id, + context, + edge.caller().displayName(), + edge.callee().displayName(), + edge.relationshipType(), + certainty, + List.of(sootupEvidence(edge)), + limitation(openWorldDynamic, certainty)); + } + + /** Normalizes source-backed dynamic observations after ordinary call IDs exist. */ + private static List dynamicFacts( + AnalysisContext context, List features, Set ids) { + List facts = new ArrayList<>(); + for (DynamicFeature feature : features) { + facts.add(dynamicFact(context, feature, nextDynamicId(feature, ids))); + } + return facts; + } + + /** Creates one unresolved fact for a reflection or proxy observation. */ + private static SemanticCallFacts.Fact dynamicFact( + AnalysisContext context, DynamicFeature feature, String id) { + String relationship = feature.kind() == DynamicFeature.Kind.REFLECTION + ? "reflection" : "proxy_dispatch"; + String evidenceKind = feature.kind() == DynamicFeature.Kind.REFLECTION + ? "reflection_api" : "dynamic_proxy_call"; + return SemanticCallFacts.Fact.provider( + id, + context, + feature.caller().displayName(), + feature.target(), + relationship, + SemanticCallFacts.Certainty.UNRESOLVED, + List.of(new SemanticCallFacts.Evidence( + evidenceKind, + feature.sourcePath(), + feature.symbols(), + feature.sourceLine() > 0 ? feature.sourceLine() : null, + dynamicEvidenceAttributes(feature))), + feature.limitation()); + } + + /** Allocates a deterministic dynamic ID without colliding with call IDs. */ + private static String nextDynamicId(DynamicFeature feature, Set ids) { + String baseId = dynamicFeatureId(feature); + String id = baseId; + int duplicate = 1; + while (!ids.add(id)) { + id = baseId + "-" + duplicate++; + } + return id; + } + + /** Applies open-world abstention and bounded-candidate limitations. */ + private static String limitation(boolean openWorldDynamic, SemanticCallFacts.Certainty certainty) { + if (openWorldDynamic) { + return DynamicCertaintyPolicy.LIMITATION; + } + return certainty == SemanticCallFacts.Certainty.POSSIBLE + ? "CHA exposes a bounded implementation/callback candidate; runtime selection is not unique." + : null; + } + + private static boolean isApplicationEdge(CallEdge edge) { + return edge.targetContext().origin() == CallEdge.TargetOrigin.APPLICATION; } /** Uses the complete source identity so separate dynamic sites cannot silently collide. */ @@ -100,6 +169,54 @@ private static String legacyCallId(CallEdge edge) { (edge.caller().descriptor() + "->" + edge.callee().descriptor() + "@" + edge.line()).hashCode()); } + /** Gives dependency targets an origin-aware ID namespace without changing application IDs. */ + private static String dependencyCallId(CallEdge edge) { + return "sootup-dependency-call-" + sha256(dependencyCallIdentity(edge)); + } + + /** Retains the pre-existing identity string for application call edges. */ + private static String applicationCallIdentity(CallEdge edge) { + return edge.caller().displayName() + "|" + edge.caller().descriptor() + + "->" + edge.callee().displayName() + "|" + edge.callee().descriptor() + + "@" + edge.line() + "|" + edge.sourcePath() + "|" + edge.relationshipType(); + } + + /** Separates dependency target origin from the optional evidence path. */ + private static String dependencyCallIdentity(CallEdge edge) { + return applicationCallIdentity(edge) + + "|" + edge.targetContext().origin().id() + + "|" + edge.targetContext().dependencyClasspathEntry(); + } + + private static String edgeIdentity(CallEdge edge) { + return edge.targetContext().origin() == CallEdge.TargetOrigin.APPLICATION + ? applicationCallIdentity(edge) + : dependencyCallIdentity(edge); + } + + private static SemanticCallFacts.Evidence sootupEvidence(CallEdge edge) { + Map attributes = new LinkedHashMap<>(); + attributes.put("basis", "SootUp CHA application call"); + attributes.put("caller", edge.caller().displayName()); + attributes.put("callee", edge.callee().displayName()); + attributes.put("callerSignature", edge.caller().descriptor()); + attributes.put("calleeSignature", edge.callee().descriptor()); + if (edge.targetContext().origin() == CallEdge.TargetOrigin.DEPENDENCY) { + attributes.put("calleeOwner", edge.callee().owner()); + attributes.put("calleeMethod", edge.callee().name()); + attributes.put("targetOrigin", edge.targetContext().origin().id()); + attributes.put("dependencyClasspathEntry", edge.targetContext().dependencyClasspathEntry()); + if (!edge.targetContext().targetBytecodePath().isBlank()) { + attributes.put("targetBytecodePath", edge.targetContext().targetBytecodePath()); + } + if (!edge.targetContext().targetSourcePath().isBlank()) { + attributes.put("targetSourcePath", edge.targetContext().targetSourcePath()); + } + } + return new SemanticCallFacts.Evidence( + "sootup_call", edge.sourcePath(), List.of(), edge.line(), attributes); + } + /** Keeps legacy IDs stable for ordinary edges while making collisions explicit and safe. */ private static String uniqueId(Set ids, String baseId, String identity) { if (ids.add(baseId)) { @@ -137,4 +254,8 @@ private static String sha256(String value) { } } + private record EdgeRef(int index, CallEdge edge, String identity) {} + + private record CallReferences( + List edges, Map applicationBaseIdCounts) {} } diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java index 7981114..d4a762a 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java @@ -1,24 +1,11 @@ package com.projectlightpath.analysis.java; -import java.io.File; -import java.io.IOException; import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; import sootup.callgraph.CallGraph; import sootup.callgraph.ClassHierarchyAnalysisAlgorithm; -import sootup.core.inputlocation.AnalysisInputLocation; -import sootup.core.jimple.common.expr.JInterfaceInvokeExpr; -import sootup.core.model.SootMethod; -import sootup.core.model.SourceType; import sootup.core.signatures.MethodSignature; -import sootup.java.bytecode.frontend.inputlocation.DefaultRuntimeAnalysisInputLocation; -import sootup.java.bytecode.frontend.inputlocation.JavaClassPathAnalysisInputLocation; import sootup.java.core.views.JavaView; /** Builds the provider-owned SootUp CHA graph for a selected AnalysisContext. */ @@ -35,12 +22,16 @@ public AnalysisGraph analyze(AnalysisContext context) { "dependency classpath is required by AnalysisContext " + context.id()); } - JavaView view = createView(context); - Set applicationClasses = discoverApplicationClasses(context.applicationClasses()); - List entryPoints = applicationMethods(view, applicationClasses); + SootUpInputLocationFactory.Inputs inputs = SootUpInputLocationFactory.createView(context); + JavaView view = inputs.view(); + Set applicationClasses = SootUpCallGraphExtractor.discoverApplicationClasses( + context.applicationClasses()); + List entryPoints = SootUpCallGraphExtractor.applicationMethods( + view, applicationClasses); CallGraph graph = new ClassHierarchyAnalysisAlgorithm(view).initialize(entryPoints); SourceMetadataScanner scanner = new SourceMetadataScanner(context); - List calls = applicationCalls(context, graph, applicationClasses, scanner); + List calls = SootUpCallGraphExtractor.applicationCalls( + context, view, graph, applicationClasses, scanner, inputs.dependencyEntries()); return new AnalysisGraph( calls, scanner.dynamicFeatures(), @@ -51,104 +42,4 @@ public AnalysisGraph analyze(AnalysisContext context) { graph.callCount()); } - private static JavaView createView(AnalysisContext context) { - List locations = new ArrayList<>(); - locations.add(new JavaClassPathAnalysisInputLocation( - context.applicationClasses().toString(), SourceType.Application)); - if (!context.dependencyClasspath().isEmpty()) { - String classpath = context.dependencyClasspath().stream() - .map(Path::toString) - .collect(Collectors.joining(File.pathSeparator)); - locations.add(new JavaClassPathAnalysisInputLocation(classpath, SourceType.Library)); - } - locations.add(new DefaultRuntimeAnalysisInputLocation(SourceType.Library)); - return new JavaView(locations); - } - - private static Set discoverApplicationClasses(Path applicationRoot) { - try (var paths = Files.walk(applicationRoot)) { - return paths - .filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(".class")) - .map(applicationRoot::relativize) - .map(Path::toString) - .map(path -> path.substring(0, path.length() - ".class".length())) - .map(path -> path.replace(applicationRoot.getFileSystem().getSeparator(), ".")) - .collect(Collectors.toCollection(HashSet::new)); - } catch (IOException error) { - throw new IllegalStateException("cannot discover application classes in " + applicationRoot, error); - } - } - - private static List applicationMethods( - JavaView view, Set applicationClasses) { - return applicationClasses.stream() - .sorted() - .flatMap(className -> view.getClass(view.getIdentifierFactory().getClassType(className)).stream()) - .flatMap(sootClass -> sootClass.getMethods().stream()) - .filter(SootMethod::hasBody) - .map(SootMethod::getSignature) - .sorted(Comparator.comparing(MethodSignature::toString)) - .toList(); - } - - private static List applicationCalls( - AnalysisContext context, - CallGraph graph, - Set applicationClasses, - SourceMetadataScanner scanner) { - List result = new ArrayList<>(); - for (CallGraph.Call call : graph.getCalls()) { - MethodSymbol caller = MethodSymbol.fromSootSignature(call.sourceMethodSignature().toString()); - MethodSymbol callee = MethodSymbol.fromSootSignature(call.targetMethodSignature().toString()); - if (!applicationClasses.contains(caller.owner()) || !applicationClasses.contains(callee.owner())) { - continue; - } - String sourcePath = sourcePath(context, caller.owner()); - boolean serviceLoaderCall = call.getLineNumber() > 0 - ? scanner.sourceLineContains(caller, call.getLineNumber(), "ServiceLoader.load") - : scanner.methodSource(caller, call.getLineNumber()) - .map(source -> source.contains("ServiceLoader.load")) - .orElse(false); - boolean interfaceDispatch = call.invokableStmt().getInvokeExpr() - .map(expression -> expression instanceof JInterfaceInvokeExpr) - .orElseGet(() -> scanner.isInterfaceType(callee.owner())); - String relationshipType = serviceLoaderCall - ? "service_loader" - : interfaceDispatch - ? "interface_dispatch" - : caller.name().startsWith("lambda$") ? "callback" : "invokes"; - SemanticCallFacts.Certainty certainty = relationshipType.equals("invokes") - ? SemanticCallFacts.Certainty.DEFINITE - : SemanticCallFacts.Certainty.POSSIBLE; - result.add(new CallEdge( - caller, - callee, - call.getLineNumber(), - sourcePath, - certainty, - relationshipType)); - } - result.sort(Comparator.comparing((CallEdge edge) -> edge.caller().displayName()) - .thenComparing(edge -> edge.caller().descriptor()) - .thenComparing(edge -> edge.callee().displayName()) - .thenComparing(edge -> edge.callee().descriptor()) - .thenComparingInt(CallEdge::line) - .thenComparing(CallEdge::relationshipType)); - return List.copyOf(result); - } - - private static String sourcePath(AnalysisContext context, String owner) { - String topLevel = owner.contains("$") ? owner.substring(0, owner.indexOf('$')) : owner; - String relative = topLevel.replace('.', '/') + ".java"; - for (AnalysisContext.SourceRoot root : - AnalysisContext.rootsBySpecificity(context.includedSourceRoots())) { - Path candidate = root.path().resolve(relative); - if (Files.isRegularFile(candidate)) { - return context.displayPath(candidate); - } - } - return "unknown"; - } - } diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java new file mode 100644 index 0000000..3a03be6 --- /dev/null +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java @@ -0,0 +1,238 @@ +package com.projectlightpath.analysis.java; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import sootup.callgraph.CallGraph; +import sootup.core.frontend.SootClassSource; +import sootup.core.inputlocation.AnalysisInputLocation; +import sootup.core.jimple.common.expr.JInterfaceInvokeExpr; +import sootup.core.jimple.common.expr.JVirtualInvokeExpr; +import sootup.core.model.SootClass; +import sootup.core.model.SootMethod; +import sootup.core.signatures.MethodSignature; +import sootup.java.core.views.JavaView; + +/** Extracts application-owned SootUp calls and exact dependency target context. */ +final class SootUpCallGraphExtractor { + private SootUpCallGraphExtractor() {} + + static Set discoverApplicationClasses(Path applicationRoot) { + try (var paths = Files.walk(applicationRoot)) { + return paths + .filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".class")) + .map(applicationRoot::relativize) + .map(Path::toString) + .map(path -> path.substring(0, path.length() - ".class".length())) + .map(path -> path.replace(applicationRoot.getFileSystem().getSeparator(), ".")) + .collect(Collectors.toCollection(HashSet::new)); + } catch (IOException error) { + throw new IllegalStateException("cannot discover application classes in " + applicationRoot, error); + } + } + + static List applicationMethods( + JavaView view, Set applicationClasses) { + return applicationClasses.stream() + .sorted() + .flatMap(className -> view.getClass(view.getIdentifierFactory().getClassType(className)).stream()) + .flatMap(sootClass -> sootClass.getMethods().stream()) + .filter(SootMethod::hasBody) + .map(SootMethod::getSignature) + .sorted(Comparator.comparing(MethodSignature::toString)) + .toList(); + } + + static List applicationCalls( + AnalysisContext context, + JavaView view, + CallGraph graph, + Set applicationClasses, + SourceMetadataScanner scanner, + Map dependencyEntries) { + List result = new ArrayList<>(); + for (CallGraph.Call call : graph.getCalls()) { + applicationCall(context, view, call, applicationClasses, scanner, dependencyEntries) + .ifPresent(result::add); + } + result.sort(Comparator.comparing((CallEdge edge) -> edge.caller().displayName()) + .thenComparing(edge -> edge.caller().descriptor()) + .thenComparing(edge -> edge.callee().displayName()) + .thenComparing(edge -> edge.callee().descriptor()) + .thenComparingInt(CallEdge::line) + .thenComparing(CallEdge::relationshipType)); + return List.copyOf(result); + } + + private static Optional applicationCall( + AnalysisContext context, + JavaView view, + CallGraph.Call call, + Set applicationClasses, + SourceMetadataScanner scanner, + Map dependencyEntries) { + if (view.getMethod(call.targetMethodSignature()).isEmpty()) { + return Optional.empty(); + } + MethodSymbol caller = MethodSymbol.fromSootSignature(call.sourceMethodSignature().toString()); + MethodSymbol callee = MethodSymbol.fromSootSignature(call.targetMethodSignature().toString()); + if (!applicationClasses.contains(caller.owner())) { + return Optional.empty(); + } + CallEdge.TargetContext targetContext = targetContext( + view, callee, applicationClasses, dependencyEntries); + if (targetContext == null) { + return Optional.empty(); + } + String relationshipType = relationshipType(call, caller, callee, scanner); + return Optional.of(new CallEdge( + caller, + callee, + call.getLineNumber(), + sourcePath(context, caller.owner()), + certainty(call, view, relationshipType, targetContext), + relationshipType, + targetContext)); + } + + private static CallEdge.TargetContext targetContext( + JavaView view, + MethodSymbol callee, + Set applicationClasses, + Map dependencyEntries) { + return applicationClasses.contains(callee.owner()) + ? CallEdge.TargetContext.application() + : dependencyTarget(view, callee, dependencyEntries); + } + + private static String relationshipType( + CallGraph.Call call, MethodSymbol caller, MethodSymbol callee, SourceMetadataScanner scanner) { + boolean serviceLoaderCall = serviceLoaderCall(call, caller, scanner); + boolean interfaceDispatch = interfaceDispatch(call, callee, scanner); + if (serviceLoaderCall) { + return "service_loader"; + } + if (interfaceDispatch) { + return "interface_dispatch"; + } + return caller.name().startsWith("lambda$") ? "callback" : "invokes"; + } + + private static boolean serviceLoaderCall( + CallGraph.Call call, MethodSymbol caller, SourceMetadataScanner scanner) { + if (call.getLineNumber() > 0) { + return scanner.sourceLineContains(caller, call.getLineNumber(), "ServiceLoader.load"); + } + return scanner.methodSource(caller, call.getLineNumber()) + .map(source -> source.contains("ServiceLoader.load")) + .orElse(false); + } + + private static boolean interfaceDispatch( + CallGraph.Call call, MethodSymbol callee, SourceMetadataScanner scanner) { + return call.invokableStmt().getInvokeExpr() + .map(expression -> expression instanceof JInterfaceInvokeExpr) + .orElseGet(() -> scanner.isInterfaceType(callee.owner())); + } + + private static SemanticCallFacts.Certainty certainty( + CallGraph.Call call, + JavaView view, + String relationshipType, + CallEdge.TargetContext targetContext) { + if (!relationshipType.equals("invokes")) { + return SemanticCallFacts.Certainty.POSSIBLE; + } + if (targetContext.origin() == CallEdge.TargetOrigin.DEPENDENCY + && virtualTargetMayDispatch(view, call)) { + return SemanticCallFacts.Certainty.POSSIBLE; + } + return SemanticCallFacts.Certainty.DEFINITE; + } + + /** Returns whether CHA may have emitted one of several virtual dependency targets. */ + private static boolean virtualTargetMayDispatch(JavaView view, CallGraph.Call call) { + boolean virtualInvoke = call.invokableStmt().getInvokeExpr() + .map(expression -> expression instanceof JVirtualInvokeExpr) + .orElse(false); + if (!virtualInvoke) { + return false; + } + MethodSignature declared = call.invokableStmt().getInvokeExpr() + .map(expression -> expression.getMethodSignature()) + .orElseThrow(); + boolean finalMethod = resolveMethod(view, declared) + .map(SootMethod::isFinal) + .orElse(false); + boolean finalClass = view.getClass(declared.getDeclClassType()) + .map(SootClass::isFinal) + .orElse(false); + return !finalMethod && !finalClass; + } + + private static Optional resolveMethod( + JavaView view, MethodSignature signature) { + MethodSignature current = signature; + while (true) { + Optional declaringClass = view.getClass(current.getDeclClassType()); + if (declaringClass.isEmpty()) { + return Optional.empty(); + } + Optional method = declaringClass.get() + .getMethod(current.getSubSignature()); + if (method.isPresent()) { + return method; + } + var superclass = declaringClass.get().getSuperclass(); + if (superclass.isEmpty()) { + return Optional.empty(); + } + current = new MethodSignature(superclass.get(), current.getSubSignature()); + } + } + + private static CallEdge.TargetContext dependencyTarget( + JavaView view, + MethodSymbol callee, + Map dependencyEntries) { + Optional targetClass = view.getClass( + view.getIdentifierFactory().getClassType(callee.owner())); + if (targetClass.isEmpty()) { + return null; + } + SootClassSource source = targetClass.get().getClassSource(); + Path dependency = dependencyEntries.get(source.getAnalysisInputLocation()); + if (dependency == null) { + return null; + } + String bytecodePath = source.getSourcePath() == null ? "" : source.getSourcePath().toString(); + String sourcePath = bytecodePath.endsWith(".java") ? bytecodePath : ""; + return new CallEdge.TargetContext( + CallEdge.TargetOrigin.DEPENDENCY, + dependency.toString(), + bytecodePath, + sourcePath); + } + + private static String sourcePath(AnalysisContext context, String owner) { + String topLevel = owner.contains("$") ? owner.substring(0, owner.indexOf('$')) : owner; + String relative = topLevel.replace('.', '/') + ".java"; + for (AnalysisContext.SourceRoot root : + AnalysisContext.rootsBySpecificity(context.includedSourceRoots())) { + Path candidate = root.path().resolve(relative); + if (Files.isRegularFile(candidate)) { + return context.displayPath(candidate); + } + } + return "unknown"; + } +} diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java b/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java new file mode 100644 index 0000000..20d3e85 --- /dev/null +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java @@ -0,0 +1,114 @@ +package com.projectlightpath.analysis.java; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import sootup.core.inputlocation.AnalysisInputLocation; +import sootup.core.inputlocation.FileType; +import sootup.core.model.SourceType; +import sootup.core.util.PathUtils; +import sootup.interceptors.BytecodeBodyInterceptors; +import sootup.java.bytecode.frontend.inputlocation.DefaultRuntimeAnalysisInputLocation; +import sootup.java.bytecode.frontend.inputlocation.JavaClassPathAnalysisInputLocation; +import sootup.java.bytecode.frontend.inputlocation.PathBasedAnalysisInputLocation; +import sootup.java.core.views.JavaView; + +/** Builds SootUp input locations while retaining exact dependency ownership. */ +final class SootUpInputLocationFactory { + private SootUpInputLocationFactory() {} + + static Inputs createView(AnalysisContext context) { + List locations = new ArrayList<>(); + locations.add(new JavaClassPathAnalysisInputLocation( + context.applicationClasses().toString(), SourceType.Application)); + Map dependencyEntries = new IdentityHashMap<>(); + Set dependencyPaths = new LinkedHashSet<>(); + for (Path dependency : context.dependencyClasspath()) { + addDependencyLocations(dependency, locations, dependencyEntries, dependencyPaths); + } + if (!context.dependencyClasspath().isEmpty() && dependencyEntries.isEmpty()) { + throw new IllegalArgumentException( + "dependency classpath contains no valid entries for AnalysisContext " + context.id()); + } + locations.add(new DefaultRuntimeAnalysisInputLocation(SourceType.Library)); + JavaView view = new JavaView(locations); + mapContainedWarLocations(view, dependencyEntries); + return new Inputs(view, dependencyEntries); + } + + private static void addDependencyLocations( + Path dependency, + List locations, + Map dependencyEntries, + Set dependencyPaths) { + for (Path expanded : expandDependencyPath(dependency)) { + Path normalized = expanded.toAbsolutePath().normalize(); + if (!isValidInputLocation(normalized) || !dependencyPaths.add(normalized)) { + continue; + } + AnalysisInputLocation location = PathBasedAnalysisInputLocation.create( + normalized, + SourceType.Library, + BytecodeBodyInterceptors.Default.getBodyInterceptors()); + locations.add(location); + dependencyEntries.put(location, normalized); + } + } + + private static boolean isValidInputLocation(Path path) { + return Files.exists(path) && (Files.isDirectory(path) || PathUtils.isArchive(path)); + } + + /** Associates WAR-contained class sources with the outer WAR dependency entry. */ + private static void mapContainedWarLocations( + JavaView view, Map dependencyEntries) { + List> wars = dependencyEntries.entrySet().stream() + .filter(entry -> PathUtils.hasExtension(entry.getValue(), FileType.WAR)) + .toList(); + for (Map.Entry war : wars) { + try (var sources = war.getKey().getClassSources(view)) { + sources.forEach(source -> dependencyEntries.put( + source.getAnalysisInputLocation(), war.getValue())); + } + } + } + + /** Expands SootUp-compatible trailing wildcards into concrete JAR entries. */ + private static List expandDependencyPath(Path dependency) { + Path normalized = dependency.toAbsolutePath().normalize(); + String value = normalized.toString(); + if (!value.endsWith("*")) { + return List.of(normalized); + } + + int wildcardIndex = value.indexOf('*'); + Path directory = normalized.getFileSystem().getPath(value.substring(0, wildcardIndex)); + if (!Files.isDirectory(directory)) { + return List.of(); + } + List entries = new ArrayList<>(); + try (var paths = Files.newDirectoryStream(directory)) { + for (Path path : paths) { + String fileName = path.getFileName().toString(); + if (Files.isRegularFile(path) + && (fileName.endsWith(".jar") || fileName.endsWith(".JAR"))) { + entries.add(path.toAbsolutePath().normalize()); + } + } + } catch (IOException error) { + throw new IllegalArgumentException( + "cannot expand dependency classpath wildcard: " + dependency, error); + } + entries.sort(Comparator.comparing(Path::toString)); + return List.copyOf(entries); + } + + record Inputs(JavaView view, Map dependencyEntries) {} +} diff --git a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java index 24c43ff..c2fc936 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -5881,6 +5881,96 @@ void collidingProviderBaseIdsAreStableAndNeverUseLegacyId() throws IOException { .collect(java.util.stream.Collectors.toMap( fact -> fact.evidence().get(0).path(), SemanticCallFacts.Fact::id)); assertEquals(idsByPath, reversedIdsByPath); + assertEquals( + "sootup-call-88a10203-afba60f10fc1e513906fc938a01324c403094e2e0cbae4df7e5d44009531b8e8", + idsByPath.get("src/main/java/demo/First.java")); + assertEquals( + "sootup-call-88a10203-cca4a53a4171b2d7c08f66caec27d67f17d14db4caa8d8b2a71a744edd30459b", + idsByPath.get("src/main/java/demo/Second.java")); + } + + /** Keeps an application legacy ID stable when a dependency edge shares its old base. */ + @Test + void dependencyEdgesDoNotStealApplicationLegacyIds() throws IOException { + Path sourceRoot = temp.resolve("dependency-identities/src/main/java"); + write(sourceRoot, "demo/DependencyIdentities.java", "package demo; class DependencyIdentities {}\n"); + AnalysisContext context = context( + AnalysisContext.Framework.QUARKUS, + sourceRoot, + null, + List.of()); + MethodSymbol caller = new MethodSymbol("demo.DependencyIdentities", "run", "()V"); + MethodSymbol callee = new MethodSymbol("demo.Target", "call", "()V"); + CallEdge application = new CallEdge( + caller, callee, 4, "src/main/java/demo/Application.java", + SemanticCallFacts.Certainty.DEFINITE, "invokes"); + CallEdge dependency = new CallEdge( + caller, callee, 4, "src/main/java/demo/Application.java", + SemanticCallFacts.Certainty.DEFINITE, + "invokes", + new CallEdge.TargetContext( + CallEdge.TargetOrigin.DEPENDENCY, + temp.resolve("vulnerable-dependency-1.0.jar").toAbsolutePath().normalize().toString(), + "vulnerable/demo/Target.class", + "")); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, new AnalysisGraph( + List.of(application, dependency), List.of(), Set.of(), "SootUp", "3.0.1", 1, 2)); + SemanticCallFacts reversedFacts = new JavaAnalysisAdapter().analyze(context, new AnalysisGraph( + List.of(dependency, application), List.of(), Set.of(), "SootUp", "3.0.1", 1, 2)); + String legacyId = "sootup-call-" + Integer.toHexString( + (caller.descriptor() + "->" + callee.descriptor() + "@4").hashCode()); + + Map idsByOrigin = facts.providerFacts().stream() + .collect(java.util.stream.Collectors.toMap( + fact -> fact.evidence().get(0).attributes().getOrDefault("targetOrigin", "application"), + SemanticCallFacts.Fact::id)); + Map reversedIdsByOrigin = reversedFacts.providerFacts().stream() + .collect(java.util.stream.Collectors.toMap( + fact -> fact.evidence().get(0).attributes().getOrDefault("targetOrigin", "application"), + SemanticCallFacts.Fact::id)); + assertEquals(legacyId, idsByOrigin.get("application")); + assertNotEquals(idsByOrigin.get("application"), idsByOrigin.get("dependency")); + assertEquals(idsByOrigin, reversedIdsByOrigin); + assertEquals( + temp.resolve("vulnerable-dependency-1.0.jar").toAbsolutePath().normalize().toString(), + facts.providerFacts().stream() + .filter(fact -> fact.evidence().get(0).attributes().containsKey("targetOrigin")) + .findFirst() + .orElseThrow() + .evidence().get(0).attributes().get("dependencyClasspathEntry")); + } + + /** Keeps open-world dynamic certainty abstained for dependency-origin graph edges. */ + @Test + void dependencyDynamicEdgesRemainUnresolvedAndAbstained() throws IOException { + Path sourceRoot = temp.resolve("dependency-dynamic/src/main/java"); + write(sourceRoot, "demo/DependencyDynamic.java", "package demo; class DependencyDynamic {}\n"); + AnalysisContext context = context( + AnalysisContext.Framework.QUARKUS, + sourceRoot, + null, + List.of()); + CallEdge dynamicDependency = new CallEdge( + new MethodSymbol("demo.DependencyDynamic", "invoke", "()V"), + new MethodSymbol("dependency.Target", "run", "()V"), + 7, + "src/main/java/demo/DependencyDynamic.java", + SemanticCallFacts.Certainty.DEFINITE, + "proxy_dispatch", + new CallEdge.TargetContext( + CallEdge.TargetOrigin.DEPENDENCY, + temp.resolve("dependency.jar").toAbsolutePath().normalize().toString(), + "dependency/Target.class", + "")); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context, new AnalysisGraph( + List.of(dynamicDependency), List.of(), Set.of(), "SootUp", "3.0.1", 1, 1)); + + SemanticCallFacts.Fact fact = facts.providerFacts().get(0); + assertEquals(SemanticCallFacts.Certainty.UNRESOLVED, fact.certainty()); + assertEquals(SemanticCallFacts.Status.ABSTAINED, fact.status()); + assertEquals("dependency", fact.evidence().get(0).attributes().get("targetOrigin")); } /** Preserves selected build context and generated roots in the serialized run metadata. */ diff --git a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java new file mode 100644 index 0000000..bf62b28 --- /dev/null +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -0,0 +1,615 @@ +package com.projectlightpath.analysis.java; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import javax.tools.JavaCompiler; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SootUpDependencyBoundaryIntegrationTest { + @TempDir Path temp; + + @Test + void retainsExactApplicationToDependencyFactsAndPreservesControls() throws IOException { + Fixture fixture = fixture(); + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(fixture.context()); + + SemanticCallFacts.Fact vulnerable = fact(facts, "app.Application#dependencyCall"); + assertEquals("vulnerable.api.VulnerableApi#check", vulnerable.callee()); + assertEquals("invokes", vulnerable.relationshipType()); + assertEquals(SemanticCallFacts.Certainty.DEFINITE, vulnerable.certainty()); + assertEquals(SemanticCallFacts.Status.EMITTED, vulnerable.status()); + assertEquals(SemanticCallFacts.FactSet.PROVIDER, vulnerable.factSet()); + assertEquals(SemanticCallFacts.EvidenceOrigin.SOOTUP_BASE_GRAPH, vulnerable.evidenceOrigin()); + assertEquals(fixture.context().id(), vulnerable.contextId()); + assertEquals(fixture.context().provenance(), facts.runMetadata().provenance()); + assertEquals(fixture.context().handoff(), facts.runMetadata().handoff()); + + SemanticCallFacts.Evidence vulnerableEvidence = vulnerable.evidence().get(0); + Map vulnerableAttributes = vulnerableEvidence.attributes(); + assertEquals("vulnerable.api.VulnerableApi", vulnerableAttributes.get("calleeOwner")); + assertEquals("check", vulnerableAttributes.get("calleeMethod")); + assertEquals("(Ljava/lang/String;)Z", vulnerableAttributes.get("calleeSignature")); + assertEquals("dependency", vulnerableAttributes.get("targetOrigin")); + assertEquals(fixture.vulnerableJar().toAbsolutePath().normalize().toString(), + vulnerableAttributes.get("dependencyClasspathEntry")); + assertTrue(vulnerableAttributes.containsKey("targetBytecodePath")); + assertFalse(vulnerableAttributes.get("targetBytecodePath").endsWith(".java")); + assertTrue(vulnerableEvidence.path().endsWith("src/main/java/app/Application.java")); + assertNotNull(vulnerableEvidence.line()); + assertTrue(vulnerableEvidence.line() > 0); + + SemanticCallFacts.Fact body = fact(facts, "app.Application#dependencyBodyCall"); + assertEquals("vulnerable.api.VulnerableApi#normalize", body.callee()); + assertEquals("(Ljava/lang/Object;)Ljava/lang/String;", + body.evidence().get(0).attributes().get("calleeSignature")); + + assertFalse(facts.providerFacts().stream() + .anyMatch(item -> item.caller().equals("app.Application#dependencyCall") + && item.callee().equals("vulnerable.api.VulnerableApi#check") + && item.evidence().stream().anyMatch(evidence -> + evidence.attributes().get("calleeSignature").equals("(I)Z")))); + + SemanticCallFacts.Fact duplicate = fact(facts, "app.Application#duplicateCall"); + assertEquals("vulnerable.api.Duplicate#origin", duplicate.callee()); + assertEquals(fixture.vulnerableJar().toAbsolutePath().normalize().toString(), + duplicate.evidence().get(0).attributes().get("dependencyClasspathEntry")); + + SemanticCallFacts.Fact peer = fact(facts, "app.Application#peerCall"); + assertEquals("peer.api.PeerApi#value", peer.callee()); + assertEquals(fixture.peerJar().toAbsolutePath().normalize().toString(), + peer.evidence().get(0).attributes().get("dependencyClasspathEntry")); + + SemanticCallFacts.Fact application = facts.providerFacts().stream() + .filter(item -> item.caller().equals("app.Application#applicationCall")) + .filter(item -> item.callee().equals("app.Local#value")) + .findFirst() + .orElseThrow(() -> new AssertionError(facts.providerFacts().toString())); + assertEquals("()Ljava/lang/String;", + application.evidence().get(0).attributes().get("callerSignature")); + assertFalse(application.evidence().get(0).attributes().containsKey("targetOrigin")); + assertFalse(application.evidence().get(0).attributes().containsKey("dependencyClasspathEntry")); + SemanticCallFacts.Evidence applicationEvidence = application.evidence().get(0); + assertEquals("sootup_call", applicationEvidence.kind()); + assertEquals("src/main/java/app/Application.java", applicationEvidence.path()); + assertTrue(applicationEvidence.symbols().isEmpty()); + assertNotNull(applicationEvidence.line()); + assertTrue(applicationEvidence.line() > 0); + assertEquals(Map.of( + "basis", "SootUp CHA application call", + "caller", "app.Application#applicationCall", + "callee", "app.Local#value", + "callerSignature", "()Ljava/lang/String;", + "calleeSignature", "()Ljava/lang/String;"), applicationEvidence.attributes()); + + assertFalse(facts.providerFacts().stream().anyMatch(item -> item.callee().startsWith("java."))); + assertTrue(facts.providerFacts().stream() + .allMatch(item -> item.caller().startsWith("app."))); + } + + @Test + void retainsPossibleDependencyDispatchWithoutPromotingCertainty() throws IOException { + Fixture fixture = fixture(); + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(fixture.context()); + + SemanticCallFacts.Fact dispatch = facts.providerFacts().stream() + .filter(item -> item.caller().equals("app.Application#dependencyInterfaceCall")) + .filter(item -> item.callee().startsWith("vulnerable.api.")) + .filter(item -> item.relationshipType().equals("interface_dispatch")) + .findFirst() + .orElseThrow(() -> new AssertionError(facts.providerFacts().toString())); + assertEquals(SemanticCallFacts.Certainty.POSSIBLE, dispatch.certainty()); + assertEquals(SemanticCallFacts.Status.EMITTED, dispatch.status()); + assertEquals("dependency", dispatch.evidence().get(0).attributes().get("targetOrigin")); + assertEquals(fixture.vulnerableJar().toAbsolutePath().normalize().toString(), + dispatch.evidence().get(0).attributes().get("dependencyClasspathEntry")); + assertFalse(facts.providerFacts().stream().anyMatch(item -> + item.certainty() == SemanticCallFacts.Certainty.UNRESOLVED + && item.status() == SemanticCallFacts.Status.EMITTED)); + } + + @Test + void retainsPossibleVirtualDependencyDispatchWithoutPromotingCertainty() throws IOException { + Fixture fixture = fixture(); + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(fixture.context()); + + List dispatches = facts.providerFacts().stream() + .filter(item -> item.caller().equals("app.Application#dependencyVirtualCall")) + .filter(item -> item.callee().endsWith("#value")) + .filter(item -> item.relationshipType().equals("invokes")) + .toList(); + assertFalse(dispatches.isEmpty(), facts.providerFacts().toString()); + assertTrue(dispatches.stream().allMatch(item -> + item.certainty() == SemanticCallFacts.Certainty.POSSIBLE)); + assertTrue(dispatches.stream().allMatch(item -> + fixture.vulnerableJar().toAbsolutePath().normalize().toString().equals( + item.evidence().get(0).attributes().get("dependencyClasspathEntry")))); + } + + @Test + void keepsFinalVirtualDependencyTargetsDefinite() throws IOException { + Fixture fixture = fixture(); + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(fixture.context()); + + assertFinalVirtualCallIsDefinite(facts, "dependencyFinalSubclassCall"); + assertFinalVirtualCallIsDefinite(facts, "dependencyFinalOverrideCall"); + assertFinalVirtualCallIsDefinite(facts, "dependencyInheritedFinalCall"); + } + + @Test + void preservesExistingInvalidDependencyClasspathBehavior() throws IOException { + Fixture fixture = fixture(); + Path missing = temp.resolve("missing-dependency.jar"); + AnalysisContext mixedContext = contextWithDependencies(fixture, List.of(fixture.vulnerableJar(), missing)); + SemanticCallFacts mixedFacts = new JavaAnalysisAdapter().analyze(mixedContext); + assertEquals("dependency", fact(mixedFacts, "app.Application#dependencyCall") + .evidence().get(0).attributes().get("targetOrigin")); + + AnalysisContext allInvalidContext = contextWithDependencies(fixture, List.of(missing)); + assertThrows(IllegalArgumentException.class, + () -> new SootUpBaseGraphProvider().analyze(allInvalidContext)); + } + + @Test + void deduplicatesRepeatedDependencyClasspathEntries() throws IOException { + Fixture fixture = fixture(); + AnalysisContext context = contextWithDependencies( + fixture, List.of(fixture.vulnerableJar(), fixture.vulnerableJar(), fixture.peerJar())); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context); + + List duplicateFacts = facts.providerFacts().stream() + .filter(item -> item.caller().equals("app.Application#duplicateCall")) + .filter(item -> item.callee().equals("vulnerable.api.Duplicate#origin")) + .toList(); + assertEquals(1, duplicateFacts.size()); + assertEquals(fixture.vulnerableJar().toAbsolutePath().normalize().toString(), + duplicateFacts.get(0).evidence().get(0).attributes().get("dependencyClasspathEntry")); + } + + @Test + void expandsTrailingDependencyClasspathWildcards() throws IOException { + Fixture fixture = fixture(); + Path wildcardDirectory = temp.resolve("wildcard-dependencies"); + Files.createDirectories(wildcardDirectory); + Path vulnerableJar = wildcardDirectory.resolve("01-vulnerable.jar"); + Path peerJar = wildcardDirectory.resolve("02-peer.JAR"); + Files.copy(fixture.vulnerableJar(), vulnerableJar); + Files.copy(fixture.peerJar(), peerJar); + + AnalysisContext context = contextWithDependencies( + fixture, List.of(wildcardDirectory.resolve("*"))); + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context); + + SemanticCallFacts.Fact vulnerable = fact(facts, "app.Application#dependencyCall"); + assertEquals(vulnerableJar.toAbsolutePath().normalize().toString(), + vulnerable.evidence().get(0).attributes().get("dependencyClasspathEntry")); + SemanticCallFacts.Fact peer = fact(facts, "app.Application#peerCall"); + assertEquals(peerJar.toAbsolutePath().normalize().toString(), + peer.evidence().get(0).attributes().get("dependencyClasspathEntry")); + } + + @Test + void attributesWarContainedClassesToTheOuterDependencyEntry() throws IOException { + Fixture fixture = fixture(); + Path warSource = temp.resolve("war/src/main/java"); + Path warClasses = temp.resolve("war/target/classes"); + write(warSource, "war/api/WarApi.java", """ + package war.api; + public class WarApi { + public static String value() { return "war"; } + } + """); + compile(warSource, warClasses, List.of()); + Path war = war(warClasses, fixture.vulnerableJar(), temp.resolve("application.war")); + + Path applicationSource = temp.resolve("war-application/src/main/java"); + Path applicationClasses = temp.resolve("war-application/target/classes"); + write(applicationSource, "app/WarApplication.java", """ + package app; + import vulnerable.api.VulnerableApi; + import war.api.WarApi; + public class WarApplication { + public String webClassesCall() { return WarApi.value(); } + public boolean nestedJarCall(String value) { return VulnerableApi.check(value); } + } + """); + compile(applicationSource, applicationClasses, List.of(warClasses, fixture.vulnerableJar())); + AnalysisContext context = contextFor( + applicationSource, applicationClasses, List.of(war)); + + SootUpInputLocationFactory.Inputs inputs = SootUpInputLocationFactory.createView(context); + Path expectedWar = war.toAbsolutePath().normalize(); + assertEquals(expectedWar, mappedDependency(inputs, "war.api.WarApi")); + assertEquals(expectedWar, mappedDependency(inputs, "vulnerable.api.VulnerableApi")); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context); + SemanticCallFacts.Fact webClasses = fact(facts, "app.WarApplication#webClassesCall"); + assertEquals("war.api.WarApi#value", webClasses.callee()); + assertEquals(expectedWar, + Path.of(webClasses.evidence().get(0).attributes().get("dependencyClasspathEntry"))); + SemanticCallFacts.Fact nestedJar = fact(facts, "app.WarApplication#nestedJarCall"); + assertEquals("vulnerable.api.VulnerableApi#check", nestedJar.callee()); + assertEquals(expectedWar, + Path.of(nestedJar.evidence().get(0).attributes().get("dependencyClasspathEntry"))); + } + + @Test + void skipsDependencyMethodsMissingFromTheResolvedArtifact() throws IOException { + Path originalDependencySource = temp.resolve("phantom/original/src/main/java"); + Path originalDependencyClasses = temp.resolve("phantom/original/target/classes"); + write(originalDependencySource, "phantom/api/PhantomApi.java", """ + package phantom.api; + public final class PhantomApi { + public static String removed() { return "original"; } + } + """); + compile(originalDependencySource, originalDependencyClasses, List.of()); + Path dependencyJar = jar( + originalDependencyClasses, temp.resolve("phantom-dependency-1.0.jar")); + + Path applicationSource = temp.resolve("phantom/application/src/main/java"); + Path applicationClasses = temp.resolve("phantom/application/target/classes"); + write(applicationSource, "phantom/App.java", """ + package phantom; + import phantom.api.PhantomApi; + public class App { + public String call() { return PhantomApi.removed(); } + } + """); + compile(applicationSource, applicationClasses, List.of(dependencyJar)); + + Path replacementDependencySource = temp.resolve("phantom/replacement/src/main/java"); + Path replacementDependencyClasses = temp.resolve("phantom/replacement/target/classes"); + write(replacementDependencySource, "phantom/api/PhantomApi.java", """ + package phantom.api; + public final class PhantomApi { + public static String present() { return "replacement"; } + } + """); + compile(replacementDependencySource, replacementDependencyClasses, List.of()); + jar(replacementDependencyClasses, dependencyJar); + + AnalysisContext context = AnalysisContext.builder() + .id("phantom-dependency") + .framework(AnalysisContext.Framework.QUARKUS) + .frameworkVersion("3.40.1") + .fixtureId("sootup-phantom-dependency") + .javaRelease(17) + .repositoryRoot(temp) + .applicationClasses(applicationClasses) + .selectedProfile("production") + .analysisScope(AnalysisContext.Scope.PRODUCTION) + .sourceRoot("src/main/java", applicationSource, AnalysisContext.Scope.PRODUCTION, true) + .dependencyClasspath(List.of(dependencyJar)) + .dependencyClasspathRequired(true) + .provenance(AnalysisContext.Provenance.forTesting()) + .build(); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context); + assertFalse(facts.providerFacts().stream() + .anyMatch(fact -> fact.caller().equals("phantom.App#call"))); + } + + private Fixture fixture() throws IOException { + Path dependencySource = temp.resolve("dependency/src/main/java"); + Path dependencyClasses = temp.resolve("dependency/target/classes"); + write(dependencySource, "vulnerable/api/VulnerableApi.java", """ + package vulnerable.api; + public final class VulnerableApi { + public static boolean check(String value) { return value != null; } + public static boolean check(int value) { return value > 0; } + public static String normalize(Object value) { + if (!(value instanceof String)) { return ""; } + String text = (String) value; + try { return text.trim(); } catch (RuntimeException ignored) { return ""; } + } + } + """); + write(dependencySource, "vulnerable/api/BoundaryService.java", """ + package vulnerable.api; + public interface BoundaryService { String execute(String value); } + """); + write(dependencySource, "vulnerable/api/BoundaryServiceImpl.java", """ + package vulnerable.api; + public final class BoundaryServiceImpl implements BoundaryService { + public String execute(String value) { return value; } + } + """); + write(dependencySource, "vulnerable/api/VirtualApi.java", """ + package vulnerable.api; + public class VirtualApi { + public String value() { return "base"; } + } + """); + write(dependencySource, "vulnerable/api/VirtualApiOverride.java", """ + package vulnerable.api; + public class VirtualApiOverride extends VirtualApi { + @Override public String value() { return "override"; } + } + """); + write(dependencySource, "vulnerable/api/FinalVirtualApi.java", """ + package vulnerable.api; + public final class FinalVirtualApi extends VirtualApi { } + """); + write(dependencySource, "vulnerable/api/FinalOverrideVirtualApi.java", """ + package vulnerable.api; + public class FinalOverrideVirtualApi extends VirtualApi { + @Override public final String value() { return "final-override"; } + } + """); + write(dependencySource, "vulnerable/api/FinalMethodVirtualApi.java", """ + package vulnerable.api; + public class FinalMethodVirtualApi extends VirtualApi { + @Override public final String value() { return "final-method"; } + } + """); + write(dependencySource, "vulnerable/api/InheritedFinalVirtualApi.java", """ + package vulnerable.api; + public class InheritedFinalVirtualApi extends FinalMethodVirtualApi { } + """); + write(dependencySource, "vulnerable/api/Duplicate.java", """ + package vulnerable.api; + public final class Duplicate { + public static String origin() { return "vulnerable"; } + } + """); + compile(dependencySource, dependencyClasses, List.of()); + Path vulnerableJar = jar(dependencyClasses, temp.resolve("vulnerable-dependency-1.0.jar")); + + Path peerSource = temp.resolve("peer/src/main/java"); + Path peerClasses = temp.resolve("peer/target/classes"); + write(peerSource, "peer/api/PeerApi.java", """ + package peer.api; + public final class PeerApi { + public static String value() { return "peer"; } + } + """); + write(peerSource, "vulnerable/api/Duplicate.java", """ + package vulnerable.api; + public final class Duplicate { + public static String origin() { return "peer"; } + } + """); + compile(peerSource, peerClasses, List.of()); + Path peerJar = jar(peerClasses, temp.resolve("peer-dependency-1.0.jar")); + + Path applicationSource = temp.resolve("application/src/main/java"); + Path applicationClasses = temp.resolve("application/target/classes"); + write(applicationSource, "app/Application.java", """ + package app; + import peer.api.PeerApi; + import vulnerable.api.BoundaryService; + import vulnerable.api.Duplicate; + import vulnerable.api.FinalOverrideVirtualApi; + import vulnerable.api.FinalVirtualApi; + import vulnerable.api.InheritedFinalVirtualApi; + import vulnerable.api.VulnerableApi; + import vulnerable.api.VirtualApi; + public class Application { + public boolean dependencyCall(String value) { + return VulnerableApi.check(value); + } + public String dependencyBodyCall(Object value) { + return VulnerableApi.normalize(value); + } + public String dependencyVirtualCall(VirtualApi value) { + return value.value(); + } + public String dependencyFinalSubclassCall(FinalVirtualApi value) { + return value.value(); + } + public String dependencyFinalOverrideCall(FinalOverrideVirtualApi value) { + return value.value(); + } + public String dependencyInheritedFinalCall(InheritedFinalVirtualApi value) { + return value.value(); + } + public String dependencyInterfaceCall(BoundaryService service) { + return service.execute("value"); + } + public String duplicateCall() { + return Duplicate.origin(); + } + public String peerCall() { + return PeerApi.value(); + } + public String applicationCall() { + return new Local().value(); + } + public int jdkCall(String value) { + return value.length(); + } + } + """); + write(applicationSource, "app/Local.java", """ + package app; + public class Local { + public String value() { return "application"; } + } + """); + compile(applicationSource, applicationClasses, List.of(vulnerableJar, peerJar)); + + AnalysisContext context = AnalysisContext.builder() + .id("dependency-boundary") + .framework(AnalysisContext.Framework.QUARKUS) + .frameworkVersion("3.40.1") + .fixtureId("sootup-dependency-boundary") + .javaRelease(17) + .repositoryRoot(temp) + .applicationClasses(applicationClasses) + .selectedProfile("production") + .analysisScope(AnalysisContext.Scope.PRODUCTION) + .sourceRoot("src/main/java", applicationSource, AnalysisContext.Scope.PRODUCTION, true) + .dependencyClasspath(List.of(vulnerableJar, peerJar)) + .dependencyClasspathRequired(true) + .provenance(AnalysisContext.Provenance.forTesting()) + .build(); + return new Fixture(context, vulnerableJar, peerJar); + } + + private static AnalysisContext contextFor( + Path applicationSource, Path applicationClasses, List dependencies) { + return AnalysisContext.builder() + .id("war-dependency-boundary") + .framework(AnalysisContext.Framework.QUARKUS) + .frameworkVersion("3.40.1") + .fixtureId("sootup-war-dependency-boundary") + .javaRelease(17) + .repositoryRoot(applicationSource.getParent().getParent()) + .applicationClasses(applicationClasses) + .selectedProfile("production") + .analysisScope(AnalysisContext.Scope.PRODUCTION) + .sourceRoot("src/main/java", applicationSource, AnalysisContext.Scope.PRODUCTION, true) + .dependencyClasspath(dependencies) + .dependencyClasspathRequired(true) + .provenance(AnalysisContext.Provenance.forTesting()) + .build(); + } + + private static AnalysisContext contextWithDependencies(Fixture fixture, List dependencies) { + AnalysisContext original = fixture.context(); + AnalysisContext.SourceRoot sourceRoot = original.sourceRoots().get(0); + AnalysisContext.Builder builder = AnalysisContext.builder() + .id(original.id()) + .framework(original.framework()) + .frameworkVersion(original.frameworkVersion()) + .fixtureId(original.fixtureId()) + .javaRelease(original.javaRelease()) + .repositoryRoot(original.repositoryRoot()) + .applicationClasses(original.applicationClasses()) + .selectedProfile(original.selectedProfile()) + .analysisScope(original.analysisScope()) + .sourceRoot(sourceRoot.declaredPath(), sourceRoot.path(), sourceRoot.scope(), sourceRoot.required()) + .dependencyClasspath(dependencies) + .dependencyClasspathRequired(original.dependencyClasspathRequired()) + .frameworkBindingMetadata(original.frameworkBindingMetadata()) + .provenance(original.provenance()) + .handoff(original.handoff()); + original.frameworkMetadata().forEach(builder::frameworkMetadata); + return builder.build(); + } + + private static SemanticCallFacts.Fact fact(SemanticCallFacts facts, String caller) { + return facts.providerFacts().stream() + .filter(item -> item.caller().equals(caller)) + .filter(item -> item.evidence().stream().anyMatch(evidence -> + evidence.attributes().containsKey("calleeSignature"))) + .filter(item -> !item.relationshipType().equals("interface_dispatch")) + .findFirst() + .orElseThrow(() -> new AssertionError(facts.providerFacts().toString())); + } + + private static Path mappedDependency( + SootUpInputLocationFactory.Inputs inputs, String className) { + return inputs.dependencyEntries().get( + inputs.view().getClass(inputs.view().getIdentifierFactory().getClassType(className)) + .orElseThrow() + .getClassSource() + .getAnalysisInputLocation()); + } + + private static void assertFinalVirtualCallIsDefinite(SemanticCallFacts facts, String caller) { + List dispatches = facts.providerFacts().stream() + .filter(item -> item.caller().equals("app.Application#" + caller)) + .filter(item -> item.relationshipType().equals("invokes")) + .toList(); + assertFalse(dispatches.isEmpty(), facts.providerFacts().toString()); + assertTrue(dispatches.stream().allMatch(item -> + item.certainty() == SemanticCallFacts.Certainty.DEFINITE)); + } + + private static void compile(Path sourceRoot, Path classes, List classpath) throws IOException { + Files.createDirectories(classes); + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + throw new IllegalStateException("a JDK compiler is required for the SootUp integration test"); + } + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) { + List sources = Files.walk(sourceRoot) + .filter(path -> path.toString().endsWith(".java")) + .sorted() + .toList(); + List options = new ArrayList<>(List.of( + "--release", "17", "-g", "-d", classes.toString())); + if (!classpath.isEmpty()) { + options.add("-classpath"); + options.add(classpath.stream().map(Path::toString).reduce((left, right) -> + left + java.io.File.pathSeparator + right).orElseThrow()); + } + var units = fileManager.getJavaFileObjectsFromFiles( + sources.stream().map(Path::toFile).toList()); + boolean compiled = compiler.getTask(null, fileManager, null, options, null, units).call(); + assertTrue(compiled, "integration fixture compilation failed"); + } + } + + private static Path jar(Path classes, Path jar) throws IOException { + try (OutputStream stream = Files.newOutputStream(jar); + JarOutputStream output = new JarOutputStream(stream)) { + List files = Files.walk(classes) + .filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + for (Path file : files) { + String entryName = classes.relativize(file).toString() + .replace(file.getFileSystem().getSeparator(), "/"); + output.putNextEntry(new JarEntry(entryName)); + Files.copy(file, output); + output.closeEntry(); + } + } + return jar; + } + + private static Path war(Path classes, Path nestedJar, Path war) throws IOException { + try (OutputStream stream = Files.newOutputStream(war); + JarOutputStream output = new JarOutputStream(stream)) { + for (String directory : List.of( + "WEB-INF/", "WEB-INF/classes/", "WEB-INF/classes/war/", + "WEB-INF/classes/war/api/", "WEB-INF/lib/")) { + output.putNextEntry(new JarEntry(directory)); + output.closeEntry(); + } + List files = Files.walk(classes) + .filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)) + .toList(); + for (Path file : files) { + String entryName = "WEB-INF/classes/" + classes.relativize(file).toString() + .replace(file.getFileSystem().getSeparator(), "/"); + output.putNextEntry(new JarEntry(entryName)); + Files.copy(file, output); + output.closeEntry(); + } + output.putNextEntry(new JarEntry("WEB-INF/lib/nested.jar")); + Files.copy(nestedJar, output); + output.closeEntry(); + } + return war; + } + + private static void write(Path root, String relative, String source) throws IOException { + Path path = root.resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, source); + } + + private record Fixture(AnalysisContext context, Path vulnerableJar, Path peerJar) {} +}