diff --git a/CHANGELOG.md b/CHANGELOG.md index 0890758..b36f515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.19.0 (2026-09-29) ### Analysis @@ -14,6 +14,7 @@ ### Plugins +- `ModuleFunction.aliases` gives a project plugin the module-level names bound to a function by assigning one name to another, transitively and in source order, so an integration declaring a handler by the name a module binds (`handlers.main`) can report a name the engine resolves to no function; a method or a nested function has none. The cache format changes. - An `EntryPoint` naming a project function or class applies through a module-level alias of it: `main = actual` makes `python.app.handlers.main` name `actual`, so a deployment declaring `handlers.main` reaches the function doing the work. An alias is an assignment of one name to another at module level, followed transitively and in source order, in a conditional or a `try` as well; a name bound inside a function or class, or to a call, an import or anything but a name, is no alias, and the engine still infers nothing from a name. A model naming a method wins over one naming its class, whichever name it uses. ## 0.18.0 (2026-09-28) diff --git a/docs/plugins.md b/docs/plugins.md index 8cc7791..a765f1b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -259,8 +259,11 @@ module graph, the dependency graph, every advisory the plugins and advisory file contributed, the policy, the project root, and each module's imports, call graph and functions. `ctx.functions(module)` gives every function as the call graph names it (`Admin.get`, `outer.inner`, ``), with its span, so a finding can be placed in -one, and the label of the entry point it is (`http` for a route, `argv` for a command) -when the models make it one. `ctx.call_graph(module)` gives each function's call sites +one, the label of the entry point it is (`http` for a route, `argv` for a command) +when the models make it one, and its `aliases`, the module-level names bound to it by +assigning one name to another (`main = actual`), transitively and in source order, so +an integration declaring a handler by the name a module binds can tell a name the +engine resolves to no function; a method or a nested function has none. `ctx.call_graph(module)` gives each function's call sites (`sites(function)`) and the symbols it reads, called or not (`reads(function)`: reading `request.form['name']` reads `python.flask.request.form`), each where it first reads it. `ctx.templates` gives the templates found under the project's `templates` directories: diff --git a/docs/usage.md b/docs/usage.md index 1c658b9..3d48bb1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -247,7 +247,7 @@ the directory of the checked file; a path outside it is printed as it is. ```json { "schema_version": 1, - "tool": {"name": "coretrace-python-analyzer", "version": "0.18.0"}, + "tool": {"name": "coretrace-python-analyzer", "version": "0.19.0"}, "root": "/home/me/project", "findings": [ { @@ -281,7 +281,7 @@ bytes. A single-file check lists no components. ```json "tool": { - "name": "coretrace-python-analyzer", "version": "0.18.0", + "name": "coretrace-python-analyzer", "version": "0.19.0", "components": [ {"kind": "plugin", "name": "curated-advisories", "version": "2026.09.25", "digest": "sha256:…"}, {"kind": "advisories", "name": "advisories.json", "digest": "sha256:…"} @@ -551,7 +551,7 @@ project's behalf would go unseen. Without a lock file, a package is never `not_affected`. The document's `@id` derives from its statements, so two runs with the same result share it; its `author` is `Unknown Author`, as OpenVEX tools write when they cannot know it. Its `tooling` names the analyzer and each component with its digest -(`coretrace-python-analyzer 0.18.0; curated-advisories 2026.09.25 (sha256:…)`), so the +(`coretrace-python-analyzer 0.19.0; curated-advisories 2026.09.25 (sha256:…)`), so the `@id` changes with the advisory data a statement was decided with. ## Large projects diff --git a/pyproject.toml b/pyproject.toml index 7e1e07f..32c82f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "coretrace-python-analyzer" -version = "0.18.0" +version = "0.19.0" description = "Static security analysis infrastructure for Python code" readme = "README.md" requires-python = ">=3.11" diff --git a/src/coretrace_python/__init__.py b/src/coretrace_python/__init__.py index a5ebe77..0979e03 100644 --- a/src/coretrace_python/__init__.py +++ b/src/coretrace_python/__init__.py @@ -1,4 +1,4 @@ """CoreTrace's Python static analysis frontend.""" -__version__ = "0.18.0" +__version__ = "0.19.0" diff --git a/src/coretrace_python/cache.py b/src/coretrace_python/cache.py index 996cc9f..10d505c 100644 --- a/src/coretrace_python/cache.py +++ b/src/coretrace_python/cache.py @@ -42,7 +42,7 @@ from coretrace_python.semantic.symbols import SymbolId from coretrace_python.source import SourceId, SourceSpan -CACHE_FORMAT = 10 +CACHE_FORMAT = 11 @dataclass(frozen=True) @@ -134,7 +134,7 @@ def store(self, key: str, module: CachedModule) -> None: def encode(module: CachedModule) -> dict[str, Any]: return { "format": CACHE_FORMAT, - "functions": [[f.name, _encode_span(f.span), f.entry_point] for f in module.functions], + "functions": [[f.name, _encode_span(f.span), f.entry_point, list(f.aliases)] for f in module.functions], "summaries": {name: _encode_summary(s) for name, s in module.summaries.items()}, "sites": [_encode_site(site) for site in module.sites], "findings": [_encode_finding(finding) for finding in module.findings], @@ -196,8 +196,13 @@ def _decode_span(data: Any) -> SourceSpan: def _decode_function(data: Any) -> ModuleFunction: - name, span, entry_point = data - return ModuleFunction(_string(name), _decode_span(span), None if entry_point is None else _string(entry_point)) + name, span, entry_point, aliases = data + return ModuleFunction( + _string(name), + _decode_span(span), + None if entry_point is None else _string(entry_point), + tuple(_string(alias) for alias in aliases), + ) def _decode_read(data: Any) -> SymbolRead: diff --git a/src/coretrace_python/interprocedural/callgraph.py b/src/coretrace_python/interprocedural/callgraph.py index 2970f91..6d73469 100644 --- a/src/coretrace_python/interprocedural/callgraph.py +++ b/src/coretrace_python/interprocedural/callgraph.py @@ -132,12 +132,16 @@ def compute(cls, ctx: AnalysisContext) -> tuple[Signature, ...]: @dataclass(frozen=True) class ModuleFunction: """One function of a module as a project plugin sees it: the name the call graph - gives it (``Class.method``, ``outer.inner``, ````), where it is, and the label - of the entry point it is (``http`` for a route, ``argv`` for a command), if any.""" + gives it (``Class.method``, ``outer.inner``, ````), where it is, the label + of the entry point it is (``http`` for a route, ``argv`` for a command), if any, and + the module-level ``aliases`` bound to it by assigning one name to another + (``main = actual``), transitively and in source order; a method or a nested + function has none.""" name: str span: SourceSpan entry_point: str | None = None + aliases: tuple[str, ...] = () class CallGraph: diff --git a/src/coretrace_python/taint/engine.py b/src/coretrace_python/taint/engine.py index f33ea32..d0ec2f6 100644 --- a/src/coretrace_python/taint/engine.py +++ b/src/coretrace_python/taint/engine.py @@ -1152,7 +1152,9 @@ def compute(cls, ctx: AnalysisContext) -> tuple[ModuleFunction, ...]: functions: list[ModuleFunction] = [] for name, function in graph.definitions.items(): entry = function_entry_point(function, ctx.module, models, scopes, symbols, instances, routes, project) - functions.append(ModuleFunction(name, function.span, entry.label if entry is not None else None)) + label = entry.label if entry is not None else None + # A dotted name, a method's or a nested function's, is bound by no assignment. + functions.append(ModuleFunction(name, function.span, label, tuple(_aliases(ctx.module, name)))) return tuple(functions) diff --git a/tests/test_function_aliases_api.py b/tests/test_function_aliases_api.py new file mode 100644 index 0000000..8f5671e --- /dev/null +++ b/tests/test_function_aliases_api.py @@ -0,0 +1,84 @@ +"""Project plugins see the module-level aliases of a function. + +An integration reading a deployment declaration names its handler as the module binds +it, ``handlers.main``, and the engine applies an entry point named that way to the +function a module-level alias, ``main = actual``, denotes. ``ModuleFunction.aliases`` +gives a project plugin the names the module binds to each function that way, in source +order and transitively, so it can report a declared name that names no function instead +of trusting the assignment. A name bound to anything but a name is no alias, a method +or a nested function has none, and the aliases survive the cache. +""" + +from __future__ import annotations + +from pathlib import Path + +from coretrace_python import engine +from coretrace_python.cache import ProjectCache +from coretrace_python.findings import Finding +from coretrace_python.plugins import ProjectContext, ProjectPlugin + +HANDLERS = ( + "def actual(event, context):\n" + " pass\n\n" + "handler = actual\n" + "main = handler\n\n" + "class Impl:\n" + " def run(self, event, context):\n" + " pass\n\n" + "Worker = Impl\n\n" + "def outer():\n" + " def inner():\n" + " pass\n" + " return inner\n\n" + "made = outer()\n" +) + + +class Peek(ProjectPlugin): + name = "peek" + + def __init__(self) -> None: + self.seen: dict[str, tuple[str, ...]] = {} + + def analyze_project(self, ctx: ProjectContext) -> tuple[Finding, ...]: + for module in ctx.modules: + for function in ctx.functions(module): + self.seen[f"{module}:{function.name}"] = function.aliases + return () + + +def project(tmp_path: Path) -> Path: + root = tmp_path / "project" + for relative, text in {"app/__init__.py": "", "app/handlers.py": HANDLERS}.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + return root + + +def peek(root: Path, cache: ProjectCache | None = None) -> tuple[dict[str, tuple[str, ...]], engine.ProjectAnalysis]: + plugin = Peek() + analysis = engine.analyze_project(root, [engine.BUNDLED_PLUGINS], plugins=[plugin], cache=cache) + return plugin.seen, analysis + + +def test_a_function_lists_the_module_level_names_bound_to_it(tmp_path: Path) -> None: + seen, _ = peek(project(tmp_path)) + + assert seen["app.handlers:actual"] == ("handler", "main") + assert seen["app.handlers:Impl.run"] == () + assert seen["app.handlers:outer"] == () + assert seen["app.handlers:outer.inner"] == () + assert seen["app.handlers:"] == () + + +def test_the_aliases_survive_the_cache(tmp_path: Path) -> None: + root = project(tmp_path) + cache = ProjectCache(tmp_path / "cache") + + first, _ = peek(root, cache) + second, analysis = peek(root, cache) + + assert analysis.reused == ("app", "app.handlers") + assert second == first + assert second["app.handlers:actual"] == ("handler", "main") diff --git a/tests/test_release.py b/tests/test_release.py index 1809f32..9c90597 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -30,7 +30,7 @@ def require_changelog() -> None: def test_the_version_is_declared_once_and_mirrored() -> None: project = tomllib.loads((REPO / "pyproject.toml").read_text(encoding="utf-8"))["project"] - assert project["version"] == coretrace_python.__version__ == "0.18.0" + assert project["version"] == coretrace_python.__version__ == "0.19.0" def test_the_changelog_starts_with_the_current_version() -> None: