coretrace-python-analyzer 0.19.0__py3-none-any.whl → 0.21.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.19.0"
3
+ __version__ = "0.21.0"
4
4
 
@@ -8,6 +8,7 @@ from coretrace_python.abstract.constants import (
8
8
  from coretrace_python.abstract.heap import (
9
9
  ATTRIBUTES,
10
10
  ELEMENTS,
11
+ KEYS,
11
12
  MUTATORS,
12
13
  AbstractObject,
13
14
  AliasSet,
@@ -24,6 +25,7 @@ from coretrace_python.abstract.values import AbstractValue, Truth
24
25
  __all__ = [
25
26
  "ATTRIBUTES",
26
27
  "ELEMENTS",
28
+ "KEYS",
27
29
  "MUTATORS",
28
30
  "AbstractObject",
29
31
  "AbstractValue",
@@ -6,7 +6,8 @@ globals and imported symbols by name, and the fields loaded from an object by fi
6
6
  Each value points to an ``AliasSet`` of objects, computed by a flow-insensitive
7
7
  points-to fixpoint over the SSA form; each object has two ``HeapLocation`` fields,
8
8
  ``elements`` for its items and ``attributes`` for its attributes, field-insensitive
9
- within each. Taint and dependence analyses key their states by these locations, so a
9
+ within each, and a third, ``keys``, for the keys of a mapping, which only the taint
10
+ engine fills. Taint and dependence analyses key their states by these locations, so a
10
11
  store, a mutating method call or a load on any alias reads and writes the same place.
11
12
  """
12
13
 
@@ -47,6 +48,8 @@ from coretrace_python.source import SourceSpan
47
48
 
48
49
  ELEMENTS = "elements"
49
50
  ATTRIBUTES = "attributes"
51
+ # The keys of a mapping, stored by the taint engine; the points-to solver never fills it.
52
+ KEYS = "keys"
50
53
 
51
54
  # Method names that store their arguments into the receiver's elements.
52
55
  MUTATORS = frozenset(
@@ -8,12 +8,14 @@ from collections.abc import Iterator, Sequence
8
8
  from typing import ClassVar
9
9
 
10
10
  from coretrace_python.analysis import AnyAnalysis
11
- from coretrace_python.dependency import Advisory, DependencyAnalysis
11
+ from coretrace_python.dependency import Advisory, AdvisoryEntryPoint, DependencyAnalysis
12
12
  from coretrace_python.dependency.correlation import (
13
13
  ConditionCheck,
14
+ affected_entries,
14
15
  affected_symbols,
15
16
  check_conditions,
16
17
  evidence,
18
+ suffix_index,
17
19
  )
18
20
  from coretrace_python.findings import Confidence, Finding
19
21
  from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
@@ -30,6 +32,7 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
30
32
  affected = affected_symbols(ctx.dependencies, ctx.advisories)
31
33
  if not affected:
32
34
  return ()
35
+ suffixes = suffix_index(affected, ctx.modules)
33
36
  findings: list[Finding] = []
34
37
  for module in ctx.modules:
35
38
  graph = ctx.call_graph(module)
@@ -37,30 +40,28 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
37
40
  for site in graph.sites(function):
38
41
  if not isinstance(site.target, ExternalSymbol):
39
42
  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):
42
44
  if entry is not None and entry.read:
43
45
  continue # a call reads its callee first: reported with the reads
44
- check = check_conditions(entry, site.arguments)
46
+ check = check_conditions(entry, site.arguments, prior_calls=site.prior_calls)
45
47
  if check.contradicted is not None:
46
48
  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))
48
50
  # Reading ``request.form.get`` reads ``request.form``: one finding per entry
49
51
  # point and function, where the function first reads it.
50
52
  reported: set[tuple[Advisory, SymbolId]] = set()
51
53
  for read in graph.reads(function):
52
54
  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):
55
56
  if entry is None or not entry.read or (advisory, symbol) in reported:
56
57
  continue
57
58
  reported.add((advisory, symbol))
58
59
  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))
60
61
  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))
64
65
  return findings
65
66
 
66
67
 
@@ -73,7 +74,12 @@ def _read_through(symbol: SymbolId) -> Iterator[SymbolId]:
73
74
 
74
75
 
75
76
  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,
77
83
  ) -> Finding:
78
84
  return Finding(
79
85
  rule_id="reachable-vulnerability",
@@ -85,5 +91,5 @@ def _reached(
85
91
  confidence=Confidence.HIGH,
86
92
  span=span,
87
93
  function=function,
88
- metadata=evidence(advisory, symbol, "reachable", check),
94
+ metadata=evidence(advisory, symbol, "reachable", check, entry),
89
95
  )
@@ -7,7 +7,13 @@ from typing import ClassVar
7
7
 
8
8
  from coretrace_python.analysis import AnyAnalysis
9
9
  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
+ )
11
17
  from coretrace_python.findings import Confidence, Finding
12
18
  from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
13
19
  from coretrace_python.plugins import ProjectContext, ProjectPlugin
@@ -57,6 +63,7 @@ def _ruled_out(ctx: ProjectContext) -> dict[str, list[str]]:
57
63
  conditions: the evidence that the requirement, imported, is not reached there."""
58
64
 
59
65
  affected = affected_symbols(ctx.dependencies, ctx.advisories)
66
+ suffixes = suffix_index(affected, ctx.modules)
60
67
  excluded: dict[str, list[str]] = {}
61
68
  for module in sorted(ctx.modules):
62
69
  graph = ctx.call_graph(module)
@@ -64,8 +71,8 @@ def _ruled_out(ctx: ProjectContext) -> dict[str, list[str]]:
64
71
  for site in graph.sites(function):
65
72
  if not isinstance(site.target, ExternalSymbol):
66
73
  continue
67
- for advisory in affected.get(site.target.symbol, ()):
68
- check = check_conditions(advisory.entry_point(site.target.symbol), site.arguments)
74
+ for advisory, entry in affected_entries(site.target.symbol, affected, suffixes):
75
+ check = check_conditions(entry, site.arguments, prior_calls=site.prior_calls)
69
76
  if check.contradicted is not None:
70
77
  excluded.setdefault(advisory.id, []).append(ruled_out(module, site, check))
71
78
  return excluded
coretrace_python/cache.py CHANGED
@@ -34,6 +34,7 @@ from coretrace_python.interprocedural import (
34
34
  ModuleGraph,
35
35
  Mutation,
36
36
  NonlocalWrite,
37
+ PriorCall,
37
38
  SummaryIndex,
38
39
  SymbolRead,
39
40
  Target,
@@ -42,7 +43,7 @@ from coretrace_python.interprocedural import (
42
43
  from coretrace_python.semantic.symbols import SymbolId
43
44
  from coretrace_python.source import SourceId, SourceSpan
44
45
 
45
- CACHE_FORMAT = 11
46
+ CACHE_FORMAT = 13
46
47
 
47
48
 
48
49
  @dataclass(frozen=True)
@@ -351,23 +352,39 @@ def _encode_site(site: CallSite) -> dict[str, Any]:
351
352
  "location": _encode_span(site.location),
352
353
  "target": _encode_target(site.target),
353
354
  "arguments": _encode_arguments(site.arguments),
355
+ "prior": None
356
+ if site.prior_calls is None
357
+ else [
358
+ [str(p.symbol), _encode_span(p.location), _encode_arguments(p.arguments), p.dominates]
359
+ for p in site.prior_calls
360
+ ],
354
361
  }
355
362
 
356
363
 
357
364
  def _decode_site(data: Mapping[str, Any]) -> CallSite:
365
+ prior = data["prior"]
358
366
  return CallSite(
359
367
  _string(data["caller"]),
360
368
  _decode_span(data["location"]),
361
369
  _decode_target(data["target"]),
362
370
  _decode_arguments(data["arguments"]),
371
+ None if prior is None else tuple(_decode_prior(entry) for entry in prior),
363
372
  )
364
373
 
365
374
 
375
+ def _decode_prior(data: Any) -> PriorCall:
376
+ symbol, span, arguments, dominates = data
377
+ if not isinstance(dominates, bool):
378
+ raise TypeError(f"expected a boolean, got {dominates!r}")
379
+ return PriorCall(SymbolId(_string(symbol)), _decode_span(span), _decode_arguments(arguments), dominates)
380
+
381
+
366
382
  def _encode_arguments(arguments: Arguments) -> dict[str, Any]:
367
383
  return {
368
384
  "positional": list(arguments.positional),
369
385
  "keywords": [[name, value] for name, value in arguments.keywords],
370
386
  "unpacked": arguments.unpacked,
387
+ "keyword_unpacked": arguments.keyword_unpacked,
371
388
  }
372
389
 
373
390
 
@@ -375,11 +392,12 @@ def _decode_arguments(data: Mapping[str, Any]) -> Arguments:
375
392
  def denoted(value: Any) -> str | None:
376
393
  return None if value is None else _string(value)
377
394
 
378
- unpacked = data["unpacked"]
379
- if not isinstance(unpacked, bool):
380
- raise TypeError(f"expected a boolean, got {unpacked!r}")
395
+ unpacked, keyword_unpacked = data["unpacked"], data["keyword_unpacked"]
396
+ if not isinstance(unpacked, bool) or not isinstance(keyword_unpacked, bool):
397
+ raise TypeError(f"expected booleans, got {unpacked!r} and {keyword_unpacked!r}")
381
398
  return Arguments(
382
399
  tuple(denoted(value) for value in data["positional"]),
383
400
  tuple((_string(name), denoted(value)) for name, value in data["keywords"]),
384
401
  unpacked,
402
+ keyword_unpacked,
385
403
  )
@@ -6,7 +6,7 @@ events into a version specifier; ``dump_advisories`` writes them as a small JSON
6
6
  that a project keeps at its root as ``advisories.json`` or passes with ``--advisories``.
7
7
  OSV records name no affected APIs, so imported advisories feed the requirement checks
8
8
  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.
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -161,6 +161,8 @@ def _entry_point_entry(entry_point: AdvisoryEntryPoint) -> dict[str, Any]:
161
161
  }
162
162
  if entry_point.read:
163
163
  entry["read"] = True
164
+ if entry_point.suffixes:
165
+ entry["suffixes"] = list(entry_point.suffixes)
164
166
  if entry_point.attacker_arguments:
165
167
  entry["attacker_arguments"] = [_attacker_argument_entry(a) for a in entry_point.attacker_arguments]
166
168
  return entry
@@ -187,6 +189,10 @@ def _condition_entry(condition: Condition) -> dict[str, Any]:
187
189
  entry["position"] = condition.position
188
190
  if condition.default:
189
191
  entry["default"] = True
192
+ if condition.present:
193
+ entry["present"] = True
194
+ if condition.method is not None:
195
+ entry["method"] = condition.method
190
196
  return entry
191
197
 
192
198
 
@@ -226,9 +232,20 @@ def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
226
232
  tuple(_condition(c) for c in entry.get("conditions") or []),
227
233
  read,
228
234
  tuple(_attacker_argument(a) for a in entry.get("attacker_arguments") or []),
235
+ tuple(_suffix(s) for s in entry.get("suffixes") or []),
229
236
  )
230
237
 
231
238
 
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
+
232
249
  def _attacker_argument(entry: Mapping[str, Any]) -> AttackerArgument:
233
250
  argument, position, variadic = entry.get("argument"), entry.get("position"), entry.get("variadic", False)
234
251
  if argument is not None and not isinstance(argument, str):
@@ -251,6 +268,9 @@ def _condition(entry: Mapping[str, Any]) -> Condition:
251
268
  default = entry.get("default", False)
252
269
  if not isinstance(default, bool):
253
270
  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}")
254
274
  condition = Condition(
255
275
  str(entry["kind"]),
256
276
  str(entry["text"]),
@@ -258,9 +278,28 @@ def _condition(entry: Mapping[str, Any]) -> Condition:
258
278
  tuple(str(v) for v in entry.get("values") or []),
259
279
  position,
260
280
  default,
281
+ present,
282
+ None if entry.get("method") is None else str(entry["method"]),
261
283
  )
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")
291
+ if condition.kind == "sequence" and not condition.method:
292
+ raise ValueError("a sequence condition names the method of the prior call")
293
+ if condition.kind == "sequence" and condition.values and condition.argument is None:
294
+ raise ValueError("the values of a sequence condition need the argument they constrain")
295
+ if condition.kind != "sequence" and condition.method is not None:
296
+ raise ValueError(f"only a sequence condition names a method, not {condition.kind!r}")
262
297
  if condition.kind == "host" and (
263
298
  (condition.argument is None and condition.position is None) or condition.values or condition.default
264
299
  ):
265
300
  raise ValueError("a host condition names the URL argument, by keyword or position, and nothing else")
301
+ if condition.kind == "keyword_name" and (
302
+ condition.argument is not None or condition.position is not None or condition.values or condition.default
303
+ ):
304
+ raise ValueError("a keyword_name condition carries its text and nothing else")
266
305
  return condition
@@ -21,9 +21,9 @@ from coretrace_python.dependency.graph import (
21
21
  )
22
22
  from coretrace_python.findings import Confidence, Finding, Severity
23
23
  from coretrace_python.findings.refutation import Status, Verdict, Verdicts
24
- from coretrace_python.interprocedural import Arguments, CallSite, ExternalSymbol
24
+ from coretrace_python.interprocedural import Arguments, CallSite, ExternalSymbol, PriorCall
25
25
  from coretrace_python.semantic.symbols import SymbolId
26
- from coretrace_python.taint import Sink, TaintFlow, TaintKind
26
+ from coretrace_python.taint import KEYWORD_NAMES, Sink, SuffixSink, TaintFlow, TaintKind
27
27
  from coretrace_python.taint.urls import UrlProof, redirects_disabled
28
28
 
29
29
  Affected = Mapping[SymbolId, tuple[Advisory, ...]]
@@ -49,6 +49,56 @@ def advisory_sinks(affected: Affected) -> tuple[Sink, ...]:
49
49
  return tuple(Sink(symbol, TaintKind.ADVISORY) for symbol in affected)
50
50
 
51
51
 
52
+ @dataclass(frozen=True)
53
+ class SuffixIndex:
54
+ """The curated entry-point suffixes of the matching advisories, and the project's
55
+ module prefixes: a derived symbol matches a suffix on a dot boundary, as a
56
+ ``SuffixSink`` does, and only when it belongs to a project module — another
57
+ package's ``objects.annotate`` stays out."""
58
+
59
+ entries: tuple[tuple[str, Advisory, AdvisoryEntryPoint], ...] = ()
60
+ prefixes: tuple[str, ...] = ()
61
+
62
+ def matches(self, symbol: SymbolId) -> tuple[tuple[Advisory, AdvisoryEntryPoint], ...]:
63
+ name = symbol.canonical_name
64
+ found = tuple((a, e) for tail, a, e in self.entries if name.endswith(tail))
65
+ return found if found and name.startswith(self.prefixes) else ()
66
+
67
+
68
+ def suffix_index(affected: Affected, modules: Iterable[str]) -> SuffixIndex:
69
+ advisories = {a: None for found in affected.values() for a in found}
70
+ entries = tuple((f".{s}", a, e) for a in advisories for e in a.entry_points for s in e.suffixes)
71
+ return SuffixIndex(entries, tuple(f"python.{m}." for m in sorted(set(modules))))
72
+
73
+
74
+ def affected_entries(
75
+ symbol: SymbolId, affected: Affected, suffixes: SuffixIndex | None = None
76
+ ) -> tuple[tuple[Advisory, AdvisoryEntryPoint | None], ...]:
77
+ """The advisories reached through ``symbol``, each with the entry point it is reached
78
+ by: the advisories affecting the exact symbol, then those a suffix matches."""
79
+
80
+ found: list[tuple[Advisory, AdvisoryEntryPoint | None]] = [
81
+ (a, a.entry_point(symbol)) for a in affected.get(symbol, ())
82
+ ]
83
+ if suffixes is not None:
84
+ seen = {(a.id, a.package, None if e is None else e.symbol) for a, e in found}
85
+ for advisory, entry in suffixes.matches(symbol):
86
+ key = (advisory.id, advisory.package, entry.symbol)
87
+ if key not in seen:
88
+ seen.add(key)
89
+ found.append((advisory, entry))
90
+ return tuple(found)
91
+
92
+
93
+ def advisory_suffix_sinks(affected: Affected) -> tuple[SuffixSink, ...]:
94
+ """A suffix sink per curated entry-point suffix, so the taint engine tracks flows
95
+ into the derived symbols; ``correlate`` then keeps only the project's."""
96
+
97
+ advisories = {a: None for found in affected.values() for a in found}
98
+ suffixes = {s: None for a in advisories for e in a.entry_points for s in e.suffixes}
99
+ return tuple(SuffixSink(s, TaintKind.ADVISORY) for s in suffixes)
100
+
101
+
52
102
  @dataclass(frozen=True)
53
103
  class ConditionCheck:
54
104
  """What one call tells of an entry point's conditions: those it meets, those left to
@@ -67,18 +117,39 @@ def check_conditions(
67
117
  arguments: Arguments | None,
68
118
  url: UrlProof | None = None,
69
119
  passed_as: frozenset[tuple[int | None, str | None]] = frozenset(),
120
+ prior_calls: tuple[PriorCall, ...] | None = None,
70
121
  ) -> ConditionCheck:
71
122
  """Decide ``entry``'s conditions against what the call's ``arguments`` denote and, for
72
123
  a ``host`` condition, what ``url`` proves of the value the attacker's input is passed
73
124
  in (``passed_as``). A host the URL's constant text fixes rules the call out when the
74
125
  call disables redirects; otherwise a redirect may still lead to a host the attacker
75
- controls, and the condition stays pending with that uncertainty."""
126
+ controls, and the condition stays pending with that uncertainty. A ``keyword_name``
127
+ condition is met when the attacker's input is passed as the keys of a ``**`` mapping,
128
+ pending when the call expands a mapping whose keys are not established, and
129
+ contradicted by a call writing every keyword name. An ``argument`` condition with
130
+ ``present`` is met when the call gives the argument, whatever it denotes, and
131
+ contradicted when the call surely does not give it. A ``sequence`` condition is met
132
+ when one of the ``prior_calls`` on the call's receiver dominates it — surely executes
133
+ before it — matches the condition's method and passes what it requires; contradicted
134
+ when the receiver is followed whole and no matching call could do so, the call's own
135
+ ``arguments`` included, since across the iterations of a loop the call is its own
136
+ prior; pending otherwise — an unfollowed receiver (``prior_calls`` is None), an
137
+ undecided or non-dominating matching call, or an undecidable self-check."""
76
138
 
77
139
  if entry is None:
78
140
  return ConditionCheck()
79
141
  met: list[Condition] = []
80
142
  pending: list[Condition] = []
81
143
  for condition in entry.conditions:
144
+ if condition.kind == "keyword_name":
145
+ if any(keyword == KEYWORD_NAMES for _, keyword in passed_as):
146
+ met.append(condition)
147
+ elif arguments is None or arguments.keyword_unpacked:
148
+ pending.append(condition)
149
+ else:
150
+ names = ", ".join(sorted({name for name, _ in arguments.keywords}))
151
+ return ConditionCheck(tuple(met), tuple(pending), condition, names or None)
152
+ continue
82
153
  if condition.kind == "host":
83
154
  origin = url.origin if url is not None and _passed_in(condition, passed_as) else None
84
155
  if origin is None:
@@ -89,10 +160,40 @@ def check_conditions(
89
160
  text = f"the host is fixed by {origin!r}, but a redirect may lead to a host the attacker controls"
90
161
  pending.append(replace(condition, text=text))
91
162
  continue
163
+ if condition.kind == "sequence":
164
+ candidates = [
165
+ p
166
+ for p in prior_calls or ()
167
+ if condition.method is not None and _named(p.symbol, condition.method)
168
+ ]
169
+ satisfied = [_first_call_satisfies(condition, p.arguments) for p in candidates]
170
+ hit = next(
171
+ (p for p, s in zip(candidates, satisfied, strict=True) if s is True and p.dominates), None
172
+ )
173
+ if hit is not None:
174
+ text = f"{condition.text} (prior {condition.method} at line {hit.location.start_line})"
175
+ met.append(replace(condition, text=text))
176
+ elif (
177
+ prior_calls is None
178
+ or condition.method is None
179
+ or None in satisfied
180
+ or True in satisfied
181
+ or _first_call_satisfies(condition, arguments) is not False
182
+ ):
183
+ pending.append(condition)
184
+ else:
185
+ return ConditionCheck(tuple(met), tuple(pending), condition, None)
186
+ continue
92
187
  given = arguments.given(condition.argument, condition.position) if condition.checkable and arguments is not None else None
93
188
  if given is None:
94
189
  pending.append(condition)
95
190
  continue
191
+ if condition.present:
192
+ if given[0]:
193
+ met.append(condition)
194
+ else:
195
+ return ConditionCheck(tuple(met), tuple(pending), condition, None)
196
+ continue
96
197
  explicit, value = given
97
198
  if explicit and value is None:
98
199
  pending.append(condition)
@@ -104,6 +205,32 @@ def check_conditions(
104
205
  return ConditionCheck(tuple(met), tuple(pending))
105
206
 
106
207
 
208
+ def _named(symbol: SymbolId, method: str) -> bool:
209
+ """Whether ``symbol`` is the ``method`` a sequence condition names, by exact
210
+ canonical name or by ``.``-suffix (``Session.get``)."""
211
+
212
+ name = symbol.canonical_name
213
+ return name == method or name.endswith("." + method)
214
+
215
+
216
+ def _first_call_satisfies(condition: Condition, arguments: Arguments | None) -> bool | None:
217
+ """Whether ``arguments`` carry what a sequence ``condition`` requires of the first
218
+ call: trivially when it names no argument, else as an argument condition reads
219
+ ``given()``; None when they cannot tell."""
220
+
221
+ if condition.argument is None and condition.position is None:
222
+ return True
223
+ if arguments is None:
224
+ return None
225
+ given = arguments.given(condition.argument, condition.position)
226
+ if given is None:
227
+ return None
228
+ explicit, value = given
229
+ if explicit and value is None:
230
+ return None
231
+ return value in condition.values if explicit else condition.default
232
+
233
+
107
234
  def _passed_in(condition: Condition, passed_as: frozenset[tuple[int | None, str | None]]) -> bool:
108
235
  """Whether a value passed as ``passed_as`` is the argument ``condition`` names."""
109
236
 
@@ -119,18 +246,32 @@ def ruled_out(module: str, site: CallSite, check: ConditionCheck) -> str:
119
246
  python.yaml.load(Loader=python.yaml.SafeLoader)``."""
120
247
 
121
248
  assert check.contradicted is not None and isinstance(site.target, ExternalSymbol)
122
- argument = check.contradicted.argument or f"#{check.contradicted.position}"
249
+ contradicted = check.contradicted
250
+ if contradicted.kind == "sequence":
251
+ return (
252
+ f"{module}:{site.location.start_line} {site.target.symbol}"
253
+ f"(no prior {contradicted.method} on the receiver)"
254
+ )
255
+ argument = contradicted.argument or ("keywords" if contradicted.kind == "keyword_name" else f"#{contradicted.position}")
123
256
  passed = f"{argument} absent" if check.passed is None else f"{argument}={check.passed}"
124
257
  return f"{module}:{site.location.start_line} {site.target.symbol}({passed})"
125
258
 
126
259
 
127
- def evidence(advisory: Advisory, symbol: SymbolId, level: str, check: ConditionCheck) -> dict[str, str]:
260
+ def evidence(
261
+ advisory: Advisory,
262
+ symbol: SymbolId,
263
+ level: str,
264
+ check: ConditionCheck,
265
+ entry: AdvisoryEntryPoint | None = None,
266
+ ) -> dict[str, str]:
128
267
  """What a finding keeps of the advisory for ``symbol``: the level of evidence
129
268
  established, how the symbol relates to the vulnerability, and its conditions — those
130
- the call meets and those left to review."""
269
+ the call meets and those left to review. ``entry`` names the entry point ``symbol``
270
+ was matched by when it is not the advisory's own symbol, as a suffix match derives
271
+ it from the project; the metadata then keeps both."""
131
272
 
132
273
  metadata = {"advisory": advisory.id, "package": advisory.package, "symbol": str(symbol), "level": level}
133
- entry = advisory.entry_point(symbol)
274
+ entry = entry if entry is not None else advisory.entry_point(symbol)
134
275
  if entry is None:
135
276
  metadata["justification"] = DIRECT
136
277
  return metadata
@@ -151,9 +292,12 @@ def correlate(
151
292
  verdicts: Verdicts | None,
152
293
  affected: Affected,
153
294
  urls: Mapping[TaintFlow, UrlProof] | None = None,
295
+ suffixes: SuffixIndex | None = None,
296
+ prior: Mapping[TaintFlow, tuple[PriorCall, ...] | None] | None = None,
154
297
  ) -> tuple[Finding, ...]:
155
298
  """Exploitable-vulnerability findings for the non-refuted ADVISORY flows of a function,
156
- one per advisory the sink is affected by; ``urls`` holds what each flow's URL proves."""
299
+ one per advisory the sink is affected by; ``urls`` holds what each flow's URL proves
300
+ and ``prior`` the other calls on each flow's receiver, for sequence conditions."""
157
301
 
158
302
  findings: list[Finding] = []
159
303
  for flow in flows:
@@ -163,25 +307,32 @@ def correlate(
163
307
  if verdict is not None and verdict.status is Status.REFUTED:
164
308
  continue
165
309
  hotspot = verdict is not None and verdict.status is Status.HOTSPOT
166
- for advisory in affected.get(flow.sink.symbol, ()):
167
- entry = advisory.entry_point(flow.sink.symbol)
310
+ for advisory, entry in affected_entries(flow.sink.symbol, affected, suffixes):
168
311
  if entry is not None and not any(entry.exploitable_through(p, k) for p, k in flow.passed_as):
169
312
  continue
170
- check = check_conditions(entry, flow.sink_arguments, (urls or {}).get(flow), flow.passed_as)
313
+ check = check_conditions(
314
+ entry, flow.sink_arguments, (urls or {}).get(flow), flow.passed_as, (prior or {}).get(flow)
315
+ )
171
316
  if check.contradicted is None:
172
- findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check))
317
+ findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check, entry))
173
318
  return tuple(findings)
174
319
 
175
320
 
176
321
  def _exploitable(
177
- function: str, flow: TaintFlow, advisory: Advisory, verdict: Verdict | None, hotspot: bool, check: ConditionCheck
322
+ function: str,
323
+ flow: TaintFlow,
324
+ advisory: Advisory,
325
+ verdict: Verdict | None,
326
+ hotspot: bool,
327
+ check: ConditionCheck,
328
+ entry: AdvisoryEntryPoint | None,
178
329
  ) -> Finding:
179
330
  message = (
180
331
  f"{advisory.id}: {flow.source.label} input reaches {flow.sink.symbol}, affected in "
181
332
  f"the required {advisory.package} {advisory.vulnerable}: {advisory.summary}"
182
333
  )
183
334
  metadata = {
184
- **evidence(advisory, flow.sink.symbol, "exploitable", check),
335
+ **evidence(advisory, flow.sink.symbol, "exploitable", check, entry),
185
336
  "source": str(flow.source.symbol),
186
337
  "source_label": flow.source.label,
187
338
  "verdict": "hotspot" if hotspot else "vulnerability",
@@ -151,10 +151,21 @@ class Condition:
151
151
  ``argument`` condition names the argument by keyword, and by ``position`` when it may
152
152
  be passed positionally, and lists the ``values`` that satisfy it — symbols
153
153
  (``python.yaml.FullLoader``) or constants as Python writes them (``True``);
154
- ``default`` says an absent argument means a vulnerable value. A ``host`` condition
154
+ ``default`` says an absent argument means a vulnerable value; ``present`` says the
155
+ argument being passed at all, whatever its value, is the condition, so it excludes
156
+ ``values`` and ``default``. A ``host`` condition
155
157
  says the attacker must choose the host of the URL passed as ``argument`` (or at
156
158
  ``position``); the engine decides it from what the URL's constant text proves. A
157
- ``semantic`` condition cannot be checked and is reported as pending review."""
159
+ ``keyword_name`` condition says the attacker must choose a keyword name of the call,
160
+ as an ORM alias expanded from a mapping; the engine decides it from the keys of what
161
+ the call expands with ``**``. A ``sequence`` condition says the flaw needs a prior
162
+ call on the same receiver: it names the ``method`` that call must be, by canonical
163
+ name or ``.``-suffix, and, when ``argument`` is named, what it must pass, as an
164
+ argument condition does. It is met when such a call dominates the entry call —
165
+ surely executes before it — matches ``method`` and passes a value in ``values``
166
+ (``default`` for an absent argument); contradicted when the receiver is fully
167
+ followed and no such prior call can precede; pending otherwise. A ``semantic``
168
+ condition cannot be checked and is reported as pending review."""
158
169
 
159
170
  kind: str
160
171
  text: str
@@ -162,6 +173,8 @@ class Condition:
162
173
  values: tuple[str, ...] = ()
163
174
  position: int | None = None
164
175
  default: bool = False
176
+ present: bool = False
177
+ method: str | None = None
165
178
 
166
179
  @property
167
180
  def checkable(self) -> bool:
@@ -201,13 +214,19 @@ class AdvisoryEntryPoint:
201
214
  An entry point is reached by a call, or — when ``read`` is set, for an attribute whose
202
215
  getter runs the affected code, such as a lazily parsed request body — by any read of
203
216
  it or of an attribute of it. When it names ``attacker_arguments``, attacker input
204
- exploits it only through one of them; otherwise through any argument."""
217
+ exploits it only through one of them; otherwise through any argument.
218
+
219
+ A suffix names the tail of a derived project symbol through which the entry point is
220
+ reached when the engine cannot type the receiver: ``Item.objects.annotate`` derives
221
+ ``python.app.models.Item.objects.annotate``, which ``objects.annotate`` matches.
222
+ Suffixes match on dot boundaries, and only symbols of the project's own modules."""
205
223
 
206
224
  symbol: SymbolId
207
225
  justification: str
208
226
  conditions: tuple[Condition, ...] = ()
209
227
  read: bool = False
210
228
  attacker_arguments: tuple[AttackerArgument, ...] = ()
229
+ suffixes: tuple[str, ...] = ()
211
230
 
212
231
  def exploitable_through(self, position: int | None, keyword: str | None) -> bool:
213
232
  """Whether attacker input in the argument at ``position`` or ``keyword`` can