Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions src/coretrace_python/interprocedural/modulegraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
@@ -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
Loading