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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

### Advisories

- An `argument` condition may carry `present: true`: the argument being passed at all, whatever its value, is the condition, as a `\r` in `web.Response(reason=…)` needs `reason` given, constant or not. It names the argument by keyword or position as today and excludes `values` and `default`. A call giving the argument — positionally, by keyword, or through a `**` literal with constant keys, folded into the keywords — meets it even when the value is unknown, so attacker input there makes the call exploitable; a call surely not giving it is ruled out (`app:5 python.aiohttp.web.Response(reason absent)`) and a project passing only such calls stays `not_affected`; a call unpacking `*args` or a `**mapping` whose keys are not written leaves it pending review (#195).
- A curated entry point may declare `suffixes`, for receivers the engine cannot type: `Item.objects.annotate(...)` derives the project's own `python.app.models.Item.objects.annotate`, which the canonical `python.django.db.models.QuerySet.annotate` entry point never matches. A suffix matches any symbol of the project's own modules ending with a dot plus the suffix, on dot boundaries as a `SuffixSink` matches, never another package's symbol; a queryset chain is declared explicitly (`objects.filter.annotate`), and a suffix is at least two dot-separated names. The finding keeps the advisory's symbol as `entry_point` and the derived one as `symbol`; conditions, `attacker_arguments` and the ruled-out evidence of contradicted calls apply through a suffix as through the exact symbol. An exact advisory sink whose symbol a bundled suffix sink also matches (`objects.extra`) now composes its kinds with the suffix sink's instead of shadowing them (#197).
- An advisory condition of kind `sequence` says the flaw spans two calls on one receiver: the first call leaves state on the object the entry point — the second call — reuses. It names the `method` the first call must be, by canonical name or `.`-suffix, and, optionally, the argument that call must carry (`{"kind": "sequence", "method": "Session.get", "argument": "verify", "values": ["False"], ...}`), so `s = requests.Session(); s.get(url, verify=False); s.get(other)` marks the second `get` reachable with the prior call as evidence (requests CVE-2024-35195). A receiver followed whole — bound once to the result of a call or a `with` and used only to call its methods — with no matching call on every path to the entry call rules it out (`app.fetch:6 python.requests.Session.get(no prior Session.get on the receiver)`), and a project with only such calls stays `not_affected`. A receiver the engine cannot follow (a parameter, a module-level instance, one passed to another function or mutated through an attribute), a matching call only in a branch or textually later, an unpacking that leaves the first call's arguments undecided, or an entry call that itself carries the named argument — its own prior across the iterations of a loop — leaves the condition pending review (#194).

### Plugins

- `CallSite.prior_calls` records, for a receiver the engine follows whole, the other external calls on the same receiver (`PriorCall`: symbol, location, arguments, and whether it surely executes before this one), in source order; `CallGraph.prior_calls_at` reads it by location, and `check_conditions` takes it to decide `sequence` conditions. The cache format changes.

## 0.20.0 (2026-09-30)

### Advisories
Expand Down
8 changes: 6 additions & 2 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,12 @@ A model plugin may also carry `advisories`, a tuple of `Advisory` values
symbols, as the shipped `sample-advisories` plugin does. Requirements matching them are
reported, calls to the affected symbols become reachable vulnerabilities and tainted
calls become exploitable ones. An advisory's entry points carry `conditions`, of kind
`argument`, `host`, `semantic` or `keyword_name`, and `attacker_arguments`, decided as
the [usage guide](usage.md) describes.
`argument` (on the value, or with `present` on the argument being passed at all),
`host`, `semantic`, `keyword_name` or `sequence`, and `attacker_arguments`, decided as
the [usage guide](usage.md) describes. An entry point may also declare `suffixes`,
matched against the tail of a derived symbol of the project's own modules on dot
boundaries, as a `SuffixSink` matches, for receivers the engine cannot type (see the
usage guide).

### Reclassifying findings: `ProjectPlugin.refine`

Expand Down
74 changes: 74 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,29 @@ 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.

An entry point may declare `suffixes`, for receivers the engine cannot type. A project
reaches `python.django.db.models.QuerySet.annotate` through a manager on its own model,
`Item.objects.annotate(...)`, whose call derives the project's own symbol
`python.app.models.Item.objects.annotate` — `Item` is a project class, `objects` an
attribute the engine does not type — so the canonical entry point never matches. A
suffix matches any symbol of the project's own modules ending with a dot plus the
suffix:

```json
{"symbol": "python.django.db.models.QuerySet.annotate", "justification": "…",
"suffixes": ["objects.annotate", "objects.filter.annotate"]}
```

A queryset chain is declared explicitly: `Item.objects.filter(...).annotate(...)`
derives `…objects.filter.annotate`, which only the second suffix matches. A suffix is
at least two dot-separated names, so a bare method name, matching every project method
of that name, cannot be declared; another package's symbol, such as
`other_lib.objects.annotate`, never matches. The finding names the advisory's symbol as
`entry_point` and the derived one as `symbol`, and the entry point's conditions and
`attacker_arguments` apply through a suffix as through the exact symbol. A project
attribute chain genuinely ending with a declared suffix matches too — the vulnerable
version range and the conditions still gate what is reported.

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
Expand Down Expand Up @@ -471,6 +494,16 @@ a call passing a variable, or unpacking `*args` or `**kwargs`, leaves the condit
pending review. A `semantic` condition — the document's tags, the platform, the
template's origin — cannot be decided and is always pending review, never dropped.

An `argument` condition with `"present": true` says the argument being passed at all,
whatever its value, is the condition, as a `\r` in `web.Response(reason=…)` needs
`reason` given, constant or not: `{"kind": "argument", "text": "…", "argument":
"reason", "position": 1, "present": true}`, exclusive with `values` and `default`. A
call giving the argument — positionally, by keyword, or through a `**` literal with
constant keys, which is folded into the keywords — meets it even when the value is
unknown; a call surely not giving it does not reach the vulnerability and is ruled out
(`(reason absent)`); a call unpacking `*args` or a `**mapping` whose keys the engine
cannot read leaves it pending review.

A `host` condition says the attacker must choose the host of the URL passed as
`argument` (or at `position`), as credentials leaking to the host of a crafted URL
need: `{"kind": "host", "text": "…", "argument": "url", "position": 0}`. It is
Expand Down Expand Up @@ -514,6 +547,47 @@ Keys passed through a project wrapper stay pending, and `mapping.update(other)`,
taint. A literal the function fills after building it (`m = {}; m[alias] = expr`)
has keys the literal does not write, so the call is not ruled out.

A `sequence` condition says the flaw spans two calls on one receiver: a first call
leaves state on the object, and the entry point — the second call — reuses it, as
`Session.get(url, verify=False)` disables certificate verification for the pooled
connection a later `Session.get(other)` on the same session reuses (requests
CVE-2024-35195). It names the `method` the first call must be, by canonical name or
`.`-suffix, and, optionally, the argument that call must carry, read as an argument
condition reads it:

```json
{
"kind": "sequence",
"text": "a prior call on the same session disabled certificate verification",
"method": "Session.get",
"argument": "verify",
"values": ["False"]
}
```

The engine follows a receiver bound once in the analysed function to the result of a
call or a `with` and used only to call its methods. The outcome has three cases:

- A prior call on the same receiver, on every path to the entry call, matches the
method and carries a value in `values` (`default` for an absent argument): the
condition is met, and the evidence names that call's line.
- The receiver's whole lifetime is visible and no such prior call can precede — one
call alone on the receiver, or the two calls on different receivers: the condition
is contradicted, the call is ruled out
(`app.fetch:6 python.requests.Session.get(no prior Session.get on the receiver)`)
and a project with only such calls stays `not_affected`.
- Otherwise the condition stays pending review: a receiver the engine cannot follow —
a parameter, a module-level instance, one passed to another function or mutated
through an attribute such as `s.verify = False` — a prior call whose arguments a
`*`/`**` unpacking leaves undecided, or a flow reaching the entry point inside a
callee.

Only a prior call on every path to the entry call counts as before it: a matching
call in a branch that may not run, or a textually later one, leaves the condition
pending, since a loop may bring it before the entry call. A call that itself carries
what the condition requires of the first call is never ruled out either: across the
iterations of a loop it is its own prior.

Every dependency finding records the highest level of evidence established in its
`level` metadata: `declared` (the requirement allows a vulnerable version), `imported`
(a module of the package is imported somewhere), `reachable` (an entry point or affected
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from typing import ClassVar

from coretrace_python.analysis import AnyAnalysis
from coretrace_python.dependency import Advisory, DependencyAnalysis
from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, DependencyAnalysis
from coretrace_python.dependency.correlation import (
ConditionCheck,
affected_entries,
affected_symbols,
check_conditions,
evidence,
suffix_index,
)
from coretrace_python.findings import Confidence, Finding
from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
Expand All @@ -30,37 +32,36 @@ def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
affected = affected_symbols(ctx.dependencies, ctx.advisories)
if not affected:
return ()
suffixes = suffix_index(affected, ctx.modules)
findings: list[Finding] = []
for module in ctx.modules:
graph = ctx.call_graph(module)
for function in graph.functions:
for site in graph.sites(function):
if not isinstance(site.target, ExternalSymbol):
continue
for advisory in affected.get(site.target.symbol, ()):
entry = advisory.entry_point(site.target.symbol)
for advisory, entry in affected_entries(site.target.symbol, affected, suffixes):
if entry is not None and entry.read:
continue # a call reads its callee first: reported with the reads
check = check_conditions(entry, site.arguments)
check = check_conditions(entry, site.arguments, prior_calls=site.prior_calls)
if check.contradicted is not None:
continue
findings.append(_reached(advisory, site.target.symbol, site.location, function, check))
findings.append(_reached(advisory, site.target.symbol, site.location, function, check, entry))
# Reading ``request.form.get`` reads ``request.form``: one finding per entry
# point and function, where the function first reads it.
reported: set[tuple[Advisory, SymbolId]] = set()
for read in graph.reads(function):
for symbol in _read_through(read.symbol):
for advisory in affected.get(symbol, ()):
entry = advisory.entry_point(symbol)
for advisory, entry in affected_entries(symbol, affected, suffixes):
if entry is None or not entry.read or (advisory, symbol) in reported:
continue
reported.add((advisory, symbol))
check = check_conditions(entry, None)
findings.append(_reached(advisory, symbol, read.location, function, check))
findings.append(_reached(advisory, symbol, read.location, function, check, entry))
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))
for advisory, entry in affected_entries(call.symbol, affected, suffixes):
check = check_conditions(entry, None)
findings.append(_reached(advisory, call.symbol, call.span, None, check, entry))
return findings


Expand All @@ -73,7 +74,12 @@ def _read_through(symbol: SymbolId) -> Iterator[SymbolId]:


def _reached(
advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str | None, check: ConditionCheck
advisory: Advisory,
symbol: SymbolId,
span: SourceSpan,
function: str | None,
check: ConditionCheck,
entry: AdvisoryEntryPoint | None,
) -> Finding:
return Finding(
rule_id="reachable-vulnerability",
Expand All @@ -85,5 +91,5 @@ def _reached(
confidence=Confidence.HIGH,
span=span,
function=function,
metadata=evidence(advisory, symbol, "reachable", check),
metadata=evidence(advisory, symbol, "reachable", check, entry),
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

from coretrace_python.analysis import AnyAnalysis
from coretrace_python.dependency import DependencyAnalysis
from coretrace_python.dependency.correlation import affected_symbols, check_conditions, ruled_out
from coretrace_python.dependency.correlation import (
affected_entries,
affected_symbols,
check_conditions,
ruled_out,
suffix_index,
)
from coretrace_python.findings import Confidence, Finding
from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
from coretrace_python.plugins import ProjectContext, ProjectPlugin
Expand Down Expand Up @@ -57,15 +63,16 @@ def _ruled_out(ctx: ProjectContext) -> dict[str, list[str]]:
conditions: the evidence that the requirement, imported, is not reached there."""

affected = affected_symbols(ctx.dependencies, ctx.advisories)
suffixes = suffix_index(affected, ctx.modules)
excluded: dict[str, list[str]] = {}
for module in sorted(ctx.modules):
graph = ctx.call_graph(module)
for function in graph.functions:
for site in graph.sites(function):
if not isinstance(site.target, ExternalSymbol):
continue
for advisory in affected.get(site.target.symbol, ()):
check = check_conditions(advisory.entry_point(site.target.symbol), site.arguments)
for advisory, entry in affected_entries(site.target.symbol, affected, suffixes):
check = check_conditions(entry, site.arguments, prior_calls=site.prior_calls)
if check.contradicted is not None:
excluded.setdefault(advisory.id, []).append(ruled_out(module, site, check))
return excluded
18 changes: 17 additions & 1 deletion src/coretrace_python/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
ModuleGraph,
Mutation,
NonlocalWrite,
PriorCall,
SummaryIndex,
SymbolRead,
Target,
Expand All @@ -42,7 +43,7 @@
from coretrace_python.semantic.symbols import SymbolId
from coretrace_python.source import SourceId, SourceSpan

CACHE_FORMAT = 12
CACHE_FORMAT = 13


@dataclass(frozen=True)
Expand Down Expand Up @@ -351,18 +352,33 @@ def _encode_site(site: CallSite) -> dict[str, Any]:
"location": _encode_span(site.location),
"target": _encode_target(site.target),
"arguments": _encode_arguments(site.arguments),
"prior": None
if site.prior_calls is None
else [
[str(p.symbol), _encode_span(p.location), _encode_arguments(p.arguments), p.dominates]
for p in site.prior_calls
],
}


def _decode_site(data: Mapping[str, Any]) -> CallSite:
prior = data["prior"]
return CallSite(
_string(data["caller"]),
_decode_span(data["location"]),
_decode_target(data["target"]),
_decode_arguments(data["arguments"]),
None if prior is None else tuple(_decode_prior(entry) for entry in prior),
)


def _decode_prior(data: Any) -> PriorCall:
symbol, span, arguments, dominates = data
if not isinstance(dominates, bool):
raise TypeError(f"expected a boolean, got {dominates!r}")
return PriorCall(SymbolId(_string(symbol)), _decode_span(span), _decode_arguments(arguments), dominates)


def _encode_arguments(arguments: Arguments) -> dict[str, Any]:
return {
"positional": list(arguments.positional),
Expand Down
Loading
Loading