Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e9cb7c9
test: specify that only module-level settings activate the request pr…
SizzleUnrlsd Sep 28, 2026
b2e1f23
fix: take only module-level TEMPLATES as Django settings
SizzleUnrlsd Sep 28, 2026
5b50472
test: specify that block.super keeps the parent block's filters in th…
SizzleUnrlsd Sep 28, 2026
9e5beee
fix: keep the parent block's filters when a child renders block.super
SizzleUnrlsd Sep 28, 2026
abeae2f
test: specify entry points declared through module-level aliases
SizzleUnrlsd Sep 28, 2026
0750d05
feat: resolve declared entry points through module-level aliases
SizzleUnrlsd Sep 28, 2026
b8f269f
fix: respect project template filter overrides before reporting built…
SizzleUnrlsd Sep 28, 2026
40f6f0a
docs: describe template filter overrides and unread library loads
SizzleUnrlsd Sep 28, 2026
02539ab
test: specify distinct modules for files sharing a Python module name
SizzleUnrlsd Sep 28, 2026
3227ee4
fix: keep files sharing a Python module name as distinct modules
SizzleUnrlsd Sep 28, 2026
5b1d648
docs: describe ambiguous modules and the ambiguous coverage status
SizzleUnrlsd Sep 28, 2026
ab3f684
test: keep an unreached advisory under investigation over ambiguous m…
SizzleUnrlsd Sep 28, 2026
25d8eeb
test: re-record regression snapshots for files sharing a module name
SizzleUnrlsd Sep 28, 2026
e274ee4
Merge branch 'fix/request-processor-scope' into fix/block-super
SizzleUnrlsd Sep 29, 2026
215aefb
Merge branch 'fix/block-super' into feat/entry-point-aliases
SizzleUnrlsd Sep 29, 2026
e15208b
Merge branch 'feat/entry-point-aliases' into fix/template-filter-over…
SizzleUnrlsd Sep 29, 2026
4570ce8
style: order the scope imports after the merge
SizzleUnrlsd Sep 29, 2026
ba3b6e8
Merge branch 'fix/template-filter-overrides' into fix/module-identity
SizzleUnrlsd Sep 29, 2026
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## Unreleased

### Analysis

- Two files with the same Python module name, `a/app.py` and `z/app.py` outside any package, no longer overwrite each other: both are discovered, analysed and covered whatever the order they are found in. The engine tells them apart by their path from the root as a module name, `a.app` and `z.app`, in the module graph, the summary index, the cache keys and what project plugins see; a unique name stays what it was. An import of such a name resolves to the file in the importer's own root only, and one no root resolves is not followed. Each colliding file carries an `ambiguous-module` note and is covered as `ambiguous`, since a model or a route naming `python.app.main` applies to every definition of that symbol, and so does an importer whose import stayed unresolved; a VEX statement over such a project stays `under_investigation` (#182).

### Precision

- A `TEMPLATES` assigned inside a function or a class no longer counts as Django settings: only a module-level assignment can activate the `request` context processor, and only a mention of the module's `TEMPLATES` elsewhere leaves that activation uncertain. A function-local `TEMPLATES`, called or not, used to enable the implicit request source, upgrading a template filter finding to exploitable (#184).
- A template block that renders `{{ block.super }}` keeps the parent block's content, so the filters the parent applies stay fed by the render context, with the parent template as the place of the call; a block without it still replaces the parent's whole block, and a chain of templates keeps exactly the parents each level renders. The parent's filters used to drop out of the exploitable flow as soon as a child defined the block (#185).
- A project tag library registering a filter of a built-in's name replaces the built-in in every Django template loading it before applying the filter, as Django's parser does: `{% load custom %}{{ bio|striptags }}` calls the project's `striptags` when `app/templatetags/custom.py` registers one, so it is no longer a reachable or exploitable call to `django.template.defaultfilters.striptags`; the project's function is ordinary code, analysed as any other. A load affects only the filters after it, a later load overrides an earlier one, `{% load striptags from custom %}` replaces only what it names, and Django's own libraries change nothing. The analyzer reads the registrations on `register`, a `django.template.Library` however it is imported, in decorator and call form, at module level or under `if`, `try` or a function. Where it cannot tell what a library registers — no such library file, one it cannot read or parse, a name not written as a constant, `register` passed elsewhere or filled through `filters=` or `register.filters`, or two apps giving the name and registering different filters — a filter of that name applied under the load is neither the built-in nor certainly replaced: no call is claimed, and the load counts among the templates the analyzer could not read (`app/templates/app/profile.html:1 loads 'custom', not read`), so an advisory naming the filter stays `under_investigation` in the VEX document. The settings' `OPTIONS["libraries"]` and `OPTIONS["builtins"]` are not read (#183).

### Plugins

- An `EntryPoint` naming a project function or class applies through a module-level alias of it: `main = actual` makes `python.app.handlers.main` name `actual`, so a deployment declaring `handlers.main` reaches the function doing the work. An alias is an assignment of one name to another at module level, followed transitively and in source order, in a conditional or a `try` as well; a name bound inside a function or class, or to a call, an import or anything but a name, is no alias, and the engine still infers nothing from a name. A model naming a method wins over one naming its class, whichever name it uses.

## 0.18.0 (2026-09-28)

### Precision
Expand Down
14 changes: 10 additions & 4 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ resolved through imports and aliases, so `python.os.system` covers `os.system`,
symbol, so a chain such as `sqlite3.connect(p).cursor().execute` is
`python.sqlite3.connect.cursor.execute`, and `app = Flask(__name__)` gives `app.route` the
symbol `python.flask.Flask.route`. Builtins are `python.builtins.<name>`. Symbols are
`SymbolId` values from `coretrace_python.semantic.symbols`.
`SymbolId` values from `coretrace_python.semantic.symbols`. A function of the project is
`python.` followed by its module's import name and its qualified name,
`python.app.handlers.main`; two files with the same import name, `a/app.py` and
`z/app.py` outside any package, define the same symbols, so a model naming
`python.app.main` names the function of each.

## Kinds of plugin

Expand Down Expand Up @@ -260,9 +264,11 @@ when the models make it one. `ctx.call_graph(module)` gives each function's call
(`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`).
their `names`, the filters of Django's own libraries they apply as `calls` (each a
`FilterCall(symbol, span)`, the function behind the filter and where the template
applies it; a filter a project tag library the template loads replaces is no call),
and where they name a template the analyzer cannot read, or load a library it cannot
read while applying a filter of that library's name (`unread`).
The shipped `vulnerable-dependency`, `reachable-vulnerability` and `dependency-policy`
plugins are of this kind.

Expand Down
58 changes: 48 additions & 10 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ coretrace-python-analyzer [--check | --emit-ir [--ssa]] [options] [path]
## What is analysed

Given a directory, every `.py` file below it is a module of one project, named after
its package (`app/views.py` is `app.views`). Hidden directories, `node_modules`,
its package (`app/views.py` is `app.views`). Two files that would share a name, `a/app.py`
and `z/app.py` outside any package, are told apart by their path from the root, `a.app`
and `z.app`, and reported: each carries an `ambiguous-module` note, since a symbol
`python.app.main` may name a function of either and an import of `app` resolves only
from the importer's own directory, and each is covered as `ambiguous` rather than
analysed. An import of such a name that no file of the importer's own directory answers
is noted where it is written and not followed. Hidden directories, `node_modules`,
`__pycache__`, `build`, `dist` and any virtual environment, recognised by its
`pyvenv.cfg` whatever its name, are skipped. Dependency files at the root
(`requirements*.txt`, `pyproject.toml`, `poetry.lock`, `uv.lock`) are read into a
Expand Down Expand Up @@ -261,7 +267,9 @@ the directory of the checked file; a path outside it is printed as it is.
}
```

`root` is the directory the paths are relative to.
`root` is the directory the paths are relative to. A file's `status` is `analysed`,
`ambiguous` (analysed, but another file has its module name, see above), `syntax-error`
or `unreadable`; only `analysed` files count in `files_analysed`.

A directory check also lists, in `tool.components`, what the result was produced with
besides the engine and the sources: each plugin it loaded, by manifest name and version,
Expand Down Expand Up @@ -372,6 +380,28 @@ Django. Most filters are functions of the same name in `django.template.defaultf
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`).

A project tag library, a module of a `templatetags` directory, that registers a filter
of that name replaces the built-in in every template loading it, as Django's parser
does: `{% load custom %}{{ bio|striptags }}` calls the project's function when
`custom.py` registers a `striptags` filter, which is no call to the built-in; the
project's function is ordinary code, whose own calls are analysed as any other. A
load affects only the filters applied after it in the file, a later load overrides an
earlier one, `{% load striptags from custom %}` replaces only the filters it names, and
Django's own libraries (`static`, `i18n`, `l10n`, `tz`, `cache`, `humanize`) change
nothing. The analyzer reads the registrations on the library's `register`, a
`django.template.Library` however it is imported: `@register.filter`,
`@register.filter("name")`, `@register.filter(name="name", is_safe=True)`,
`register.filter("name", function)` and `register.filter(function)`, at module level or
under `if`, `try` or a function. Where it cannot tell what a library registers, the
filter is neither the built-in nor certainly replaced: a library found in no
`templatetags` directory (one an installed package ships, or that the settings'
`OPTIONS["libraries"]` or `OPTIONS["builtins"]` name, which are not read), a file it
cannot read or parse, a name given by a variable or unpacked arguments, `register`
passed to another function, filled through `filters=` or `register.filters`, or bound
to anything but a `Library`, or two apps giving the same library name and registering
different filters. The analyzer then claims no call, and counts the template among those
it could not read (below) when a filter of the library's name is applied under the load.

Attacker input in the render context makes the filter exploitable at the render call:
`render(request, "app/profile.html", {"bio": request.POST["bio"]})` with
`{{ bio|striptags }}`. The analyzer links a context value to a filter only where it is
Expand All @@ -386,7 +416,8 @@ certain of it:
arguments of the filters before it, or the output of a `{% filter %}` block. A name a
tag binds shadows the context, and `{% include ... only %}` passes none of it.
- The filter renders: a template extending another renders only its blocks, which
replace the blocks of that name in what it extends.
replace the blocks of that name in what it extends, unless the block renders
`{{ block.super }}`, which keeps the parent's content and the filters it applies.
- One file has the template's name; of two files of the same name, which one renders
depends on the loaders.

Expand All @@ -399,9 +430,12 @@ With the `django.template.context_processors.request` context processor, a templ
rendered with the request reads it as `request`: `{{ request.GET.q|striptags }}` is
exploitable at the render call too. The analyzer counts it only where all of these hold:

- The project's settings assign `TEMPLATES` a literal list, every `DjangoTemplates`
engine of it lists the processor in a literal `context_processors`, and no other code
of the project names `TEMPLATES`. A mere mention of the processor proves nothing.
- The project's settings assign `TEMPLATES` a literal list at module level, every
`DjangoTemplates` engine of it lists the processor in a literal `context_processors`,
and no other code of the project names the module's `TEMPLATES`, or a `TEMPLATES` it
does not bind, as a star import gives. A `TEMPLATES` bound in a function or a class
is another name: it configures nothing and changes nothing.
A mere mention of the processor proves nothing.
- The render call passes the request: `render` and `TemplateResponse` always do,
`render_to_string` when given `request`, `SimpleTemplateResponse` never.
- The render's context is certain, absent or a dict literal as above, so it cannot hide
Expand Down Expand Up @@ -502,10 +536,14 @@ A template the analyzer cannot read may apply any filter. The project names one
`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.
too, and so does a template loading a tag library the analyzer cannot read, or find,
or finds in several apps registering different filters, where it then applies a filter
of a built-in's name, since the library may replace it. 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`, `app/templates/app/profile.html:1 loads 'crispy_forms_tags', not found`).
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
Expand Down
Loading
Loading