Skip to content

Commit ebbcb9f

Browse files
committed
Merge branch 'feat/advisory-entry-suffixes' into feat/sequence-conditions
# Conflicts: # CHANGELOG.md # docs/plugins.md # src/coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py # src/coretrace_python/dependency/advisories.py # src/coretrace_python/dependency/correlation.py # src/coretrace_python/dependency/graph.py # src/coretrace_python/engine.py
2 parents 8425a4d + dbc0885 commit ebbcb9f

12 files changed

Lines changed: 688 additions & 46 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Advisories
66

7+
- 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).
8+
- 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).
79
- 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).
810

911
### Plugins

‎docs/plugins.md‎

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

201205
### Reclassifying findings: `ProjectPlugin.refine`
202206

‎docs/usage.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,29 @@ it is reachable, called or not — `request.form['name']`, `for key in request.f
370370
`request.form.get('name')` all read it. A function reading it several times is reported
371371
once, where it first reads it.
372372

373+
An entry point may declare `suffixes`, for receivers the engine cannot type. A project
374+
reaches `python.django.db.models.QuerySet.annotate` through a manager on its own model,
375+
`Item.objects.annotate(...)`, whose call derives the project's own symbol
376+
`python.app.models.Item.objects.annotate` — `Item` is a project class, `objects` an
377+
attribute the engine does not type — so the canonical entry point never matches. A
378+
suffix matches any symbol of the project's own modules ending with a dot plus the
379+
suffix:
380+
381+
```json
382+
{"symbol": "python.django.db.models.QuerySet.annotate", "justification": "…",
383+
"suffixes": ["objects.annotate", "objects.filter.annotate"]}
384+
```
385+
386+
A queryset chain is declared explicitly: `Item.objects.filter(...).annotate(...)`
387+
derives `…objects.filter.annotate`, which only the second suffix matches. A suffix is
388+
at least two dot-separated names, so a bare method name, matching every project method
389+
of that name, cannot be declared; another package's symbol, such as
390+
`other_lib.objects.annotate`, never matches. The finding names the advisory's symbol as
391+
`entry_point` and the derived one as `symbol`, and the entry point's conditions and
392+
`attacker_arguments` apply through a suffix as through the exact symbol. A project
393+
attribute chain genuinely ending with a declared suffix matches too — the vulnerable
394+
version range and the conditions still gate what is reported.
395+
373396
A filter of Django's own libraries that a project template applies is a call to the
374397
function behind it, placed at the template's line: `{{ bio|striptags }}` calls
375398
`python.django.template.defaultfilters.striptags`, and so do filters in tag arguments
@@ -471,6 +494,16 @@ a call passing a variable, or unpacking `*args` or `**kwargs`, leaves the condit
471494
pending review. A `semantic` condition — the document's tags, the platform, the
472495
template's origin — cannot be decided and is always pending review, never dropped.
473496

497+
An `argument` condition with `"present": true` says the argument being passed at all,
498+
whatever its value, is the condition, as a `\r` in `web.Response(reason=…)` needs
499+
`reason` given, constant or not: `{"kind": "argument", "text": "…", "argument":
500+
"reason", "position": 1, "present": true}`, exclusive with `values` and `default`. A
501+
call giving the argument — positionally, by keyword, or through a `**` literal with
502+
constant keys, which is folded into the keywords — meets it even when the value is
503+
unknown; a call surely not giving it does not reach the vulnerability and is ruled out
504+
(`(reason absent)`); a call unpacking `*args` or a `**mapping` whose keys the engine
505+
cannot read leaves it pending review.
506+
474507
A `host` condition says the attacker must choose the host of the URL passed as
475508
`argument` (or at `position`), as credentials leaking to the host of a crafted URL
476509
need: `{"kind": "host", "text": "…", "argument": "url", "position": 0}`. It is

‎src/coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py‎

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
from typing import ClassVar
99

1010
from coretrace_python.analysis import AnyAnalysis
11-
from coretrace_python.dependency import Advisory, DependencyAnalysis
11+
from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, DependencyAnalysis
1212
from coretrace_python.dependency.correlation import (
1313
ConditionCheck,
14+
affected_entries,
1415
affected_symbols,
1516
check_conditions,
1617
evidence,
18+
suffix_index,
1719
)
1820
from coretrace_python.findings import Confidence, Finding
1921
from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
@@ -30,37 +32,36 @@ def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
3032
affected = affected_symbols(ctx.dependencies, ctx.advisories)
3133
if not affected:
3234
return ()
35+
suffixes = suffix_index(affected, ctx.modules)
3336
findings: list[Finding] = []
3437
for module in ctx.modules:
3538
graph = ctx.call_graph(module)
3639
for function in graph.functions:
3740
for site in graph.sites(function):
3841
if not isinstance(site.target, ExternalSymbol):
3942
continue
40-
for advisory in affected.get(site.target.symbol, ()):
41-
entry = advisory.entry_point(site.target.symbol)
43+
for advisory, entry in affected_entries(site.target.symbol, affected, suffixes):
4244
if entry is not None and entry.read:
4345
continue # a call reads its callee first: reported with the reads
4446
check = check_conditions(entry, site.arguments, prior_calls=site.prior_calls)
4547
if check.contradicted is not None:
4648
continue
47-
findings.append(_reached(advisory, site.target.symbol, site.location, function, check))
49+
findings.append(_reached(advisory, site.target.symbol, site.location, function, check, entry))
4850
# Reading ``request.form.get`` reads ``request.form``: one finding per entry
4951
# point and function, where the function first reads it.
5052
reported: set[tuple[Advisory, SymbolId]] = set()
5153
for read in graph.reads(function):
5254
for symbol in _read_through(read.symbol):
53-
for advisory in affected.get(symbol, ()):
54-
entry = advisory.entry_point(symbol)
55+
for advisory, entry in affected_entries(symbol, affected, suffixes):
5556
if entry is None or not entry.read or (advisory, symbol) in reported:
5657
continue
5758
reported.add((advisory, symbol))
5859
check = check_conditions(entry, None)
59-
findings.append(_reached(advisory, symbol, read.location, function, check))
60+
findings.append(_reached(advisory, symbol, read.location, function, check, entry))
6061
for call in ctx.templates.calls:
61-
for advisory in affected.get(call.symbol, ()):
62-
check = check_conditions(advisory.entry_point(call.symbol), None)
63-
findings.append(_reached(advisory, call.symbol, call.span, None, check))
62+
for advisory, entry in affected_entries(call.symbol, affected, suffixes):
63+
check = check_conditions(entry, None)
64+
findings.append(_reached(advisory, call.symbol, call.span, None, check, entry))
6465
return findings
6566

6667

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

7475

7576
def _reached(
76-
advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str | None, check: ConditionCheck
77+
advisory: Advisory,
78+
symbol: SymbolId,
79+
span: SourceSpan,
80+
function: str | None,
81+
check: ConditionCheck,
82+
entry: AdvisoryEntryPoint | None,
7783
) -> Finding:
7884
return Finding(
7985
rule_id="reachable-vulnerability",
@@ -85,5 +91,5 @@ def _reached(
8591
confidence=Confidence.HIGH,
8692
span=span,
8793
function=function,
88-
metadata=evidence(advisory, symbol, "reachable", check),
94+
metadata=evidence(advisory, symbol, "reachable", check, entry),
8995
)

‎src/coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77

88
from coretrace_python.analysis import AnyAnalysis
99
from coretrace_python.dependency import DependencyAnalysis
10-
from coretrace_python.dependency.correlation import affected_symbols, check_conditions, ruled_out
10+
from coretrace_python.dependency.correlation import (
11+
affected_entries,
12+
affected_symbols,
13+
check_conditions,
14+
ruled_out,
15+
suffix_index,
16+
)
1117
from coretrace_python.findings import Confidence, Finding
1218
from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
1319
from coretrace_python.plugins import ProjectContext, ProjectPlugin
@@ -57,17 +63,16 @@ def _ruled_out(ctx: ProjectContext) -> dict[str, list[str]]:
5763
conditions: the evidence that the requirement, imported, is not reached there."""
5864

5965
affected = affected_symbols(ctx.dependencies, ctx.advisories)
66+
suffixes = suffix_index(affected, ctx.modules)
6067
excluded: dict[str, list[str]] = {}
6168
for module in sorted(ctx.modules):
6269
graph = ctx.call_graph(module)
6370
for function in graph.functions:
6471
for site in graph.sites(function):
6572
if not isinstance(site.target, ExternalSymbol):
6673
continue
67-
for advisory in affected.get(site.target.symbol, ()):
68-
check = check_conditions(
69-
advisory.entry_point(site.target.symbol), site.arguments, prior_calls=site.prior_calls
70-
)
74+
for advisory, entry in affected_entries(site.target.symbol, affected, suffixes):
75+
check = check_conditions(entry, site.arguments, prior_calls=site.prior_calls)
7176
if check.contradicted is not None:
7277
excluded.setdefault(advisory.id, []).append(ruled_out(module, site, check))
7378
return excluded

‎src/coretrace_python/dependency/advisories.py‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
that a project keeps at its root as ``advisories.json`` or passes with ``--advisories``.
77
OSV records name no affected APIs, so imported advisories feed the requirement checks
88
and the SBOM; a file completed by hand with ``affected_symbols``, ``entry_points`` and
9-
their ``conditions`` also feeds the reachability and correlation checks.
9+
their ``conditions`` and ``suffixes`` also feeds the reachability and correlation checks.
1010
"""
1111

1212
from __future__ import annotations
@@ -161,6 +161,8 @@ def _entry_point_entry(entry_point: AdvisoryEntryPoint) -> dict[str, Any]:
161161
}
162162
if entry_point.read:
163163
entry["read"] = True
164+
if entry_point.suffixes:
165+
entry["suffixes"] = list(entry_point.suffixes)
164166
if entry_point.attacker_arguments:
165167
entry["attacker_arguments"] = [_attacker_argument_entry(a) for a in entry_point.attacker_arguments]
166168
return entry
@@ -187,6 +189,8 @@ def _condition_entry(condition: Condition) -> dict[str, Any]:
187189
entry["position"] = condition.position
188190
if condition.default:
189191
entry["default"] = True
192+
if condition.present:
193+
entry["present"] = True
190194
if condition.method is not None:
191195
entry["method"] = condition.method
192196
return entry
@@ -228,9 +232,20 @@ def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
228232
tuple(_condition(c) for c in entry.get("conditions") or []),
229233
read,
230234
tuple(_attacker_argument(a) for a in entry.get("attacker_arguments") or []),
235+
tuple(_suffix(s) for s in entry.get("suffixes") or []),
231236
)
232237

233238

239+
def _suffix(value: Any) -> str:
240+
if not isinstance(value, str):
241+
raise TypeError(f"an entry point suffix must be a string, got {value!r}")
242+
parts = value.split(".")
243+
# A single name (``annotate``) would match every project method of that name.
244+
if len(parts) < 2 or not all(p.isidentifier() for p in parts):
245+
raise ValueError(f"an entry point suffix is at least two dot-separated names, got {value!r}")
246+
return value
247+
248+
234249
def _attacker_argument(entry: Mapping[str, Any]) -> AttackerArgument:
235250
argument, position, variadic = entry.get("argument"), entry.get("position"), entry.get("variadic", False)
236251
if argument is not None and not isinstance(argument, str):
@@ -253,15 +268,26 @@ def _condition(entry: Mapping[str, Any]) -> Condition:
253268
default = entry.get("default", False)
254269
if not isinstance(default, bool):
255270
raise TypeError(f"condition default must be true or false, got {default!r}")
271+
present = entry.get("present", False)
272+
if not isinstance(present, bool):
273+
raise TypeError(f"condition present must be true or false, got {present!r}")
256274
condition = Condition(
257275
str(entry["kind"]),
258276
str(entry["text"]),
259277
None if entry.get("argument") is None else str(entry["argument"]),
260278
tuple(str(v) for v in entry.get("values") or []),
261279
position,
262280
default,
281+
present,
263282
None if entry.get("method") is None else str(entry["method"]),
264283
)
284+
if condition.present and (
285+
condition.kind != "argument"
286+
or (condition.argument is None and condition.position is None)
287+
or condition.values
288+
or condition.default
289+
):
290+
raise ValueError("a present condition names the argument that must be passed, by keyword or position, and nothing else")
265291
if condition.kind == "sequence" and not condition.method:
266292
raise ValueError("a sequence condition names the method of the prior call")
267293
if condition.kind == "sequence" and condition.values and condition.argument is None:

0 commit comments

Comments
 (0)