diff --git a/python/src/main/java/com/ibm/plugin/rules/detection/PythonBaseDetectionRule.java b/python/src/main/java/com/ibm/plugin/rules/detection/PythonBaseDetectionRule.java index a54cbbf7f..1991a649c 100644 --- a/python/src/main/java/com/ibm/plugin/rules/detection/PythonBaseDetectionRule.java +++ b/python/src/main/java/com/ibm/plugin/rules/detection/PythonBaseDetectionRule.java @@ -31,14 +31,26 @@ import com.ibm.plugin.translation.reorganizer.PythonReorganizerRules; import com.ibm.rules.IReportableDetectionRule; import com.ibm.rules.issue.Issue; +import java.util.ArrayList; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import javax.annotation.Nonnull; import org.sonar.plugins.python.api.PythonCheck; import org.sonar.plugins.python.api.PythonVisitorCheck; import org.sonar.plugins.python.api.PythonVisitorContext; import org.sonar.plugins.python.api.symbols.Symbol; +import org.sonar.plugins.python.api.tree.AliasedName; import org.sonar.plugins.python.api.tree.CallExpression; +import org.sonar.plugins.python.api.tree.DottedName; +import org.sonar.plugins.python.api.tree.ImportFrom; +import org.sonar.plugins.python.api.tree.Name; +import org.sonar.plugins.python.api.tree.FunctionDef; import org.sonar.plugins.python.api.tree.Tree; public abstract class PythonBaseDetectionRule extends PythonVisitorCheck @@ -46,14 +58,27 @@ public abstract class PythonBaseDetectionRule extends PythonVisitorCheck IReportableDetectionRule { private final boolean isInventory; + private final Map functionToFile = new HashMap<>(); + private final Set alreadyVisitedFiles = new HashSet<>(); + private int scanDepth; + // NOTE: + // Prototype registry for cross-file function resolution. + // Currently static and not scoped per analysis context. + // Cleared per top-level scan to avoid state leakage. + // Future work should move this into a context-aware component. + private final Set visitedFunctions = new HashSet<>(); + private static final Map> functionDefinitions = new HashMap<>(); + // Feature flag to keep behavior disabled by default. + // Ensures no regression in existing rules/tests while validating approach. + private static final boolean ENABLE_REGISTRY = false; + @Nonnull protected final PythonTranslationProcess pythonTranslationProcess; @Nonnull protected final List> detectionRules; protected PythonBaseDetectionRule() { this.isInventory = false; this.detectionRules = PythonDetectionRules.rules(); - this.pythonTranslationProcess = - new PythonTranslationProcess(PythonReorganizerRules.rules()); + this.pythonTranslationProcess = new PythonTranslationProcess(PythonReorganizerRules.rules()); } protected PythonBaseDetectionRule( @@ -65,8 +90,108 @@ protected PythonBaseDetectionRule( this.pythonTranslationProcess = new PythonTranslationProcess(reorganizerRules); } + @Override + public void scanFile(@Nonnull PythonVisitorContext context) { + if (scanDepth == 0) { + // Reset per top-level scan to avoid skipping normal analysis of other files. + alreadyVisitedFiles.clear(); + if (ENABLE_REGISTRY) { + visitedFunctions.clear(); + functionDefinitions.clear(); // Prevent registry leak across scans + } + } + + scanDepth++; + try { + String currentFilePath = normalizePath(Paths.get(context.pythonFile().uri())); + if (!alreadyVisitedFiles.add(currentFilePath)) { + return; + } + + Map previousFunctionToFile = new HashMap<>(functionToFile); + functionToFile.clear(); + // NOTE: Name-based resolution only; does not handle shadowing/aliases correctly. + try { + super.scanFile(context); + } finally { + functionToFile.clear(); + functionToFile.putAll(previousFunctionToFile); + } + } finally { + scanDepth--; + } + } + + @Override + public void visitImportFrom(@Nonnull ImportFrom tree) { + String module = moduleName(tree); + if (module != null) { + for (AliasedName importedName : tree.importedNames()) { + functionToFile.put(importedName(importedName), module); + } + } + super.visitImportFrom(tree); + } + + @Override + public void visitFunctionDef(@Nonnull FunctionDef tree) { + String functionName = tree.name().name(); + + if (ENABLE_REGISTRY) { + functionDefinitions.computeIfAbsent(functionName, k -> new ArrayList<>()); + List list = functionDefinitions.get(functionName); + if (!list.contains(tree)) { + list.add(tree); + } + } + + // Preserve default behavior: always traverse function body so detections are discovered + // during normal file scanning. + super.visitFunctionDef(tree); + if (ENABLE_REGISTRY) { + // Mark as visited so call-resolution does not re-traverse the same body. + visitedFunctions.add(tree); + } + } + @Override public void visitCallExpression(@Nonnull CallExpression tree) { + String functionName = null; + Symbol symbol = tree.calleeSymbol(); + if (symbol != null) { + functionName = symbol.name(); + } else if (tree.callee() instanceof Name calleeName) { + functionName = calleeName.name(); + } + + if (functionName != null) { + // NOTE: + // Resolution is name-based only. + // Does not handle shadowing, aliases, or imports across packages. + // Resolution may depend on analysis order (definitions must be visited before calls). + // First try local registry of function definitions (same-project, name-based) + if (ENABLE_REGISTRY) { + List defs = functionDefinitions.get(functionName); + if (defs != null) { + for (Tree functionTree : new ArrayList<>(defs)) { + if (!visitedFunctions.contains(functionTree)) { + visitedFunctions.add(functionTree); + // Traverse the function body in-place using the same visitor + functionTree.accept(this); + } + } + } + } else { + // Fallback: attempt to resolve via imports (legacy behavior) + String module = functionToFile.get(functionName); + if (module != null) { + // NOTE: Name-based resolution only; does not handle shadowing/aliases correctly. + // Previously this would scan the imported file; that behavior is removed + // in favor of same-project function resolution. + } + } + } + detectionRules.forEach( rule -> { DetectionExecutive @@ -82,6 +207,53 @@ public void visitCallExpression(@Nonnull CallExpression tree) { super.visitCallExpression(tree); // Necessary to visit children nodes of this CallExpression } + /** + * Recursively analyzes an imported module to discover cryptographic patterns in imported + * functions. + * + *

NOTE: Simple resolution for same-directory modules only. Does not handle complex import + * paths, package hierarchies, or relative imports yet. Future versions should integrate with + * Sonar's file resolution system for full path handling. + * + * @param module The module name to analyze (e.g., "imports.helper") + */ + // analyzeImportedModule removed: cross-file scanning via test utilities was replaced by + // same-project function resolution using a global registry. See visitFunctionDef and + // visitCallExpression for the new behavior. + + @Nonnull + private String importedName(@Nonnull AliasedName importedName) { + Name alias = importedName.alias(); + if (alias != null) { + return alias.name(); + } + + DottedName dottedName = importedName.dottedName(); + List names = dottedName.names(); + return names.get(names.size() - 1).name(); + } + + private String moduleName(@Nonnull ImportFrom tree) { + DottedName module = tree.module(); + if (module == null) { + return null; + } + + StringBuilder builder = new StringBuilder(); + for (Name name : module.names()) { + if (builder.length() > 0) { + builder.append('.'); + } + builder.append(name.name()); + } + return builder.toString(); + } + + @Nonnull + private String normalizePath(@Nonnull Path path) { + return path.toAbsolutePath().normalize().toString(); + } + /** * Updates the output file with the translated nodes resulting from a finding. * diff --git a/python/src/test/files/rules/resolve/ResolveImportedSignTestFile.py b/python/src/test/files/rules/resolve/ResolveImportedSignTestFile.py new file mode 100644 index 000000000..20935ac4c --- /dev/null +++ b/python/src/test/files/rules/resolve/ResolveImportedSignTestFile.py @@ -0,0 +1,4 @@ +from imports.ResolveImportedSignImport import custom_sign + +data = b"A message I want to sign" +signature = custom_sign(data) \ No newline at end of file diff --git a/python/src/test/files/rules/resolve/imports/ResolveImportedSignImport.py b/python/src/test/files/rules/resolve/imports/ResolveImportedSignImport.py new file mode 100644 index 000000000..45b60723d --- /dev/null +++ b/python/src/test/files/rules/resolve/imports/ResolveImportedSignImport.py @@ -0,0 +1,21 @@ +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric import utils + + +def custom_sign(data): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + signature = private_key.sign( + data, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH, + ), + utils.Prehashed(hashes.SHA384()), + ) + return signature \ No newline at end of file diff --git a/python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java b/python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java new file mode 100644 index 000000000..b8e0fa3cb --- /dev/null +++ b/python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java @@ -0,0 +1,125 @@ +/* + * Sonar Cryptography Plugin + * Copyright (C) 2024 PQCA + * + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you 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 com.ibm.plugin.rules.resolve; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.ibm.engine.detection.DetectionStore; +import com.ibm.engine.model.IValue; +import com.ibm.engine.model.KeySize; +import com.ibm.engine.model.SignatureAction; +import com.ibm.engine.model.ValueAction; +import com.ibm.engine.model.context.DigestContext; +import com.ibm.engine.model.context.PrivateKeyContext; +import com.ibm.engine.model.context.SignatureContext; +import com.ibm.mapper.model.INode; +import com.ibm.plugin.TestBase; +import java.io.File; +import java.util.List; +import javax.annotation.Nonnull; +import org.junit.jupiter.api.Test; +import org.sonar.plugins.python.api.PythonCheck; +import org.sonar.plugins.python.api.PythonVisitorContext; +import org.sonar.plugins.python.api.symbols.Symbol; +import org.sonar.plugins.python.api.tree.Tree; +import org.sonar.python.TestPythonVisitorRunner; + +class ResolveImportedSignTest extends TestBase { + + private DetectionStore capturedStore; + private List capturedNodes; + private int capturedFindingCount; + + @Test + void test() { + // Scan the implementation file first to register function definitions in the global registry + TestPythonVisitorRunner.scanFile( + new File("src/test/files/rules/resolve/imports/ResolveImportedSignImport.py"), this); + + TestPythonVisitorRunner.scanFile( + new File("src/test/files/rules/resolve/ResolveImportedSignTestFile.py"), this); + + assertThat(capturedFindingCount).isEqualTo(1); + assertThat(capturedStore).isNotNull(); + assertThat(capturedNodes).isNotNull(); + } + + @Override + public void asserts( + int findingId, + @Nonnull DetectionStore detectionStore, + @Nonnull List nodes) { + assertThat(findingId).isZero(); + + assertThat(detectionStore.getDetectionValues()).hasSize(1); + assertThat(detectionStore.getDetectionValueContext()).isInstanceOf(PrivateKeyContext.class); + IValue value0 = detectionStore.getDetectionValues().get(0); + assertThat(value0).isInstanceOf(KeySize.class); + assertThat(value0.asString()).isEqualTo("2048"); + + DetectionStore store_1 = + getStoreOfValueType(SignatureAction.class, detectionStore.getChildren()); + assertThat(store_1).isNotNull(); + assertThat(store_1.getDetectionValues()).hasSize(1); + assertThat(store_1.getDetectionValueContext()).isInstanceOf(SignatureContext.class); + IValue value0_1 = store_1.getDetectionValues().get(0); + assertThat(value0_1).isInstanceOf(SignatureAction.class); + assertThat(value0_1.asString()).isEqualTo("SIGN"); + + DetectionStore store_1_1 = + getStoreOfValueType(ValueAction.class, store_1.getChildren()); + assertThat(store_1_1).isNotNull(); + assertThat(store_1_1.getDetectionValues()).hasSize(1); + assertThat(store_1_1.getDetectionValueContext()).isInstanceOf(SignatureContext.class); + IValue value0_1_1 = store_1_1.getDetectionValues().get(0); + assertThat(value0_1_1).isInstanceOf(ValueAction.class); + assertThat(value0_1_1.asString()).isEqualTo("RSA-PSS"); + + DetectionStore store_1_1_1 = + getStoreOfValueType(ValueAction.class, store_1_1.getChildren()); + assertThat(store_1_1_1).isNotNull(); + assertThat(store_1_1_1.getDetectionValues()).hasSize(1); + assertThat(store_1_1_1.getDetectionValueContext()).isInstanceOf(SignatureContext.class); + IValue value0_1_1_1 = store_1_1_1.getDetectionValues().get(0); + assertThat(value0_1_1_1).isInstanceOf(ValueAction.class); + assertThat(value0_1_1_1.asString()).isEqualTo("MGF1"); + + DetectionStore store_1_1_1_1 = + getStoreOfValueType(ValueAction.class, store_1_1_1.getChildren()); + assertThat(store_1_1_1_1).isNotNull(); + assertThat(store_1_1_1_1.getDetectionValues()).hasSize(1); + assertThat(store_1_1_1_1.getDetectionValueContext()).isInstanceOf(DigestContext.class); + IValue value0_1_1_1_1 = store_1_1_1_1.getDetectionValues().get(0); + assertThat(value0_1_1_1_1).isInstanceOf(ValueAction.class); + assertThat(value0_1_1_1_1.asString()).isEqualTo("SHA256"); + + assertThat(nodes).hasSize(1); + } + + @Override + public void update( + @Nonnull com.ibm.engine.detection.Finding + finding) { + capturedFindingCount++; + capturedStore = finding.detectionStore(); + capturedNodes = pythonTranslationProcess.initiate(capturedStore); + asserts(capturedFindingCount - 1, capturedStore, capturedNodes); + } +} \ No newline at end of file