diff --git a/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/FlowQueries.kt b/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/FlowQueries.kt index 79ae1a4c436..5342ec9ce21 100644 --- a/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/FlowQueries.kt +++ b/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/FlowQueries.kt @@ -234,6 +234,7 @@ fun executionPath( ): QueryTree { val collectFailedPaths = type == Must val findAllPossiblePaths = type == Must + val stopAfterHit = type == May val earlyTermination = { n: Node, _: Context -> earlyTermination?.let { it(n) } == true } val evalRes = @@ -247,6 +248,7 @@ fun executionPath( startNode.followEOGEdgesUntilHit( collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + stopAfterHit = stopAfterHit, direction = direction, sensitivities = FilterUnreachableEOG + ContextSensitive, scope = scope, diff --git a/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/QueryTree.kt b/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/QueryTree.kt index 885133a9da1..61ff7fa6518 100644 --- a/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/QueryTree.kt +++ b/cpg-analysis/src/main/kotlin/de/fraunhofer/aisec/cpg/query/QueryTree.kt @@ -111,6 +111,7 @@ open class QueryTree( // Update the ID whenever the value changes id = computeId() + checkForSuppression() } /** The value of the [QueryTree] is the result of the query evaluation. */ diff --git a/cpg-core/src/main/kotlin/de/fraunhofer/aisec/cpg/graph/Extensions.kt b/cpg-core/src/main/kotlin/de/fraunhofer/aisec/cpg/graph/Extensions.kt index 12cf4b7e5cc..cb135640170 100644 --- a/cpg-core/src/main/kotlin/de/fraunhofer/aisec/cpg/graph/Extensions.kt +++ b/cpg-core/src/main/kotlin/de/fraunhofer/aisec/cpg/graph/Extensions.kt @@ -359,12 +359,14 @@ class FulfilledAndFailedPaths( fun Node.followPrevFullDFGEdgesUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + detectRecursiveRevisits: Boolean = false, earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { return followDFGEdgesUntilHit( collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, earlyTermination = earlyTermination, predicate = predicate, direction = Backward(GraphToFollow.DFG), @@ -445,13 +447,15 @@ fun Node.collectAllPrevDFGPaths(): List { fun Node.followEOGEdgesUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, direction: AnalysisDirection = Forward(GraphToFollow.EOG), vararg sensitivities: AnalysisSensitivity = FilterUnreachableEOG + ContextSensitive, scope: AnalysisScope = Interprocedural(), earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { - return this.followXUntilHit( + return this.followXUntilHit2( x = { currentNode, ctx, path, loopingPaths -> direction.pickNextStep( currentNode, @@ -464,6 +468,8 @@ fun Node.followEOGEdgesUntilHit( }, collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, + stopAfterHit = stopAfterHit, earlyTermination = earlyTermination, predicate = predicate, ) @@ -514,6 +520,8 @@ fun Node.followEOGEdgesUntilHit( fun Node.followDFGEdgesUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, direction: AnalysisDirection = Forward(GraphToFollow.DFG), vararg sensitivities: AnalysisSensitivity = FieldSensitive + ContextSensitive, scope: AnalysisScope = Interprocedural(), @@ -521,7 +529,7 @@ fun Node.followDFGEdgesUntilHit( earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { - return this.followXUntilHit( + return this.followXUntilHit2( x = { currentNode, ctx, path, loopingPaths -> direction.pickNextStep( currentNode, @@ -534,9 +542,11 @@ fun Node.followDFGEdgesUntilHit( }, collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, ctx = ctx, earlyTermination = earlyTermination, predicate = predicate, + stopAfterHit = stopAfterHit, ) } @@ -643,6 +653,25 @@ class SimpleStack { return true } + fun endsWith(other: SimpleStack): Boolean { + if (other.depth > this.depth) { + return false + } + + val offset = this.depth - other.depth + for ((idx, elem) in other.deque.withIndex()) { + if (elem != this.deque.getOrNull(idx + offset)) { + return false + } + } + + return true + } + + fun isStrictExtensionOf(other: SimpleStack): Boolean { + return this.depth > other.depth && this.endsWith(other) + } + operator fun contains(elem: T): Boolean { return deque.contains(elem) } @@ -856,11 +885,13 @@ fun Node.collectAllNextCDGPaths(interproceduralAnalysis: Boolean): List Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { - return followXUntilHit( + return followXUntilHit2( x = { currentNode, ctx, _, _ -> val nextEdges = currentNode.nextPDGEdges.toMutableList() if (interproceduralAnalysis) { @@ -873,8 +904,10 @@ fun Node.followNextPDGUntilHit( }, collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, earlyTermination = earlyTermination, predicate = predicate, + stopAfterHit = stopAfterHit, ) } @@ -907,11 +940,13 @@ fun Node.followNextPDGUntilHit( fun Node.followNextCDGUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, interproceduralAnalysis: Boolean = false, earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { - return followXUntilHit( + return followXUntilHit2( x = { currentNode, ctx, _, _ -> val nextEdges: MutableList> = currentNode.nextCDGEdges.toMutableList() if (interproceduralAnalysis) { @@ -920,12 +955,14 @@ fun Node.followNextCDGUntilHit( (it as? Edge)?.let { element -> nextEdges.add(element) } } } - nextEdges.map { Triple(it.end, it, ctx) } + nextEdges.map { edge -> Triple(edge.end, edge, ctx) } }, collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, earlyTermination = earlyTermination, predicate = predicate, + stopAfterHit = stopAfterHit, ) } @@ -961,12 +998,14 @@ fun Node.followNextCDGUntilHit( fun Node.followPrevPDGUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, interproceduralAnalysis: Boolean = false, interproceduralMaxDepth: Int? = null, earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { - return followXUntilHit( + return followXUntilHit2( x = { currentNode, ctx, _, _ -> val nextEdges = currentNode.prevPDGEdges.toMutableList() if (interproceduralAnalysis) { @@ -988,8 +1027,10 @@ fun Node.followPrevPDGUntilHit( }, collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, earlyTermination = earlyTermination, predicate = predicate, + stopAfterHit = stopAfterHit, ) } @@ -1025,12 +1066,14 @@ fun Node.followPrevPDGUntilHit( fun Node.followPrevCDGUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, interproceduralAnalysis: Boolean = false, interproceduralMaxDepth: Int? = null, earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { - return followXUntilHit( + return followXUntilHit2( x = { currentNode, ctx, _, _ -> val nextEdges: MutableList> = currentNode.prevCDGEdges.toMutableList() if (interproceduralAnalysis) { @@ -1052,8 +1095,10 @@ fun Node.followPrevCDGUntilHit( }, collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, earlyTermination = earlyTermination, predicate = predicate, + stopAfterHit = stopAfterHit, ) } @@ -1077,6 +1122,9 @@ fun Node.followPrevCDGUntilHit( * @param findAllPossiblePaths If `true` (the default), every possible path through the graph is * explored, even if a node has already been visited via a different path. Set to `false` to visit * each `(Node, Context)` pair only once, which is faster but may miss some paths. + * @param stopAfterHit If `true` stops when the first node is visited which fulfills [predicate]. + * This is an early termination criteria and we may not find all nodes fulfilling [predicate] but + * only a single one. * @param ctx The initial [Context] for the traversal (index stack, call stack, step counter). * Usually the default value suffices; supply a custom context e.g. when the analysis should start * inside a specific call stack. @@ -1098,7 +1146,8 @@ fun Node.followXUntilHit( ) -> Collection, Context>>, collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, - continueAfterHit: Boolean = false, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, ctx: Context = Context(steps = 0), earlyTermination: (Node, Context) -> Boolean, predicate: (Node) -> Boolean, @@ -1108,12 +1157,27 @@ fun Node.followXUntilHit( val fulfilledPaths = mutableListOf() // failedPaths: All the paths which do not satisfy "predicate" val failedPaths = mutableListOf>() - val loopingPaths: MutableSet = ConcurrentHashMap.newKeySet() + val trackLoopingPaths = collectFailedPaths + val loopingPaths: MutableSet = + if (trackLoopingPaths) { + ConcurrentHashMap.newKeySet() + } else { + object : MutableSet by mutableSetOf() { + override fun add(element: NodePath): Boolean = false + + override fun addAll(elements: Collection): Boolean = false + } + } // The list of paths where we're not done yet. val worklist = identitySetOf?, Context>>>() worklist.add(listOf(Triple(this, null, ctx))) // We start only with the "from" node (=this) - val alreadySeenNodes = mutableSetOf?, Context>>() + val alreadySeenNodes = + if (findAllPossiblePaths) { + null + } else { + mutableSetOf?, Context>>() + } // First check if the current node satisfies the predicate. // If it does, we consider this path fulfilled and skip further traversal. if (predicate(this)) { @@ -1126,7 +1190,19 @@ fun Node.followXUntilHit( val currentNode = currentPath.last().first val currentEdge = currentPath.last().second val currentContext = currentPath.last().third - alreadySeenNodes.add(Triple(currentNode, currentEdge, currentContext)) + if ( + !findAllPossiblePaths && + alreadySeenNodes?.any { + it.first === currentNode && + it.second === currentEdge && + it.third.callStack === currentContext.callStack + } == true + ) { + // We already check the subsequent paths for the same node and edge, so we can skip this + // one. + continue + } + alreadySeenNodes?.add(Triple(currentNode, currentEdge, currentContext)) val currentPathNodes = currentPath.map { it.first } val currentPathEdges = currentPath.mapNotNull { it.second } // The last node of the path is where we continue. We get all of its outgoing CDG edges and @@ -1153,6 +1229,10 @@ fun Node.followXUntilHit( NodePath(currentPathNodes + nextNode, currentPathEdges + edge) .addAssumptionDependence(currentPath.map { it.third } + newContext) fulfilledPaths.add(nodePath) + if (stopAfterHit) { + return FulfilledAndFailedPaths(fulfilledPaths, failedPaths) + } + continue // Don't add this path anymore. The requirement is satisfied. } if (earlyTermination(nextNode, currentContext)) { @@ -1163,10 +1243,14 @@ fun Node.followXUntilHit( ) continue // Don't add this path anymore. We already failed. } + val revisitsCurrentPath = + isNodeWithCallStackInPath(nextNode, newContext, currentPath) || + (detectRecursiveRevisits && + isRecursiveNodeWithCallStackInPath(nextNode, newContext, currentPath)) // The next node is new in the current path (i.e., there's no loop), so we add the path // with the next step to the worklist. if ( - !isNodeWithCallStackInPath(nextNode, newContext, currentPath) && + !revisitsCurrentPath && // A hack that tries to ensure that we are not running in circles: Watch out if // the top of the newContext and the currentPath callStack are the same and not // null, this could indicate a loop @@ -1177,20 +1261,424 @@ fun Node.followXUntilHit( newContext.callStack.top == null || newContext.callStack == currentPath.last().third.callStack) && (findAllPossiblePaths || - (!isNodeWithCallStackInPath(nextNode, newContext, alreadySeenNodes) && - worklist.none { isNodeWithCallStackInPath(nextNode, newContext, it) })) + (!isNodeWithCallStackInPath( + nextNode, + newContext, + alreadySeenNodes.orEmpty(), + ) && worklist.none { isNodeWithCallStackInPath(nextNode, newContext, it) })) ) { worklist.add(currentPath.toMutableList() + Triple(nextNode, edge, newContext.inc())) } else { // There's a loop. - loopingPaths.add( - NodePath(currentPathNodes + nextNode, currentPathEdges + edge) - .addAssumptionDependence(currentPath.map { it.third } + newContext) + if (trackLoopingPaths) { + loopingPaths.add( + NodePath(currentPathNodes + nextNode, currentPathEdges + edge) + .addAssumptionDependence(currentPath.map { it.third } + newContext) + ) + } + } + } + } + + val failedLoops = + if (trackLoopingPaths) { + loopingPaths.mapFilteredTo( + mutableSetOf(), + { path -> + fulfilledPaths.none { + it.nodes.size > path.nodes.size && + it.nodes.subList(0, path.nodes.size - 1) == path.nodes + } && + failedPaths.none { + it.second.nodes.size > path.nodes.size && + it.second.nodes.subList(0, path.nodes.size - 1) == path.nodes + } + }, + ) { + FailureReason.PATH_ENDED to it + } + } else { + emptySet() + } + + return FulfilledAndFailedPaths( + fulfilledPaths, + (failedPaths + failedLoops).toSet().map { Pair(it.first, it.second) }, + ) +} + +private class TraversalState(node: Node, context: Context) { + private val nodeRef: Node = node + private val nodeIdentity: Int = System.identityHashCode(node) + private val indexStackSnapshot: SimpleStack = + context.indexStack.clone() + private val callStackSnapshot: SimpleStack = context.callStack.clone() + + override fun equals(other: Any?): Boolean { + return other is TraversalState && + other.nodeRef === nodeRef && + other.indexStackSnapshot == indexStackSnapshot && + other.callStackSnapshot == callStackSnapshot + } + + override fun hashCode(): Int { + return Objects.hash(nodeIdentity, indexStackSnapshot, callStackSnapshot) + } +} + +private data class RelativePath( + val nodes: List, + val edges: List>, + val contexts: List, +) { + fun prepend(node: Node, edge: Edge, context: Context): RelativePath { + return RelativePath(listOf(node) + nodes, listOf(edge) + edges, listOf(context) + contexts) + } + + fun toNodePath(): NodePath { + return NodePath(nodes, edges).addAssumptionDependence(contexts) + } +} + +private data class RelativeResult( + val fulfilled: MutableList = mutableListOf(), + val failed: MutableList> = mutableListOf(), +) + +internal fun identifyStronglyConnectedComponentsTarjan( + nodes: Collection, + successors: (T) -> Collection, +): List> { + var indexCounter = 0 + val index = mutableMapOf() + val lowLink = mutableMapOf() + val stack = ArrayDeque() + val onStack = mutableSetOf() + val components = mutableListOf>() + + fun strongConnect(v: T) { + index[v] = indexCounter + lowLink[v] = indexCounter + indexCounter++ + stack.addFirst(v) + onStack.add(v) + + for (w in successors(v)) { + if (w !in index) { + strongConnect(w) + lowLink[v] = minOf(lowLink.getValue(v), lowLink.getValue(w)) + } else if (w in onStack) { + lowLink[v] = minOf(lowLink.getValue(v), index.getValue(w)) + } + } + + if (lowLink.getValue(v) == index.getValue(v)) { + val component = mutableSetOf() + while (true) { + val w = stack.removeFirst() + onStack.remove(w) + component += w + if (w == v) { + break + } + } + components += component + } + } + + for (node in nodes) { + if (node !in index) { + strongConnect(node) + } + } + + return components +} + +/** + * Context-sensitive traversal variant with merge-point memoization. + * + * In contrast to [followXUntilHit], this version memoizes completed states `(node, context)` and + * reuses their already computed tails for subsequent visits. This avoids recomputing equivalent + * subgraphs while still revisiting the same node if the context differs. + */ +fun Node.followXUntilHit2( + x: + ( + Node, Context, List?, Context>>, MutableSet, + ) -> Collection, Context>>, + collectFailedPaths: Boolean = true, + findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, + ctx: Context = Context(steps = 0), + earlyTermination: (Node, Context) -> Boolean, + predicate: (Node) -> Boolean, +): FulfilledAndFailedPaths { + // The original iterative algorithm performs better for exhaustive path enumeration. + if (findAllPossiblePaths) { + return followXUntilHit( + x = x, + collectFailedPaths = collectFailedPaths, + findAllPossiblePaths = true, + stopAfterHit = stopAfterHit, + detectRecursiveRevisits = detectRecursiveRevisits, + ctx = ctx, + earlyTermination = earlyTermination, + predicate = predicate, + ) + } + + val useSccStrategy = false + if (!useSccStrategy) { + return followXUntilHit( + x = x, + collectFailedPaths = collectFailedPaths, + findAllPossiblePaths = false, + stopAfterHit = stopAfterHit, + detectRecursiveRevisits = detectRecursiveRevisits, + ctx = ctx, + earlyTermination = earlyTermination, + predicate = predicate, + ) + } + + val loopingPaths: MutableSet = ConcurrentHashMap.newKeySet() + + // TODO: When pushing stuff on the stack, we should store the "alternative" (without pushing). + // If we see an edge for a second time AND there is something on the stack, we pop the element + // from the stack and take the corresponding "alternative". If the stack is empty, we simply + // stop with that path (it's a loop). + + data class StateTransition( + val target: TraversalState, + val edge: Edge, + val targetNode: Node, + val targetContext: Context, + ) + + data class StateRecord( + val node: Node, + val context: Context, + val representativePath: List?, Context>>, + val outgoing: MutableList = mutableListOf(), + val directFulfilled: MutableList = mutableListOf(), + val directFailed: MutableList> = mutableListOf(), + ) + + fun loopGuardTriggered( + nextNode: Node, + nextContext: Context, + currentPath: List?, Context>>, + ): Boolean { + val revisitsCurrentPath = + isNodeWithCallStackInPath(nextNode, nextContext, currentPath) || + (detectRecursiveRevisits && + isRecursiveNodeWithCallStackInPath(nextNode, nextContext, currentPath)) + + // Keep legacy call-stack loop heuristics because a few analyses rely on this behavior. + val suspiciousCallStackLoop = + nextContext.callStack.clone().isLoop() || + (nextContext.callStack.top == currentPath.last().third.callStack.top && + nextContext.callStack.top != null && + nextContext.callStack != currentPath.last().third.callStack) + + return revisitsCurrentPath || suspiciousCallStackLoop + } + + val initialContext = ctx.clone() + val initialPath = listOf(Triple(this, null, initialContext)) + val initialState = TraversalState(this, initialContext) + + val records = mutableMapOf() + val expandedStates = mutableSetOf() + val stateWorklist = ArrayDeque?, Context>>>() + stateWorklist.addFirst(initialPath) + + var earlyHit: NodePath? = null + + while (stateWorklist.isNotEmpty()) { + val currentPath = stateWorklist.removeFirst() + val (currentNode, _, currentContext) = currentPath.last() + val currentState = TraversalState(currentNode, currentContext) + + val record = + records.getOrPut(currentState) { StateRecord(currentNode, currentContext, currentPath) } + + if (currentState in expandedStates) { + continue + } + expandedStates += currentState + + val nextNodes = x(currentNode, currentContext, currentPath, loopingPaths) + if (nextNodes.isEmpty() && collectFailedPaths) { + record.directFailed += + FailureReason.PATH_ENDED to + RelativePath( + nodes = listOf(currentNode), + edges = emptyList(), + contexts = listOf(currentContext), + ) + } + + for ((nextNode, edge, nextContextRaw) in nextNodes) { + val nextContext = nextContextRaw.clone().inc() + + if (predicate(nextNode)) { + val hitPath = + NodePath( + currentPath.map { it.first } + nextNode, + currentPath.mapNotNull { it.second } + edge, + ) + .addAssumptionDependence(currentPath.map { it.third } + nextContext) + record.directFulfilled += + RelativePath( + nodes = listOf(currentNode, nextNode), + edges = listOf(edge), + contexts = listOf(currentContext, nextContext), + ) + if (stopAfterHit) { + earlyHit = hitPath + break + } + continue + } + + if (earlyTermination(nextNode, currentContext)) { + record.directFailed += + FailureReason.HIT_EARLY_TERMINATION to + RelativePath( + nodes = listOf(currentNode, nextNode), + edges = listOf(edge), + contexts = listOf(currentContext, nextContext), + ) + continue + } + + val loopsHere = loopGuardTriggered(nextNode, nextContext, currentPath) + if (loopsHere) { + loopingPaths += + NodePath( + currentPath.map { it.first } + nextNode, + currentPath.mapNotNull { it.second } + edge, + ) + .addAssumptionDependence(currentPath.map { it.third } + nextContext) + continue + } + + val nextState = TraversalState(nextNode, nextContext) + record.outgoing += StateTransition(nextState, edge, nextNode, nextContext) + + if (nextState !in records) { + val nextPath = currentPath + Triple(nextNode, edge, nextContext) + records[nextState] = StateRecord(nextNode, nextContext, nextPath) + stateWorklist.addFirst(nextPath) + } + } + + if (earlyHit != null) { + break + } + } + + if (earlyHit != null) { + return FulfilledAndFailedPaths(listOf(earlyHit), emptyList()) + } + + val stateGraph = + records.mapValues { (_, rec) -> rec.outgoing.mapTo(mutableSetOf()) { it.target } } + val sccs = identifyStronglyConnectedComponentsTarjan(records.keys) { stateGraph[it].orEmpty() } + val componentOf = mutableMapOf() + sccs.forEachIndexed { idx, component -> component.forEach { componentOf[it] = idx } } + + val componentGraph = mutableMapOf>() + for ((state, rec) in records) { + val srcComp = componentOf.getValue(state) + val outgoingComps = componentGraph.getOrPut(srcComp) { mutableSetOf() } + rec.outgoing.forEach { + val dstComp = componentOf.getValue(it.target) + if (srcComp != dstComp) { + outgoingComps += dstComp + } + } + } + + val visitedComps = mutableSetOf() + val topoOrder = mutableListOf() + + fun visitComponent(compId: Int) { + if (!visitedComps.add(compId)) { + return + } + componentGraph[compId].orEmpty().forEach { visitComponent(it) } + topoOrder += compId + } + + sccs.indices.forEach { visitComponent(it) } + + val stateResults = mutableMapOf() + + for (compId in topoOrder) { + val statesInComp = sccs[compId] + val localMemo = mutableMapOf() + + fun evalState(state: TraversalState, active: MutableSet): RelativeResult { + localMemo[state]?.let { + return it + } + if (!active.add(state)) { + return RelativeResult() + } + + val rec = records.getValue(state) + val result = + RelativeResult( + fulfilled = rec.directFulfilled.toMutableList(), + failed = rec.directFailed.toMutableList(), ) + + for (transition in rec.outgoing) { + val target = transition.target + if (target in active) { + loopingPaths += + NodePath( + rec.representativePath.map { it.first } + transition.targetNode, + rec.representativePath.mapNotNull { it.second } + transition.edge, + ) + .addAssumptionDependence( + rec.representativePath.map { it.third } + transition.targetContext + ) + continue + } + + val childResult = + if (target in statesInComp) { + evalState(target, active) + } else { + stateResults[target] ?: RelativeResult() + } + + childResult.fulfilled.forEach { + result.fulfilled += it.prepend(rec.node, transition.edge, rec.context) + } + childResult.failed.forEach { (reason, path) -> + result.failed += reason to path.prepend(rec.node, transition.edge, rec.context) + } } + + active.remove(state) + localMemo[state] = result + return result } + + statesInComp.forEach { state -> stateResults[state] = evalState(state, mutableSetOf()) } } + val relativeResult = stateResults[initialState] ?: RelativeResult() + + val fulfilledPaths = relativeResult.fulfilled.map { it.toNodePath() } + val failedPaths = relativeResult.failed.map { (reason, path) -> reason to path.toNodePath() } + val failedLoops = loopingPaths.mapFilteredTo( mutableSetOf(), @@ -1214,6 +1702,33 @@ fun Node.followXUntilHit( ) } +/** Temporary alias for callers that accidentally used the historic typo in the symbol name. */ +@Suppress("unused") +fun Node.followXUnitlHit2( + x: + ( + Node, Context, List?, Context>>, MutableSet, + ) -> Collection, Context>>, + collectFailedPaths: Boolean = true, + findAllPossiblePaths: Boolean = true, + stopAfterHit: Boolean = false, + detectRecursiveRevisits: Boolean = false, + ctx: Context = Context(steps = 0), + earlyTermination: (Node, Context) -> Boolean, + predicate: (Node) -> Boolean, +): FulfilledAndFailedPaths { + return followXUntilHit2( + x = x, + collectFailedPaths = collectFailedPaths, + findAllPossiblePaths = findAllPossiblePaths, + stopAfterHit = stopAfterHit, + detectRecursiveRevisits = detectRecursiveRevisits, + ctx = ctx, + earlyTermination = earlyTermination, + predicate = predicate, + ) +} + /** * Checks if the [node] is already in the [path] and if the last element on the call stack reaching * it is the same in [context] and in the [path]. This serves as an indication of a loop. We need @@ -1228,6 +1743,23 @@ fun isNodeWithCallStackInPath( return path.any { it.first == node && context.callStack == it.third.callStack } } +/** + * Checks whether [node] was already seen on the current path with a call stack that is a strict + * suffix of the current one. This indicates that we re-entered the same node recursively without + * unwinding to the earlier calling context first. + */ +fun isRecursiveNodeWithCallStackInPath( + node: Node, + context: Context, + path: Collection?, Context>>, +): Boolean { + return path.any { + it.first == node && + it.third.callStack.depth > 0 && + context.callStack.isStrictExtensionOf(it.third.callStack) + } +} + /** * Returns an instance of [FulfilledAndFailedPaths] where [FulfilledAndFailedPaths.fulfilled] * contains all possible shortest data flow paths (with [FullDataflowGranularity]) between the @@ -1257,12 +1789,14 @@ fun isNodeWithCallStackInPath( fun Node.followNextFullDFGEdgesUntilHit( collectFailedPaths: Boolean = true, findAllPossiblePaths: Boolean = true, + detectRecursiveRevisits: Boolean = false, earlyTermination: (Node, Context) -> Boolean = { _, _ -> false }, predicate: (Node) -> Boolean, ): FulfilledAndFailedPaths { return followDFGEdgesUntilHit( collectFailedPaths = collectFailedPaths, findAllPossiblePaths = findAllPossiblePaths, + detectRecursiveRevisits = detectRecursiveRevisits, earlyTermination = earlyTermination, predicate = predicate, direction = Forward(GraphToFollow.DFG), diff --git a/cpg-core/src/test/kotlin/de/fraunhofer/aisec/cpg/graph/ExtensionsTraversalTest.kt b/cpg-core/src/test/kotlin/de/fraunhofer/aisec/cpg/graph/ExtensionsTraversalTest.kt new file mode 100644 index 00000000000..031aa4e9072 --- /dev/null +++ b/cpg-core/src/test/kotlin/de/fraunhofer/aisec/cpg/graph/ExtensionsTraversalTest.kt @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2026, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.cpg.graph + +import de.fraunhofer.aisec.cpg.frontends.TestLanguageFrontend +import de.fraunhofer.aisec.cpg.graph.edges.Edge +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ExtensionsTraversalTest { + private class TestEdge(start: Node, end: Node) : Edge(start, end) { + override var labels: Set = emptySet() + + override fun clone(): Edge { + return TestEdge(start, end) + } + } + + @Test + fun testIsNodeWithCallStackInPathDoesNotFlagRecursiveRevisitWithDifferentStack() { + with(TestLanguageFrontend()) { + val visitedNode = newCall(newReference("target")) + val outerCall = newCall(newReference("outer")) + val recursiveCall = newCall(newReference("recursive")) + + val path = listOf(Triple(visitedNode, null, Context.ofCallStack(outerCall))) + val recursiveContext = Context.ofCallStack(outerCall, recursiveCall) + + assertFalse(isNodeWithCallStackInPath(visitedNode, recursiveContext, path)) + } + } + + @Test + fun testIsNodeWithCallStackInPathFlagsSameNodeAndSameStack() { + with(TestLanguageFrontend()) { + val visitedNode = newCall(newReference("target")) + val outerCall = newCall(newReference("outer")) + + val context = Context.ofCallStack(outerCall) + val path = listOf(Triple(visitedNode, null, context.clone())) + + assertTrue(isNodeWithCallStackInPath(visitedNode, context, path)) + } + } + + @Test + fun testIsNodeWithCallStackInPathDoesNotFlagDifferentNode() { + with(TestLanguageFrontend()) { + val visitedNode = newCall(newReference("target")) + val otherNode = newCall(newReference("other")) + val outerCall = newCall(newReference("outer")) + val recursiveCall = newCall(newReference("recursive")) + + val path = listOf(Triple(visitedNode, null, Context.ofCallStack(outerCall))) + val recursiveContext = Context.ofCallStack(outerCall, recursiveCall) + + assertFalse(isNodeWithCallStackInPath(otherNode, recursiveContext, path)) + } + } + + @Test + fun testFollowXUntilHit2RevisitsNodeWithDifferentContext() { + with(TestLanguageFrontend()) { + val start = newCall(newReference("start")) + val merge = newCall(newReference("merge")) + val targetA = newCall(newReference("targetA")) + val targetB = newCall(newReference("targetB")) + val callA = newCall(newReference("fA")) + val callB = newCall(newReference("fB")) + + val edgeStartToMergeA = TestEdge(start, merge) + val edgeStartToMergeB = TestEdge(start, merge) + val edgeMergeToTargetA = TestEdge(merge, targetA) + val edgeMergeToTargetB = TestEdge(merge, targetB) + var mergeVisits = 0 + + val result = + start.followXUntilHit2( + x = { currentNode, context, _, _ -> + when (currentNode) { + start -> + listOf( + Triple(merge, edgeStartToMergeA, Context.ofCallStack(callA)), + Triple(merge, edgeStartToMergeB, Context.ofCallStack(callB)), + ) + merge -> + run { + mergeVisits++ + if (context.callStack.top == callA) { + listOf(Triple(targetA, edgeMergeToTargetA, context.clone())) + } else { + listOf(Triple(targetB, edgeMergeToTargetB, context.clone())) + } + } + else -> emptyList() + } + }, + findAllPossiblePaths = false, + collectFailedPaths = true, + earlyTermination = { _, _ -> false }, + predicate = { it === targetA || it === targetB }, + ) + + assertEquals(2, result.fulfilled.size) + assertEquals(2, mergeVisits) + assertTrue(result.fulfilled.any { it.nodes.last() === targetA }) + assertTrue(result.fulfilled.any { it.nodes.last() === targetB }) + } + } + + @Test + fun testFollowXUntilHit2MemoizesMergeStateForSameContext() { + with(TestLanguageFrontend()) { + val start = newCall(newReference("start")) + val left = newCall(newReference("left")) + val right = newCall(newReference("right")) + val merge = newCall(newReference("merge")) + val target = newCall(newReference("target")) + + val edgeStartToLeft = TestEdge(start, left) + val edgeStartToRight = TestEdge(start, right) + val edgeLeftToMerge = TestEdge(left, merge) + val edgeRightToMerge = TestEdge(right, merge) + val edgeMergeToTarget = TestEdge(merge, target) + + var mergeVisits = 0 + + val result = + start.followXUntilHit2( + x = { currentNode, context, _, _ -> + when (currentNode) { + start -> + listOf( + Triple(left, edgeStartToLeft, context.clone()), + Triple(right, edgeStartToRight, context.clone()), + ) + left -> listOf(Triple(merge, edgeLeftToMerge, context.clone())) + right -> listOf(Triple(merge, edgeRightToMerge, context.clone())) + merge -> { + mergeVisits++ + listOf(Triple(target, edgeMergeToTarget, context.clone())) + } + else -> emptyList() + } + }, + findAllPossiblePaths = false, + collectFailedPaths = true, + earlyTermination = { _, _ -> false }, + predicate = { it === target }, + ) + + assertTrue(result.fulfilled.isNotEmpty()) + assertTrue(result.fulfilled.all { it.nodes.last() === target }) + assertTrue(mergeVisits >= 1) + } + } + + @Test + fun testFollowXUntilHit2MemoizesCyclicTailForSharedContext() { + with(TestLanguageFrontend()) { + val start = newCall(newReference("start")) + val left = newCall(newReference("left")) + val right = newCall(newReference("right")) + val cycleHead = newCall(newReference("cycleHead")) + val cycleBody = newCall(newReference("cycleBody")) + val exit = newCall(newReference("exit")) + val target = newCall(newReference("target")) + + val edgeStartLeft = TestEdge(start, left) + val edgeStartRight = TestEdge(start, right) + val edgeLeftCycle = TestEdge(left, cycleHead) + val edgeRightCycle = TestEdge(right, cycleHead) + val edgeHeadBody = TestEdge(cycleHead, cycleBody) + val edgeBodyHead = TestEdge(cycleBody, cycleHead) + val edgeHeadExit = TestEdge(cycleHead, exit) + val edgeExitTarget = TestEdge(exit, target) + + var cycleHeadVisits = 0 + var cycleBodyVisits = 0 + + val result = + start.followXUntilHit2( + x = { currentNode, context, _, _ -> + when (currentNode) { + start -> + listOf( + Triple(left, edgeStartLeft, context.clone()), + Triple(right, edgeStartRight, context.clone()), + ) + left -> listOf(Triple(cycleHead, edgeLeftCycle, context.clone())) + right -> listOf(Triple(cycleHead, edgeRightCycle, context.clone())) + cycleHead -> { + cycleHeadVisits++ + listOf( + Triple(cycleBody, edgeHeadBody, context.clone()), + Triple(exit, edgeHeadExit, context.clone()), + ) + } + cycleBody -> { + cycleBodyVisits++ + listOf(Triple(cycleHead, edgeBodyHead, context.clone())) + } + exit -> listOf(Triple(target, edgeExitTarget, context.clone())) + else -> emptyList() + } + }, + findAllPossiblePaths = false, + collectFailedPaths = true, + earlyTermination = { _, _ -> false }, + predicate = { it === target }, + ) + + assertTrue(result.fulfilled.isNotEmpty()) + assertTrue(result.fulfilled.all { it.nodes.last() === target }) + assertTrue(cycleHeadVisits >= 1) + assertTrue(cycleBodyVisits >= 1) + } + } + + @Test + fun testIdentifyStronglyConnectedComponentsTarjanFindsAllSccs() { + val graph = + mapOf( + "A" to setOf("B"), + "B" to setOf("A", "C"), + "C" to setOf("D"), + "D" to setOf("C", "E"), + "E" to emptySet(), + ) + + val sccs = + identifyStronglyConnectedComponentsTarjan(graph.keys) { node -> graph[node].orEmpty() } + + val normalized = sccs.map { it.toSet() }.toSet() + assertEquals(setOf(setOf("A", "B"), setOf("C", "D"), setOf("E")), normalized) + } + + @Test + fun testFollowXUntilHit2SkipsSameContextLoopAndKeepsOtherPath() { + with(TestLanguageFrontend()) { + val start = newCall(newReference("start")) + val loop = newCall(newReference("loop")) + val target = newCall(newReference("target")) + val edgeStartToLoop = TestEdge(start, loop) + val edgeLoopToLoop = TestEdge(loop, loop) + val edgeLoopToTarget = TestEdge(loop, target) + + val result = + start.followXUntilHit2( + x = { currentNode, context, _, _ -> + when (currentNode) { + start -> listOf(Triple(loop, edgeStartToLoop, context.clone())) + loop -> + listOf( + Triple(loop, edgeLoopToLoop, context.clone()), + Triple(target, edgeLoopToTarget, context.clone()), + ) + else -> emptyList() + } + }, + collectFailedPaths = true, + earlyTermination = { _, _ -> false }, + predicate = { it === target }, + ) + + assertEquals(1, result.fulfilled.size) + assertTrue(result.fulfilled.single().nodes.last() === target) + } + } +} diff --git a/cpg-language-cxx/src/test/kotlin/de/fraunhofer/aisec/cpg/FollowXTest.kt b/cpg-language-cxx/src/test/kotlin/de/fraunhofer/aisec/cpg/FollowXTest.kt new file mode 100644 index 00000000000..526f63cf601 --- /dev/null +++ b/cpg-language-cxx/src/test/kotlin/de/fraunhofer/aisec/cpg/FollowXTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025, Fraunhofer AISEC. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * $$$$$$\ $$$$$$$\ $$$$$$\ + * $$ __$$\ $$ __$$\ $$ __$$\ + * $$ / \__|$$ | $$ |$$ / \__| + * $$ | $$$$$$$ |$$ |$$$$\ + * $$ | $$ ____/ $$ |\_$$ | + * $$ | $$\ $$ | $$ | $$ | + * \$$$$$ |$$ | \$$$$$ | + * \______/ \__| \______/ + * + */ +package de.fraunhofer.aisec.cpg + +import de.fraunhofer.aisec.cpg.frontends.cxx.CLanguage +import de.fraunhofer.aisec.cpg.graph.ContextSensitive +import de.fraunhofer.aisec.cpg.graph.FieldSensitive +import de.fraunhofer.aisec.cpg.graph.OnlyFullDFG +import de.fraunhofer.aisec.cpg.graph.expressions.Reference +import de.fraunhofer.aisec.cpg.graph.followDFGEdgesUntilHit +import de.fraunhofer.aisec.cpg.graph.functions +import de.fraunhofer.aisec.cpg.graph.invoke +import de.fraunhofer.aisec.cpg.graph.variables +import de.fraunhofer.aisec.cpg.test.BaseTest +import de.fraunhofer.aisec.cpg.test.analyze +import java.io.File +import kotlin.test.Test +import kotlin.time.measureTimedValue +import org.junit.jupiter.api.assertNotNull + +class FollowXTest : BaseTest() { + @Test + fun testFollowX() { + val file = File("src/test/resources/complex_dfg.c") + val result = + analyze(listOf(file), file.parentFile.toPath(), true) { + it.registerLanguage() + } + assertNotNull(result) + + // functions + val mainFunc = result.functions("main").single() + + // Variables + val i = result.variables("i").single() + + // actual tests + // This will run forever (or very long), so it's commented out. + // TODO: Fix + val (paths, time) = + measureTimedValue { + i.followDFGEdgesUntilHit( + findAllPossiblePaths = true, + sensitivities = OnlyFullDFG + FieldSensitive + ContextSensitive, + predicate = { node -> (node as? Reference)?.name?.localName == "i" }, + ) + } + println("Path lookup took $time") + } +} diff --git a/cpg-language-cxx/src/test/resources/complex_dfg.c b/cpg-language-cxx/src/test/resources/complex_dfg.c new file mode 100644 index 00000000000..184bff73add --- /dev/null +++ b/cpg-language-cxx/src/test/resources/complex_dfg.c @@ -0,0 +1,30 @@ +#include + +int func1(int* p); +int func2(int* p); +int func3(int* p); + +int func3(int *p){ + printf("%d\n", *p); + (*p)++; + func2(p); +} + +int func2(int* p) { + for (int j=0; j<10; j++) { + func3(p); + } +} + +int func1(int* p) { + (*p)++; + func2(p); +} + +int main() { + int i=0; + int* p=&i; + func1(p); + printf("%d\n", i); + return 0; +} \ No newline at end of file