From c0cbd6fc897c055a46bc017b9503f17cda638331 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 26 Sep 2026 18:13:57 +0200 Subject: [PATCH 1/4] test: specify template filters as calls to the functions behind them A filter of Django's own libraries that a project template applies runs the function behind it whenever the template is rendered. The tests specify that it counts as a reachable call at the template's line, in variables, tag arguments and filter blocks, and that a template the engine cannot read keeps an advisory a template filter can reach under investigation in the VEX report. --- tests/test_template_filter_calls.py | 282 ++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 tests/test_template_filter_calls.py diff --git a/tests/test_template_filter_calls.py b/tests/test_template_filter_calls.py new file mode 100644 index 0000000..80c323f --- /dev/null +++ b/tests/test_template_filter_calls.py @@ -0,0 +1,282 @@ +"""Template filters as calls to the functions behind them. + +A Django template applying ``{{ bio|striptags }}`` runs +``django.template.defaultfilters.striptags`` whenever it is rendered, though no Python +call names it. Every filter of Django's own libraries that a project template applies, +in a variable, a tag argument or a ``{% filter %}`` block, therefore counts as a call to +the function behind it, at the template's line, whether or not a Python call names the +template: a class-based view renders its ``template_name`` inside Django. Comments, +``{% comment %}`` and ``{% verbatim %}`` blocks, quoted text and custom filters apply +none. + +A template the engine cannot read may apply any filter. When the project names one — a +render call or an ``{% include %}`` or ``{% extends %}`` naming it by an expression, or +naming a template found under no ``templates`` directory — an advisory whose entry +points a template can reach stays ``under_investigation`` in the VEX report rather than +``not_affected``. Advisories no template can reach are not concerned. +""" + +from __future__ import annotations + +import json +import os +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import pytest + +from coretrace_python import engine +from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories, render_vex +from coretrace_python.findings import Severity +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.taint import project_templates + +NOW = datetime(2026, 9, 26, 12, 0, tzinfo=UTC) +STRIPTAGS = "python.django.template.defaultfilters.striptags" +STRIP_TAGS = Advisory( + "CVE-2099-5301", + "django", + "<4.2.17", + "strip_tags is quadratic in nested incomplete tags", + Severity.MEDIUM, + entry_points=( + AdvisoryEntryPoint(SymbolId("python.django.utils.html.strip_tags"), "changed by the fix"), + AdvisoryEntryPoint(SymbolId(STRIPTAGS), "the striptags filter calls strip_tags"), + ), +) +SIGNING = Advisory( + "CVE-2099-5302", + "django", + "<4.2.17", + "signing.loads decompresses without a limit", + Severity.MEDIUM, + entry_points=(AdvisoryEntryPoint(SymbolId("python.django.core.signing.loads"), "changed by the fix"),), +) +LOCK = """version = 1 + +[[package]] +name = "app" +version = "0.1.0" +source = { virtual = "." } +dependencies = [{ name = "django" }] + +[[package]] +name = "django" +version = "4.2.16" +source = { registry = "https://pypi.org/simple" } +""" +VIEW = ( + "from django.http import HttpResponse\n" + "from django.shortcuts import render\n" + "from django.template.loader import render_to_string\n" + "from django.template.response import TemplateResponse\n\n" + "def profile(request):\n" + " return {call}\n" +) +RENDER = "render(request, {name}, {{'bio': request.POST['bio']}})" +PAGE = "app/templates/app/profile.html" + + +def check(root: Path, files: dict[str, str], advisories: tuple[Advisory, ...] = (STRIP_TAGS,)) -> engine.ProjectAnalysis: + for relative, text in {"uv.lock": LOCK, "app/__init__.py": "", **files}.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + (root / "advisories.json").write_text(dump_advisories(advisories), encoding="utf-8") + return engine.analyze_project(root, [engine.BUNDLED_PLUGINS]) + + +def project(root: Path, template: str, name: str = "'app/profile.html'", **files: str) -> dict[str, str]: + return {"app/views.py": VIEW.format(call=RENDER.format(name=name)), PAGE: template, **files} + + +def reached(root: Path, analysis: engine.ProjectAnalysis) -> list[tuple[str, int, str]]: + return [ + (Path(str(f.span.source_id)).relative_to(root).as_posix(), f.span.start_line, f.metadata["symbol"]) + for f in analysis.findings + if f.rule_id == "reachable-vulnerability" + ] + + +def vex(root: Path, analysis: engine.ProjectAnalysis, name: str) -> dict[str, Any]: + evidence = (*analysis.findings, *analysis.suppressed, *analysis.accepted) + document = render_vex( + analysis.dependencies, analysis.advisories, evidence, analysis.coverage, root, "coretrace", "0.16.0", NOW + ) + return next(s for s in json.loads(document)["statements"] if s["vulnerability"]["name"] == name) + + +# --------------------------------------------------------------------------- calls + + +def test_a_filter_a_project_template_applies_is_a_call_to_the_function_behind_it(tmp_path: Path) -> None: + analysis = check(tmp_path, project(tmp_path, "

Profile

\n

\n {{ bio|striptags }}\n

\n")) + + assert reached(tmp_path, analysis) == [(PAGE, 3, STRIPTAGS)] + finding = next(f for f in analysis.findings if f.rule_id == "reachable-vulnerability") + assert (finding.span.start_column, finding.function) == (10, None) + assert finding.metadata["entry_point"] == STRIPTAGS + assert finding.metadata["level"] == "reachable" + + +@pytest.mark.parametrize( + "template", + [ + "{{ bio | striptags }}", + "{{ bio|lower|striptags }}", + "{{ bio|default:'none'|striptags }}", + "{% if bio|striptags %}yes{% endif %}", + "{% with text=bio|striptags %}{{ text }}{% endwith %}", + "{% filter lower|striptags %}{{ bio }}{% endfilter %}", + "{% include 'app/card.html' with text=bio|striptags %}", + ], +) +def test_a_filter_in_a_variable_a_tag_or_a_filter_block_is_a_call(tmp_path: Path, template: str) -> None: + analysis = check(tmp_path, project(tmp_path, f"

\n{template}\n

\n", **{"app/templates/app/card.html": ""})) + + assert reached(tmp_path, analysis) == [(PAGE, 2, STRIPTAGS)] + + +@pytest.mark.parametrize( + "template", + [ + "{# {{ bio|striptags }} #}", + "{% comment %}\n{{ bio|striptags }}\n{% endcomment %}", + "{% verbatim %}\n{{ bio|striptags }}\n{% endverbatim %}", + "{{ 'a|striptags' }}", + "{% load text %}{{ bio|strip_html }}", + "

bio|striptags

", + ], +) +def test_comments_verbatim_text_quoted_text_and_custom_filters_call_nothing(tmp_path: Path, template: str) -> None: + analysis = check(tmp_path, project(tmp_path, template)) + + assert reached(tmp_path, analysis) == [] + + +@pytest.mark.parametrize( + "applied, function", + [ + ("escape", "python.django.template.defaultfilters.escape_filter"), + ("slice:':2'", "python.django.template.defaultfilters.slice_filter"), + ("urlizetrunc:15", "python.django.template.defaultfilters.urlizetrunc"), + ("timezone:'UTC'", "python.django.templatetags.tz.do_timezone"), + ("unlocalize", "python.django.templatetags.l10n.unlocalize"), + ("language_name", "python.django.templatetags.i18n.language_name"), + ("intcomma", "python.django.contrib.humanize.templatetags.humanize.intcomma"), + ], +) +def test_each_filter_calls_the_function_django_registers_it_from(tmp_path: Path, applied: str, function: str) -> None: + advisory = Advisory( + "CVE-2099-5303", + "django", + "<4.2.17", + "a filter misbehaves", + Severity.LOW, + entry_points=(AdvisoryEntryPoint(SymbolId(function), "registered as a filter"),), + ) + + analysis = check(tmp_path, project(tmp_path, f"{{{{ value|{applied} }}}}\n"), (advisory,)) + + assert reached(tmp_path, analysis) == [(PAGE, 1, function)] + + +def test_a_template_no_python_call_names_still_calls_its_filters(tmp_path: Path) -> None: + view = ( + "from django.views.generic import TemplateView\n\n" + "class Profile(TemplateView):\n" + " template_name = 'app/profile.html'\n" + ) + + analysis = check(tmp_path, {"app/views.py": view, PAGE: "{{ bio|striptags }}\n"}) + + assert reached(tmp_path, analysis) == [(PAGE, 1, STRIPTAGS)] + + +@pytest.mark.skipif(os.name != "posix" or os.geteuid() == 0, reason="needs a file its owner cannot read") +def test_a_template_file_that_cannot_be_read_is_unread(tmp_path: Path) -> None: + page = tmp_path / PAGE + page.parent.mkdir(parents=True) + page.write_text("{{ bio|striptags }}\n", encoding="utf-8") + page.chmod(0) + try: + templates = project_templates(tmp_path) + finally: + page.chmod(0o644) + + assert (templates.calls, templates.unread) == ((), (f"{PAGE}, unreadable",)) + + +# --------------------------------------------------------------------------- VEX + + +def test_a_template_applying_an_entry_point_makes_the_project_affected(tmp_path: Path) -> None: + analysis = check(tmp_path, project(tmp_path, "

\n{{ bio|striptags }}\n

\n")) + + found = vex(tmp_path, analysis, "CVE-2099-5301") + + assert found["status"] == "affected" + assert found["status_notes"] == f"Reached by the project's code at {PAGE}:2 {STRIPTAGS} (reachable)." + + +def test_templates_applying_no_entry_point_leave_the_project_not_affected(tmp_path: Path) -> None: + base = {"app/templates/app/base.html": "{% block body %}{% endblock %}\n"} + + analysis = check(tmp_path, project(tmp_path, "{% extends 'app/base.html' %}{{ bio|lower }}\n", **base)) + + assert vex(tmp_path, analysis, "CVE-2099-5301")["status"] == "not_affected" + + +UNREAD = [ + (VIEW.format(call=RENDER.format(name="request.GET['page']")), {}, "app.views:7 names a template by an expression"), + ( + VIEW.format(call="HttpResponse(render_to_string(request.GET['page']))"), + {}, + "app.views:7 names a template by an expression", + ), + ( + VIEW.format(call="TemplateResponse(request, template=request.GET['page'])"), + {}, + "app.views:7 names a template by an expression", + ), + (VIEW.format(call=RENDER.format(name="'app/other.html'")), {}, "app.views:7 names 'app/other.html', not found"), + ( + VIEW.format(call=RENDER.format(name="'app/profile.html'")), + {PAGE: "

\n{% include 'app/other.html' %}\n

\n"}, + f"{PAGE}:2 names 'app/other.html', not found", + ), + ( + VIEW.format(call=RENDER.format(name="'app/profile.html'")), + {PAGE: "{% extends layout %}\n"}, + f"{PAGE}:1 names a template by an expression", + ), +] + + +@pytest.mark.parametrize( + "view, templates, unread", + UNREAD, + ids=["render", "render-to-string", "template-response", "not-found", "include-not-found", "extends-expression"], +) +def test_a_template_the_engine_cannot_read_keeps_it_under_investigation( + tmp_path: Path, view: str, templates: dict[str, str], unread: str +) -> None: + files = {"app/views.py": view, PAGE: "{{ bio|lower }}\n", **templates} + + found = vex(tmp_path, check(tmp_path, files), "CVE-2099-5301") + + assert found["status"] == "under_investigation" + assert found["status_notes"] == ( + "No analysed code reaches the entry points of CVE-2099-5301 (python.django.template.defaultfilters.striptags, " + "python.django.utils.html.strip_tags), but a template can reach them, and the engine could not read every " + f"template the project names: {unread}." + ) + + +def test_a_template_the_engine_cannot_read_leaves_advisories_no_template_reaches_alone(tmp_path: Path) -> None: + view = VIEW.format(call=RENDER.format(name="request.GET['page']")) + + found = vex(tmp_path, check(tmp_path, {"app/views.py": view}, (STRIP_TAGS, SIGNING)), "CVE-2099-5302") + + assert found["status"] == "not_affected" From d71fac6e2b651b4c57d532a7731f2a82cb78efa3 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 26 Sep 2026 18:13:57 +0200 Subject: [PATCH 2/4] feat: count template filters as calls to the functions behind them Project templates are read for the filters they apply, each mapped to the function Django registers it from, and reachable-vulnerability reports an entry point a template filter reaches at the template's line, whether or not a Python call names the template. The escaping judgement reads its filter names from the same table. Where the project names a template the engine cannot read (by an expression, or by a name found under no templates directory, in a render call, an include or an extends), coverage lists it, and the VEX report keeps an advisory whose entry points include a template filter under investigation. render, TemplateResponse and SimpleTemplateResponse are template renders like render_to_string. --- CHANGELOG.md | 6 + docs/plugins.md | 6 +- docs/usage.md | 23 ++- .../reachable_vulnerability.py | 11 +- .../bundled/models/django/django_models.py | 3 + src/coretrace_python/dependency/vex.py | 11 +- src/coretrace_python/engine.py | 12 +- src/coretrace_python/findings/coverage.py | 5 +- src/coretrace_python/plugins/api.py | 11 +- src/coretrace_python/taint/__init__.py | 15 +- src/coretrace_python/taint/models.py | 3 +- src/coretrace_python/taint/templates.py | 171 ++++++++++++++++-- 12 files changed, 245 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fe17f..db721f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Advisories + +- A filter of Django's own libraries that a project template applies is a call to the function behind it, at the template's line: `{{ bio|striptags }}` calls `django.template.defaultfilters.striptags`, and so do filters in tag arguments and `{% filter %}` blocks. An advisory entry point naming that function is reachable there, whether or not a Python call names the template, since a class-based view renders its `template_name` inside Django. The data a template passes to a filter is not tracked yet, so such a call is reachable, not exploitable. A template the analyzer cannot read may apply any filter: when the project names one by an expression, or by a name found under no `templates` directory, in `render`, `render_to_string`, `TemplateResponse`, `{% include %}` or `{% extends %}`, an advisory whose entry points include a template filter stays `under_investigation` in the VEX document, and the notes say where. `render`, `TemplateResponse` and `SimpleTemplateResponse` are now template renders like `render_to_string` (#146). + ## 0.15.0 (2026-09-26) ### Precision diff --git a/docs/plugins.md b/docs/plugins.md index ec46911..4167750 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -157,7 +157,7 @@ class BottleModels(ModelPlugin): | `RouteRegistrar(symbol, argument, label, kinds=ALL, keyword=None)` | A call registering a view at argument `argument` (or `keyword`), such as Django's `path`; the view receives `label` input. | | `SuffixSink(suffix, kinds, positions=())` | A sink matched by the end of the symbol, for methods of any class such as `objects.raw`. | | `SafeArgument(symbol, argument, values, position=None, kinds=ALL)` | A call to the sink `symbol` whose `argument` (by keyword, or at `position`) denotes one of `values` is not a sink for `kinds`, such as `yaml.load` with `Loader=yaml.SafeLoader`. Values are written as the analyzer records arguments: a symbol by its canonical name, a constant as Python writes it (`True`). Only a value given explicitly makes the call safe. | -| `TemplateRender(symbol, position=0, keyword="template_name")` | A call rendering, with autoescaping, the template named by its argument at `position` or `keyword`, such as `render_to_string`. What it returns carries no `HTML` when the name is written in the call and the project's template escapes everything it prints (see the usage guide). | +| `TemplateRender(symbol, position=0, keyword="template_name")` | A call rendering, with autoescaping, the template named by its argument at `position` or `keyword`, such as `render_to_string`. What it returns carries no `HTML` when the name is written in the call and the project's template escapes everything it prints (see the usage guide). A name the analyzer cannot read keeps an advisory a template filter reaches under investigation in the VEX document. | | `Validator(symbol, kinds=ALL, argument=0)` | A callable whose truth proves its argument safe for `kinds`, such as `re.fullmatch`. A flow guarded by it is refuted when it carries no other kind into the sink, and is a hotspot otherwise: a redirect validator does not make a shell command safe. `symbol` may be a function of the project, by its project symbol (`python.hc.accounts.views._allow_redirect`), which then counts wherever it is called, in its own module too. | | `RequestObject(symbol, text=())` | The inputs from `symbol`, an entry point, a route registrar, a typed parameter or a source, are request objects. A view receives the request first (after `self`), then URL parameters, which are text. Reading one of the request's `text` attributes gives text, as Django's `GET` or aiohttp's `query`; its body and files keep the request's kinds. An attribute read counts only when every source of the value is such a request. | | `AuthorizationGuard(symbol, label)` | A decorator or a condition restricting who reaches the code, such as `login_required`; a flow behind it is a hotspot. | @@ -258,6 +258,10 @@ one, and the label of the entry point it is (`http` for a route, `argv` for a co when the models make it one. `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: +their `names`, the filters they apply as `calls` (each a `FilterCall(symbol, span)`, the +function behind the filter and where the template applies it), and where they name a +template the analyzer cannot read (`unread`). The shipped `vulnerable-dependency`, `reachable-vulnerability` and `dependency-policy` plugins are of this kind. diff --git a/docs/usage.md b/docs/usage.md index 3643ed0..f752916 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -362,6 +362,18 @@ it is reachable, called or not — `request.form['name']`, `for key in request.f `request.form.get('name')` all read it. A function reading it several times is reported once, where it first reads it. +A filter of Django's own libraries that a project template applies is a call to the +function behind it, placed at the template's line: `{{ bio|striptags }}` calls +`python.django.template.defaultfilters.striptags`, and so do filters in tag arguments +(`{% if bio|striptags %}`) and `{% filter %}` blocks. A template counts whether or not a +Python call names it, since a class-based view renders its `template_name` inside +Django. Most filters are functions of the same name in `django.template.defaultfilters`; +`escape`, `escapejs`, `linebreaks`, `phone2numeric`, `slice`, `timesince` and `timeuntil` +are `_filter` there, and the filters of `i18n`, `l10n`, `tz` and `humanize` are in +their library's module (`timezone` is `django.templatetags.tz.do_timezone`). The data a +template passes to a filter is not tracked yet, so such a call is reachable, not +exploitable. + Most vulnerabilities need attacker input in one argument: the path `send_from_directory` serves, not its `download_name`; the URL `requests.get` fetches, not its body. An entry point may name them in `attacker_arguments`, by keyword `argument` and, when it may be @@ -444,9 +456,18 @@ check, including findings the policy accepts or a comment suppresses: | Status | When | |---|---| | `affected` | The project's code reaches the vulnerable code. The notes give each place and its level, `reachable` or `exploitable`. | -| `not_affected` | Justified as `vulnerable_code_not_in_execute_path`, only when all of these hold: the advisory names entry points, no code of the project reaches them, every file and function was analysed, and a lock file (`uv.lock` or `poetry.lock`) shows that no other package requires the vulnerable one. The impact statement names the entry points and any calls ruled out by their arguments. | +| `not_affected` | Justified as `vulnerable_code_not_in_execute_path`, only when all of these hold: the advisory names entry points, no code of the project reaches them, every file and function was analysed, every template the project names was read if a template filter is among them, and a lock file (`uv.lock` or `poetry.lock`) shows that no other package requires the vulnerable one. The impact statement names the entry points and any calls ruled out by their arguments. | | `under_investigation` | Anything else. The notes say which condition failed. | +A template the analyzer cannot read may apply any filter. The project names one when +`render`, `render_to_string`, `TemplateResponse`, an `{% include %}` or an `{% extends %}` +names a template by an expression, or by a name found under no `templates` directory, +including one an installed package ships; a template file that cannot be read counts +too. An advisory whose entry points include a template filter then stays +`under_investigation`, and the notes list where the project names each such template +(`app.views:12 names a template by an expression`). A template named elsewhere, such as +a class-based view's `template_name`, is not checked. + `not_affected` needs the lock-file check because the analyzer reads the project's code, not the code of installed packages. A framework calling the vulnerable function on the project's behalf would go unseen. Without a lock file, a package is never diff --git a/src/coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py b/src/coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py index 9f11f88..be80c3e 100644 --- a/src/coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +++ b/src/coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py @@ -1,5 +1,6 @@ """Calls, anywhere in the project, to an API affected by a vulnerable requirement, and -reads of an affected attribute whose getter runs the vulnerable code.""" +reads of an affected attribute whose getter runs the vulnerable code. A filter a project +template applies is a call to the function behind it, at the template's line.""" from __future__ import annotations @@ -56,6 +57,10 @@ def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]: reported.add((advisory, symbol)) check = check_conditions(entry, None) findings.append(_reached(advisory, symbol, read.location, function, check)) + for call in ctx.templates.calls: + for advisory in affected.get(call.symbol, ()): + check = check_conditions(advisory.entry_point(call.symbol), None) + findings.append(_reached(advisory, call.symbol, call.span, None, check)) return findings @@ -67,7 +72,9 @@ def _read_through(symbol: SymbolId) -> Iterator[SymbolId]: yield SymbolId(".".join(components[:end])) -def _reached(advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str, check: ConditionCheck) -> Finding: +def _reached( + advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str | None, check: ConditionCheck +) -> Finding: return Finding( rule_id="reachable-vulnerability", message=( diff --git a/src/coretrace_python/bundled/models/django/django_models.py b/src/coretrace_python/bundled/models/django/django_models.py index 49ccdf4..591dc6d 100644 --- a/src/coretrace_python/bundled/models/django/django_models.py +++ b/src/coretrace_python/bundled/models/django/django_models.py @@ -140,6 +140,9 @@ class DjangoModels(ModelPlugin): Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY), Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML), TemplateRender(_sym("django.template.loader.render_to_string")), + TemplateRender(_sym("django.shortcuts.render"), 1), + TemplateRender(_sym("django.template.response.TemplateResponse"), 1, "template"), + TemplateRender(_sym("django.template.response.SimpleTemplateResponse"), 0, "template"), Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML), # The masked CSRF secret: ASCII letters and digits only, a malformed cookie is # replaced before it is used. diff --git a/src/coretrace_python/dependency/vex.py b/src/coretrace_python/dependency/vex.py index 3619fdc..614864a 100644 --- a/src/coretrace_python/dependency/vex.py +++ b/src/coretrace_python/dependency/vex.py @@ -7,7 +7,8 @@ exploitable finding, whatever the policy or a suppression did with it; - ``not_affected``, as ``vulnerable_code_not_in_execute_path``, only when the advisory is curated (it names entry points), no code of the project reaches them, every file and - function was analysed, and a lock file shows that no other package requires the + function was analysed, every template the project names was read when a template + filter can reach them, and a lock file shows that no other package requires the vulnerable one, since the code of installed packages is not analysed; - ``under_investigation`` otherwise, with the reason in its notes. """ @@ -25,6 +26,7 @@ from coretrace_python.dependency.sbom import purl from coretrace_python.findings import Component, Finding from coretrace_python.findings.coverage import Coverage +from coretrace_python.taint import FILTER_FUNCTIONS CONTEXT = "https://openvex.dev/ns/v0.2.0" # OpenVEX's shared namespace for documents without an IRI of their own, and the author @@ -33,6 +35,8 @@ AUTHOR = "Unknown Author" # Evidence levels that put the vulnerable code in the project's execution path, lowest first. REACHED = ("reachable", "exploitable") +# What a template calls: a template the engine cannot read may reach these. +_TEMPLATE_CALLS = frozenset(FILTER_FUNCTIONS.values()) def render_vex( @@ -140,6 +144,11 @@ def _status( f"No analysed code reaches {entries}, but {', '.join(partial)} could not be fully " f"analysed.{ruled}" ) + if coverage.unread_templates and _TEMPLATE_CALLS.intersection(advisory.reachable_symbols): + return _investigating( + f"No analysed code reaches {entries}, but a template can reach them, and the engine could " + f"not read every template the project names: {'; '.join(coverage.unread_templates)}.{ruled}" + ) required_by = dependencies.required_by(package) if required_by is None: return _investigating( diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index af5e9e2..275b73e 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -124,7 +124,9 @@ TaintAnalysis, TaintKind, escaped_templates, + project_templates, registered_routes, + unread_renders, ) from coretrace_python.taint.urls import flow_url @@ -431,6 +433,7 @@ def analyze_project( for symbol, registered in _routes_of(analysable[name]).items(): routes.setdefault(symbol, registered) escaped = escaped_templates(root) + templates = project_templates(root) clearing = models.clearing(escaped) for manager in analysable.values(): manager.provide(RegisteredRoutes, routes) @@ -516,7 +519,9 @@ def analyze_project( frozenset(), reads={f: tuple(r) for f, r in reads.items()}, ) - context = ProjectContext(graph, dependencies, advisories, analysable, call_graphs, policy, root, functions) + context = ProjectContext( + graph, dependencies, advisories, analysable, call_graphs, policy, root, functions, templates + ) for plugin in all_plugins: if isinstance(plugin, ProjectPlugin): findings.extend(plugin.analyze_project(context)) @@ -526,6 +531,9 @@ def analyze_project( findings = [_sourced(finding, origins) for finding in findings] accepted = tuple(f for f in findings if policy.accepts(f)) kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources)) + unread = list(templates.unread) + for name in sorted(call_graphs): + unread.extend(unread_renders(name, call_graphs[name], models, templates.names)) return ProjectAnalysis( graph, index, @@ -534,7 +542,7 @@ def analyze_project( MappingProxyType(keys), reused, advisories, - Coverage(tuple(sorted(coverage, key=lambda c: c.path))), + Coverage(tuple(sorted(coverage, key=lambda c: c.path)), tuple(unread)), suppressed, accepted, components, diff --git a/src/coretrace_python/findings/coverage.py b/src/coretrace_python/findings/coverage.py index 717ac4c..0d98027 100644 --- a/src/coretrace_python/findings/coverage.py +++ b/src/coretrace_python/findings/coverage.py @@ -2,7 +2,9 @@ "No findings" only means something when the reader knows what was analysed. Each file is ``analysed``, a ``syntax-error`` (the frontend rejected it) or ``unreadable`` (it could -not be decoded); analysed files count their functions and how many lowered. +not be decoded); analysed files count their functions and how many lowered. The +templates the project names but the run could not read are listed, each where the +project names it: they may call any template filter. """ from __future__ import annotations @@ -21,6 +23,7 @@ class FileCoverage: @dataclass(frozen=True) class Coverage: details: tuple[FileCoverage, ...] = () + unread_templates: tuple[str, ...] = () @property def files(self) -> int: diff --git a/src/coretrace_python/plugins/api.py b/src/coretrace_python/plugins/api.py index 063300f..5b5ff8e 100644 --- a/src/coretrace_python/plugins/api.py +++ b/src/coretrace_python/plugins/api.py @@ -33,7 +33,7 @@ ) from coretrace_python.ir.lowering import analyzable_functions from coretrace_python.semantic.imports import ImportAnalysis, ImportTable -from coretrace_python.taint import EntryPointAnalysis, Model +from coretrace_python.taint import EntryPointAnalysis, Model, ProjectTemplates PLUGIN_API_VERSION = 1 @@ -69,9 +69,10 @@ def analyze(self, ctx: PluginContext) -> Sequence[Finding]: class ProjectContext: """What a project-scoped plugin sees: the module graph, the dependency graph, the - advisories every plugin and advisory file contributed, the dependency policy, and - each module's imports, call graph and functions. The engine passes the call graphs - and functions of modules it served from its cache.""" + advisories every plugin and advisory file contributed, the dependency policy, each + module's imports, call graph and functions, and the project's templates with the + filters they call. The engine passes the call graphs and functions of modules it + served from its cache.""" def __init__( self, @@ -83,8 +84,10 @@ def __init__( policy: Policy | None = None, root: Path | None = None, functions: Mapping[str, tuple[ModuleFunction, ...]] | None = None, + templates: ProjectTemplates | None = None, ) -> None: self.root = root + self.templates = templates or ProjectTemplates() self.graph = graph self.dependencies = dependencies self.advisories = advisories diff --git a/src/coretrace_python/taint/__init__.py b/src/coretrace_python/taint/__init__.py index 9219967..f28f56b 100644 --- a/src/coretrace_python/taint/__init__.py +++ b/src/coretrace_python/taint/__init__.py @@ -32,19 +32,30 @@ Validator, ) from coretrace_python.taint.routes import RegisteredRoutes, Routes, registered_routes -from coretrace_python.taint.templates import EscapedTemplates, escaped_templates +from coretrace_python.taint.templates import ( + FILTER_FUNCTIONS, + EscapedTemplates, + FilterCall, + ProjectTemplates, + escaped_templates, + project_templates, + unread_renders, +) __all__ = [ + "FILTER_FUNCTIONS", "TEXT_KINDS", "AuthorizationGuard", "EntryPoint", "EntryPointAnalysis", "EscapedTemplates", + "FilterCall", "Members", "Model", "ModelError", "ModelTable", "NamedParameter", + "ProjectTemplates", "RegisteredRoutes", "RequestObject", "RouteRegistrar", @@ -65,6 +76,8 @@ "TypedParameter", "Validator", "escaped_templates", + "project_templates", "propagate_taint", "registered_routes", + "unread_renders", ] diff --git a/src/coretrace_python/taint/models.py b/src/coretrace_python/taint/models.py index 75c8035..2ed5d48 100644 --- a/src/coretrace_python/taint/models.py +++ b/src/coretrace_python/taint/models.py @@ -198,7 +198,8 @@ class Validator: class TemplateRender: """A call rendering the template it names (argument ``position``, or ``keyword``) with autoescaping on, such as ``render_to_string``: what it returns carries no - ``HTML`` when the project shows that template escaping everything it renders.""" + ``HTML`` when the project shows that template escaping everything it renders. A + template it names that the engine cannot read may call any filter.""" symbol: SymbolId position: int = 0 diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index 951f52c..8a6d3aa 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -16,17 +16,29 @@ 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. + +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 }}`` +calls ``django.template.defaultfilters.striptags``. Every template found counts, whether +or not a Python call names it, since a class-based view renders its ``template_name`` +inside Django. A template the engine cannot read may call any filter; the engine says +where the project names one. """ from __future__ import annotations import re -from collections.abc import Iterator +from collections.abc import Iterator, Mapping +from dataclasses import dataclass from pathlib import Path +from types import MappingProxyType from typing import ClassVar from coretrace_python.analysis import Analysis, AnalysisContext -from coretrace_python.interprocedural import discover_files +from coretrace_python.interprocedural import CallGraph, ExternalSymbol, discover_files +from coretrace_python.semantic.symbols import SymbolId +from coretrace_python.source import SourceId, SourceSpan +from coretrace_python.taint.models import ModelTable TEMPLATES_DIRECTORY = "templates" @@ -34,8 +46,7 @@ _SYNTAX = re.compile(r"{{(.*?)}}|{%(.*?)%}|{#.*?#}", re.DOTALL) _AUTOESCAPE_OFF = re.compile(r"""["']autoescape["']\s*:\s*False|\bautoescape\s*=\s*False""") -# Django's own libraries, tags and filters. Every filter but ``safe`` and ``safeseq`` -# escapes its input, or returns it for autoescaping to escape, when autoescaping is on. +# Django's own libraries and tags. _LIBRARIES = frozenset({"static", "i18n", "l10n", "tz", "cache", "humanize"}) _TAGS = frozenset( { @@ -53,22 +64,36 @@ ) # Tags that print a value: checked where they stand, like a variable. _PRINTING = frozenset({"firstof", "cycle", "trans", "translate"}) -_FILTERS = frozenset( +# Where Django defines the function behind each filter of its own libraries, by module; +# ``name:function`` for a filter registered under another name than its function's. +_FILTER_MODULES = { + "django.template.defaultfilters": ( + "add addslashes capfirst center cut date default default_if_none dictsort dictsortreversed " + "divisibleby escape:escape_filter escapejs:escapejs_filter escapeseq filesizeformat first " + "floatformat force_escape get_digit iriencode join json_script last length length_is " + "linebreaks:linebreaks_filter linebreaksbr linenumbers ljust lower make_list " + "phone2numeric:phone2numeric_filter pluralize pprint random rjust safe safeseq " + "slice:slice_filter slugify stringformat striptags time timesince:timesince_filter " + "timeuntil:timeuntil_filter title truncatechars truncatechars_html truncatewords " + "truncatewords_html unordered_list upper urlencode urlize urlizetrunc wordcount wordwrap yesno" + ), + "django.templatetags.i18n": "language_bidi language_name language_name_local language_name_translated", + "django.templatetags.l10n": "localize unlocalize", + "django.templatetags.tz": "localtime timezone:do_timezone utc", + "django.contrib.humanize.templatetags.humanize": "apnumber intcomma intword naturalday naturaltime ordinal", +} +# The function behind each filter of Django's own libraries, which a template applying +# the filter calls. +FILTER_FUNCTIONS: Mapping[str, SymbolId] = MappingProxyType( { - "add", "addslashes", "capfirst", "center", "cut", "date", "default", "default_if_none", - "dictsort", "dictsortreversed", "divisibleby", "escape", "escapejs", "escapeseq", - "filesizeformat", "first", "floatformat", "force_escape", "get_digit", "iriencode", - "join", "json_script", "last", "length", "length_is", "linebreaks", "linebreaksbr", - "linenumbers", "ljust", "lower", "make_list", "phone2numeric", "pluralize", "pprint", - "random", "rjust", "slice", "slugify", "stringformat", "striptags", "time", "timesince", - "timeuntil", "title", "truncatechars", "truncatechars_html", "truncatewords", - "truncatewords_html", "unordered_list", "upper", "urlencode", "urlize", "urlizetrunc", - "wordcount", "wordwrap", "yesno", "language_name", "language_name_local", - "language_bidi", "language_name_translated", "localize", "unlocalize", "localtime", - "utc", "timezone", "apnumber", "intcomma", "intword", "naturalday", "naturaltime", - "ordinal", + name: SymbolId(f"python.{module}.{function or name}") + for module, filters in _FILTER_MODULES.items() + for name, _, function in (entry.partition(":") for entry in filters.split()) } ) +# Every filter but ``safe`` and ``safeseq`` escapes its input, or returns it for +# autoescaping to escape, when autoescaping is on. +_FILTERS = frozenset(FILTER_FUNCTIONS) - {"safe", "safeseq"} _URL_ATTRIBUTES = frozenset( { @@ -86,6 +111,7 @@ _RAW_TEXT = re.compile(r"<(script|style)\b[^>]*>(.*?)", re.DOTALL | re.IGNORECASE) _COMMENT = re.compile(r"", re.DOTALL) _END_TAG = re.compile(r"]*>") +_APPLIED = re.compile(r"\|\s*(\w+)") def escaped_templates(root: Path) -> frozenset[str]: @@ -120,10 +146,16 @@ def _names(parts: tuple[str, ...]) -> Iterator[str]: def _read(path: Path) -> str: + return _text(path) or "" + + +def _text(path: Path) -> str | None: + """The text of ``path``, or None when it cannot be read.""" + try: return path.read_text(encoding="utf-8", errors="replace") except OSError: - return "" + return None def _inspect(text: str) -> tuple[bool, set[str]]: @@ -239,6 +271,109 @@ def _attributes_escape(attributes: str) -> bool: return True +@dataclass(frozen=True) +class FilterCall: + """A filter a project template applies: a call to the function behind it, placed at + the filter's name in the template.""" + + symbol: SymbolId + span: SourceSpan + + +@dataclass(frozen=True) +class ProjectTemplates: + """The templates found under the project's ``templates`` directories: their + ``names``, as a render call names them; the filters they apply, as ``calls``; and + where they name a template the engine cannot read, as ``unread``.""" + + names: frozenset[str] = frozenset() + calls: tuple[FilterCall, ...] = () + unread: tuple[str, ...] = () + + +def project_templates(root: Path) -> ProjectTemplates: + """What the templates under ``root`` call, and the templates they include or extend + that the engine cannot read: named by an expression, or by a name found under no + ``templates`` directory. A template file that cannot be read is unread too.""" + + found = {path: tuple(_names(path.relative_to(root).parts)) for path in discover_files(root)} + templates = sorted(path for path, names in found.items() if names) + names = frozenset(name for path in templates for name in found[path]) + calls: list[FilterCall] = [] + unread: list[str] = [] + for path in templates: + relative = path.relative_to(root).as_posix() + text = _text(path) + if text is None: + unread.append(f"{relative}, unreadable") + continue + for offset, expression, tag in _expressions(text): + blanked = re.sub(_QUOTED, lambda quoted: " " * len(quoted.group()), expression) + for applied in _APPLIED.finditer(blanked): + symbol = FILTER_FUNCTIONS.get(applied.group(1)) + if symbol is not None: + line, column = _position(text, offset + applied.start(1)) + calls.append(FilterCall(symbol, SourceSpan(SourceId(str(path)), line, column))) + words = expression.split() + if tag and words[:1] in (["include"], ["extends"]): + named = _constant(words[1]) if len(words) > 1 else None + where = f"{relative}:{_position(text, offset)[0]}" + if named is None: + unread.append(f"{where} names a template by an expression") + elif named not in names: + unread.append(f"{where} names {named!r}, not found") + return ProjectTemplates(names, tuple(calls), tuple(unread)) + + +def unread_renders(module: str, graph: CallGraph, models: ModelTable, names: frozenset[str]) -> Iterator[str]: + """Where ``module`` renders a template the engine cannot read: named by an + expression, or by a name none of the project's templates has.""" + + found = {repr(name) for name in names} + for function in graph.functions: + for site in graph.sites(function): + if not isinstance(site.target, ExternalSymbol): + continue + render = models.template_render(site.target.symbol) + if render is None: + continue + given = site.arguments.given(render.keyword, render.position) + if given is not None and not given[0]: + continue # names no template: the call fails + where = f"{module}:{site.location.start_line}" + named = given[1] if given is not None else None + # A constant string, as Python writes it; anything else is an expression. + if named is None or named[0] not in "'\"": + yield f"{where} names a template by an expression" + elif named not in found: + yield f"{where} names {named}, not found" + + +def _expressions(text: str) -> Iterator[tuple[int, str, bool]]: + """Each variable and tag of a template, with its offset and whether it is a tag, + leaving out comments and what ``{% comment %}`` and ``{% verbatim %}`` enclose.""" + + skipping: str | None = None + for match in _SYNTAX.finditer(text): + variable, tag = match.group(1), match.group(2) + words = tag.split() if tag is not None else [] + if skipping is not None: + if words[:1] == [skipping]: + skipping = None + elif words[:1] in (["comment"], ["verbatim"]): + skipping = f"end{words[0]}" + elif variable is not None: + yield match.start(1), variable, False + elif tag is not None: + yield match.start(2), tag, True + + +def _position(text: str, offset: int) -> tuple[int, int]: + """The one-based line and column of ``offset`` in ``text``.""" + + return text.count("\n", 0, offset) + 1, offset - text.rfind("\n", 0, offset) + + class EscapedTemplates(Analysis[frozenset[str]]): """The names of the project templates that escape everything they render, provided by the engine for a project; none on its own.""" From 421046cfec19cce0e4513e13d20edbe5c79a5f13 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 Sep 2026 14:10:14 +0200 Subject: [PATCH 3/4] test: specify a filter block applying a single filter {% filter striptags %} has no | before its filter; it is the usual form of a filter block. --- tests/test_template_filter_calls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_template_filter_calls.py b/tests/test_template_filter_calls.py index 80c323f..88eed91 100644 --- a/tests/test_template_filter_calls.py +++ b/tests/test_template_filter_calls.py @@ -129,6 +129,7 @@ def test_a_filter_a_project_template_applies_is_a_call_to_the_function_behind_it "{% if bio|striptags %}yes{% endif %}", "{% with text=bio|striptags %}{{ text }}{% endwith %}", "{% filter lower|striptags %}{{ bio }}{% endfilter %}", + "{% filter striptags %}{{ bio }}{% endfilter %}", "{% include 'app/card.html' with text=bio|striptags %}", ], ) From 3174c9a7aee902e1b51f480329faf97c10710f43 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 27 Sep 2026 14:10:14 +0200 Subject: [PATCH 4/4] fix: count the first filter of a filter block A {% filter %} tag names its first filter without a | before it, so {% filter striptags %} applied no filter. The tag's argument is now read as a chain of filters. --- src/coretrace_python/taint/templates.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index 8a6d3aa..97eeca2 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -308,13 +308,17 @@ def project_templates(root: Path) -> ProjectTemplates: unread.append(f"{relative}, unreadable") continue for offset, expression, tag in _expressions(text): + words = expression.split() + if tag and words[:1] == ["filter"]: + # ``{% filter striptags %}``: no ``|`` stands before its first filter. + start = expression.index("filter") + len("filter") + offset, expression = offset + start - 1, "|" + expression[start:] blanked = re.sub(_QUOTED, lambda quoted: " " * len(quoted.group()), expression) for applied in _APPLIED.finditer(blanked): symbol = FILTER_FUNCTIONS.get(applied.group(1)) if symbol is not None: line, column = _position(text, offset + applied.start(1)) calls.append(FilterCall(symbol, SourceSpan(SourceId(str(path)), line, column))) - words = expression.split() if tag and words[:1] in (["include"], ["extends"]): named = _constant(words[1]) if len(words) > 1 else None where = f"{relative}:{_position(text, offset)[0]}"