From 4c2d62a0737bfe22dd812169c01bcbb7822780b8 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 28 Sep 2026 15:47:00 +0200 Subject: [PATCH 1/4] test: specify that an unreadable template file is not escaped A template file the engine cannot read proves nothing about escaping: the flow through render_to_string stays xss, and a template extending or including it is not established to escape either. --- tests/test_unreadable_templates.py | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/test_unreadable_templates.py diff --git a/tests/test_unreadable_templates.py b/tests/test_unreadable_templates.py new file mode 100644 index 0000000..09273a3 --- /dev/null +++ b/tests/test_unreadable_templates.py @@ -0,0 +1,70 @@ +"""A template file the engine cannot read proves nothing about escaping. + +The analyzer establishes that a Django template escapes everything it renders only from +its text. A template file it cannot read, because of its permissions, is not assumed to +escape, no more than a template it cannot find: the flow through ``render_to_string`` +stays ``xss``, and a template extending or including the unreadable one is not +established either. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.taint.templates import escaped_templates + +VIEW = ( + "from django.http import HttpRequest, HttpResponse\n" + "from django.template.loader import render_to_string\n\n" + "def page(request: HttpRequest):\n" + " return HttpResponse(render_to_string('app/page.html', {'name': request.GET['name']}))\n" +) +ESCAPING = "

{{ name }}

\n" + +pytestmark = pytest.mark.skipif(os.name != "posix" or os.geteuid() == 0, reason="needs a file its owner cannot read") + + +def write(root: Path, files: dict[str, str]) -> None: + for relative, text in files.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + + +def unreadable(path: Path) -> None: + path.chmod(0) + + +@pytest.fixture +def readable_again(tmp_path: Path): # type: ignore[no-untyped-def] + yield + for path in tmp_path.rglob("*"): + if path.is_file(): + path.chmod(0o644) + + +def test_a_template_file_that_cannot_be_read_is_not_escaped(tmp_path: Path, readable_again: None) -> None: + write(tmp_path, {"app/templates/app/page.html": ESCAPING, "app/templates/app/other.html": ESCAPING}) + unreadable(tmp_path / "app/templates/app/page.html") + + assert escaped_templates(tmp_path) == frozenset({"app/other.html"}) + + +def test_rendering_a_template_that_cannot_be_read_stays_cross_site_scripting(tmp_path: Path, readable_again: None) -> None: + write(tmp_path, {"app/views.py": VIEW, "app/templates/app/page.html": ESCAPING}) + unreadable(tmp_path / "app/templates/app/page.html") + + findings = engine.analyze_project(tmp_path, [engine.BUNDLED_PLUGINS]).findings + + assert [f.span.start_line for f in findings if f.rule_id == "xss"] == [5] + + +@pytest.mark.parametrize("tag", ["{% extends 'app/base.html' %}", "{% include 'app/base.html' %}"]) +def test_a_template_needing_one_that_cannot_be_read_is_not_escaped(tmp_path: Path, readable_again: None, tag: str) -> None: + write(tmp_path, {"app/templates/app/page.html": tag + ESCAPING, "app/templates/app/base.html": ESCAPING}) + unreadable(tmp_path / "app/templates/app/base.html") + + assert escaped_templates(tmp_path) == frozenset() From 034ecaf7125700042f374646eb94ffac61a26746 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 28 Sep 2026 15:47:00 +0200 Subject: [PATCH 2/4] fix: never assume an unreadable template file escapes escaped_templates read a template file it could not open as empty text, and an empty template escapes everything it renders. Such a file now yields no escaping verdict, like a template the engine cannot find, so the flow stays xss and the templates needing it are not established either. --- CHANGELOG.md | 6 ++++++ docs/usage.md | 6 +++--- src/coretrace_python/taint/templates.py | 13 +++++++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad35b7..7f3ca2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Precision + +- A Django template file the analyzer cannot read, because of its permissions, no longer counts as escaping everything it renders: it read the file as empty text, and an empty template escapes. The flow through `render_to_string` stays `xss`, and a template extending or including the unreadable one is not established to escape either, as for a template the analyzer cannot find. An unreadable file was already listed among the templates the project names but the run could not read. + ## 0.17.0 (2026-09-28) ### Plugins diff --git a/docs/usage.md b/docs/usage.md index a26cd13..e3f866e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -156,9 +156,9 @@ It establishes escaping only when all of the following hold: - No Python file of the project sets `autoescape` to `False`. The analyzer never assumes escaping where it cannot read the template. That covers a -template named by an expression, a template found through a `DIRS` entry not named -`templates`, one shipped by an installed package, one rendered through `get_template`, -and Flask's Jinja2 templates. The flow is then reported as before. A single-file check +template file it cannot read, a template named by an expression, a template found +through a `DIRS` entry not named `templates`, one shipped by an installed package, one +rendered through `get_template`, and Flask's Jinja2 templates. The flow is then reported as before. A single-file check reads no template. ### Dangerous API usage diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index 10bc65a..a41ed57 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -15,7 +15,8 @@ Templates are found under the project's ``templates`` directories, by their path below one. A template the engine cannot find — named by an expression, under a ``DIRS`` entry -of another name, shipped by an installed package — is never assumed to escape. +of another name, shipped by an installed package — or cannot read is never assumed to +escape. A template applying a filter of Django's own libraries calls the function behind it whenever it is rendered, though no Python call names that function: ``{{ bio|striptags }}`` @@ -149,7 +150,7 @@ def escaped_templates(root: Path) -> frozenset[str]: local: dict[str, bool] = {} requires: dict[str, set[str]] = {} for name, paths in found.items(): - verdicts = [_inspect(_read(path)) for path in paths] + verdicts = [_verdict(path) for path in paths] local[name] = all(safe for safe, _ in verdicts) requires[name] = {needed for _, needs in verdicts for needed in needs} escaped = {name for name, safe in local.items() if safe} @@ -171,6 +172,14 @@ def _read(path: Path) -> str: return _text(path) or "" +def _verdict(path: Path) -> tuple[bool, set[str]]: + """Whether one template file escapes all it prints, and what it needs: a file the + engine cannot read is not established to escape, like a template it cannot find.""" + + text = _text(path) + return (False, set()) if text is None else _inspect(text) + + def _text(path: Path) -> str | None: """The text of ``path``, or None when it cannot be read.""" From 3393f09f73068cb08b7bb8c0287ab2f870619982 Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 28 Sep 2026 16:05:10 +0200 Subject: [PATCH 3/4] test: specify entry points declared by their own symbol An EntryPoint naming a project function or class by its symbol makes it an entry point, whatever its parameters are called, and inputs limits the parameters that are input to the given positions: a handler's event, not its context. A function named handler is nothing without a declaration. --- tests/test_declared_entry_points.py | 155 ++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/test_declared_entry_points.py diff --git a/tests/test_declared_entry_points.py b/tests/test_declared_entry_points.py new file mode 100644 index 0000000..bbedc41 --- /dev/null +++ b/tests/test_declared_entry_points.py @@ -0,0 +1,155 @@ +"""Entry points declared by their own symbol, with the parameters that are input. + +Nothing in a bare AWS Lambda handler, ``def main(event, context)``, marks it: no +decorator, no base class, no registration call. What marks it is a deployment +declaration, a SAM template or a ``serverless.yml``, which an integration reads and +turns into an ``EntryPoint`` naming the project function by its symbol, +``python.app.handlers.main``. The engine then treats the function as it treats a +decorated one, whatever its parameters are called. An ``EntryPoint`` may also say which +parameters are input, by position after ``self``: a handler's event is, its context is +not. A class named this way makes every method an entry point, as a registered class +does. A function named ``handler`` is nothing without a declaration. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.findings import Finding +from coretrace_python.plugins import ProjectContext, ProjectPlugin +from coretrace_python.source import SourceManager + +MANIFEST = ( + 'name = "declared-handlers"\nversion = "1.0.0"\nplugin_api = ">=1,<2"\nrequires = []\n' + 'provides = ["model.declared-handlers"]\n\n[entrypoint]\nmodule = "declared"\nclass = "Declared"\n' +) +PLUGIN = ''' +from typing import ClassVar + +from coretrace_python.plugins import ModelPlugin +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.taint import EntryPoint, Model + + +class Declared(ModelPlugin): + name: ClassVar[str] = "declared-handlers" + models: ClassVar[tuple[Model, ...]] = ( + EntryPoint(SymbolId("python.app.handlers.main"), "event", inputs=(0,)), + EntryPoint(SymbolId("python.app.handlers.Jobs.run"), "event", inputs=(0,)), + EntryPoint(SymbolId("python.app.handlers.Worker"), "event"), + EntryPoint(SymbolId("python.handlers.main"), "event", inputs=(0,)), + EntryPoint(SymbolId("python.jobs.task"), "queue", inputs=(1,)), + ) +''' +HANDLERS = ( + "import os\n\n" + "def main(evt, ctx):\n" + " os.system(evt['cmd'])\n" + " os.system(ctx.function_name)\n\n" + "def handler(event, context):\n" + " os.system(event['cmd'])\n\n" + "class Jobs:\n" + " def run(self, event, context):\n" + " os.system(event['cmd'])\n" + " os.system(context.function_name)\n\n" + " def other(self, event, context):\n" + " os.system(event['cmd'])\n\n" + "class Worker:\n" + " def start(self, event, context):\n" + " os.system(event['cmd'])\n" + " os.system(context.function_name)\n" +) +EVENT_LINES = [4, 12, 20, 21] + + +def plugins(tmp_path: Path) -> Path: + plugin = tmp_path / "plugins" / "declared" + plugin.mkdir(parents=True, exist_ok=True) + (plugin / "plugin.toml").write_text(MANIFEST, encoding="utf-8") + (plugin / "declared.py").write_text(PLUGIN, encoding="utf-8") + return tmp_path / "plugins" + + +def project(tmp_path: Path, files: dict[str, str]) -> Path: + root = tmp_path / "project" + for relative, text in {"app/__init__.py": "", **files}.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + return root + + +def commands(findings: tuple[Finding, ...]) -> list[tuple[int, str]]: + return sorted((f.span.start_line, f.metadata["source_label"]) for f in findings if f.rule_id == "command-injection") + + +def analyze(tmp_path: Path, files: dict[str, str], **options: object) -> list[tuple[int, str]]: + root = project(tmp_path, files) + roots = [engine.BUNDLED_PLUGINS, plugins(tmp_path)] + return commands(engine.analyze_project(root, roots, **options).findings) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- declared + + +@pytest.mark.parametrize("jobs", [1, 2]) +def test_a_function_declared_by_its_symbol_receives_its_inputs_whatever_their_names(tmp_path: Path, jobs: int) -> None: + files = {"app/handlers.py": HANDLERS, "app/other.py": "X = 1\n"} + + # ``main``'s event, ``Jobs.run``'s event, and everything ``Worker.start`` takes; + # not the contexts of ``main`` and ``Jobs.run``, not ``handler``, not ``Jobs.other``. + assert analyze(tmp_path, files, jobs=jobs) == [(line, "event") for line in EVENT_LINES] + + +def test_a_function_named_handler_is_nothing_without_a_declaration(tmp_path: Path) -> None: + module = "import os\n\ndef handler(event, context):\n os.system(event['cmd'])\n\ndef lambda_handler(event, context):\n os.system(event['cmd'])\n" + + assert analyze(tmp_path, {"app/lambda_function.py": module}) == [] + + +def test_a_single_file_check_honours_a_declared_symbol(tmp_path: Path) -> None: + source = SourceManager().add_source("handlers.py", HANDLERS) + + findings = engine.analyze_file(source, [engine.BUNDLED_PLUGINS, plugins(tmp_path)]).findings + + assert commands(findings) == [(4, "event")] + + +def test_the_inputs_of_a_decorated_entry_point_can_be_limited_too(tmp_path: Path) -> None: + module = ( + "import os\n" + "from jobs import task\n\n" + "@task\n" + "def send(channel, payload):\n" + " os.system(channel)\n" + " os.system(payload)\n" + ) + + assert analyze(tmp_path, {"app/tasks.py": module}) == [(7, "queue")] + + +def test_a_project_plugin_sees_the_declared_functions_as_entry_points(tmp_path: Path) -> None: + seen: dict[str, str | None] = {} + + class Peek(ProjectPlugin): + name = "peek" + + def analyze_project(self, ctx: ProjectContext) -> tuple[Finding, ...]: + for module in ctx.modules: + for function in ctx.functions(module): + seen[function.name] = function.entry_point + return () + + root = project(tmp_path, {"app/handlers.py": HANDLERS}) + + engine.analyze_project(root, [engine.BUNDLED_PLUGINS, plugins(tmp_path)], plugins=[Peek()]) + + assert {name: seen[name] for name in ("main", "handler", "Jobs.run", "Jobs.other", "Worker.start")} == { + "main": "event", + "handler": None, + "Jobs.run": "event", + "Jobs.other": None, + "Worker.start": "event", + } From aa235d66ce72385af6338e309582a4a2039f8cbc Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 28 Sep 2026 16:05:10 +0200 Subject: [PATCH 4/4] feat: let an entry point name a project function by its own symbol function_entry_point now matches the project symbol of a function, or of its class, against the EntryPoint models, after the decorators, the bases and the registrations, so an integration reading a deployment declaration can make a bare Lambda handler an entry point. EntryPoint.inputs limits the parameters that are input to the given positions after self. --- CHANGELOG.md | 6 ++++++ docs/plugins.md | 2 +- src/coretrace_python/taint/engine.py | 19 +++++++++++++++---- src/coretrace_python/taint/models.py | 9 +++++++-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad35b7..4e13a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Plugins + +- An `EntryPoint` may name a project function or class by its own symbol, `python.app.handlers.main`, so an integration reading a deployment declaration, a SAM template or a `serverless.yml`, can make a bare Lambda handler an entry point; the engine itself infers nothing from a function's name. `EntryPoint.inputs` limits the parameters that are input to the given positions, after `self` for a method: the handler's event, not its context (#180). + ## 0.17.0 (2026-09-28) ### Plugins diff --git a/docs/plugins.md b/docs/plugins.md index 9f39592..0da9842 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -151,7 +151,7 @@ class BottleModels(ModelPlugin): | `Source(symbol, label, kinds=ALL)` | A call or attribute whose value is attacker-controlled; `label` names it in messages (`http`, `stdin`, `argv`, `http-response`). `kinds` restricts what it can inject. Text input, such as a form field, a header or a URL parameter, carries `TEXT_KINDS`, every kind but `NOSQL`: a string cannot hold a query operator. | | `Sink(symbol, kinds, positions=(), keywords=())` | A call dangerous for `kinds`. `positions` limits a kind to the given argument indexes, and `keywords` to the given keyword names; a restricted kind reaches only the arguments listed there. SQL sinks read the statement only, and the SSRF sinks of HTTP clients read the URL, by position or as `url=`. A starred argument or `**kwargs` reaches no restricted kind. | | `Sanitizer(symbol, kinds)` | A call whose result is safe for `kinds`, including when the call is inside a project function the data goes through. | -| `EntryPoint(symbol, label, kinds=ALL)` | A decorator or a class base whose functions receive their parameters as `label` input, such as `flask.Flask.route`. | +| `EntryPoint(symbol, label, kinds=ALL, inputs=None)` | A decorator or a class base whose functions receive their parameters as `label` input, such as `flask.Flask.route`; or a project function or class named by its own symbol (`python.app.handlers.main`), as an integration reading a deployment declaration names a Lambda handler. `inputs` limits the parameters that are input to those positions, after `self` for a method: a handler's event, not its context. | | `TypedParameter(symbol, label, kinds=ALL)` | A class whose annotated parameters carry `label` input, such as Django's `HttpRequest`. | | `NamedParameter(pattern, label, kinds)` | A regular expression on parameter names; matching parameters carry `kinds`, as `password` carries `CREDENTIAL`. | | `BasePattern(pattern, label, parameters=(None,), kinds=ALL)` | A regular expression on the canonical symbol of a class's base, for bases generated per project such as a gRPC servicer's (`(?:^|\.)\w+_pb2_grpc\.\w+Servicer$`), whatever the module's prefix and however the base is imported. The methods taking the parameters listed, by position after `self`, are entry points: a position left `None` is `label` input carrying `kinds`; one holding a class symbol denotes an object of that class, which the sources on the class taint, as `invocation_metadata()` on a `ServicerContext`, without the object itself being input. When the project holds the base, only the methods the base defines count; otherwise every method with that number of parameters. Inheritance identifies a potential service, not its registration on a server. | diff --git a/src/coretrace_python/taint/engine.py b/src/coretrace_python/taint/engine.py index 5faffca..0430201 100644 --- a/src/coretrace_python/taint/engine.py +++ b/src/coretrace_python/taint/engine.py @@ -835,7 +835,8 @@ def function_entry_point( project: SummaryIndex | None = None, ) -> EntryPoint | None: """The entry point ``function`` is: one of its decorators or, for a method, one of its - class's bases matching an entry-point model, or a registration elsewhere (``routes``).""" + class's bases matching an entry-point model, a registration elsewhere (``routes``), or + a model naming the function, or its class, by its own project symbol.""" owner = _owner(module, function) enclosing_function = _enclosing(module, function) @@ -843,14 +844,22 @@ def function_entry_point( # ``app = Flask(__name__)`` inside ``create_app``: routes defined there resolve. instances = {**(instances or {}), **local_instances(enclosing_function, scopes, symbols)} entry = entry_point_of(function, models, scopes, symbols, owner, instances, project) - if entry is None and routes: + if entry is None: qualified = function.name if owner is None else f"{owner.name}.{function.name}" - entry = routes.get(project_symbol(module.name, qualified)) + entry = _declared(project_symbol(module.name, qualified), models, routes) if entry is None and owner is not None: - entry = routes.get(project_symbol(module.name, owner.name)) + entry = _declared(project_symbol(module.name, owner.name), models, routes) return entry +def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> EntryPoint | None: + """The entry point a project ``symbol`` is: registered as a handler, or named by a + model.""" + + registered = routes.get(symbol) if routes else None + return registered if registered is not None else models.entry_point(symbol) + + def _owner(module: nodes.Module, function: nodes.Function) -> nodes.Class | None: """The top-level class ``function`` is a method of, if any.""" @@ -891,6 +900,8 @@ class is not input: the sources on that class say what it gives.""" for index in range(first, len(function.parameters)): if index - first < len(roles) and roles[index - first] is not None: continue + if entry.inputs is not None and index - first not in entry.inputs: + continue annotation = function.parameters[index].annotation text = (request is not None and index > first) or ( annotation is not None and _scalar(annotation, symbols, enclosing) diff --git a/src/coretrace_python/taint/models.py b/src/coretrace_python/taint/models.py index e951556..2469238 100644 --- a/src/coretrace_python/taint/models.py +++ b/src/coretrace_python/taint/models.py @@ -110,12 +110,17 @@ class Sanitizer: @dataclass(frozen=True) class EntryPoint: - """Functions decorated by ``symbol``, and methods of classes deriving from it, - receive attacker-controlled parameters.""" + """Functions decorated by ``symbol``, methods of classes deriving from it, and, when + ``symbol`` names a project function or class, that function or the class's methods + receive attacker-controlled parameters: those at ``inputs``, by position after + ``self`` for a method, or every parameter when ``inputs`` is None. A deployment + declaration an integration reads names a Lambda handler this way, whose event is + input and whose context is not.""" symbol: SymbolId label: str kinds: TaintKind = TaintKind.ALL + inputs: tuple[int, ...] | None = None @dataclass(frozen=True)