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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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.

Expand Down
23 changes: 22 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>_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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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


Expand All @@ -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=(
Expand Down
3 changes: 3 additions & 0 deletions src/coretrace_python/bundled/models/django/django_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion src/coretrace_python/dependency/vex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@
TaintAnalysis,
TaintKind,
escaped_templates,
project_templates,
registered_routes,
unread_renders,
)
from coretrace_python.taint.urls import flow_url

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/coretrace_python/findings/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +23,7 @@ class FileCoverage:
@dataclass(frozen=True)
class Coverage:
details: tuple[FileCoverage, ...] = ()
unread_templates: tuple[str, ...] = ()

@property
def files(self) -> int:
Expand Down
11 changes: 7 additions & 4 deletions src/coretrace_python/plugins/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
15 changes: 14 additions & 1 deletion src/coretrace_python/taint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -65,6 +76,8 @@
"TypedParameter",
"Validator",
"escaped_templates",
"project_templates",
"propagate_taint",
"registered_routes",
"unread_renders",
]
3 changes: 2 additions & 1 deletion src/coretrace_python/taint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading