From e601329ad96c3080eb1c25039152368193435b0a Mon Sep 17 00:00:00 2001 From: Sachin Kumar Date: Tue, 5 May 2026 18:56:45 +0530 Subject: [PATCH 1/4] feat(python): initial support for cross-file function resolution (simple imports) - Track imported functions via visitImportFrom - Resolve and analyze imported modules on function calls - Prevent recursive scanning using visited file tracking - Add regression test for cross-file RSA sign detection - Limit scope to same-directory modules (prototype implementation) Signed-off-by: Sachin Kumar --- .../detection/PythonBaseDetectionRule.java | 142 +++++++++++++++++- .../resolve/ResolveImportedSignTestFile.py | 4 + .../imports/ResolveImportedSignImport.py | 21 +++ .../resolve/ResolveImportedSignTest.java | 121 +++++++++++++++ 4 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 python/src/test/files/rules/resolve/ResolveImportedSignTestFile.py create mode 100644 python/src/test/files/rules/resolve/imports/ResolveImportedSignImport.py create mode 100644 python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java 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..0c66c1733 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,29 +31,42 @@ import com.ibm.plugin.translation.reorganizer.PythonReorganizerRules; import com.ibm.rules.IReportableDetectionRule; import com.ibm.rules.issue.Issue; +import java.io.File; +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.Tree; +import org.sonar.python.TestPythonVisitorRunner; public abstract class PythonBaseDetectionRule extends PythonVisitorCheck implements IObserver>, IReportableDetectionRule { private final boolean isInventory; + private final Map functionToFile = new HashMap<>(); + private final Set alreadyVisitedFiles = new HashSet<>(); @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 +78,52 @@ protected PythonBaseDetectionRule( this.pythonTranslationProcess = new PythonTranslationProcess(reorganizerRules); } + @Override + public void scanFile(@Nonnull PythonVisitorContext context) { + String currentFilePath = normalizePath(Paths.get(context.pythonFile().uri())); + if (!alreadyVisitedFiles.add(currentFilePath)) { + return; + } + + Map previousFunctionToFile = new HashMap<>(functionToFile); + functionToFile.clear(); + try { + super.scanFile(context); + } finally { + functionToFile.clear(); + functionToFile.putAll(previousFunctionToFile); + } + } + + @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 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) { + String module = functionToFile.get(functionName); + if (module != null) { + // DEBUG: Attempting to resolve and scan imported module for function: {functionName} + analyzeImportedModule(module); + } + } + detectionRules.forEach( rule -> { DetectionExecutive @@ -82,6 +139,87 @@ 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") + */ + private void analyzeImportedModule(@Nonnull String module) { + if (module == null || module.isBlank()) { + return; + } + + try { + Path currentFile = Paths.get(getContext().pythonFile().uri()).toAbsolutePath().normalize(); + Path currentDirectory = currentFile.getParent(); + if (currentDirectory == null) { + return; // Cannot resolve relative imports without a parent directory + } + + // NOTE: Simple resolution for same-directory modules only. + // Does not handle complex import paths, package hierarchies, or relative imports yet. + Path importedPath = currentDirectory.resolve(module.replace('.', File.separatorChar) + ".py"); + + File resolvedFile = importedPath.toFile(); + if (!resolvedFile.isFile()) { + return; // Module file not found in expected location + } + + String resolvedFilePath = normalizePath(resolvedFile.toPath()); + // Guard: Check if file has already been visited to prevent redundant scanning within the call tree + if (!alreadyVisitedFiles.contains(resolvedFilePath)) { + // FIXME: Use TestPythonVisitorRunner as temporary prototype. + // In production, this should be replaced with proper Sonar API integration + // to create PythonVisitorContext for the imported file and call: + // super.scanFile(context); + // or integrate with Sonar's InputFile and SensorContext to enable proper + // cross-file analysis without test utilities. + TestPythonVisitorRunner.scanFile(resolvedFile, this); + } + } catch (Exception e) { + // DEBUG: Failed to analyze imported module. This may indicate unresolved import paths or missing files. + // Gracefully continue without cross-file resolution for this module. + } + } + + @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..14b8ed4a1 --- /dev/null +++ b/python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java @@ -0,0 +1,121 @@ +/* + * 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() { + 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 From b9096fc35325651167aad5bc271b3a06948591e5 Mon Sep 17 00:00:00 2001 From: Sachin Kumar Date: Tue, 5 May 2026 19:24:00 +0530 Subject: [PATCH 2/4] fix(python): address review feedback for cross-file resolution prototype - Fix lifecycle bug using scanDepth - Guard TestPythonVisitorRunner via reflection - Prevent repeated scans - Document limitations clearly Signed-off-by: Sachin Kumar --- .../detection/PythonBaseDetectionRule.java | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) 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 0c66c1733..e4b142f69 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 @@ -32,6 +32,7 @@ import com.ibm.rules.IReportableDetectionRule; import com.ibm.rules.issue.Issue; import java.io.File; +import java.lang.reflect.Method; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collections; @@ -51,7 +52,6 @@ import org.sonar.plugins.python.api.tree.ImportFrom; import org.sonar.plugins.python.api.tree.Name; import org.sonar.plugins.python.api.tree.Tree; -import org.sonar.python.TestPythonVisitorRunner; public abstract class PythonBaseDetectionRule extends PythonVisitorCheck implements IObserver>, @@ -60,6 +60,7 @@ public abstract class PythonBaseDetectionRule extends PythonVisitorCheck private final boolean isInventory; private final Map functionToFile = new HashMap<>(); private final Set alreadyVisitedFiles = new HashSet<>(); + private int scanDepth; @Nonnull protected final PythonTranslationProcess pythonTranslationProcess; @Nonnull protected final List> detectionRules; @@ -80,18 +81,29 @@ protected PythonBaseDetectionRule( @Override public void scanFile(@Nonnull PythonVisitorContext context) { - String currentFilePath = normalizePath(Paths.get(context.pythonFile().uri())); - if (!alreadyVisitedFiles.add(currentFilePath)) { - return; + if (scanDepth == 0) { + // Reset per top-level scan to avoid skipping normal analysis of other files. + alreadyVisitedFiles.clear(); } - Map previousFunctionToFile = new HashMap<>(functionToFile); - functionToFile.clear(); + scanDepth++; try { - super.scanFile(context); - } finally { + String currentFilePath = normalizePath(Paths.get(context.pythonFile().uri())); + if (!alreadyVisitedFiles.add(currentFilePath)) { + return; + } + + Map previousFunctionToFile = new HashMap<>(functionToFile); functionToFile.clear(); - functionToFile.putAll(previousFunctionToFile); + // NOTE: Name-based resolution only; does not handle shadowing/aliases correctly. + try { + super.scanFile(context); + } finally { + functionToFile.clear(); + functionToFile.putAll(previousFunctionToFile); + } + } finally { + scanDepth--; } } @@ -119,6 +131,7 @@ public void visitCallExpression(@Nonnull CallExpression tree) { if (functionName != null) { String module = functionToFile.get(functionName); if (module != null) { + // NOTE: Name-based resolution only; does not handle shadowing/aliases correctly. // DEBUG: Attempting to resolve and scan imported module for function: {functionName} analyzeImportedModule(module); } @@ -163,6 +176,7 @@ private void analyzeImportedModule(@Nonnull String module) { // NOTE: Simple resolution for same-directory modules only. // Does not handle complex import paths, package hierarchies, or relative imports yet. + // NOTE: Entire module is scanned; may include unrelated findings. Path importedPath = currentDirectory.resolve(module.replace('.', File.separatorChar) + ".py"); File resolvedFile = importedPath.toFile(); @@ -173,13 +187,18 @@ private void analyzeImportedModule(@Nonnull String module) { String resolvedFilePath = normalizePath(resolvedFile.toPath()); // Guard: Check if file has already been visited to prevent redundant scanning within the call tree if (!alreadyVisitedFiles.contains(resolvedFilePath)) { - // FIXME: Use TestPythonVisitorRunner as temporary prototype. - // In production, this should be replaced with proper Sonar API integration - // to create PythonVisitorContext for the imported file and call: - // super.scanFile(context); - // or integrate with Sonar's InputFile and SensorContext to enable proper - // cross-file analysis without test utilities. - TestPythonVisitorRunner.scanFile(resolvedFile, this); + // Prototype cross-file scan (test-only utility). Guard to avoid production/runtime issues. + try { + Class runnerClass = Class.forName("org.sonar.python.TestPythonVisitorRunner"); + Method scanFile = runnerClass.getMethod("scanFile", File.class, PythonCheck[].class); + scanFile.invoke(null, resolvedFile, new PythonCheck[] {this}); + } catch (ClassNotFoundException e) { + // Not available in production classpath; skip cross-file analysis for now. + // TODO: Replace with Sonar InputFile/SensorContext-based scanning. + } catch (ReflectiveOperationException e) { + // DEBUG: Failed to invoke prototype cross-file scan. + // Gracefully continue without cross-file resolution for this module. + } } } catch (Exception e) { // DEBUG: Failed to analyze imported module. This may indicate unresolved import paths or missing files. From 536df28e1180b4e48e3a7228d8c32452ee7156f6 Mon Sep 17 00:00:00 2001 From: Sachin Kumar Date: Tue, 5 May 2026 20:16:19 +0530 Subject: [PATCH 3/4] fix(python): stabilize registry-based cross-file resolution - clear registry per top-level scan - add null-safe iteration - prevent duplicate function definitions Signed-off-by: Sachin Kumar --- .../detection/PythonBaseDetectionRule.java | 105 +++++++++--------- .../resolve/ResolveImportedSignTest.java | 4 + 2 files changed, 59 insertions(+), 50 deletions(-) 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 e4b142f69..19b3674b3 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,8 +31,7 @@ import com.ibm.plugin.translation.reorganizer.PythonReorganizerRules; import com.ibm.rules.IReportableDetectionRule; import com.ibm.rules.issue.Issue; -import java.io.File; -import java.lang.reflect.Method; +import java.util.ArrayList; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Collections; @@ -51,6 +50,7 @@ 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 @@ -61,6 +61,11 @@ public abstract class PythonBaseDetectionRule extends PythonVisitorCheck private final Map functionToFile = new HashMap<>(); private final Set alreadyVisitedFiles = new HashSet<>(); private int scanDepth; + private final Set visitedFunctions = new HashSet<>(); + private static final Map> functionDefinitions = new HashMap<>(); + // Feature flag: keep registry off by default to avoid impacting unrelated rules/tests. + private static final boolean ENABLE_REGISTRY = false; + @Nonnull protected final PythonTranslationProcess pythonTranslationProcess; @Nonnull protected final List> detectionRules; @@ -84,6 +89,10 @@ 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++; @@ -118,6 +127,27 @@ public void visitImportFrom(@Nonnull ImportFrom tree) { 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; @@ -129,11 +159,26 @@ public void visitCallExpression(@Nonnull CallExpression tree) { } if (functionName != null) { - String module = functionToFile.get(functionName); - if (module != null) { - // NOTE: Name-based resolution only; does not handle shadowing/aliases correctly. - // DEBUG: Attempting to resolve and scan imported module for function: {functionName} - analyzeImportedModule(module); + // 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. + } } } @@ -162,49 +207,9 @@ public void visitCallExpression(@Nonnull CallExpression tree) { * * @param module The module name to analyze (e.g., "imports.helper") */ - private void analyzeImportedModule(@Nonnull String module) { - if (module == null || module.isBlank()) { - return; - } - - try { - Path currentFile = Paths.get(getContext().pythonFile().uri()).toAbsolutePath().normalize(); - Path currentDirectory = currentFile.getParent(); - if (currentDirectory == null) { - return; // Cannot resolve relative imports without a parent directory - } - - // NOTE: Simple resolution for same-directory modules only. - // Does not handle complex import paths, package hierarchies, or relative imports yet. - // NOTE: Entire module is scanned; may include unrelated findings. - Path importedPath = currentDirectory.resolve(module.replace('.', File.separatorChar) + ".py"); - - File resolvedFile = importedPath.toFile(); - if (!resolvedFile.isFile()) { - return; // Module file not found in expected location - } - - String resolvedFilePath = normalizePath(resolvedFile.toPath()); - // Guard: Check if file has already been visited to prevent redundant scanning within the call tree - if (!alreadyVisitedFiles.contains(resolvedFilePath)) { - // Prototype cross-file scan (test-only utility). Guard to avoid production/runtime issues. - try { - Class runnerClass = Class.forName("org.sonar.python.TestPythonVisitorRunner"); - Method scanFile = runnerClass.getMethod("scanFile", File.class, PythonCheck[].class); - scanFile.invoke(null, resolvedFile, new PythonCheck[] {this}); - } catch (ClassNotFoundException e) { - // Not available in production classpath; skip cross-file analysis for now. - // TODO: Replace with Sonar InputFile/SensorContext-based scanning. - } catch (ReflectiveOperationException e) { - // DEBUG: Failed to invoke prototype cross-file scan. - // Gracefully continue without cross-file resolution for this module. - } - } - } catch (Exception e) { - // DEBUG: Failed to analyze imported module. This may indicate unresolved import paths or missing files. - // Gracefully continue without cross-file resolution for this module. - } - } + // 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) { 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 index 14b8ed4a1..b8e0fa3cb 100644 --- a/python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java +++ b/python/src/test/java/com/ibm/plugin/rules/resolve/ResolveImportedSignTest.java @@ -49,6 +49,10 @@ class ResolveImportedSignTest extends TestBase { @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); From 0b6a22ab29861b853db0b6fdc99278d9e41702dc Mon Sep 17 00:00:00 2001 From: Sachin Kumar Date: Tue, 5 May 2026 20:20:50 +0530 Subject: [PATCH 4/4] docs(python): annotate registry and resolution behavior for cross-file prototype Signed-off-by: Sachin Kumar --- .../rules/detection/PythonBaseDetectionRule.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 19b3674b3..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 @@ -61,9 +61,15 @@ public abstract class PythonBaseDetectionRule extends PythonVisitorCheck 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: keep registry off by default to avoid impacting unrelated rules/tests. + // 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; @@ -159,6 +165,10 @@ public void visitCallExpression(@Nonnull CallExpression tree) { } 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);