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

### Precision

- A sanitizer called inside a project function protects the flows through that function, for every caller. That covers a helper that escapes before answering, a view redirecting to `reverse(...)`, and a helper rendering a template the analyzer shows escaping. Function summaries keep, for each parameter, the taint kinds cleared on every path from it to an external call or to the return value. A path that skips the sanitizer, or a kind it does not clear, still reaches the sink. The cache format is bumped (#134).

## 0.7.0 (2026-09-25)

### Precision
Expand Down
2 changes: 1 addition & 1 deletion docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class BottleModels(ModelPlugin):
|---|---|
| `Source(symbol, label, kinds=ALL)` | A call or attribute whose value is attacker-controlled; `label` names it in messages (`http`, `stdin`, `argv`, `http-response`). `kinds` restricts what it can inject. |
| `Sink(symbol, kinds, positions=(), keywords=())` | A call dangerous for `kinds`. `positions` limits a kind to the given argument indexes, and `keywords` to the given keyword names; a restricted kind reaches only the arguments listed there. SQL sinks read the statement only, and the SSRF sinks of HTTP clients read the URL, by position or as `url=`. A starred argument or `**kwargs` reaches no restricted kind. |
| `Sanitizer(symbol, kinds)` | A call whose result is safe for `kinds`. |
| `Sanitizer(symbol, kinds)` | A call whose result is safe for `kinds`, including when the call is inside a project function the data goes through. |
| `EntryPoint(symbol, label, kinds=ALL)` | A decorator or a class base whose functions receive their parameters as `label` input, such as `flask.Flask.route`. |
| `TypedParameter(symbol, label, kinds=ALL)` | A class whose annotated parameters carry `label` input, such as Django's `HttpRequest`. |
| `NamedParameter(pattern, label, kinds)` | A regular expression on parameter names; matching parameters carry `kinds`, as `password` carries `CREDENTIAL`. |
Expand Down
6 changes: 5 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ confidence (`high`, `medium`, `low`).
### Taint flows

Reported when attacker-controlled data reaches a sensitive call without passing through
a sanitizer for that kind of sink.
a sanitizer for that kind of sink. The sanitizer may also sit inside a project function
the data goes through, like a helper that escapes before it answers, or a view that
redirects to `reverse(...)`. It then protects every caller, on every path through the
function where it is called. What a function stores into an object or a `nonlocal`
variable is not treated that way yet.

| Rule | Reached sink |
|---|---|
Expand Down
17 changes: 16 additions & 1 deletion src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from coretrace_python.interprocedural import (
Arguments,
CallSite,
Cleared,
ExternalCall,
ExternalSymbol,
FunctionSummary,
Expand All @@ -41,7 +42,7 @@
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan

CACHE_FORMAT = 9
CACHE_FORMAT = 10


@dataclass(frozen=True)
Expand Down Expand Up @@ -237,6 +238,8 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
"location": _encode_span(call.location),
"call_site": None if call.call_site is None else _encode_span(call.call_site),
"given": _encode_arguments(call.arguments),
"argument_cleared": [_encode_cleared(c) for c in call.argument_cleared],
"keyword_cleared": [_encode_cleared(c) for c in call.keyword_cleared],
}


Expand All @@ -249,9 +252,19 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
_decode_span(data["location"]),
None if site is None else _decode_span(site),
_decode_arguments(data["given"]),
tuple(_decode_cleared(c) for c in data["argument_cleared"]),
tuple(_decode_cleared(c) for c in data["keyword_cleared"]),
)


def _encode_cleared(cleared: Cleared) -> list[list[int]]:
return [[parameter, bits] for parameter, bits in cleared]


def _decode_cleared(data: Any) -> Cleared:
return tuple((_integer(parameter), _integer(bits)) for parameter, bits in data)


def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
return {
"name": summary.name,
Expand All @@ -275,6 +288,7 @@ def _encode_summary(summary: FunctionSummary) -> dict[str, Any]:
for w in summary.nonlocal_writes
],
"static": summary.static,
"return_cleared": _encode_cleared(summary.return_cleared),
}


Expand Down Expand Up @@ -303,6 +317,7 @@ def _decode_summary(data: Mapping[str, Any]) -> FunctionSummary:
for w in data["nonlocal_writes"]
),
bool(data["static"]),
_decode_cleared(data["return_cleared"]),
)


Expand Down
17 changes: 14 additions & 3 deletions src/coretrace_python/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
CallGraph,
CallGraphAnalysis,
CallSite,
ClearingAnalysis,
FunctionSummary,
ModuleFunction,
ModuleGraph,
Expand Down Expand Up @@ -149,6 +150,7 @@
DependencyAnalysis,
RegisteredRoutes,
EscapedTemplates,
ClearingAnalysis,
)


Expand Down Expand Up @@ -188,6 +190,7 @@ class ResultsEvicted(TransformationPass):
DependencyAnalysis,
RegisteredRoutes,
EscapedTemplates,
ClearingAnalysis,
}
)

Expand Down Expand Up @@ -246,7 +249,9 @@ def build_manager(
"""A manager with every engine analysis registered and the engine inputs provided."""

manager = _register_all(module)
manager.provide(SecurityModelAnalysis, (models or SecurityModelRegistry()).freeze())
table = (models or SecurityModelRegistry()).freeze()
manager.provide(SecurityModelAnalysis, table)
manager.provide(ClearingAnalysis, table.clearing())
manager.provide(ProjectSummaries, SummaryIndex())
manager.provide(RegisteredRoutes, _routes_of(manager))
return manager
Expand Down Expand Up @@ -294,7 +299,9 @@ def analyze_file(source: SourceFile, plugin_roots: Sequence[Path]) -> FileAnalys

manager = _register_all(build_hir(source))
registry = load_plugins(plugin_roots, manager)
manager.provide(SecurityModelAnalysis, plugin_models(loaded.plugin for loaded in registry))
table = plugin_models(loaded.plugin for loaded in registry)
manager.provide(SecurityModelAnalysis, table)
manager.provide(ClearingAnalysis, table.clearing())
manager.provide(ProjectSummaries, SummaryIndex())
manager.provide(RegisteredRoutes, _routes_of(manager))
findings, supported = _check_module(manager, tuple(loaded.plugin for loaded in registry))
Expand Down Expand Up @@ -408,9 +415,11 @@ def analyze_project(
for symbol, registered in _routes_of(analysable[name]).items():
routes.setdefault(symbol, registered)
escaped = escaped_templates(root)
clearing = models.clearing(escaped)
for manager in analysable.values():
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, escaped)
manager.provide(ClearingAnalysis, clearing)

configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
keys = module_keys(
Expand Down Expand Up @@ -622,11 +631,13 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
affected = affected_symbols(dependencies, advisories)
models = plugin_models(all_plugins).extended(*advisory_sinks(affected))
routes = _decode_routes(batch.routes)
escaped = frozenset(batch.escaped)
for manager in managers.values():
manager.provide(SecurityModelAnalysis, models)
manager.provide(DependencyAnalysis, dependencies)
manager.provide(RegisteredRoutes, routes)
manager.provide(EscapedTemplates, frozenset(batch.escaped))
manager.provide(EscapedTemplates, escaped)
manager.provide(ClearingAnalysis, models.clearing(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
8 changes: 8 additions & 0 deletions src/coretrace_python/interprocedural/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
project_symbol,
)
from coretrace_python.interprocedural.summaries import (
Cleared,
Clearing,
ClearingAnalysis,
ExternalCall,
FunctionSummary,
Mutation,
Expand All @@ -28,13 +31,17 @@
SummaryAnalysis,
SummaryIndex,
SummaryTable,
cleared_by,
)

__all__ = [
"Arguments",
"CallGraph",
"CallGraphAnalysis",
"CallSite",
"Cleared",
"Clearing",
"ClearingAnalysis",
"ExternalCall",
"ExternalSymbol",
"FunctionSummary",
Expand All @@ -51,6 +58,7 @@
"Target",
"UnknownTarget",
"build_module_graph",
"cleared_by",
"discover_files",
"discover_sources",
"project_symbol",
Expand Down
Loading
Loading