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.
- coretrace_python/__init__.py +1 -1
- coretrace_python/abstract/__init__.py +2 -0
- coretrace_python/abstract/heap.py +4 -1
- coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +19 -13
- coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +10 -3
- coretrace_python/cache.py +22 -4
- coretrace_python/dependency/advisories.py +40 -1
- coretrace_python/dependency/correlation.py +165 -14
- coretrace_python/dependency/graph.py +22 -3
- coretrace_python/engine.py +32 -7
- coretrace_python/interprocedural/__init__.py +2 -0
- coretrace_python/interprocedural/callgraph.py +192 -10
- coretrace_python/taint/__init__.py +2 -0
- coretrace_python/taint/engine.py +53 -1
- coretrace_python/taint/models.py +10 -7
- {coretrace_python_analyzer-0.19.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/METADATA +1 -1
- {coretrace_python_analyzer-0.19.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/RECORD +21 -21
- {coretrace_python_analyzer-0.19.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.19.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.19.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.19.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/licenses/NOTICE +0 -0
coretrace_python/__init__.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
62
|
-
check = check_conditions(
|
|
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,
|
|
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
|
|
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
|
|
68
|
-
check = check_conditions(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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,
|
|
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
|
|
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
|
-
``
|
|
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
|