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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Precision

- The SSRF sinks of Requests and httpx read the destination only: the URL (the first argument, the second after the method for `request` and `stream`, or `url=`) or the prepared request `send` takes. Attacker data in the body, the JSON, the headers or the query parameters is no longer a server-side request forgery. `Sink` gains `keywords` next to `positions`, and function summaries keep the name of each keyword argument, so a project function forwarding `url=` still reaches the sink; the cache format is bumped (#128).
- `render_to_string` escapes what it renders where the analyzer can establish it: on a directory check, it reads the project's Django templates, and a template escapes when neither it nor what it extends or includes marks output safe, uses a tag, filter or library beyond Django's own, or prints a variable where HTML escaping does not protect it, and no settings turn autoescaping off. Data reaching a response through such a template is no longer `xss`; any other template keeps the flow reported. `TemplateRender` declares such rendering calls, and the escaped templates are part of the cache key (#130).

## 0.6.0 (2026-09-24)

Expand Down
1 change: 1 addition & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,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). |
| `Validator(symbol, kinds=ALL, argument=0)` | A callable whose truth proves its argument safe, such as `re.fullmatch`; a flow guarded by it is refuted. |
| `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
25 changes: 24 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ a sanitizer for that kind of sink.
| `sql-injection` | A database statement (`cursor.execute`, SQLAlchemy `text`, Django `raw`, …). Parameters of a parameterised query are not statements. |
| `path-traversal` | A file system path (`open`, `send_file`, `os.remove`, …). |
| `ssrf` | The URL of an HTTP client request (Requests, httpx, `urllib`), by position or as `url=`; the body, headers and query parameters are not the destination. |
| `xss` | An HTTP response body built without escaping. |
| `xss` | An HTTP response body built without escaping. A Django template rendered by `render_to_string` escapes, where the analyzer can establish it (below). |
| `insecure-deserialization` | `pickle`, `marshal`, `dill`, `jsonpickle`, `shelve`, and the YAML loaders (`yaml.load`, `load_all`, `full_load`, `unsafe_load` and their several-document versions); `yaml.load` and `load_all` given a safe loader (`SafeLoader`, `BaseLoader`) are not. |
| `open-redirect` | An HTTP redirect target. |
| `plaintext-credential-storage` | A parameter named like a password stored in a database without hashing. Medium confidence, since a name is a hint. |
Expand All @@ -122,6 +122,29 @@ numeric value proven by `int()`, `len()` or bounded arithmetic, a validator such
or an authorization decorator such as `login_required`, makes it a hotspot reported at
medium confidence. The verdict and its evidence are in the finding's metadata.

Django's `render_to_string(name, context)` returns HTML in which autoescaping escaped
every variable, so data reaching a response through it is no `xss`, if the template really
escapes. On a directory check, the analyzer reads the project's templates to decide it.

It establishes escaping only when all of the following hold:

- It finds every template of that name under a `templates` directory, by the path Django
uses (`app/page.html`), and everything those templates extend or include.
- None of them marks output safe: `|safe`, `|safeseq` or `{% autoescape off %}`.
- None of them uses a tag or filter beyond Django's own, or loads a library beyond its
built-ins (`static`, `i18n`, `l10n`, `tz`, `cache`, `humanize`).
- None of them places a variable where HTML escaping does not protect it: a `<script>`
or `<style>` block, an event handler (`onclick="…"`), a `style` attribute, a URL
attribute not fixed by a relative or `http(s)://host/` prefix, an unquoted attribute,
or an attribute name.
- No Python file of the project sets `autoescape` to `False`.

The analyzer never assumes escaping where it cannot read the template. That covers a
template named by an expression, a template found through a `DIRS` entry not named
`templates`, one shipped by an installed package, one rendered through `get_template`,
and Flask's Jinja2 templates. The flow is then reported as before. A single-file check
reads no template.

### Dangerous API usage

| Rule | Reported call |
Expand Down
2 changes: 2 additions & 0 deletions src/coretrace_python/bundled/models/django/django_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
Source,
SuffixSink,
TaintKind,
TemplateRender,
TypedParameter,
)

Expand Down Expand Up @@ -119,6 +120,7 @@ class DjangoModels(ModelPlugin):
Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
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")),
Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML),
*(AuthorizationGuard(_sym(decorator), label) for decorator, label in _AUTHORIZATION_DECORATORS),
# ``urlpatterns = [path('login/', views.log_in)]``: the referenced view is an
Expand Down
21 changes: 19 additions & 2 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,15 @@
from coretrace_python.taint import (
EntryPoint,
EntryPointAnalysis,
EscapedTemplates,
ModelTable,
RegisteredRoutes,
Routes,
SecurityModelAnalysis,
SecurityModelRegistry,
TaintAnalysis,
TaintKind,
escaped_templates,
registered_routes,
)

Expand Down Expand Up @@ -146,6 +148,7 @@
RefutationAnalysis,
DependencyAnalysis,
RegisteredRoutes,
EscapedTemplates,
)


Expand Down Expand Up @@ -178,7 +181,14 @@ class ResultsEvicted(TransformationPass):

name: ClassVar[str] = "project.results-evicted"
preserves: ClassVar[frozenset[AnyAnalysis]] = frozenset(
{*SEMANTIC_ANALYSES, SecurityModelAnalysis, ProjectSummaries, DependencyAnalysis, RegisteredRoutes}
{
*SEMANTIC_ANALYSES,
SecurityModelAnalysis,
ProjectSummaries,
DependencyAnalysis,
RegisteredRoutes,
EscapedTemplates,
}
)

@classmethod
Expand Down Expand Up @@ -397,10 +407,12 @@ def analyze_project(
for name in sorted(analysable):
for symbol, registered in _routes_of(analysable[name]).items():
routes.setdefault(symbol, registered)
escaped = escaped_templates(root)
for manager in analysable.values():
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, escaped)

configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes)
configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
keys = module_keys(
graph,
{name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
Expand Down Expand Up @@ -441,6 +453,7 @@ def analyze_project(
encode_index(_seed(results, graph, component)),
advisory_paths,
_encode_routes(routes),
tuple(sorted(escaped)),
),
)
for component in pending
Expand Down Expand Up @@ -588,6 +601,7 @@ class _Batch:
seed: Mapping[str, Any]
advisory_paths: tuple[Path, ...] = ()
routes: tuple[tuple[str, str, str, int], ...] = ()
escaped: tuple[str, ...] = ()


def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
Expand All @@ -612,6 +626,7 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
manager.provide(SecurityModelAnalysis, models)
manager.provide(DependencyAnalysis, dependencies)
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, frozenset(batch.escaped))
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected)
return {name: encode(entry) for name, entry in results.items()}
Expand Down Expand Up @@ -639,6 +654,7 @@ def _configuration_key(
advisories: tuple[Advisory, ...],
dependencies: DependencyGraph,
routes: Routes | None = None,
escaped: frozenset[str] = frozenset(),
) -> str:
"""Everything a module's results depend on besides the project sources (§11)."""

Expand All @@ -658,6 +674,7 @@ def _configuration_key(
repr(dependencies.requirements),
repr(dependencies.errors),
repr(_encode_routes(routes or {})),
repr(sorted(escaped)),
)


Expand Down
5 changes: 5 additions & 0 deletions src/coretrace_python/taint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,18 @@
Source,
SuffixSink,
TaintKind,
TemplateRender,
TypedParameter,
Validator,
)
from coretrace_python.taint.routes import RegisteredRoutes, Routes, registered_routes
from coretrace_python.taint.templates import EscapedTemplates, escaped_templates

__all__ = [
"AuthorizationGuard",
"EntryPoint",
"EntryPointAnalysis",
"EscapedTemplates",
"Model",
"ModelError",
"ModelTable",
Expand All @@ -52,8 +55,10 @@
"TaintFacts",
"TaintFlow",
"TaintKind",
"TemplateRender",
"TypedParameter",
"Validator",
"escaped_templates",
"propagate_taint",
"registered_routes",
]
19 changes: 18 additions & 1 deletion src/coretrace_python/taint/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
TaintKind,
)
from coretrace_python.taint.routes import RegisteredRoutes, Routes
from coretrace_python.taint.templates import EscapedTemplates


@dataclass(frozen=True)
Expand Down Expand Up @@ -152,9 +153,12 @@ def __init__(
heap: HeapFacts | None = None,
seeds: Mapping[HeapLocation, Taint] | None = None,
module: str = "",
escaped: frozenset[str] = frozenset(),
) -> None:
self.name = name
self.module = module
# As call sites record a constant argument: the way Python writes it.
self.escaped = frozenset(repr(name) for name in escaped)
self.function = function
self.models = models
self.graph = graph
Expand Down Expand Up @@ -410,13 +414,23 @@ def external(
sanitizer = self.models.sanitizer(symbol)
if sanitizer is not None:
return everything.without(sanitizer.kinds)
if self.renders_escaped(symbol, given):
return everything.without(TaintKind.HTML)
# A method on a tainted object returns tainted data (``request.args.get``).
everything = everything.join(state.get(call.callee, Taint.none()))
source = self.models.source_covering(symbol)
if source is not None:
return everything.join(Taint(source.kinds, frozenset({source})))
return everything

def renders_escaped(self, symbol: SymbolId, given: Arguments) -> bool:
"""Whether the call renders, by a name written in the call, a template the
project shows escaping everything it renders."""

render = self.models.template_render(symbol)
named = given.given(render.keyword, render.position) if render is not None else None
return named is not None and named[0] and named[1] in self.escaped

def guarded(self, sink: Sink | None, arguments: Arguments) -> Sink | None:
"""The sink as this call makes it: an argument declared safe (``yaml.load`` with
``Loader=SafeLoader``) takes off the kinds it covers, and no kind left, no sink."""
Expand Down Expand Up @@ -802,8 +816,9 @@ def propagate_taint(
heap: HeapFacts | None = None,
seeds: Mapping[HeapLocation, Taint] | None = None,
module: str = "",
escaped: frozenset[str] = frozenset(),
) -> TaintFacts:
problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module)
problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module, escaped)
solution = solve(problem, cfg)
taints: dict[Key, Taint] = {}
flows: list[TaintFlow] = []
Expand Down Expand Up @@ -831,6 +846,7 @@ class TaintAnalysis(FunctionAnalysis[TaintFacts]):
ProjectSummaries,
HeapAnalysis,
RegisteredRoutes,
EscapedTemplates,
}
)

Expand Down Expand Up @@ -867,6 +883,7 @@ def sources_of(member: nodes.Function) -> Mapping[int, Source]:
heap,
seeds,
ctx.module.name,
ctx.get(EscapedTemplates),
)


Expand Down
20 changes: 20 additions & 0 deletions src/coretrace_python/taint/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,17 @@ class Validator:
argument: int = 0


@dataclass(frozen=True)
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."""

symbol: SymbolId
position: int = 0
keyword: str = "template_name"


@dataclass(frozen=True)
class AuthorizationGuard:
"""A decorator, or a condition, that restricts who reaches the code behind it; a
Expand All @@ -208,6 +219,7 @@ class AuthorizationGuard:
| RouteRegistrar
| SuffixSink
| SafeArgument
| TemplateRender
)


Expand All @@ -224,6 +236,7 @@ class ModelTable:
route_registrars: tuple[RouteRegistrar, ...] = ()
suffix_sinks: tuple[SuffixSink, ...] = ()
safe_arguments: tuple[SafeArgument, ...] = ()
template_renders: tuple[TemplateRender, ...] = ()
_by_symbol: dict[type[Model], dict[SymbolId, Model]] = field(
init=False, repr=False, compare=False
)
Expand All @@ -239,6 +252,7 @@ def __post_init__(self) -> None:
AuthorizationGuard: {m.symbol: m for m in self.authorizations},
RouteRegistrar: {m.symbol: m for m in self.route_registrars},
SafeArgument: {m.symbol: m for m in self.safe_arguments},
TemplateRender: {m.symbol: m for m in self.template_renders},
}
object.__setattr__(self, "_by_symbol", MappingProxyType(index))

Expand Down Expand Up @@ -305,6 +319,7 @@ def extended(self, *sinks: Sink) -> ModelTable:
self.route_registrars,
self.suffix_sinks,
self.safe_arguments,
self.template_renders,
)

def sanitizer(self, symbol: SymbolId) -> Sanitizer | None:
Expand All @@ -315,6 +330,10 @@ def safe_argument(self, symbol: SymbolId) -> SafeArgument | None:
found = self._by_symbol[SafeArgument].get(symbol)
return found if isinstance(found, SafeArgument) else None

def template_render(self, symbol: SymbolId) -> TemplateRender | None:
found = self._by_symbol[TemplateRender].get(symbol)
return found if isinstance(found, TemplateRender) else None


class SecurityModelRegistry:
"""Mutable collection point for the models plugins register."""
Expand Down Expand Up @@ -361,6 +380,7 @@ def freeze(self) -> ModelTable:
route_registrars=tuple(m for m in models if isinstance(m, RouteRegistrar)),
suffix_sinks=tuple(m for m in models if isinstance(m, SuffixSink)),
safe_arguments=tuple(m for m in models if isinstance(m, SafeArgument)),
template_renders=tuple(m for m in models if isinstance(m, TemplateRender)),
)


Expand Down
Loading
Loading