diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20f0475d1..0d3c521c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: # store version in GitHub environment file echo "version=$VERSION" >> $GITHUB_ENV - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: Build ${{ env.version }} run: ./gradlew :codyze-cli:build -x check --parallel -Pversion=${{ env.version }} - name: Push Release Docker Image diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 2bea31a01..b37675113 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -20,7 +20,7 @@ jobs: distribution: "temurin" java-version: 17 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: Generate coverage report run: ./gradlew testCodeCoverageReport --continue - name: Archive test reports diff --git a/.github/workflows/detekt.yml b/.github/workflows/detekt.yml index c574ccedd..1dbdb872e 100644 --- a/.github/workflows/detekt.yml +++ b/.github/workflows/detekt.yml @@ -26,7 +26,7 @@ jobs: java-version: 17 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: Run analysis run: ./gradlew detektMain detektTest --continue diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index bf2f96fbc..5c42e9a25 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -17,7 +17,7 @@ jobs: distribution: "temurin" java-version: 17 - name: 'Setup Gradle' - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: 'Build API pages' run: | ./gradlew dokkaHtmlMultiModule \ diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 0b0a79f6b..e1588a874 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -38,7 +38,7 @@ jobs: distribution: "temurin" java-version: ${{ matrix.java-lts }} - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v4 - name: Build and Test id: build-and-test run: ./gradlew build --parallel @@ -65,7 +65,7 @@ jobs: merge-multiple: true - name: Process failures id: process-failures - run: test -f all-failures/failure && echo 'hasFails=true' >> "$GITHUB_OUTPUT" + run: (test -f all-failures/failure && echo 'hasFails=true' || echo 'hasFails=false') >> "$GITHUB_OUTPUT" - if: ${{ steps.process-failures.outputs.hasFails == 'true' }} uses: actions/github-script@v7 with: diff --git a/Dockerfile b/Dockerfile index 14bf667fe..2a7f557bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:17.0.11_9-jre +FROM eclipse-temurin:17.0.12_7-jre LABEL org.opencontainers.image.authors="Fraunhofer AISEC " diff --git a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/CokoCpgBackend.kt b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/CokoCpgBackend.kt index 34721ad3a..7fb1af8fb 100644 --- a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/CokoCpgBackend.kt +++ b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/CokoCpgBackend.kt @@ -34,6 +34,7 @@ import io.github.detekt.sarif4k.Artifact import io.github.detekt.sarif4k.ArtifactLocation import io.github.detekt.sarif4k.ToolComponent import kotlin.io.path.absolutePathString +import kotlin.reflect.KFunction typealias Nodes = Collection @@ -61,6 +62,9 @@ class CokoCpgBackend(config: BackendConfiguration) : /** For each of the nodes in [this], there is a path to at least one of the nodes in [that]. */ override infix fun Op.followedBy(that: Op): FollowsEvaluator = FollowsEvaluator(ifOp = this, thenOp = that) + /** For each of the nodes in [that], there is a path from at least one of the nodes in [this]. */ + override infix fun Op.precedes(that: Op): PrecedesEvaluator = PrecedesEvaluator(prevOp = this, thisOp = that) + /* * Ensures the order of nodes as specified in the user configured [Order] object. * The order evaluation starts at the given [baseNodes]. @@ -81,11 +85,21 @@ class CokoCpgBackend(config: BackendConfiguration) : order = Order().apply(block) ) + /** Verifies that the argument at [argPos] of [targetOp] stems from a call to [originOp] */ + override fun argumentOrigin(targetOp: KFunction, argPos: Int, originOp: KFunction): ArgumentEvaluator = + ArgumentEvaluator( + targetCall = targetOp.getOp(), + argPos = argPos, + originCall = originOp.getOp() + ) + /** * Ensures that all calls to the [ops] have arguments that fit the parameters specified in [ops] */ - override fun only(vararg ops: Op): OnlyEvaluator = OnlyEvaluator(ops.toList()) - override fun never(vararg ops: Op): NeverEvaluator = NeverEvaluator(ops.toList()) + override fun only(vararg ops: Op): OnlyNeverEvaluator = + OnlyNeverEvaluator(ops.toList(), OnlyNeverEvaluator.Functionality.ONLY) + override fun never(vararg ops: Op): OnlyNeverEvaluator = + OnlyNeverEvaluator(ops.toList(), OnlyNeverEvaluator.Functionality.NEVER) override fun whenever( premise: Condition.() -> ConditionComponent, assertionBlock: WheneverEvaluator.() -> Unit diff --git a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/dsl/ImplementationDsl.kt b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/dsl/ImplementationDsl.kt index 07e1fdbbd..69749fcfd 100644 --- a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/dsl/ImplementationDsl.kt +++ b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/dsl/ImplementationDsl.kt @@ -276,3 +276,27 @@ private fun CallExpression.cpgCheckArgsSize(parameters: Array<*>, hasVarargs: Bo } else { parameters.size == arguments.size } + +/** + * To generate more interesting Findings + * we want to find key points where the forbidden operation influences other code. + * For this we traverse the DFG for a fixed amount of steps and search for all usages of declared values. + */ +fun Node.findUsages(depth: Int = 5): Collection { + val currentNodes: MutableSet = mutableSetOf(this) + val usages = mutableSetOf() + for (i in 0..depth) { + // The set will be empty if we found a usage or no further DFG for all branches + if (currentNodes.isEmpty()) { + break + } + for (current in currentNodes) { + currentNodes.remove(current) + when (current) { + is ValueDeclaration -> usages += current.usages + else -> currentNodes += current.nextDFG + } + } + } + return usages +} diff --git a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluator.kt b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluator.kt new file mode 100644 index 000000000..f4261b8df --- /dev/null +++ b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluator.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024, 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.codyze.backends.cpg.coko.evaluators + +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CokoCpgBackend +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding +import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.cpgGetAllNodes +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.EvaluationContext +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Evaluator +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Finding +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.Op +import de.fraunhofer.aisec.cpg.graph.declarations.VariableDeclaration +import de.fraunhofer.aisec.cpg.graph.followPrevEOGEdgesUntilHit +import de.fraunhofer.aisec.cpg.graph.statements.expressions.CallExpression +import de.fraunhofer.aisec.cpg.graph.statements.expressions.Expression +import de.fraunhofer.aisec.cpg.graph.statements.expressions.Literal +import de.fraunhofer.aisec.cpg.graph.statements.expressions.Reference + +context(CokoCpgBackend) +class ArgumentEvaluator(val targetCall: Op, val argPos: Int, val originCall: Op) : Evaluator { + override fun evaluate(context: EvaluationContext): List { + // Get all good calls and the associated variables + val originCalls = originCall.cpgGetAllNodes() + val variables = originCalls.mapNotNull { + it.tryGetVariableDeclaration() + } + val findings = mutableListOf() + // Get all target calls using the variable and check whether it is in a good state + val targetCalls = targetCall.cpgGetAllNodes() + for (call in targetCalls) { + val arg: VariableDeclaration? = + (call.arguments.getOrNull(argPos) as? Reference)?.refersTo as? VariableDeclaration + if (arg in variables && arg?.allowsInvalidPaths(originCalls.toList(), call) == false) { + findings.add( + CpgFinding( + message = "Complies with rule: " + + "arg $argPos of \"${call.code}\" stems from a call to \"$originCall\"", + kind = Finding.Kind.Pass, + node = call, + relatedNodes = listOfNotNull(originCalls.firstOrNull { it.tryGetVariableDeclaration() == arg }) + ) + ) + } else { + findings.add( + CpgFinding( + message = "Violation against rule: " + + "arg $argPos of \"${call.code}\" does not stem from a call to \"$originCall\"", + kind = Finding.Kind.Fail, + node = call, + relatedNodes = listOf() + ) + ) + } + } + + return findings + } + + /** + * Tries to resolve which variable is modified by a CallExpression + * @return The VariableExpression modified by the CallExpression or null + */ + private fun CallExpression.tryGetVariableDeclaration(): VariableDeclaration? { + return when (val nextDFG = this.nextDFG.firstOrNull()) { + is VariableDeclaration -> nextDFG + is Reference -> nextDFG.refersTo as? VariableDeclaration + else -> null + } + } + + /** + * This method tries to get all possible CallExpressions that try to override the variable value + * @return The CallExpressions modifying the variable + */ + private fun VariableDeclaration.getOverrides(): List { + val assignments = this.typeObservers.mapNotNull { (it as? Reference)?.prevDFG?.firstOrNull() } + // Consider overwrites caused by CallExpressions and Literals + return assignments.mapNotNull { + when (it) { + is CallExpression -> it + is Literal<*> -> it + else -> null + } + } + } + + /** + * This method checks whether there are any paths with forbidden values for the variable that end in the target call + * @param allowedCalls The calls that set the variable to an allowed value + * @param targetCall The target call using the variable as an argument + * @return whether there is at least one path that allows an invalid value for the variable to reach the target + */ + private fun VariableDeclaration.allowsInvalidPaths( + allowedCalls: List, + targetCall: CallExpression + ): Boolean { + // Get every MemberCall that tries to override our variable, ignoring allowed calls + val interferingDeclarations = this.getOverrides().toMutableList() - allowedCalls.toSet() + // Check whether there is a path from any invalid call to our target call that is not overridden at least once + val targetToNoise = targetCall.followPrevEOGEdgesUntilHit { interferingDeclarations.contains(it) }.fulfilled + .filterNot { badPath -> allowedCalls.any { goodCall -> goodCall in badPath } } + return targetToNoise.isNotEmpty() + } +} diff --git a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluator.kt b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluator.kt deleted file mode 100644 index bccb8d14d..000000000 --- a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluator.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright (c) 2023, 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.codyze.backends.cpg.coko.evaluators - -import de.fraunhofer.aisec.codyze.backends.cpg.coko.CokoCpgBackend -import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding -import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.Result -import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.cpgGetAllNodes -import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.cpgGetNodes -import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.EvaluationContext -import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Evaluator -import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Finding -import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.Op -import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.Rule -import de.fraunhofer.aisec.cpg.graph.statements.expressions.CallExpression -import kotlin.reflect.full.findAnnotation - -context(CokoCpgBackend) -class NeverEvaluator(private val forbiddenOps: List) : Evaluator { - var violating = with(this@CokoCpgBackend) { - forbiddenOps.flatMap { it.cpgGetNodes().entries }.associate { it.toPair() } - } - - /** Default message if a violation is found */ - private val defaultFailMessage: String by lazy { - "Calls to ${forbiddenOps.joinToString()} are not allowed." - } - - /** Default message if the analyzed code complies with rule */ - private val defaultPassMessage by lazy { - "No calls to ${forbiddenOps.joinToString()} found which is in compliance with rule." - } - - override fun evaluate(context: EvaluationContext): Collection { - val (violatingNodes, correctAndOpenNodes) = getNodes() - val (failMessage, passMessage) = getMessages(context) - return createFindings(violatingNodes, correctAndOpenNodes, failMessage, passMessage) - } - - private fun getNodes(): Pair, Set> { - val violatingNodes = violating.keys.toSet() - - val distinctOps = forbiddenOps.toSet() - val allNodes = - with(this@CokoCpgBackend) { distinctOps.flatMap { it.cpgGetAllNodes() } } - .toSet() - - // `correctNodes` is a subset of `allNodes` - // we want to find nodes in `allNodes` that are not contained in `correctNodes` since they are violations - val correctAndOpenNodes = allNodes.minus(violatingNodes) - return violatingNodes to correctAndOpenNodes - } - - private fun getMessages(context: EvaluationContext): Pair { - val ruleAnnotation = context.rule.findAnnotation() - val failMessage = ruleAnnotation?.failMessage?.takeIf { it.isNotEmpty() } ?: defaultFailMessage - val passMessage = ruleAnnotation?.passMessage?.takeIf { it.isNotEmpty() } ?: defaultPassMessage - return failMessage to passMessage - } - - fun createFindings( - violatingNodes: Set, - correctAndOpenNodes: Set, - failMessage: String, - passMessage: String - ): List { - val findings = mutableListOf() - for (node in violatingNodes) { - findings.add( - CpgFinding( - message = "Violation against rule: \"${node.code}\". $failMessage", - kind = Finding.Kind.Fail, - node = node - ) - ) - } - - for (node in correctAndOpenNodes) { - if (violating[node] == Result.OPEN) { - findings.add( - CpgFinding( - message = "Not enough information to evaluate \"${node.code}\"", - kind = Finding.Kind.Open, - node = node - ) - ) - } else { - findings.add( - CpgFinding( - message = "Complies with rule: \"${node.code}\". $passMessage", - kind = Finding.Kind.Pass, - node = node - ) - ) - } - } - - return findings - } -} diff --git a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluator.kt b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyNeverEvaluator.kt similarity index 74% rename from codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluator.kt rename to codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyNeverEvaluator.kt index 2abcb7478..ffdb217bf 100644 --- a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluator.kt +++ b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyNeverEvaluator.kt @@ -20,6 +20,7 @@ import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.Result import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.cpgGetAllNodes import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.cpgGetNodes +import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.findUsages import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.EvaluationContext import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Evaluator import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Finding @@ -29,15 +30,16 @@ import de.fraunhofer.aisec.cpg.graph.statements.expressions.CallExpression import kotlin.reflect.full.findAnnotation context(CokoCpgBackend) -class OnlyEvaluator(private val ops: List) : Evaluator { - var correctAndOpen = with(this@CokoCpgBackend) { - ops.flatMap { it.cpgGetNodes().entries }.associate { it.toPair() } - } +class OnlyNeverEvaluator(private val ops: List, private val functionality: Functionality) : Evaluator { + + var interestingNodes = with(this@CokoCpgBackend) { + ops.flatMap { it.cpgGetNodes().entries } + }.associate { it.toPair() } /** Default message if a violation is found */ private val defaultFailMessage: String by lazy { // Try to model what the allowed calls look like with `toString` call of `Op` - "Only calls to ${ops.joinToString()} allowed." + "${if (functionality == Functionality.NEVER) "No" else "Only"} calls to ${ops.joinToString()} allowed." } /** Default message if node complies with rule */ @@ -50,16 +52,22 @@ class OnlyEvaluator(private val ops: List) : Evaluator { } private fun getNodes(): Pair, Set> { - val correctAndOpenNodes = correctAndOpen.keys.toSet() - val distinctOps = ops.toSet() val allNodes = with(this@CokoCpgBackend) { distinctOps.flatMap { it.cpgGetAllNodes() } } + .filter { it.location != null } .toSet() - // `correctNodes` is a subset of `allNodes` - // we want to find nodes in `allNodes` that are not contained in `correctNodes` since they are violations - val violatingNodes = allNodes.minus(correctAndOpenNodes) + // `matchingNodes` is a subset of `allNodes` + // we want to find nodes in `allNodes` that are not contained in `matchingNodes` + // since they are contrary Findings + val matchingNodes = interestingNodes.keys.toSet() + val differingNodes = allNodes.minus(matchingNodes) + + // define what violations and passes are, depending on selected functionality + val correctAndOpenNodes = if (functionality == Functionality.NEVER) differingNodes else matchingNodes + val violatingNodes = if (functionality == Functionality.NEVER) matchingNodes else differingNodes + return violatingNodes to correctAndOpenNodes } @@ -77,23 +85,26 @@ class OnlyEvaluator(private val ops: List) : Evaluator { passMessage: String ): List { val findings = mutableListOf() + for (node in violatingNodes) { findings.add( CpgFinding( message = "Violation against rule: \"${node.code}\". $failMessage", kind = Finding.Kind.Fail, - node = node + node = node, + relatedNodes = node.findUsages() ) ) } for (node in correctAndOpenNodes) { - if (correctAndOpen[node] == Result.OPEN) { + if (interestingNodes[node] == Result.OPEN) { findings.add( CpgFinding( message = "Not enough information to evaluate \"${node.code}\"", kind = Finding.Kind.Open, - node = node + node = node, + relatedNodes = node.findUsages() ) ) } else { @@ -101,12 +112,17 @@ class OnlyEvaluator(private val ops: List) : Evaluator { CpgFinding( message = "Complies with rule: \"${node.code}\". $passMessage", kind = Finding.Kind.Pass, - node = node + node = node, + relatedNodes = node.findUsages() ) ) } } - return findings } + + enum class Functionality { + ONLY, + NEVER + } } diff --git a/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/PrecedesEvaluator.kt b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/PrecedesEvaluator.kt new file mode 100644 index 000000000..15bef31ba --- /dev/null +++ b/codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/PrecedesEvaluator.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2022, 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.codyze.backends.cpg.coko.evaluators + +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CokoCpgBackend +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding +import de.fraunhofer.aisec.codyze.backends.cpg.coko.dsl.cpgGetNodes +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.EvaluationContext +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Evaluator +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Finding +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.Op +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.Rule +import de.fraunhofer.aisec.cpg.graph.* +import de.fraunhofer.aisec.cpg.graph.edge.Properties +import kotlin.reflect.full.findAnnotation + +context(CokoCpgBackend) +class PrecedesEvaluator(val prevOp: Op, val thisOp: Op) : Evaluator { + + private val defaultFailMessage: String by lazy { + "It is not preceded by any of these calls: $prevOp." + } + + private val defaultPassMessage = "" + + override fun evaluate(context: EvaluationContext): List { + val (unreachableThisNodes, thisNodes) = + with(this@CokoCpgBackend) { thisOp.cpgGetNodes().keys } + .partition { it.isUnreachable() } + + val prevNodes = with(this@CokoCpgBackend) { prevOp.cpgGetNodes().keys } + + val findings = mutableListOf() + + // add all unreachable `this` nodes as NotApplicable findings + findings.addAll( + unreachableThisNodes.map { + CpgFinding( + message = "Rule is not applicable for \"${it.code}\" because it is unreachable", + kind = Finding.Kind.NotApplicable, + node = it + ) + } + ) + + val ruleAnnotation = context.rule.findAnnotation() + val failMessage = ruleAnnotation?.failMessage?.takeIf { it.isNotEmpty() } ?: defaultFailMessage + val passMessage = ruleAnnotation?.passMessage?.takeIf { it.isNotEmpty() } ?: defaultPassMessage + + for (target in thisNodes) { + val paths = target.followPrevEOGEdgesUntilHit { prevNodes.contains(it) } + + val newFindings = + if (paths.fulfilled.isNotEmpty() && paths.failed.isEmpty()) { + val availablePrevNodes = paths.fulfilled.mapNotNull { it.firstOrNull() } + // All paths starting from `from` end in one of the `that` nodes + listOf( + CpgFinding( + message = "Complies with rule: ${availablePrevNodes.joinToString( + prefix = "\"", + separator = "\", \"", + postfix = "\"", + transform = { node -> node.code ?: node.toString() } + )} precedes ${target.code}. $passMessage", + kind = Finding.Kind.Pass, + node = target, + relatedNodes = availablePrevNodes + ) + ) + } else { + // Some (or all) paths starting from `from` do not end in any of the `that` nodes + paths.failed.map { failedPath -> + // make a finding for each failed path + CpgFinding( + message = + "Violation against rule in execution path to \"${target.code}\". $failMessage", + kind = Finding.Kind.Fail, + node = target, + // improve: specify paths more precisely + // for example one branch passes and one fails skip part in path after branches are combined + relatedNodes = listOf(failedPath.first()) + ) + } + } + + findings.addAll(newFindings) + } + + return findings + } + + /** Checks if this node is unreachable */ + private fun Node.isUnreachable(): Boolean { + val prevPaths = this.followPrevEOGEdgesUntilHit { + it.prevEOGEdges.isNotEmpty() && it.prevEOGEdges.all { + edge -> + edge.getProperty(Properties.UNREACHABLE) == true + } + } + return prevPaths.fulfilled.isNotEmpty() + } +} diff --git a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluationTest.kt new file mode 100644 index 000000000..d3429637c --- /dev/null +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluationTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2024, 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.codyze.backends.cpg.coko.evaluators + +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CokoCpgBackend +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding +import de.fraunhofer.aisec.codyze.backends.cpg.createCpgConfiguration +import de.fraunhofer.aisec.codyze.backends.cpg.dummyRule +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.EvaluationContext +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Finding +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.definition +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.op +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.signature +import de.fraunhofer.aisec.cpg.graph.Node +import de.fraunhofer.aisec.cpg.graph.scopes.FunctionScope +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.nio.file.Path +import kotlin.io.path.toPath +import kotlin.reflect.full.valueParameters +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ArgumentEvaluationTest { + @Suppress("UNUSED") + class FooModel { + fun strong() = op { + definition("Foo.strong") { + signature() + } + } + + fun weak() = op { + definition("Foo.weak") { + signature() + } + } + } + + class BarModel { + fun critical(foundation: Any?) = op { + definition("Bar.critical") { + signature(foundation) + } + } + } + + @Test + fun `test simple argument pass`() { + val okFindings = findings.filter { it.kind == Finding.Kind.Pass } + for (finding in okFindings) { + // pass finding has to be in function that has "ok" in its name + assertTrue("Found PASS finding that was from function ${finding.node?.getFunction()} -> false negative") { + finding.node?.getFunction()?.contains(Regex(".*ok.*", RegexOption.IGNORE_CASE)) == true + } + } + } + + @Test + fun `test simple argument fail`() { + val failFindings = findings.filter { it.kind == Finding.Kind.Fail } + for (finding in failFindings) { + // fail finding should not be in function that has "ok" in its name + assertFalse("Found FAIL finding that was from function ${finding.node?.getFunction()} -> false positive") { + finding.node?.getFunction()?.contains(Regex(".*ok.*", RegexOption.IGNORE_CASE)) == true + } + + // fail finding should not be in function that has "noFinding" in its name + assertFalse("Found FAIL finding that was from function ${finding.node?.getFunction()} -> false positive") { + finding.node?.getFunction()?.contains(Regex(".*noFinding.*", RegexOption.IGNORE_CASE)) == true + } + } + } + + @Test + fun `test simple argument not applicable`() { + val notApplicableFindings = findings.filter { it.kind == Finding.Kind.NotApplicable } + for (finding in notApplicableFindings) { + // notApplicable finding has to be in function that has "notApplicable" in its name + assertTrue( + "Found NotApplicable finding that was from function ${finding.node?.getFunction()} -> false negative" + ) { + finding.node?.getFunction()?.contains(Regex(".*notApplicable.*", RegexOption.IGNORE_CASE)) == true + } + } + } + + private fun Node.getFunction(): String? { + var scope = this.scope + while (scope != null) { + if (scope is FunctionScope) { + return scope.astNode?.name?.localName + } + scope = scope.parent + } + return null + } + + companion object { + + private lateinit var testFile: Path + lateinit var findings: List + + @BeforeAll + @JvmStatic + fun startup() { + val classLoader = ArgumentEvaluationTest::class.java.classLoader + + val testFileResource = classLoader.getResource("ArgumentEvaluationTest/SimpleArgument.java") + assertNotNull(testFileResource) + testFile = testFileResource.toURI().toPath() + + val fooInstance = FooModel() + val barInstance = BarModel() + + val backend = CokoCpgBackend(config = createCpgConfiguration(testFile)) + + with(backend) { + val evaluator = argumentOrigin(barInstance::critical, 0, fooInstance::strong) + findings = evaluator.evaluate( + EvaluationContext( + rule = ::dummyRule, + parameterMap = ::dummyRule.valueParameters.associateWith { listOf(fooInstance, barInstance) } + ) + ) + } + assertTrue("There were no findings which is unexpected") { findings.isNotEmpty() } + } + } +} diff --git a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/FollowsEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/FollowsEvaluationTest.kt index a0f43063f..8e64480e5 100644 --- a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/FollowsEvaluationTest.kt +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/FollowsEvaluationTest.kt @@ -37,6 +37,7 @@ import kotlin.test.assertTrue class FollowsEvaluationTest { + @Suppress("UNUSED") class FooModel { fun first() = op { definition("Foo.first") { @@ -47,6 +48,7 @@ class FollowsEvaluationTest { fun f2() = op {} } + @Suppress("UNUSED") class BarModel { fun second() = op { definition("Bar.second") { diff --git a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluationTest.kt index 84a76caf6..758419db1 100644 --- a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluationTest.kt +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/NeverEvaluationTest.kt @@ -39,6 +39,7 @@ import kotlin.test.assertTrue class NeverEvaluationTest { + @Suppress("UNUSED") class FooModel { fun first(i: Any) = op { definition("Foo.first") { @@ -106,7 +107,7 @@ class NeverEvaluationTest { fun `test finding creation`() { val backend = CokoCpgBackend(config = createCpgConfiguration(violationFile)) with(backend) { - val evaluator = NeverEvaluator(listOf()) + val evaluator = OnlyNeverEvaluator(listOf(), OnlyNeverEvaluator.Functionality.NEVER) // Set violating Regions to 0, 1, 2 as Line and Column val violating = listOf(CallExpression(), CallExpression(), CallExpression()) violating.forEachIndexed { @@ -124,7 +125,7 @@ class NeverEvaluationTest { } // Associate INVALID to violating expressions, VALID to correct result with index 3 and OPEN to the others - evaluator.violating = violating.associateWith { Result.INVALID } + correctAndOpen.associateWith { + evaluator.interestingNodes = violating.associateWith { Result.INVALID } + correctAndOpen.associateWith { if (it.location!!.region.startLine < 4) { Result.VALID } else { diff --git a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluationTest.kt index 756f76e01..0aff257ca 100644 --- a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluationTest.kt +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OnlyEvaluationTest.kt @@ -39,6 +39,7 @@ import kotlin.test.assertTrue class OnlyEvaluationTest { + @Suppress("UNUSED") class FooModel { fun first(i: Any) = op { definition("Foo.fun") { @@ -73,7 +74,7 @@ class OnlyEvaluationTest { fun `test finding creation`() { val backend = CokoCpgBackend(config = createCpgConfiguration(testFile)) with(backend) { - val evaluator = OnlyEvaluator(listOf()) + val evaluator = OnlyNeverEvaluator(listOf(), OnlyNeverEvaluator.Functionality.ONLY) // Set violating Regions to 0, 1, 2 as Line and Column val violating = listOf(CallExpression(), CallExpression(), CallExpression()) violating.forEachIndexed { @@ -91,7 +92,7 @@ class OnlyEvaluationTest { } // Associate INVALID to violating expressions, VALID to correct result with index 3 and OPEN to the others - evaluator.correctAndOpen = violating.associateWith { Result.INVALID } + correctAndOpen.associateWith { + evaluator.interestingNodes = violating.associateWith { Result.INVALID } + correctAndOpen.associateWith { if (it.location!!.region.startLine < 4) { Result.VALID } else { diff --git a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OrderEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OrderEvaluationTest.kt index 741230829..9c7a9c766 100644 --- a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OrderEvaluationTest.kt +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/OrderEvaluationTest.kt @@ -38,6 +38,7 @@ import kotlin.test.assertEquals * - [NfaDfaConstructionTest] */ class OrderEvaluationTest { + @Suppress("UNUSED") class CokoOrderImpl { fun constructor(value: Int?) = constructor("Botan") { signature(value) } fun init() = op { "Botan.set_key" { signature(Wildcard) } } @@ -47,6 +48,7 @@ class OrderEvaluationTest { fun finish() = op { "Botan.finish" { signature(Wildcard) } } } + @Suppress("UNUSED") class OtherImpl { fun foo() = op { definition("Botan.foo") { signature(Wildcard) } } } diff --git a/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/PrecedesEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/PrecedesEvaluationTest.kt new file mode 100644 index 000000000..aaab1f220 --- /dev/null +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/PrecedesEvaluationTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2023, 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.codyze.backends.cpg.coko.evaluators + +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CokoCpgBackend +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding +import de.fraunhofer.aisec.codyze.backends.cpg.createCpgConfiguration +import de.fraunhofer.aisec.codyze.backends.cpg.dummyRule +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.EvaluationContext +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.Finding +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.definition +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.op +import de.fraunhofer.aisec.codyze.specificationLanguages.coko.core.dsl.signature +import de.fraunhofer.aisec.cpg.graph.Node +import de.fraunhofer.aisec.cpg.graph.scopes.FunctionScope +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import java.nio.file.Path +import kotlin.io.path.* +import kotlin.reflect.full.valueParameters +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PrecedesEvaluationTest { + + @Suppress("UNUSED") + class FooModel { + fun first() = op { + definition("Foo.first") { + signature() + } + } + + fun f2() = op {} + } + + @Suppress("UNUSED") + class BarModel { + fun second() = op { + definition("Bar.second") { + signature() + } + } + } + + @Test + fun `test simple precedes pass`() { + val okFindings = findings.filter { it.kind == Finding.Kind.Pass } + for (finding in okFindings) { + // pass finding has to be in function that has "ok" in its name + assertTrue("Found PASS finding that was from function ${finding.node?.getFunction()} -> false negative") { + finding.node?.getFunction()?.contains(Regex(".*ok.*", RegexOption.IGNORE_CASE)) == true + } + } + } + + @Test + fun `test simple follows fail`() { + val failFindings = findings.filter { it.kind == Finding.Kind.Fail } + for (finding in failFindings) { + // fail finding should not be in function that has "ok" in its name + assertFalse("Found FAIL finding that was from function ${finding.node?.getFunction()} -> false positive") { + finding.node?.getFunction()?.contains(Regex(".*ok.*", RegexOption.IGNORE_CASE)) == true + } + + // fail finding should not be in function that has "noFinding" in its name + assertFalse("Found FAIL finding that was from function ${finding.node?.getFunction()} -> false positive") { + finding.node?.getFunction()?.contains(Regex(".*noFinding.*", RegexOption.IGNORE_CASE)) == true + } + } + } + + @Test + fun `test simple follows not applicable`() { + val notApplicableFindings = findings.filter { it.kind == Finding.Kind.NotApplicable } + for (finding in notApplicableFindings) { + // notApplicable finding has to be in function that has "notApplicable" in its name + assertTrue( + "Found NotApplicable finding that was from function ${finding.node?.getFunction()} -> false negative" + ) { + finding.node?.getFunction()?.contains(Regex(".*notApplicable.*", RegexOption.IGNORE_CASE)) == true + } + } + } + + private fun Node.getFunction(): String? { + var scope = this.scope + while (scope != null) { + if (scope is FunctionScope) { + return scope.astNode?.name?.localName + } + scope = scope.parent + } + return null + } + + companion object { + + lateinit var testFile: Path + lateinit var findings: List + + @BeforeAll + @JvmStatic + fun startup() { + val classLoader = PrecedesEvaluationTest::class.java.classLoader + + val testFileResource = classLoader.getResource("PrecedesEvaluationTest/SimplePrecedes.java") + assertNotNull(testFileResource) + testFile = testFileResource.toURI().toPath() + + val fooInstance = FooModel() + val barInstance = BarModel() + + val backend = CokoCpgBackend(config = createCpgConfiguration(testFile)) + + with(backend) { + val evaluator = fooInstance.first() precedes barInstance.second() + findings = evaluator.evaluate( + EvaluationContext( + rule = ::dummyRule, + parameterMap = ::dummyRule.valueParameters.associateWith { listOf(fooInstance, barInstance) } + ) + ) + } + assertTrue("There were no findings which is unexpected") { findings.isNotEmpty() } + } + } +} diff --git a/codyze-backends/cpg/src/test/resources/ArgumentEvaluationTest/SimpleArgument.java b/codyze-backends/cpg/src/test/resources/ArgumentEvaluationTest/SimpleArgument.java new file mode 100644 index 000000000..4be7bae4d --- /dev/null +++ b/codyze-backends/cpg/src/test/resources/ArgumentEvaluationTest/SimpleArgument.java @@ -0,0 +1,162 @@ +import java.util.Random; + +public class SimpleArgument { + + public void ok() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object strong = f.strong(); + b.critical(strong); + } + + public void branchOk() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object strong; + if(new Random().nextBoolean()) + strong = f.strong(); + else + strong = f.strong(); + + b.critical(strong); + } + + public void unreachableFirstNotApplicable() { + Foo f = new Foo(); + Bar b = new Bar(); + + if(false) { + b.critical(null) // unreachable -> never executed so no `strong()` is needed + } + } + + // Should be ok because the `f.weak()` branch is unreachable + public void unreachableOk() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object strong; + if(false) + strong = f.weak(); + else + strong = f.strong(); + + b.critical(strong); + } + + // Should be ok as the final value is strong + public void overwriteOk() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object strong; + strong = f.weak(); + strong = f.strong(); + + b.critical(strong); + } + + // Ok even though we do not use a CallExpression to initialize + public void overwrite2Ok() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object strong; + strong = 46; + strong = f.strong(); + + b.critical(strong); + } + + // Ok since the value is only overwritten later + public void repurposeOk() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object strong; + strong = f.strong(); + + b.critical(strong); + + strong = f.weak(); + } + + public void fail() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object weak = f.weak(); + b.critical(weak); + } + + // should fail because `f.strong()` is only called in one branch TODO + public void branchFail() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object unknown; + if(new Random().nextBoolean()) { + unknown = f.strong(); + } else { + unknown = f.weak(); + } + + b.critical(unknown); + } + + // Should fail as the final value is weak TODO + public void overwriteFail() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object weak; + weak = f.strong(); + weak = f.weak(); + + b.critical(weak); + } + + // Should fail even though we do not use a CallExpression to overwrite + public void overwrite2Fail() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object weak; + weak = f.strong(); + weak = 2; + + b.critical(weak); + } + + // Fail since the value is only set correctly afterwards + public void repurposeFail() { + Foo f = new Foo(); + Bar b = new Bar(); + + Object weak; + weak = f.weak(); + + b.critical(weak); + + weak = f.strong(); + } + + // There is no `Bar.critical()` so there should be no finding + public void noFinding() { + Foo f = new Foo(); + Object strong = f.strong(); + } + +} + +public class Foo { + public Object strong() {} + + public Object weak() {} +} + +public class Bar { + public void critical(Object foundation) {} +} diff --git a/codyze-backends/cpg/src/test/resources/PrecedesEvaluationTest/SimplePrecedes.java b/codyze-backends/cpg/src/test/resources/PrecedesEvaluationTest/SimplePrecedes.java new file mode 100644 index 000000000..8c08b1cd7 --- /dev/null +++ b/codyze-backends/cpg/src/test/resources/PrecedesEvaluationTest/SimplePrecedes.java @@ -0,0 +1,71 @@ +import java.util.Random; + +public class SimpleFollows { + + public void ok() { + Foo f = new Foo(); + Bar b = new Bar(); + f.first(); + b.second(); + } + + public void branchOk() { + Foo f = new Foo(); + Bar b = new Bar(); + if(new Random().nextBoolean()) + f.first(); + else + f.first(); + b.second(); + } + + public void unreachableSecondNotApplicable() { + Bar b = new Bar(); + if(false) { + b.second(); // unreachable -> never executed so no `first()` is needed + } + } + + // Should be ok because the `f.f2()` branch is unreachable + public void unreachableOk() { + Foo f = new Foo(); + Bar b = new Bar(); + if(false) + f.f2(); + else + f.first(); + b.second(); + } + + // should fail because `f.first()` is only called in one branch + public void branchFail() { + Foo f = new Foo(); + Bar b = new Bar(); + if(new Random().nextBoolean()) { + f.first(); + } + b.second(); + } + + public void fail() { + Bar b = new Bar(); + b.second(); + } + + // There is no `Bar.second()` so there should be no finding + public void noFinding() { + Foo f = new Foo(); + f.first(); + } + +} + +public class Foo { + public int first() {} + + public void f2() {} +} + +public class Bar { + public void second() {} +} \ No newline at end of file diff --git a/codyze-core/src/test/kotlin/de/fraunhofer/aisec/codyze/core/VersionProviderTest.kt b/codyze-core/src/test/kotlin/de/fraunhofer/aisec/codyze/core/VersionProviderTest.kt index 5af5d0bf0..87e423576 100644 --- a/codyze-core/src/test/kotlin/de/fraunhofer/aisec/codyze/core/VersionProviderTest.kt +++ b/codyze-core/src/test/kotlin/de/fraunhofer/aisec/codyze/core/VersionProviderTest.kt @@ -16,7 +16,7 @@ package de.fraunhofer.aisec.codyze.core import org.junit.jupiter.api.Test -import java.io.FileOutputStream +import java.io.File import java.util.Properties import kotlin.test.assertEquals @@ -34,7 +34,7 @@ class VersionProviderTest { // change property s.t. internal check fails val oldValue = properties.setProperty("project.name", "test") as String - FileOutputStream(propFile.file).use { + File(propFile.toURI()).outputStream().use { properties.store(it, null) } @@ -49,7 +49,7 @@ class VersionProviderTest { // restore original properties file properties.setProperty("project.name", oldValue) - FileOutputStream(propFile.file).use { + File(propFile.toURI()).outputStream().use { properties.store(it, null) } } diff --git a/codyze-plugins/build.gradle.kts b/codyze-plugins/build.gradle.kts index affcd9577..ccb44dda9 100644 --- a/codyze-plugins/build.gradle.kts +++ b/codyze-plugins/build.gradle.kts @@ -25,8 +25,8 @@ dependencies { implementation("com.h3xstream.findsecbugs:findsecbugs-plugin:1.13.0") // https://mvnrepository.com/artifact/net.sourceforge.pmd/ - implementation("net.sourceforge.pmd:pmd-core:7.3.0") - implementation("net.sourceforge.pmd:pmd-java:7.3.0") + implementation("net.sourceforge.pmd:pmd-core:7.5.0") + implementation("net.sourceforge.pmd:pmd-java:7.5.0") } publishing { diff --git a/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/PluginTest.kt b/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/PluginTest.kt index 3270bc2eb..4813b33a8 100644 --- a/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/PluginTest.kt +++ b/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/PluginTest.kt @@ -21,6 +21,8 @@ import io.github.detekt.sarif4k.Result import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import java.io.File +import kotlin.io.path.deleteExisting +import kotlin.io.path.toPath import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -31,12 +33,19 @@ abstract class PluginTest { open val expectedSuccess: Boolean = true abstract val expectedResults: List + val reportUri by lazy { + // use an existing anchor in the resources directory + PluginTest::class.java.classLoader.getResource("targets")!!.toURI() + .resolve("..") + .resolve("generatedReports") + .resolve(resultFileName) + } + @Test fun testResults() { scanFiles() - val resultURI = PluginTest::class.java.classLoader.getResource("generatedReports/$resultFileName")?.toURI() - assertNotNull(resultURI) - val run = extractLastRun(File(resultURI)) + + val run = extractLastRun(File(reportUri)) assertNotNull(run) var results = run.results @@ -58,9 +67,8 @@ abstract class PluginTest { @Test fun testInvocation() { scanFiles() - val resultURI = PluginTest::class.java.classLoader.getResource("generatedReports/$resultFileName")?.toURI() - assertNotNull(resultURI) - val run = extractLastRun(File(resultURI)) + + val run = extractLastRun(File(reportUri)) assertNotNull(run) if (!run.invocations.isNullOrEmpty()) { @@ -72,9 +80,8 @@ abstract class PluginTest { @AfterEach fun cleanup() { - val resultURI = PluginTest::class.java.classLoader.getResource("generatedReports/$resultFileName")?.toURI() - if (resultURI != null) { - File(resultURI).delete() + reportUri.runCatching { + this.toPath().deleteExisting() } } diff --git a/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/compiled/CompiledPluginTest.kt b/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/compiled/CompiledPluginTest.kt index 80c92c34e..6b1979360 100644 --- a/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/compiled/CompiledPluginTest.kt +++ b/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/compiled/CompiledPluginTest.kt @@ -16,24 +16,32 @@ package de.fraunhofer.aisec.codyze.plugins.compiled import de.fraunhofer.aisec.codyze.plugins.PluginTest -import java.nio.file.Path +import java.io.File +import kotlin.io.path.toPath import kotlin.test.assertNotNull abstract class CompiledPluginTest : PluginTest() { override fun scanFiles() { - val libPath = PluginTest::class.java.classLoader.getResource("targets/libs/demo-cloud-service-1.0.0.jar")?.path + // certain that resources are available + val libPath = + PluginTest::class.java.classLoader.getResource("targets/libs/demo-cloud-service-1.0.0.jar")!! + .toURI().toPath() val contextPaths = listOf( - PluginTest::class.java.classLoader.getResource("targets/libs/bcpkix-jdk18on-1.75.jar")?.path, - PluginTest::class.java.classLoader.getResource("targets/libs/bcprov-jdk18on-1.75.jar")?.path, - PluginTest::class.java.classLoader.getResource("targets/libs/bctls-jdk18on-1.75.jar")?.path, - PluginTest::class.java.classLoader.getResource("targets/libs/bcutil-jdk18on-1.75.jar")?.path + PluginTest::class.java.classLoader.getResource("targets/libs/bcpkix-jdk18on-1.75.jar")!! + .toURI().toPath(), + PluginTest::class.java.classLoader.getResource("targets/libs/bcprov-jdk18on-1.75.jar")!! + .toURI().toPath(), + PluginTest::class.java.classLoader.getResource("targets/libs/bctls-jdk18on-1.75.jar")!! + .toURI().toPath(), + PluginTest::class.java.classLoader.getResource("targets/libs/bcutil-jdk18on-1.75.jar")!! + .toURI().toPath() ) assertNotNull(libPath) plugin.execute( - listOf(Path.of(libPath)), - contextPaths.map { Path.of(it!!) }, - Path.of(libPath).parent.parent.parent.resolve("generatedReports").resolve(resultFileName).toFile() + listOf(libPath), + contextPaths, + File(reportUri) ) } } diff --git a/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/source/SourcePluginTest.kt b/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/source/SourcePluginTest.kt index 1e0d7a479..bcdbadf32 100644 --- a/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/source/SourcePluginTest.kt +++ b/codyze-plugins/src/test/kotlin/de/fraunhofer/aisec/codyze/plugins/source/SourcePluginTest.kt @@ -16,18 +16,19 @@ package de.fraunhofer.aisec.codyze.plugins.source import de.fraunhofer.aisec.codyze.plugins.PluginTest -import java.nio.file.Path +import java.io.File +import kotlin.io.path.toPath import kotlin.test.assertNotNull abstract class SourcePluginTest : PluginTest() { override fun scanFiles() { - val sourcePath = PluginTest::class.java.classLoader.getResource("targets/TlsServer.java")?.path + val sourcePath = PluginTest::class.java.classLoader.getResource("targets/TlsServer.java")!!.toURI().toPath() assertNotNull(sourcePath) plugin.execute( - listOf(Path.of(sourcePath)), + listOf(sourcePath), listOf(), - Path.of(sourcePath).parent.parent.resolve("generatedReports").resolve(resultFileName).toFile() + File(reportUri) ) } } diff --git a/codyze-specification-languages/coko/coko-core/src/main/kotlin/de/fraunhofer/aisec/codyze/specificationLanguages/coko/core/CokoBackend.kt b/codyze-specification-languages/coko/coko-core/src/main/kotlin/de/fraunhofer/aisec/codyze/specificationLanguages/coko/core/CokoBackend.kt index 09f00d662..230d3d708 100644 --- a/codyze-specification-languages/coko/coko-core/src/main/kotlin/de/fraunhofer/aisec/codyze/specificationLanguages/coko/core/CokoBackend.kt +++ b/codyze-specification-languages/coko/coko-core/src/main/kotlin/de/fraunhofer/aisec/codyze/specificationLanguages/coko/core/CokoBackend.kt @@ -44,6 +44,9 @@ interface CokoBackend : Backend { /** For each of the nodes in [this], there is a path to at least one of the nodes in [that]. */ infix fun Op.followedBy(that: Op): Evaluator + /** For each of the nodes in [that], there is a path from at least one of the nodes in [this]. */ + infix fun Op.precedes(that: Op): Evaluator + /** Ensures the order of nodes as specified in the user configured [Order] object */ fun order( baseNodes: OrderToken, @@ -71,4 +74,11 @@ interface CokoBackend : Backend { ): WheneverEvaluator fun whenever(premise: ConditionComponent, assertionBlock: WheneverEvaluator.() -> Unit): WheneverEvaluator + + /** Verifies that the argument at [argPos] of [targetOp] stems from a call to [originOp] */ + fun argumentOrigin( + targetOp: KFunction, + argPos: Int, + originOp: KFunction, + ): Evaluator } diff --git a/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/Test.java b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/Test.java new file mode 100644 index 000000000..f0083f15b --- /dev/null +++ b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/Test.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2024, 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. + */ +import java.util.Random; +import java.lang.System; + +class Test { + int kelvin = null; + int celsius = null; + + private void setKelvin(int tempInKelvin) { + // MUST NOT BE ZERO! + // HOWEVER, I'D REALLY HATE TO CHECK THIS HERE + this.kelvin = tempInKelvin; + } + + // MUST NOT BE CALLED BEFORE `setKelvin` + // BUT IMPLEMENTING FAIL-SAFES IS FOR NERDS + private int calculateCelsius() { + this.celsius = this.kelvin - 272; + } + + // EAT: Extremely Accurate Temperature + private int measureTemperatureInK() { + return Random.nextInt(250, 300); + } + + // covered by 11 Unit tests and manually reviewed by 4 developers + public void goodCall() { + int temp = measureTemperatureInK(); + setKelvin(temp); + calculateCelsius(); + System.out.println("The current temperature is " + celsius + "°C"); + } + + // TODO: force merge, should work, might rename later + public void badCall() { + float rnd = Random.nextFloat(); + int temp = measureTemperatureInK(); + if (rnd < 0.33) { + // set Kelvin to zero, break the world + setKelvin(0); + calculateCelsius(); + } else if (rnd < 0.66) { + // we only need Celsius + calculateCelsius(); + } else { + // Kelvin is superior anyways + setKelvin(temp); + } + System.out.println("The current temperature is " + celsius + "°C"); + } +} \ No newline at end of file diff --git a/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/altmodel.codyze.kts b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/altmodel.codyze.kts new file mode 100644 index 000000000..0f6d94c3b --- /dev/null +++ b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/altmodel.codyze.kts @@ -0,0 +1,31 @@ +class TestModel { + fun setKelvin(temp: Any?) = op { + definition("Test.setKelvin") { + signature(temp) + } + } + + fun getCelsius() = op { + definition("Test.calculateCelsius") { + signature() + } + } + + fun call() = op { + definition("Test.badCall") { + signature() + } + } +} + +@Rule("Must not call kelvin with 0") +fun preventZeroKelvin(test: TestModel) = + never(test.setKelvin(0)) + +@Rule("Must call kelvin before celsius") +fun forceKelvinBeforeCelsius(test: TestModel) = + test.setKelvin(Wildcard) precedes test.getCelsius() + +@Rule("Must call celsius after kelvin") +fun forceCelsiusAfterKelvin(test: TestModel) = + test.setKelvin(Wildcard) followedBy test.getCelsius() \ No newline at end of file diff --git a/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/kotlinimpl.codyze.kts b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/kotlinimpl.codyze.kts new file mode 100644 index 000000000..f06498b24 --- /dev/null +++ b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/kotlinimpl.codyze.kts @@ -0,0 +1,30 @@ +@file:Import("model.codyze.kts") + +plugins { id("cpg") } + +class KotlinKelvin: SetKelvin { + override fun kelvin( + temp: Int? + ) = op { + definition("Test.setKelvin") { + signature(temp) + } + } +} + +class KotlinCelsius: SetCelsius { + override fun celsius() = op { + definition("Test.calculateCelsius") { + signature() + } + } +} + +class BadCall: Call { + override fun call() = op { + definition("Test.badCall") { + signature() + } + } +} + diff --git a/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/model.codyze.kts b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/model.codyze.kts new file mode 100644 index 000000000..520c5e114 --- /dev/null +++ b/codyze-specification-languages/coko/coko-dsl/src/test/resources/kotlin/model.codyze.kts @@ -0,0 +1,24 @@ +interface SetKelvin { + fun kelvin(temp: Int?): Op +} + +interface SetCelsius { + fun celsius(): Op +} + +interface Call { + fun call(): Op +} + + +@Rule("Must not call kelvin with 0") +fun preventZeroKelvin(kelv: SetKelvin) = + never(kelv.kelvin(0)) + + +@Rule("Must call kelvin before celsius") +fun forceKelvinBeforeCelsius(kelv: SetKelvin, cels: SetCelsius, call: Call) = + order(call.call()) { + - some(kelv::kelvin) + - maybe(cels::celsius) + } diff --git a/docs/Coko/rules.md b/docs/Coko/rules.md index 879ff4e38..b62949624 100644 --- a/docs/Coko/rules.md +++ b/docs/Coko/rules.md @@ -47,7 +47,6 @@ Therefore, it takes one `Op` as argument. @Rule fun `only calls to first with 1 allowed`(foo: Foo) = only(foo.first(1)) - ``` @@ -89,7 +88,6 @@ fun `order of Foo`(foo: Foo) = - foo.first(...) // (3)! maybe(foo::second) // (4)! } - ``` 1. This starts the type-safe builder for the order. @@ -107,7 +105,17 @@ Compared to the `order` evaluator, `followedBy` is more flexible because `Ops` f @Rule fun `if first then second`(foo: Foo, bar: Bar) = foo.first(Wildcard) followedBy bar.second() +``` +## Precedes Evaluator +The `precedes` evaluator is the logical counterpart to the `followedBy` evaluator, implementing a logical reverse implication. +Similar to the previous evaluator, it takes two `Ops`. Whenever the second `Op` is called, the first `Op` must have been called before. +In contrast to the `followedBy` evaluator, the second `Op`acts as the trigger for the rule and no finding is generated when only the first `Op` is encountered in any given context. + +```kotlin title="Rule example using precedes" +@Rule +fun `always first before second`(foo: Foo, bar: Bar) = + foo.first(Wildcard) precedes bar.second() ``` ## Never Evaluator @@ -118,5 +126,20 @@ It takes one `Op` as argument. @Rule fun `never call second with 1`(foo: Foo) = never(foo.second(1)) +``` + +## Argument Evaluator +The `argumentOrigin` evaluator is used to trace back the argument of a call to a specific method call. +It takes three arguments: + - The target `Op` whose argument we want to verify + - The position of the argument in question (0-based indexing) + - The origin `Op` which should have produced the argument +The evaluator will then try to check whether the argument of the target `Op` was always produced by a call to the origin `Op`. +If this is not the case or the Evaluator lacks information to clearly determine the origin of the argument, it will generate a finding. + +```kotlin title="Rule example using argumentOrigin" +@Rule +fun `only call Foo::critical with argument produced by Bar::strong`(foo: Foo, bar: Bar) = + argumentOrigin(Foo::critical, 0, Bar::strong) ``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6a0c60bcc..ff0e3f1e7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,16 +1,16 @@ [versions] -kotlin = "1.9.24" +kotlin = "1.9.25" cpg = "8.3.0" koin = "3.5.6" koin-test = "3.5.6" -detekt = "1.23.6" +detekt = "1.23.7" spotless = "6.25.0" dokka = "1.9.20" [libraries] sarif4k = { module = "io.github.detekt.sarif4k:sarif4k", version = "0.6.0"} # The code can be found here: https://github.com/detekt/sarif4k. It was generated using https://app.quicktype.io/ -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.1"} +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.7.2"} kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin"} # CPG official releases @@ -29,7 +29,7 @@ cpg-language-java = { module = "de.fraunhofer.aisec:cpg-language-java", version. #cpg-language-go = { module = "com.github.Fraunhofer-AISEC.cpg:cpg-language-go", version.ref = "cpg"} kotlin-logging = { module = "io.github.oshai:kotlin-logging-jvm", version = "7.0.0" } -log4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version = "2.23.1"} +log4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version = "2.24.0"} clikt = { module = "com.github.ajalt.clikt:clikt", version = "4.4.0"} koin = { module = "io.insert-koin:koin-core", version.ref = "koin"} koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin-test"} @@ -41,8 +41,8 @@ kotlin-scripting-dependencies = { module = "org.jetbrains.kotlin:kotlin-scriptin detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt"} # test -junit-bom = { module = "org.junit:junit-bom", version = "5.10.3" } -mockk = { module = "io.mockk:mockk", version = "1.13.11"} +junit-bom = { module = "org.junit:junit-bom", version = "5.11.0" } +mockk = { module = "io.mockk:mockk", version = "1.13.12"} # this is necessary for the plugins to be used in the buildSrc folder kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f..a4b76b953 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a4413138c..0aaefbcaf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index b740cf133..f5feea6d6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 7101f8e46..9b42019c7 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ##########################################################################