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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# 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.

### 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
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 3 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -835,22 +835,31 @@ 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)
if enclosing_function is not None:
# ``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."""

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions src/coretrace_python/taint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions src/coretrace_python/taint/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}``
Expand Down Expand Up @@ -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}
Expand All @@ -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."""

Expand Down
155 changes: 155 additions & 0 deletions tests/test_declared_entry_points.py
Original file line number Diff line number Diff line change
@@ -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",
}
70 changes: 70 additions & 0 deletions tests/test_unreadable_templates.py
Original file line number Diff line number Diff line change
@@ -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 = "<p>{{ name }}</p>\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()
Loading