From 21c0c29485e234e52c2a4f264adb42fc96477ac4 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 12:23:54 -0500 Subject: [PATCH 1/7] FLPATH-5086 preserve dependency-boundary call facts --- .../analysis/java/CallEdge.java | 67 ++- .../analysis/java/ProviderFactNormalizer.java | 98 ++++- .../java/SootUpBaseGraphProvider.java | 94 +++- .../java/JavaAnalysisAdapterTest.java | 90 ++++ ...otUpDependencyBoundaryIntegrationTest.java | 407 ++++++++++++++++++ 5 files changed, 724 insertions(+), 32 deletions(-) create mode 100644 src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java 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 d095f9f..c95c15c 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; @@ -18,18 +20,35 @@ 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); + List edgeRefs = new ArrayList<>(); + Map applicationBaseIdCounts = new HashMap<>(); + for (int index = 0; index < graph.calls().size(); index++) { + CallEdge edge = graph.calls().get(index); + EdgeRef ref = new EdgeRef(index, edge, edgeIdentity(edge)); + edgeRefs.add(ref); + if (edge.targetContext().origin() == CallEdge.TargetOrigin.APPLICATION) { + 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); + Map idsByIndex = new HashMap<>(); + edgeRefs.stream() + .sorted(Comparator.comparing(EdgeRef::identity).thenComparingInt(EdgeRef::index)) + .forEach(ref -> { + CallEdge edge = ref.edge(); + String id; + if (edge.targetContext().origin() == CallEdge.TargetOrigin.APPLICATION) { + String baseId = legacyCallId(edge); + id = applicationBaseIdCounts.get(baseId) == 1 + ? uniqueId(ids, baseId, ref.identity()) + : uniqueId(ids, baseId + "-" + sha256(ref.identity()), ref.identity()); + } else { + id = uniqueId(ids, dependencyCallId(edge), ref.identity()); + } + idsByIndex.put(ref.index(), id); + }); + for (EdgeRef ref : edgeRefs) { + CallEdge edge = ref.edge(); + String id = idsByIndex.get(ref.index()); boolean openWorldDynamic = isOpenWorldDynamic(edge.relationshipType()); SemanticCallFacts.Certainty certainty = openWorldDynamic ? SemanticCallFacts.Certainty.UNRESOLVED @@ -46,12 +65,7 @@ static List normalize(AnalysisContext context, AnalysisG edge.callee().displayName(), edge.relationshipType(), certainty, - List.of(SemanticCallFacts.Evidence.sootup( - edge.sourcePath(), - edge.line(), - edge.caller(), - edge.callee(), - "SootUp CHA application call")), + List.of(sootupEvidence(edge)), limitation)); } for (DynamicFeature feature : graph.dynamicFeatures()) { @@ -99,6 +113,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)) { @@ -142,4 +204,6 @@ private static boolean isOpenWorldDynamic(String relationshipType) { || "dynamic_proxy".equals(relationshipType) || "proxy_dispatch".equals(relationshipType); } + + private record EdgeRef(int index, CallEdge edge, String identity) {} } diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java index 8734a8d..f06cd56 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java @@ -1,24 +1,32 @@ 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.IdentityHashMap; +import java.util.LinkedHashSet; 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.callgraph.ClassHierarchyAnalysisAlgorithm; +import sootup.core.frontend.SootClassSource; import sootup.core.inputlocation.AnalysisInputLocation; import sootup.core.jimple.common.expr.JInterfaceInvokeExpr; +import sootup.core.model.SootClass; import sootup.core.model.SootMethod; import sootup.core.model.SourceType; import sootup.core.signatures.MethodSignature; +import sootup.core.util.PathUtils; import sootup.java.bytecode.frontend.inputlocation.DefaultRuntimeAnalysisInputLocation; import sootup.java.bytecode.frontend.inputlocation.JavaClassPathAnalysisInputLocation; +import sootup.java.bytecode.frontend.inputlocation.PathBasedAnalysisInputLocation; +import sootup.interceptors.BytecodeBodyInterceptors; import sootup.java.core.views.JavaView; /** Builds the provider-owned SootUp CHA graph for a selected AnalysisContext. */ @@ -35,12 +43,14 @@ public AnalysisGraph analyze(AnalysisContext context) { "dependency classpath is required by AnalysisContext " + context.id()); } - JavaView view = createView(context); + ProviderInputs inputs = createView(context); + JavaView view = inputs.view(); Set applicationClasses = discoverApplicationClasses(context.applicationClasses()); List entryPoints = applicationMethods(view, applicationClasses); CallGraph graph = new ClassHierarchyAnalysisAlgorithm(view).initialize(entryPoints); SourceMetadataScanner scanner = new SourceMetadataScanner(context); - List calls = applicationCalls(context, graph, applicationClasses, scanner); + List calls = applicationCalls( + context, view, graph, applicationClasses, scanner, inputs.dependencyEntries()); return new AnalysisGraph( calls, scanner.dynamicFeatures(), @@ -51,18 +61,37 @@ public AnalysisGraph analyze(AnalysisContext context) { graph.callCount()); } - private static JavaView createView(AnalysisContext context) { + private static ProviderInputs 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)); + Map dependencyEntries = new IdentityHashMap<>(); + Set dependencyPaths = new LinkedHashSet<>(); + for (Path dependency : context.dependencyClasspath()) { + Path normalized = dependency.toAbsolutePath().normalize(); + if (!isValidInputLocation(normalized)) { + continue; + } + if (!dependencyPaths.add(normalized)) { + continue; + } + AnalysisInputLocation location = PathBasedAnalysisInputLocation.create( + normalized, + SourceType.Library, + BytecodeBodyInterceptors.Default.getBodyInterceptors()); + locations.add(location); + dependencyEntries.put(location, normalized); + } + 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)); - return new JavaView(locations); + return new ProviderInputs(new JavaView(locations), dependencyEntries); + } + + private static boolean isValidInputLocation(Path path) { + return Files.exists(path) && (Files.isDirectory(path) || PathUtils.isArchive(path)); } private static Set discoverApplicationClasses(Path applicationRoot) { @@ -94,14 +123,25 @@ private static List applicationMethods( private static List applicationCalls( AnalysisContext context, + JavaView view, CallGraph graph, Set applicationClasses, - SourceMetadataScanner scanner) { + SourceMetadataScanner scanner, + Map dependencyEntries) { List result = new ArrayList<>(); for (CallGraph.Call call : graph.getCalls()) { + if (view.getMethod(call.targetMethodSignature()).isEmpty()) { + continue; + } MethodSymbol caller = MethodSymbol.fromSootSignature(call.sourceMethodSignature().toString()); MethodSymbol callee = MethodSymbol.fromSootSignature(call.targetMethodSignature().toString()); - if (!applicationClasses.contains(caller.owner()) || !applicationClasses.contains(callee.owner())) { + if (!applicationClasses.contains(caller.owner())) { + continue; + } + CallEdge.TargetContext targetContext = applicationClasses.contains(callee.owner()) + ? CallEdge.TargetContext.application() + : dependencyTarget(view, callee, dependencyEntries); + if (targetContext == null) { continue; } String sourcePath = sourcePath(context, caller.owner()); @@ -127,7 +167,8 @@ private static List applicationCalls( call.getLineNumber(), sourcePath, certainty, - relationshipType)); + relationshipType, + targetContext)); } result.sort(Comparator.comparing((CallEdge edge) -> edge.caller().displayName()) .thenComparing(edge -> edge.caller().descriptor()) @@ -138,6 +179,29 @@ private static List applicationCalls( return List.copyOf(result); } + 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"; @@ -150,4 +214,8 @@ private static String sourcePath(AnalysisContext context, String owner) { return "unknown"; } + private record ProviderInputs( + 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 7dd7fa2..579225e 100644 --- a/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/JavaAnalysisAdapterTest.java @@ -5177,6 +5177,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..7dd6177 --- /dev/null +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -0,0 +1,407 @@ +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 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 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/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.VulnerableApi; + public class Application { + public boolean dependencyCall(String value) { + return VulnerableApi.check(value); + } + public String dependencyBodyCall(Object value) { + return VulnerableApi.normalize(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 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 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 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) {} +} From aad7bc118657623244e6adbae51e3e9b776c26e0 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 12:46:37 -0500 Subject: [PATCH 2/7] FLPATH-5086 expand wildcard dependency classpaths --- .../java/SootUpBaseGraphProvider.java | 53 ++++++++++++++----- ...otUpDependencyBoundaryIntegrationTest.java | 22 ++++++++ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java index f06cd56..e5b2871 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java @@ -68,19 +68,18 @@ private static ProviderInputs createView(AnalysisContext context) { Map dependencyEntries = new IdentityHashMap<>(); Set dependencyPaths = new LinkedHashSet<>(); for (Path dependency : context.dependencyClasspath()) { - Path normalized = dependency.toAbsolutePath().normalize(); - if (!isValidInputLocation(normalized)) { - continue; - } - if (!dependencyPaths.add(normalized)) { - continue; + 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); } - AnalysisInputLocation location = PathBasedAnalysisInputLocation.create( - normalized, - SourceType.Library, - BytecodeBodyInterceptors.Default.getBodyInterceptors()); - locations.add(location); - dependencyEntries.put(location, normalized); } if (!context.dependencyClasspath().isEmpty() && dependencyEntries.isEmpty()) { throw new IllegalArgumentException( @@ -94,6 +93,36 @@ private static boolean isValidInputLocation(Path path) { return Files.exists(path) && (Files.isDirectory(path) || PathUtils.isArchive(path)); } + /** 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); + } + private static Set discoverApplicationClasses(Path applicationRoot) { try (var paths = Files.walk(applicationRoot)) { return paths diff --git a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java index 7dd6177..f90ed86 100644 --- a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -155,6 +155,28 @@ void deduplicatesRepeatedDependencyClasspathEntries() throws IOException { 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 skipsDependencyMethodsMissingFromTheResolvedArtifact() throws IOException { Path originalDependencySource = temp.resolve("phantom/original/src/main/java"); From 53403f5129295bb6cb49c5c6f0db4eb083db088c Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 13:22:07 -0500 Subject: [PATCH 3/7] FLPATH-5086 satisfy Java lint complexity checks --- .../analysis/java/ProviderFactNormalizer.java | 182 ++++++++++----- .../java/SootUpBaseGraphProvider.java | 217 +----------------- .../java/SootUpCallGraphExtractor.java | 186 +++++++++++++++ .../java/SootUpInputLocationFactory.java | 97 ++++++++ 4 files changed, 409 insertions(+), 273 deletions(-) create mode 100644 src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java create mode 100644 src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java diff --git a/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java b/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java index b8230ad..d1c4e5f 100644 --- a/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java +++ b/src/main/java/com/projectlightpath/analysis/java/ProviderFactNormalizer.java @@ -18,81 +18,136 @@ final class ProviderFactNormalizer { private ProviderFactNormalizer() {} static List normalize(AnalysisContext context, AnalysisGraph graph) { - List facts = new ArrayList<>(); Set ids = new HashSet<>(); + 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); - EdgeRef ref = new EdgeRef(index, edge, edgeIdentity(edge)); - edgeRefs.add(ref); - if (edge.targetContext().origin() == CallEdge.TargetOrigin.APPLICATION) { + edgeRefs.add(new EdgeRef(index, edge, edgeIdentity(edge))); + if (isApplicationEdge(edge)) { applicationBaseIdCounts.merge(legacyCallId(edge), 1, Integer::sum); } } + 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<>(); - edgeRefs.stream() + references.edges().stream() .sorted(Comparator.comparing(EdgeRef::identity).thenComparingInt(EdgeRef::index)) - .forEach(ref -> { - CallEdge edge = ref.edge(); - String id; - if (edge.targetContext().origin() == CallEdge.TargetOrigin.APPLICATION) { - String baseId = legacyCallId(edge); - id = applicationBaseIdCounts.get(baseId) == 1 - ? uniqueId(ids, baseId, ref.identity()) - : uniqueId(ids, baseId + "-" + sha256(ref.identity()), ref.identity()); - } else { - id = uniqueId(ids, dependencyCallId(edge), ref.identity()); - } - idsByIndex.put(ref.index(), id); - }); + .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()); + } + String baseId = legacyCallId(ref.edge()); + if (applicationBaseIdCounts.get(baseId) == 1) { + return uniqueId(ids, baseId, ref.identity()); + } + 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) { - CallEdge edge = ref.edge(); - String id = idsByIndex.get(ref.index()); - 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(sootupEvidence(edge)), - limitation)); + facts.add(callFact(context, ref.edge(), callIds.get(ref.index()))); } - 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())); + 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 List.copyOf(facts); + 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. */ @@ -200,4 +255,7 @@ 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 e5b2871..d4a762a 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpBaseGraphProvider.java @@ -1,32 +1,11 @@ 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.IdentityHashMap; -import java.util.LinkedHashSet; 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.callgraph.ClassHierarchyAnalysisAlgorithm; -import sootup.core.frontend.SootClassSource; -import sootup.core.inputlocation.AnalysisInputLocation; -import sootup.core.jimple.common.expr.JInterfaceInvokeExpr; -import sootup.core.model.SootClass; -import sootup.core.model.SootMethod; -import sootup.core.model.SourceType; import sootup.core.signatures.MethodSignature; -import sootup.core.util.PathUtils; -import sootup.java.bytecode.frontend.inputlocation.DefaultRuntimeAnalysisInputLocation; -import sootup.java.bytecode.frontend.inputlocation.JavaClassPathAnalysisInputLocation; -import sootup.java.bytecode.frontend.inputlocation.PathBasedAnalysisInputLocation; -import sootup.interceptors.BytecodeBodyInterceptors; import sootup.java.core.views.JavaView; /** Builds the provider-owned SootUp CHA graph for a selected AnalysisContext. */ @@ -43,13 +22,15 @@ public AnalysisGraph analyze(AnalysisContext context) { "dependency classpath is required by AnalysisContext " + context.id()); } - ProviderInputs inputs = createView(context); + SootUpInputLocationFactory.Inputs inputs = SootUpInputLocationFactory.createView(context); JavaView view = inputs.view(); - Set applicationClasses = discoverApplicationClasses(context.applicationClasses()); - List entryPoints = applicationMethods(view, applicationClasses); + 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( + List calls = SootUpCallGraphExtractor.applicationCalls( context, view, graph, applicationClasses, scanner, inputs.dependencyEntries()); return new AnalysisGraph( calls, @@ -61,190 +42,4 @@ public AnalysisGraph analyze(AnalysisContext context) { graph.callCount()); } - private static ProviderInputs 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()) { - 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); - } - } - 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)); - return new ProviderInputs(new JavaView(locations), dependencyEntries); - } - - private static boolean isValidInputLocation(Path path) { - return Files.exists(path) && (Files.isDirectory(path) || PathUtils.isArchive(path)); - } - - /** 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); - } - - 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, - JavaView view, - CallGraph graph, - Set applicationClasses, - SourceMetadataScanner scanner, - Map dependencyEntries) { - List result = new ArrayList<>(); - for (CallGraph.Call call : graph.getCalls()) { - if (view.getMethod(call.targetMethodSignature()).isEmpty()) { - continue; - } - MethodSymbol caller = MethodSymbol.fromSootSignature(call.sourceMethodSignature().toString()); - MethodSymbol callee = MethodSymbol.fromSootSignature(call.targetMethodSignature().toString()); - if (!applicationClasses.contains(caller.owner())) { - continue; - } - CallEdge.TargetContext targetContext = applicationClasses.contains(callee.owner()) - ? CallEdge.TargetContext.application() - : dependencyTarget(view, callee, dependencyEntries); - if (targetContext == null) { - 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, - targetContext)); - } - 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 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 : context.includedSourceRoots()) { - Path candidate = root.path().resolve(relative); - if (Files.isRegularFile(candidate)) { - return context.displayPath(candidate); - } - } - return "unknown"; - } - - private record ProviderInputs( - JavaView view, - Map dependencyEntries) {} - } 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..5345cc7 --- /dev/null +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java @@ -0,0 +1,186 @@ +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.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(relationshipType), + 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(String relationshipType) { + return relationshipType.equals("invokes") + ? SemanticCallFacts.Certainty.DEFINITE + : SemanticCallFacts.Certainty.POSSIBLE; + } + + 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 : 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..65a9b4d --- /dev/null +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java @@ -0,0 +1,97 @@ +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.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)); + return new Inputs(new JavaView(locations), 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)); + } + + /** 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) {} +} From 416dedfe5b1047f68bb92f32e5a3b670bb014e10 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 16:35:16 -0500 Subject: [PATCH 4/7] FLPATH-5086 preserve virtual and WAR dependency facts --- .../java/SootUpCallGraphExtractor.java | 37 +++++- .../java/SootUpInputLocationFactory.java | 19 ++- ...otUpDependencyBoundaryIntegrationTest.java | 121 ++++++++++++++++++ 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java index 5345cc7..aba7fdb 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java @@ -15,6 +15,7 @@ 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; @@ -98,7 +99,7 @@ private static Optional applicationCall( callee, call.getLineNumber(), sourcePath(context, caller.owner()), - certainty(relationshipType), + certainty(call, view, relationshipType, targetContext), relationshipType, targetContext)); } @@ -143,10 +144,36 @@ private static boolean interfaceDispatch( .orElseGet(() -> scanner.isInterfaceType(callee.owner())); } - private static SemanticCallFacts.Certainty certainty(String relationshipType) { - return relationshipType.equals("invokes") - ? SemanticCallFacts.Certainty.DEFINITE - : SemanticCallFacts.Certainty.POSSIBLE; + 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; + } + boolean finalMethod = view.getMethod(call.targetMethodSignature()) + .map(SootMethod::isFinal) + .orElse(false); + boolean finalClass = view.getClass(call.targetMethodSignature().getDeclClassType()) + .map(SootClass::isFinal) + .orElse(false); + return !finalMethod && !finalClass; } private static CallEdge.TargetContext dependencyTarget( diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java b/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java index 65a9b4d..20d3e85 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpInputLocationFactory.java @@ -11,6 +11,7 @@ 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; @@ -37,7 +38,9 @@ static Inputs createView(AnalysisContext context) { "dependency classpath contains no valid entries for AnalysisContext " + context.id()); } locations.add(new DefaultRuntimeAnalysisInputLocation(SourceType.Library)); - return new Inputs(new JavaView(locations), dependencyEntries); + JavaView view = new JavaView(locations); + mapContainedWarLocations(view, dependencyEntries); + return new Inputs(view, dependencyEntries); } private static void addDependencyLocations( @@ -63,6 +66,20 @@ 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(); diff --git a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java index f90ed86..15f0e74 100644 --- a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -124,6 +124,24 @@ void retainsPossibleDependencyDispatchWithoutPromotingCertainty() throws IOExcep && 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().startsWith("vulnerable.api.VirtualApi")) + .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 preservesExistingInvalidDependencyClasspathBehavior() throws IOException { Fixture fixture = fixture(); @@ -177,6 +195,47 @@ void expandsTrailingDependencyClasspathWildcards() throws IOException { 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)); + + SemanticCallFacts facts = new JavaAnalysisAdapter().analyze(context); + String expectedWar = war.toAbsolutePath().normalize().toString(); + SemanticCallFacts.Fact webClasses = fact(facts, "app.WarApplication#webClassesCall"); + assertEquals("war.api.WarApi#value", webClasses.callee()); + assertEquals(expectedWar, + webClasses.evidence().get(0).attributes().get("dependencyClasspathEntry")); + SemanticCallFacts.Fact nestedJar = fact(facts, "app.WarApplication#nestedJarCall"); + assertEquals("vulnerable.api.VulnerableApi#check", nestedJar.callee()); + assertEquals(expectedWar, + nestedJar.evidence().get(0).attributes().get("dependencyClasspathEntry")); + } + @Test void skipsDependencyMethodsMissingFromTheResolvedArtifact() throws IOException { Path originalDependencySource = temp.resolve("phantom/original/src/main/java"); @@ -259,6 +318,18 @@ 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/Duplicate.java", """ package vulnerable.api; public final class Duplicate { @@ -293,6 +364,7 @@ public final class Duplicate { import vulnerable.api.BoundaryService; import vulnerable.api.Duplicate; import vulnerable.api.VulnerableApi; + import vulnerable.api.VirtualApi; public class Application { public boolean dependencyCall(String value) { return VulnerableApi.check(value); @@ -300,6 +372,9 @@ public boolean dependencyCall(String value) { public String dependencyBodyCall(Object value) { return VulnerableApi.normalize(value); } + public String dependencyVirtualCall(VirtualApi value) { + return value.value(); + } public String dependencyInterfaceCall(BoundaryService service) { return service.execute("value"); } @@ -343,6 +418,25 @@ public class Local { 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); @@ -419,6 +513,33 @@ private static Path jar(Path classes, Path jar) throws IOException { 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()); From d43fc5783447fc93609999dcb18d4fd9eb42c377 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 17:11:13 -0500 Subject: [PATCH 5/7] FLPATH-5086 classify virtual targets from declared calls --- .../java/SootUpCallGraphExtractor.java | 7 +++- ...otUpDependencyBoundaryIntegrationTest.java | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java index aba7fdb..5f28edd 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java @@ -167,10 +167,13 @@ private static boolean virtualTargetMayDispatch(JavaView view, CallGraph.Call ca if (!virtualInvoke) { return false; } - boolean finalMethod = view.getMethod(call.targetMethodSignature()) + MethodSignature declared = call.invokableStmt().getInvokeExpr() + .map(expression -> expression.getMethodSignature()) + .orElseThrow(); + boolean finalMethod = view.getMethod(declared) .map(SootMethod::isFinal) .orElse(false); - boolean finalClass = view.getClass(call.targetMethodSignature().getDeclClassType()) + boolean finalClass = view.getClass(declared.getDeclClassType()) .map(SootClass::isFinal) .orElse(false); return !finalMethod && !finalClass; diff --git a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java index 15f0e74..ccca83c 100644 --- a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -142,6 +142,15 @@ void retainsPossibleVirtualDependencyDispatchWithoutPromotingCertainty() throws 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"); + } + @Test void preservesExistingInvalidDependencyClasspathBehavior() throws IOException { Fixture fixture = fixture(); @@ -330,6 +339,16 @@ 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/Duplicate.java", """ package vulnerable.api; public final class Duplicate { @@ -363,6 +382,8 @@ public final class Duplicate { import peer.api.PeerApi; import vulnerable.api.BoundaryService; import vulnerable.api.Duplicate; + import vulnerable.api.FinalOverrideVirtualApi; + import vulnerable.api.FinalVirtualApi; import vulnerable.api.VulnerableApi; import vulnerable.api.VirtualApi; public class Application { @@ -375,6 +396,12 @@ public String dependencyBodyCall(Object 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 dependencyInterfaceCall(BoundaryService service) { return service.execute("value"); } @@ -470,6 +497,16 @@ private static SemanticCallFacts.Fact fact(SemanticCallFacts facts, String calle .orElseThrow(() -> new AssertionError(facts.providerFacts().toString())); } + 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(); From 66c33c392350335539f05c0fa0f9600a1ce28a5c Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 17:25:57 -0500 Subject: [PATCH 6/7] FLPATH-5086 resolve inherited final virtual methods --- .../java/SootUpCallGraphExtractor.java | 23 ++++++++++++++++++- ...otUpDependencyBoundaryIntegrationTest.java | 17 +++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java index 5f28edd..e1ec640 100644 --- a/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java +++ b/src/main/java/com/projectlightpath/analysis/java/SootUpCallGraphExtractor.java @@ -170,7 +170,7 @@ private static boolean virtualTargetMayDispatch(JavaView view, CallGraph.Call ca MethodSignature declared = call.invokableStmt().getInvokeExpr() .map(expression -> expression.getMethodSignature()) .orElseThrow(); - boolean finalMethod = view.getMethod(declared) + boolean finalMethod = resolveMethod(view, declared) .map(SootMethod::isFinal) .orElse(false); boolean finalClass = view.getClass(declared.getDeclClassType()) @@ -179,6 +179,27 @@ private static boolean virtualTargetMayDispatch(JavaView view, CallGraph.Call ca 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, diff --git a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java index ccca83c..8909d18 100644 --- a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -131,7 +131,7 @@ void retainsPossibleVirtualDependencyDispatchWithoutPromotingCertainty() throws List dispatches = facts.providerFacts().stream() .filter(item -> item.caller().equals("app.Application#dependencyVirtualCall")) - .filter(item -> item.callee().startsWith("vulnerable.api.VirtualApi")) + .filter(item -> item.callee().endsWith("#value")) .filter(item -> item.relationshipType().equals("invokes")) .toList(); assertFalse(dispatches.isEmpty(), facts.providerFacts().toString()); @@ -149,6 +149,7 @@ void keepsFinalVirtualDependencyTargetsDefinite() throws IOException { assertFinalVirtualCallIsDefinite(facts, "dependencyFinalSubclassCall"); assertFinalVirtualCallIsDefinite(facts, "dependencyFinalOverrideCall"); + assertFinalVirtualCallIsDefinite(facts, "dependencyInheritedFinalCall"); } @Test @@ -349,6 +350,16 @@ 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 { @@ -384,6 +395,7 @@ public final class Duplicate { 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 { @@ -402,6 +414,9 @@ public String dependencyFinalSubclassCall(FinalVirtualApi 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"); } From 881a72eb8a03a9c7bc31f558194af8475fc44423 Mon Sep 17 00:00:00 2001 From: Jordi Gil Date: Thu, 12 Nov 2026 17:52:29 -0500 Subject: [PATCH 7/7] FLPATH-5086 prove WAR ownership mappings --- ...otUpDependencyBoundaryIntegrationTest.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java index 8909d18..bf62b28 100644 --- a/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java +++ b/src/test/java/com/projectlightpath/analysis/java/SootUpDependencyBoundaryIntegrationTest.java @@ -234,16 +234,20 @@ public class WarApplication { 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); - String expectedWar = war.toAbsolutePath().normalize().toString(); SemanticCallFacts.Fact webClasses = fact(facts, "app.WarApplication#webClassesCall"); assertEquals("war.api.WarApi#value", webClasses.callee()); assertEquals(expectedWar, - webClasses.evidence().get(0).attributes().get("dependencyClasspathEntry")); + 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, - nestedJar.evidence().get(0).attributes().get("dependencyClasspathEntry")); + Path.of(nestedJar.evidence().get(0).attributes().get("dependencyClasspathEntry"))); } @Test @@ -512,6 +516,15 @@ private static SemanticCallFacts.Fact fact(SemanticCallFacts facts, String calle .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))