diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f04f2..fba42dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### 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). + +### 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 diff --git a/docs/usage.md b/docs/usage.md index 35c8e90..9a71543 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -386,7 +386,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. @@ -399,9 +400,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 diff --git a/src/coretrace_python/engine.py b/src/coretrace_python/engine.py index b877096..2d70a21 100644 --- a/src/coretrace_python/engine.py +++ b/src/coretrace_python/engine.py @@ -447,7 +447,8 @@ def analyze_project( templates = project_templates(root) clearing = models.clearing(escaped) template_calls = models.template_calls( - templates.filters, request_processor(modules[name] for name in sorted(analysable)) + templates.filters, + request_processor((modules[name], analysable[name].get(ScopeAnalysis)) for name in sorted(analysable)), ) for manager in analysable.values(): manager.provide(RegisteredRoutes, routes) diff --git a/src/coretrace_python/semantic/scopes.py b/src/coretrace_python/semantic/scopes.py index 9f4a3b2..4120871 100644 --- a/src/coretrace_python/semantic/scopes.py +++ b/src/coretrace_python/semantic/scopes.py @@ -113,8 +113,8 @@ def scope(self, scope_id: ScopeId) -> Scope: def children(self, scope_id: ScopeId) -> tuple[Scope, ...]: return self._children[scope_id] - def scope_for(self, node: nodes.Function | nodes.Class | nodes.Comprehension) -> Scope: - """Return the scope introduced by a definition or comprehension node.""" + def scope_for(self, node: nodes.Function | nodes.Class | nodes.Lambda | nodes.Comprehension) -> Scope: + """Return the scope introduced by a definition, lambda or comprehension node.""" return self._scopes[self._spans[node.span]] diff --git a/src/coretrace_python/taint/engine.py b/src/coretrace_python/taint/engine.py index 0430201..f33ea32 100644 --- a/src/coretrace_python/taint/engine.py +++ b/src/coretrace_python/taint/engine.py @@ -836,7 +836,9 @@ def function_entry_point( ) -> 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, a registration elsewhere (``routes``), or - a model naming the function, or its class, by its own project symbol.""" + a model naming the function, or its class, by its own project symbol or by a + module-level alias of it (``main = actual`` names ``actual`` as ``main``). A model + naming the method wins over one naming its class, whichever name it uses.""" owner = _owner(module, function) enclosing_function = _enclosing(module, function) @@ -844,12 +846,16 @@ def function_entry_point( # ``app = Flask(__name__)`` inside ``create_app``: routes defined there resolve. instances = {**(instances or {}), **local_instances(enclosing_function, scopes, symbols)} entry = entry_point_of(function, models, scopes, symbols, owner, instances, project) - if entry is None: - qualified = function.name if owner is None else f"{owner.name}.{function.name}" - entry = _declared(project_symbol(module.name, qualified), models, routes) - if entry is None and owner is not None: - entry = _declared(project_symbol(module.name, owner.name), models, routes) - return entry + if entry is not None: + return entry + defined = function if owner is None else owner + names = (defined.name, *_aliases(module, defined.name)) + qualified = names if owner is None else (*(f"{name}.{function.name}" for name in names), *names) + for name in qualified: + entry = _declared(project_symbol(module.name, name), models, routes) + if entry is not None: + return entry + return None def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> EntryPoint | None: @@ -860,6 +866,43 @@ def _declared(symbol: SymbolId, models: ModelTable, routes: Routes | None) -> En return registered if registered is not None else models.entry_point(symbol) +def _aliases(module: nodes.Module, name: str) -> Iterable[str]: + """The module-level names bound to ``name`` by assigning one name to another, + transitively and in source order: ``handler = actual`` then ``main = handler`` make + both aliases of ``actual``. A name bound inside a function or class, or to anything + but a name (a call, an import), is no alias.""" + + aliases: dict[str, list[str]] = {} + for statement in _module_level(module.body): + if isinstance(statement, nodes.Assign) and isinstance(statement.target, nodes.Name) and isinstance(statement.value, nodes.Name): + aliases.setdefault(statement.value.identifier, []).append(statement.target.identifier) + found = {name} + pending = [name] + while pending: + for alias in aliases.get(pending.pop(0), ()): + if alias not in found: + found.add(alias) + pending.append(alias) + yield alias + + +def _module_level(body: Iterable[nodes.Statement]) -> Iterable[nodes.Statement]: + """The statements executed at module level: the body and what its compound + statements hold, not what a function or class defines.""" + + for statement in body: + if isinstance(statement, nodes.Function | nodes.Class): + continue + yield statement + for attribute in ("body", "orelse", "finalbody"): + nested = getattr(statement, attribute, None) + if isinstance(nested, tuple): + yield from _module_level(nested) + if isinstance(statement, nodes.Try): + for handler in statement.handlers: + yield from _module_level(handler.body) + + def _owner(module: nodes.Module, function: nodes.Function) -> nodes.Class | None: """The top-level class ``function`` is a method of, if any.""" diff --git a/src/coretrace_python/taint/templates.py b/src/coretrace_python/taint/templates.py index a41ed57..95056d1 100644 --- a/src/coretrace_python/taint/templates.py +++ b/src/coretrace_python/taint/templates.py @@ -44,6 +44,7 @@ TemplateFilter, discover_files, ) +from coretrace_python.semantic.scopes import ResolutionKind, ScopeId, ScopeTable from coretrace_python.semantic.symbols import SymbolId from coretrace_python.source import SourceId, SourceSpan from coretrace_python.taint.models import ModelTable @@ -131,6 +132,8 @@ # the request context processor adds gives (``request.GET``). _VARIABLE = re.compile(r"([A-Za-z_]\w*)(?:\.(\w+))?") _LITERALS = frozenset({"True", "False", "None"}) +# The variable a block renders to keep the parent's content, as ``_chain`` reads it. +_BLOCK_SUPER = "block.super" REQUEST_PROCESSOR = "django.template.context_processors.request" _DJANGO_ENGINE = "django.template.backends.django.DjangoTemplates" @@ -365,11 +368,13 @@ def maps(self, names: frozenset[str]) -> frozenset[str]: @dataclass(frozen=True) class _Template: - """What one template file applies and references, and the ``blocks`` it defines.""" + """What one template file applies and references, the ``blocks`` it defines, and + among them the ``extended`` ones, which render ``{{ block.super }}``.""" applications: tuple[_Application, ...] references: tuple[_Reference, ...] blocks: frozenset[str] + extended: frozenset[str] = frozenset() def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool: """Whether what stands in ``blocks`` renders when a template extending this one @@ -379,6 +384,13 @@ def rendered(self, blocks: tuple[str, ...], overridden: frozenset[str]) -> bool: extends = any(reference.extends for reference in self.references) return not overridden.intersection(blocks) and (bool(blocks) or not extends) + @property + def replaced(self) -> frozenset[str]: + """The blocks this template replaces in what it extends: those it defines + without rendering ``{{ block.super }}``, which keeps the parent's content.""" + + return self.blocks - self.extended + def project_templates(root: Path) -> ProjectTemplates: """What the templates under ``root`` call, and the templates they include or extend @@ -444,7 +456,7 @@ def _reaching( if reference.name is None: continue if reference.extends: - found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.blocks) + found = _reaching(reference.name, files, read, visiting | {name}, overridden | template.replaced) elif template.rendered(reference.blocks, overridden): found = _reaching(reference.name, files, read, visiting | {name}) else: @@ -463,6 +475,7 @@ def _read_scopes(text: str, source: SourceId) -> _Template: references: list[_Reference] = [] opened: list[str] = [] defined: set[str] = set() + extended: set[str] = set() scopes: list[dict[str, frozenset[str]]] = [{}] blocks: list[tuple[list[tuple[str, int, frozenset[str]]], set[str]]] = [] @@ -491,6 +504,8 @@ def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) -> for offset, expression, tag in _expressions(text): if not tag: head, chain = _chain(expression, offset) + if opened and _BLOCK_SUPER in head: + extended.add(opened[-1]) printed = apply(free(head), chain) for _, collected in blocks: collected |= printed @@ -546,7 +561,7 @@ def apply(head: frozenset[str], chain: list[tuple[str, int, frozenset[str]]]) -> elif "as" in words[1:-1]: target = words[words.index("as", 1) + 1] scopes[-1][target] = values.get(words[1], frozenset()) if name == "regroup" else frozenset() - return _Template(tuple(applications), tuple(references), frozenset(defined)) + return _Template(tuple(applications), tuple(references), frozenset(defined), frozenset(extended)) def _chain(expression: str, offset: int) -> tuple[frozenset[str], list[tuple[str, int, frozenset[str]]]]: @@ -578,29 +593,41 @@ def _through(bound: frozenset[str], name: str) -> frozenset[str]: return frozenset(".".join(f"{b}.{attribute}".split(".")[:2]) if attribute else b for b in bound) -def request_processor(modules: Iterable[nodes.Module]) -> bool: +def request_processor(modules: Iterable[tuple[nodes.Module, ScopeTable]]) -> bool: """Whether every Django template engine the project's settings configure runs the - ``request`` context processor, for certain: each assignment to ``TEMPLATES`` is a - literal list of literal engines, at least one of them ``DjangoTemplates``, and each - of those lists the processor in a literal ``context_processors``; no other code - names ``TEMPLATES``, which could change it. A mere mention of the processor proves - nothing.""" + ``request`` context processor, for certain: each module-level assignment to + ``TEMPLATES`` is a literal list of literal engines, at least one of them + ``DjangoTemplates``, and each of those lists the processor in a literal + ``context_processors``; no other code names the module's ``TEMPLATES``, or a + ``TEMPLATES`` it does not bind (a star import), which could change it. A + ``TEMPLATES`` bound in a function or a class is another name, which configures + nothing and changes nothing. A mere mention of the processor proves nothing.""" assignments = mentions = 0 - for module in modules: - stack: list[Node] = list(module.body) - while stack: - node = stack.pop() - stack.extend(children(node)) + for module, scopes in modules: + for node, scope in _scoped(module, scopes): if isinstance(node, nodes.Name) and node.identifier == _ENGINES_SETTING: - mentions += 1 - elif isinstance(node, nodes.Assign) and _names_engines(node.target): + if scopes.resolve(scope, node.identifier).kind in (ResolutionKind.GLOBAL, ResolutionKind.UNBOUND): + mentions += 1 + elif isinstance(node, nodes.Assign) and _names_engines(node.target) and scope == scopes.module_scope.id: if not _runs_request_processor(node.value): return False assignments += 1 return assignments > 0 and mentions == assignments +def _scoped(module: nodes.Module, scopes: ScopeTable) -> Iterator[tuple[Node, ScopeId]]: + """Every node of ``module`` with the scope it stands in.""" + + stack: list[tuple[Node, ScopeId]] = [(node, scopes.module_scope.id) for node in module.body] + while stack: + node, scope = stack.pop() + yield node, scope + if isinstance(node, nodes.Function | nodes.Lambda | nodes.Class | nodes.Comprehension): + scope = scopes.scope_for(node).id + stack.extend((child, scope) for child in children(node)) + + def _names_engines(target: nodes.Expression) -> bool: return isinstance(target, nodes.Name) and target.identifier == _ENGINES_SETTING diff --git a/tests/test_block_super.py b/tests/test_block_super.py new file mode 100644 index 0000000..b5a4e63 --- /dev/null +++ b/tests/test_block_super.py @@ -0,0 +1,155 @@ +"""``{{ block.super }}`` keeps the parent block's filters in the render context flow. + +A template extending another renders only its blocks, which replace the blocks of that +name in the parent. A block that renders ``{{ block.super }}`` extends the parent's +block instead of replacing it: Django executes the parent's content, so the filters it +applies stay fed by the render context, with the parent template as the place of the +call. A block without ``block.super`` still replaces the parent's whole block, other +overridden blocks stay excluded, and a chain of templates keeps exactly the parents each +level renders, without duplicate findings. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories +from coretrace_python.findings import Severity +from coretrace_python.semantic.symbols import SymbolId + +STRIPTAGS = "python.django.template.defaultfilters.striptags" +ADVISORY = Advisory( + "CVE-2099-5701", + "django", + "<4.2.17", + "striptags is quadratic in its input", + Severity.MEDIUM, + entry_points=(AdvisoryEntryPoint(SymbolId(STRIPTAGS), "a template filter"),), +) +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 HttpRequest\n" + "from django.shortcuts import render\n\n" + "def profile(request: HttpRequest):\n" + " return render(request, 'app/profile.html', {'bio': request.POST['bio']})\n" +) +LINE = 5 +BASE = "app/templates/app/base.html" +MIDDLE = "app/templates/app/middle.html" +PAGE = "app/templates/app/profile.html" + + +def exploitable(root: Path, templates: dict[str, str]) -> list[tuple[int, str, str]]: + """The exploitable striptags calls the render makes: the render line, the template + holding the filter, and the line of the filter there.""" + + files = {"uv.lock": LOCK, "app/__init__.py": "", "app/views.py": VIEW, **templates} + for relative, text in files.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + (root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8") + findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS]).findings + return sorted( + (f.span.start_line, f.metadata["through"], f.metadata["sink_line"]) + for f in findings + if f.rule_id == "exploitable-vulnerability" and f.metadata["advisory"] == ADVISORY.id + ) + + +def test_a_block_rendering_block_super_keeps_the_parent_flow(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")] + + +def test_a_block_may_add_its_own_filters_beside_block_super(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}

{{ bio|striptags }}

\n{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1"), (LINE, "app/profile.html", "2")] + + +def test_a_replacement_without_block_super_still_excludes_the_parent(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}{{ bio }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [] + + +def test_another_block_does_not_come_back_through_one_block_super(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n{% block aside %}{{ bio|striptags }}{% endblock %}\n", + PAGE: ( + "{% extends 'app/base.html' %}\n" + "{% block body %}{{ block.super }}{% endblock %}\n" + "{% block aside %}{{ bio }}{% endblock %}\n" + ), + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")] + + +def test_block_super_at_each_level_keeps_the_whole_chain_once(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1"), (LINE, "app/middle.html", "2")] + + +def test_a_replacement_in_the_middle_cuts_the_chain_above_it(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ block.super }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/middle.html", "2")] + + +def test_a_replacement_at_the_end_cuts_the_whole_chain(tmp_path: Path) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + MIDDLE: "{% extends 'app/base.html' %}\n{% block body %}{{ block.super }}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/middle.html' %}\n{% block body %}{{ bio }}{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [] + + +@pytest.mark.parametrize( + "child_block", + ["{{ block.super|lower }}", "{% if bio %}{{ block.super }}{% endif %}", "{{ block.super }}{{ block.super }}"], + ids=["filtered", "conditional", "twice"], +) +def test_block_super_counts_however_it_is_rendered(tmp_path: Path, child_block: str) -> None: + templates = { + BASE: "{% block body %}{{ bio|striptags }}{% endblock %}\n", + PAGE: "{% extends 'app/base.html' %}\n{% block body %}" + child_block + "{% endblock %}\n", + } + + assert exploitable(tmp_path, templates) == [(LINE, "app/base.html", "1")] diff --git a/tests/test_entry_point_aliases.py b/tests/test_entry_point_aliases.py new file mode 100644 index 0000000..88b38da --- /dev/null +++ b/tests/test_entry_point_aliases.py @@ -0,0 +1,160 @@ +"""A symbol naming an alias of a project function names that function. + +A deployment declaration names its handler as ``app.main``, and the module may define +``main`` as an alias, ``main = actual``, of the function that does the work. An +``EntryPoint`` naming ``python.app.main`` then makes ``actual`` the entry point, with the +inputs the model gives, as it would a function named ``main``. An alias is a module-level +assignment of one name to another, followed transitively; a class alias names the +class's methods the same way. A name bound to anything else, a call or an imported name, +is no alias: the model names nothing through it, and an integration reporting declared +handlers sees no entry point. +""" + +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 + +MANIFEST = ( + 'name = "declared-aliases"\nversion = "1.0.0"\nplugin_api = ">=1,<2"\nrequires = []\n' + 'provides = ["model.declared-aliases"]\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-aliases" + models: ClassVar[tuple[Model, ...]] = ( + EntryPoint(SymbolId("python.app.handlers.main"), "event", inputs=(0,)), + EntryPoint(SymbolId("python.app.handlers.Worker"), "event"), + EntryPoint(SymbolId("python.app.handlers.Jobs.run"), "event", inputs=(0,)), + ) +''' +ACTUAL = "import os\n\ndef actual(event, context):\n os.system(event['cmd'])\n os.system(context.function_name)\n\n" + + +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, handlers: str, **options: object) -> list[tuple[int, str]]: + root = project(tmp_path, {"app/handlers.py": handlers}) + return commands(engine.analyze_project(root, [engine.BUNDLED_PLUGINS, plugins(tmp_path)], **options).findings) # type: ignore[arg-type] + + +@pytest.mark.parametrize("jobs", [1, 2]) +def test_an_alias_of_a_function_names_that_function(tmp_path: Path, jobs: int) -> None: + assert analyze(tmp_path, ACTUAL + "main = actual\n", jobs=jobs) == [(4, "event")] + + +@pytest.mark.parametrize( + "aliases", + [ + "handler = actual\nmain = handler\n", + "main = handler = actual\n", + "if os.environ.get('DEBUG'):\n main = actual\nelse:\n main = actual\n", + "try:\n main = actual\nexcept NameError:\n pass\n", + ], + ids=["chained", "chained-in-one", "conditional", "guarded"], +) +def test_module_level_aliases_are_followed_transitively(tmp_path: Path, aliases: str) -> None: + assert analyze(tmp_path, ACTUAL + aliases) == [(4, "event")] + + +@pytest.mark.parametrize( + "aliases", + [ + "main = decorate(actual)\n", + "main = actual()\n", + "from app.other import main\n", + "def outer():\n main = actual\n", + "class Config:\n main = actual\n", + "main = 'actual'\n", + ], + ids=["a-call", "a-result", "an-import", "in-a-function", "in-a-class", "a-string"], +) +def test_a_name_bound_to_anything_else_is_no_alias(tmp_path: Path, aliases: str) -> None: + files = {"app/handlers.py": "def decorate(f):\n return f\n\n" + ACTUAL + aliases, "app/other.py": "main = 1\n"} + root = project(tmp_path, files) + + findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS, plugins(tmp_path)]).findings + + assert commands(findings) == [] + + +def test_an_alias_of_a_class_names_its_methods(tmp_path: Path) -> None: + handlers = ( + "import os\n\n" + "class Impl:\n" + " def start(self, event, context):\n" + " os.system(event['cmd'])\n" + " os.system(context.function_name)\n\n" + " def run(self, event, context):\n" + " os.system(event['cmd'])\n" + " os.system(context.function_name)\n\n" + "Worker = Impl\n" + "Jobs = Impl\n" + ) + + # ``Worker`` makes every parameter of every method input; ``Jobs.run``, naming the + # method, wins over it for ``run`` and makes only the event input. + assert analyze(tmp_path, handlers) == [(5, "event"), (6, "event"), (9, "event")] + + +def test_a_class_alias_alone_limits_the_inputs_it_declares(tmp_path: Path) -> None: + handlers = ( + "import os\n\n" + "class Impl:\n" + " def run(self, event, context):\n" + " os.system(event['cmd'])\n" + " os.system(context.function_name)\n\n" + "Jobs = Impl\n" + ) + + assert analyze(tmp_path, handlers) == [(5, "event")] + + +def test_a_project_plugin_sees_the_aliased_function_as_the_entry_point(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[f"{module}:{function.name}"] = function.entry_point + return () + + root = project(tmp_path, {"app/handlers.py": ACTUAL + "main = actual\n\ndef other(event, context):\n pass\n"}) + + engine.analyze_project(root, [engine.BUNDLED_PLUGINS, plugins(tmp_path)], plugins=[Peek()]) + + assert seen["app.handlers:actual"] == "event" + assert seen["app.handlers:other"] is None diff --git a/tests/test_settings_scope.py b/tests/test_settings_scope.py new file mode 100644 index 0000000..0a1ec5b --- /dev/null +++ b/tests/test_settings_scope.py @@ -0,0 +1,109 @@ +"""Only module-level settings configure Django. + +The request context processor is taken as active only from the settings a project +assigns at module level: a ``TEMPLATES`` assigned inside a function, called or not, +binds a local name and configures nothing, and so does a class attribute. A function +that reads or rebinds the module's ``TEMPLATES`` (through ``global``, or by mutating +it) may change the effective settings, so it leaves the activation uncertain, as any +other mention does. Uncertain activation leaves the filter reachable, neither +exploitable through the processor nor declared absent. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, dump_advisories +from coretrace_python.findings import Severity +from coretrace_python.semantic.symbols import SymbolId + +STRIPTAGS = "python.django.template.defaultfilters.striptags" +ADVISORY = Advisory( + "CVE-2099-5601", + "django", + "<4.2.17", + "striptags is quadratic in its input", + Severity.MEDIUM, + entry_points=(AdvisoryEntryPoint(SymbolId(STRIPTAGS), "a template filter"),), +) +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" } +""" +ENGINE = ( + "{'BACKEND': 'django.template.backends.django.DjangoTemplates', 'APP_DIRS': True, " + "'OPTIONS': {'context_processors': ['django.template.context_processors.request']}}" +) +VIEW = ( + "from django.http import HttpRequest\n" + "from django.shortcuts import render\n\n" + "def search(request: HttpRequest):\n" + " return render(request, 'app/search.html')\n" +) +PAGE = "app/templates/app/search.html" + + +def levels(root: Path, settings: str) -> list[str]: + files = { + "uv.lock": LOCK, + "app/__init__.py": "", + "app/settings.py": settings, + "app/views.py": VIEW, + PAGE: "{{ request.GET.q|striptags }}\n", + } + for relative, text in files.items(): + (root / relative).parent.mkdir(parents=True, exist_ok=True) + (root / relative).write_text(text, encoding="utf-8") + (root / "advisories.json").write_text(dump_advisories((ADVISORY,)), encoding="utf-8") + findings = engine.analyze_project(root, [engine.BUNDLED_PLUGINS]).findings + reached = ("reachable-vulnerability", "exploitable-vulnerability") + return sorted(f.metadata["level"] for f in findings if f.rule_id in reached and f.metadata["advisory"] == ADVISORY.id) + + +def test_module_level_settings_activate_the_processor(tmp_path: Path) -> None: + assert levels(tmp_path, f"TEMPLATES = [{ENGINE}]\n") == ["exploitable", "reachable"] + + +@pytest.mark.parametrize( + "settings", + [ + f"def never_called():\n TEMPLATES = [{ENGINE}]\n", + f"def configure():\n TEMPLATES = [{ENGINE}]\n return TEMPLATES\n\nconfigure()\n", + f"class Settings:\n TEMPLATES = [{ENGINE}]\n", + f"def build():\n templates = [{ENGINE}]\n return templates\n", + ], + ids=["uncalled-function", "called-function", "class-attribute", "other-local-name"], +) +def test_settings_bound_below_the_module_do_not_configure_django(tmp_path: Path, settings: str) -> None: + assert levels(tmp_path, settings) == ["reachable"] + + +@pytest.mark.parametrize( + "settings", + [ + f"TEMPLATES = [{ENGINE}]\n\ndef tweak():\n TEMPLATES[0]['OPTIONS']['context_processors'].pop(0)\n", + f"TEMPLATES = [{ENGINE}]\n\ndef reset():\n global TEMPLATES\n TEMPLATES = []\n", + f"TEMPLATES = [{ENGINE}]\n\ndef read():\n return TEMPLATES\n", + ], + ids=["mutated-in-a-function", "rebound-through-global", "read-in-a-function"], +) +def test_a_function_touching_the_module_settings_leaves_activation_uncertain(tmp_path: Path, settings: str) -> None: + assert levels(tmp_path, settings) == ["reachable"] + + +def test_a_local_name_beside_module_settings_changes_nothing(tmp_path: Path) -> None: + settings = f"TEMPLATES = [{ENGINE}]\n\ndef helper():\n TEMPLATES = []\n return TEMPLATES\n" + + assert levels(tmp_path, settings) == ["exploitable", "reachable"]