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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Changelog

## Unreleased
## 0.19.0 (2026-09-29)

### Analysis

Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<module>`), 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:
Expand Down
6 changes: 3 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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:…"}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/coretrace_python/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""CoreTrace's Python static analysis frontend."""

__version__ = "0.18.0"
__version__ = "0.19.0"

13 changes: 9 additions & 4 deletions src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions src/coretrace_python/interprocedural/callgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``, ``<module>``), 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``, ``<module>``), 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:
Expand Down
4 changes: 3 additions & 1 deletion src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
84 changes: 84 additions & 0 deletions tests/test_function_aliases_api.py
Original file line number Diff line number Diff line change
@@ -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:<module>"] == ()


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")
2 changes: 1 addition & 1 deletion tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading