From d0c45be7af6ffb6eeaf6f3348d642247af27e85d Mon Sep 17 00:00:00 2001 From: Robert Haimerl Date: Tue, 17 Sep 2024 14:46:06 +0200 Subject: [PATCH] Coko 'ArgumentOrigin' Evaluator (#873) Co-authored-by: Florian Wendland --- .../backends/cpg/coko/CokoCpgBackend.kt | 9 + .../cpg/coko/evaluators/ArgumentEvaluator.kt | 117 +++++++++++++ .../backends/cpg/ArgumentEvaluationTest.kt | 142 +++++++++++++++ .../SimpleArgument.java | 162 ++++++++++++++++++ .../coko/core/CokoBackend.kt | 7 + docs/Coko/rules.md | 16 ++ 6 files changed, 453 insertions(+) create mode 100644 codyze-backends/cpg/src/main/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/coko/evaluators/ArgumentEvaluator.kt create mode 100644 codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/ArgumentEvaluationTest.kt create mode 100644 codyze-backends/cpg/src/test/resources/ArgumentEvaluationTest/SimpleArgument.java 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 f0b13cc65..46b453d2d 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 @@ -84,6 +85,14 @@ 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] */ 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/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/ArgumentEvaluationTest.kt b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/ArgumentEvaluationTest.kt new file mode 100644 index 000000000..93df712c2 --- /dev/null +++ b/codyze-backends/cpg/src/test/kotlin/de/fraunhofer/aisec/codyze/backends/cpg/ArgumentEvaluationTest.kt @@ -0,0 +1,142 @@ +/* + * 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 + +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CokoCpgBackend +import de.fraunhofer.aisec.codyze.backends.cpg.coko.CpgFinding +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 = ArgumentEvaluationTest.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 = ArgumentEvaluationTest.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 = ArgumentEvaluationTest.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 = ArgumentEvaluationTest.FooModel() + val barInstance = ArgumentEvaluationTest.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/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-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 b8baef218..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 @@ -74,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/docs/Coko/rules.md b/docs/Coko/rules.md index 2030f7d38..b62949624 100644 --- a/docs/Coko/rules.md +++ b/docs/Coko/rules.md @@ -127,3 +127,19 @@ It takes one `Op` as argument. 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) +```