From 1e3f182c073f8d9f101230e261249ae8e5aacbcd Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 4 Sep 2026 05:23:14 +0200 Subject: [PATCH 1/2] test: define discovery of virtual environments and non-identifier module names --- tests/test_discovery.py | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/test_discovery.py diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..daf72dc --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,73 @@ +"""Acceptance tests for project discovery robustness, found on real repositories. + +A virtual environment is skipped whatever its directory is called, detected by the +``pyvenv.cfg`` the ``venv`` module writes at its root. A module or package whose name +is not a Python identifier (``network-topologer``, ``docutils-script.py``) cannot be +imported by that name but still deserves analysis: its project symbols use a sanitised +name instead of raising. + +Expected to remain red until ``discover_sources`` and ``project_symbol`` handle both. +""" + +from __future__ import annotations + +from pathlib import Path + +from coretrace_python import engine +from coretrace_python.interprocedural import discover_sources, project_symbol +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceManager + + +def project(root: Path, files: dict[str, str]) -> Path: + for relative, text in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return root + + +def test_virtual_environments_are_skipped_whatever_their_name(tmp_path: Path) -> None: + root = project( + tmp_path, + { + "env/pyvenv.cfg": "home = /usr/bin\n", + "env/Scripts/docutils-script.py": "import os\nos.system(input())\n", + "env/Lib/site-packages/thing.py": "x = 1\n", + "tools/pyvenv.cfg": "", + "tools/helper.py": "y = 2\n", + "app.py": "z = 3\n", + }, + ) + + names = [source.module_name for source in discover_sources(root, SourceManager())] + + assert names == ["app"] + + +def test_project_symbols_tolerate_non_identifier_module_names() -> None: + assert project_symbol("network-topologer.main", "run") == SymbolId("python.network_topologer.main.run") + assert project_symbol("docutils-script", "load") == SymbolId("python.docutils_script.load") + assert project_symbol("2fa.codes", "verify") == SymbolId("python._2fa.codes.verify") + assert project_symbol("app.views", "Ping.get") == SymbolId("python.app.views.Ping.get") + + +def test_dashed_packages_are_analysed_instead_of_crashing(tmp_path: Path) -> None: + root = project( + tmp_path, + { + "network-topologer/__init__.py": "", + "network-topologer/traceroute.py": "import os\n\ndef probe(host):\n os.system('ping ' + host)\n", + "network-topologer/__main__.py": "import os\n\ndef main():\n os.system(input())\n", + "env/pyvenv.cfg": "", + "env/Scripts/docutils-script.py": "def importlib_load_entry_point():\n return 1\n", + }, + ) + + analysis = engine.analyze_project(root, [Path(__file__).resolve().parent.parent / "plugins"]) + + assert set(analysis.keys) == {"network-topologer", "network-topologer.traceroute", "network-topologer.__main__"} + assert [(f.rule_id, Path(str(f.span.source_id)).name, f.span.start_line) for f in analysis.findings] == [ + ("command-injection", "__main__.py", 4) + ] + assert SymbolId("python.network_topologer.traceroute.probe") in analysis.index.symbols From 7274d03340a99023884120c2d8cceaf41eb7fccd Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 4 Sep 2026 05:23:14 +0200 Subject: [PATCH 2/2] fix: skip virtual environments by pyvenv.cfg and tolerate non-identifier module names --- README.md | 4 ++- .../interprocedural/modulegraph.py | 27 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 96327ed..0328981 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ coretrace-python-analyzer --check src/ --plugins plugins/ --format sarif > repor Given a directory, every Python file below it is analysed as one project: modules are named after their packages, and taint follows calls into functions defined in other files through a project-wide index of function summaries iterated to a fixpoint. Hidden and -tooling directories (`.venv`, `node_modules`, `__pycache__`, `build`, `dist`) are skipped. +tooling directories (`.venv`, `node_modules`, `__pycache__`, `build`, `dist`) are skipped, +and so is any virtual environment, recognised by its `pyvenv.cfg` whatever its name. A +module or package whose name is not an identifier is analysed like any other. The dependency files at the root (`requirements*.txt`, `pyproject.toml`, `poetry.lock`, `uv.lock`) are resolved into a dependency graph. Plugins may contribute advisories; the shipped `dependency/` plugins report a requirement that allows a vulnerable version at diff --git a/src/coretrace_python/interprocedural/modulegraph.py b/src/coretrace_python/interprocedural/modulegraph.py index 553f57d..e812c54 100644 --- a/src/coretrace_python/interprocedural/modulegraph.py +++ b/src/coretrace_python/interprocedural/modulegraph.py @@ -25,19 +25,42 @@ def discover_sources(root: Path, manager: SourceManager) -> tuple[SourceFile, ...]: - """Load every ``.py`` file under ``root``, skipping hidden and tooling directories.""" + """Load every ``.py`` file under ``root``, skipping hidden and tooling directories and + virtual environments, recognised by the ``pyvenv.cfg`` at their root whatever their + name.""" found: list[SourceFile] = [] + environments: dict[Path, bool] = {} for path in sorted(root.rglob("*.py")): relative = path.relative_to(root) if any(p.startswith(".") or p in IGNORED_DIRECTORIES for p in relative.parts[:-1]): continue + if any(_is_environment(root / Path(*relative.parts[:depth]), environments) for depth in range(1, len(relative.parts))): + continue found.append(manager.load_file(path)) return tuple(found) +def _is_environment(directory: Path, known: dict[Path, bool]) -> bool: + if directory not in known: + known[directory] = (directory / "pyvenv.cfg").is_file() + return known[directory] + + def project_symbol(module_name: str, qualified_name: str) -> SymbolId: - return SymbolId(f"python.{module_name}.{qualified_name}") + """The canonical symbol of a project function. A module whose name is not an + identifier cannot be imported by that name, but its functions still need stable + symbols: invalid characters become underscores.""" + + module = ".".join(_component(part) for part in module_name.split(".")) + return SymbolId(f"python.{module}.{qualified_name}") + + +def _component(part: str) -> str: + if part.isidentifier(): + return part + cleaned = "".join(c if (c.isalnum() or c == "_") else "_" for c in part) + return cleaned if cleaned.isidentifier() else f"_{cleaned}" @dataclass(frozen=True)