coretrace-python-analyzer 0.20.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/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +19 -13
- coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +10 -3
- coretrace_python/cache.py +17 -1
- coretrace_python/dependency/advisories.py +36 -1
- coretrace_python/dependency/correlation.py +151 -13
- coretrace_python/dependency/graph.py +20 -4
- coretrace_python/engine.py +32 -7
- coretrace_python/interprocedural/__init__.py +2 -0
- coretrace_python/interprocedural/callgraph.py +131 -5
- coretrace_python/taint/models.py +10 -7
- {coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/METADATA +1 -1
- {coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/RECORD +17 -17
- {coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/licenses/NOTICE +0 -0
coretrace_python/__init__.py
CHANGED
|
@@ -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,18 +352,33 @@ 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),
|
|
@@ -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,7 +278,22 @@ 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
|
):
|
|
@@ -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 KEYWORD_NAMES, 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,6 +117,7 @@ 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
|
|
@@ -75,7 +126,15 @@ def check_conditions(
|
|
|
75
126
|
controls, and the condition stays pending with that uncertainty. A ``keyword_name``
|
|
76
127
|
condition is met when the attacker's input is passed as the keys of a ``**`` mapping,
|
|
77
128
|
pending when the call expands a mapping whose keys are not established, and
|
|
78
|
-
contradicted by a call writing every keyword name.
|
|
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."""
|
|
79
138
|
|
|
80
139
|
if entry is None:
|
|
81
140
|
return ConditionCheck()
|
|
@@ -101,10 +160,40 @@ def check_conditions(
|
|
|
101
160
|
text = f"the host is fixed by {origin!r}, but a redirect may lead to a host the attacker controls"
|
|
102
161
|
pending.append(replace(condition, text=text))
|
|
103
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
|
|
104
187
|
given = arguments.given(condition.argument, condition.position) if condition.checkable and arguments is not None else None
|
|
105
188
|
if given is None:
|
|
106
189
|
pending.append(condition)
|
|
107
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
|
|
108
197
|
explicit, value = given
|
|
109
198
|
if explicit and value is None:
|
|
110
199
|
pending.append(condition)
|
|
@@ -116,6 +205,32 @@ def check_conditions(
|
|
|
116
205
|
return ConditionCheck(tuple(met), tuple(pending))
|
|
117
206
|
|
|
118
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
|
+
|
|
119
234
|
def _passed_in(condition: Condition, passed_as: frozenset[tuple[int | None, str | None]]) -> bool:
|
|
120
235
|
"""Whether a value passed as ``passed_as`` is the argument ``condition`` names."""
|
|
121
236
|
|
|
@@ -132,18 +247,31 @@ def ruled_out(module: str, site: CallSite, check: ConditionCheck) -> str:
|
|
|
132
247
|
|
|
133
248
|
assert check.contradicted is not None and isinstance(site.target, ExternalSymbol)
|
|
134
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
|
+
)
|
|
135
255
|
argument = contradicted.argument or ("keywords" if contradicted.kind == "keyword_name" else f"#{contradicted.position}")
|
|
136
256
|
passed = f"{argument} absent" if check.passed is None else f"{argument}={check.passed}"
|
|
137
257
|
return f"{module}:{site.location.start_line} {site.target.symbol}({passed})"
|
|
138
258
|
|
|
139
259
|
|
|
140
|
-
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]:
|
|
141
267
|
"""What a finding keeps of the advisory for ``symbol``: the level of evidence
|
|
142
268
|
established, how the symbol relates to the vulnerability, and its conditions — those
|
|
143
|
-
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."""
|
|
144
272
|
|
|
145
273
|
metadata = {"advisory": advisory.id, "package": advisory.package, "symbol": str(symbol), "level": level}
|
|
146
|
-
entry = advisory.entry_point(symbol)
|
|
274
|
+
entry = entry if entry is not None else advisory.entry_point(symbol)
|
|
147
275
|
if entry is None:
|
|
148
276
|
metadata["justification"] = DIRECT
|
|
149
277
|
return metadata
|
|
@@ -164,9 +292,12 @@ def correlate(
|
|
|
164
292
|
verdicts: Verdicts | None,
|
|
165
293
|
affected: Affected,
|
|
166
294
|
urls: Mapping[TaintFlow, UrlProof] | None = None,
|
|
295
|
+
suffixes: SuffixIndex | None = None,
|
|
296
|
+
prior: Mapping[TaintFlow, tuple[PriorCall, ...] | None] | None = None,
|
|
167
297
|
) -> tuple[Finding, ...]:
|
|
168
298
|
"""Exploitable-vulnerability findings for the non-refuted ADVISORY flows of a function,
|
|
169
|
-
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."""
|
|
170
301
|
|
|
171
302
|
findings: list[Finding] = []
|
|
172
303
|
for flow in flows:
|
|
@@ -176,25 +307,32 @@ def correlate(
|
|
|
176
307
|
if verdict is not None and verdict.status is Status.REFUTED:
|
|
177
308
|
continue
|
|
178
309
|
hotspot = verdict is not None and verdict.status is Status.HOTSPOT
|
|
179
|
-
for advisory in
|
|
180
|
-
entry = advisory.entry_point(flow.sink.symbol)
|
|
310
|
+
for advisory, entry in affected_entries(flow.sink.symbol, affected, suffixes):
|
|
181
311
|
if entry is not None and not any(entry.exploitable_through(p, k) for p, k in flow.passed_as):
|
|
182
312
|
continue
|
|
183
|
-
check = check_conditions(
|
|
313
|
+
check = check_conditions(
|
|
314
|
+
entry, flow.sink_arguments, (urls or {}).get(flow), flow.passed_as, (prior or {}).get(flow)
|
|
315
|
+
)
|
|
184
316
|
if check.contradicted is None:
|
|
185
|
-
findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check))
|
|
317
|
+
findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check, entry))
|
|
186
318
|
return tuple(findings)
|
|
187
319
|
|
|
188
320
|
|
|
189
321
|
def _exploitable(
|
|
190
|
-
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,
|
|
191
329
|
) -> Finding:
|
|
192
330
|
message = (
|
|
193
331
|
f"{advisory.id}: {flow.source.label} input reaches {flow.sink.symbol}, affected in "
|
|
194
332
|
f"the required {advisory.package} {advisory.vulnerable}: {advisory.summary}"
|
|
195
333
|
)
|
|
196
334
|
metadata = {
|
|
197
|
-
**evidence(advisory, flow.sink.symbol, "exploitable", check),
|
|
335
|
+
**evidence(advisory, flow.sink.symbol, "exploitable", check, entry),
|
|
198
336
|
"source": str(flow.source.symbol),
|
|
199
337
|
"source_label": flow.source.label,
|
|
200
338
|
"verdict": "hotspot" if hotspot else "vulnerability",
|
|
@@ -151,13 +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,
|
|
158
160
|
as an ORM alias expanded from a mapping; the engine decides it from the keys of what
|
|
159
|
-
the call expands with ``**``. A ``
|
|
160
|
-
|
|
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."""
|
|
161
169
|
|
|
162
170
|
kind: str
|
|
163
171
|
text: str
|
|
@@ -165,6 +173,8 @@ class Condition:
|
|
|
165
173
|
values: tuple[str, ...] = ()
|
|
166
174
|
position: int | None = None
|
|
167
175
|
default: bool = False
|
|
176
|
+
present: bool = False
|
|
177
|
+
method: str | None = None
|
|
168
178
|
|
|
169
179
|
@property
|
|
170
180
|
def checkable(self) -> bool:
|
|
@@ -204,13 +214,19 @@ class AdvisoryEntryPoint:
|
|
|
204
214
|
An entry point is reached by a call, or — when ``read`` is set, for an attribute whose
|
|
205
215
|
getter runs the affected code, such as a lazily parsed request body — by any read of
|
|
206
216
|
it or of an attribute of it. When it names ``attacker_arguments``, attacker input
|
|
207
|
-
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."""
|
|
208
223
|
|
|
209
224
|
symbol: SymbolId
|
|
210
225
|
justification: str
|
|
211
226
|
conditions: tuple[Condition, ...] = ()
|
|
212
227
|
read: bool = False
|
|
213
228
|
attacker_arguments: tuple[AttackerArgument, ...] = ()
|
|
229
|
+
suffixes: tuple[str, ...] = ()
|
|
214
230
|
|
|
215
231
|
def exploitable_through(self, position: int | None, keyword: str | None) -> bool:
|
|
216
232
|
"""Whether attacker input in the argument at ``position`` or ``keyword`` can
|
coretrace_python/engine.py
CHANGED
|
@@ -53,9 +53,12 @@ from coretrace_python.dependency import (
|
|
|
53
53
|
)
|
|
54
54
|
from coretrace_python.dependency.correlation import (
|
|
55
55
|
Affected,
|
|
56
|
+
SuffixIndex,
|
|
56
57
|
advisory_sinks,
|
|
58
|
+
advisory_suffix_sinks,
|
|
57
59
|
affected_symbols,
|
|
58
60
|
correlate,
|
|
61
|
+
suffix_index,
|
|
59
62
|
)
|
|
60
63
|
from coretrace_python.findings import (
|
|
61
64
|
ADVISORIES,
|
|
@@ -424,7 +427,9 @@ def analyze_project(
|
|
|
424
427
|
components = _components(registry, root, (path for path, _ in file_advisories))
|
|
425
428
|
advisories, origins = _merge_advisories(_contributions(registry, plugins, root, file_advisories))
|
|
426
429
|
affected = affected_symbols(dependencies, advisories)
|
|
427
|
-
models = plugin_models(all_plugins, root).extended(
|
|
430
|
+
models = plugin_models(all_plugins, root).extended(
|
|
431
|
+
*advisory_sinks(affected), suffixes=advisory_suffix_sinks(affected)
|
|
432
|
+
)
|
|
428
433
|
members = models.members_by_class()
|
|
429
434
|
for manager in managers.values():
|
|
430
435
|
manager.provide(SecurityModelAnalysis, models)
|
|
@@ -480,6 +485,7 @@ def analyze_project(
|
|
|
480
485
|
reused = tuple(sorted(results))
|
|
481
486
|
|
|
482
487
|
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
|
|
488
|
+
suffixes = suffix_index(affected, analysable)
|
|
483
489
|
pool = None
|
|
484
490
|
if jobs > 1:
|
|
485
491
|
pool = concurrent.futures.ProcessPoolExecutor(
|
|
@@ -492,7 +498,9 @@ def analyze_project(
|
|
|
492
498
|
if pool is None:
|
|
493
499
|
for component in pending:
|
|
494
500
|
batch = {name: analysable[name] for name in sorted(component)}
|
|
495
|
-
computed.update(
|
|
501
|
+
computed.update(
|
|
502
|
+
_analyse_managers(batch, _seed(results, graph, component), module_plugins, affected, suffixes)
|
|
503
|
+
)
|
|
496
504
|
for manager in batch.values():
|
|
497
505
|
manager.run(ResultsEvicted)
|
|
498
506
|
else:
|
|
@@ -509,6 +517,7 @@ def analyze_project(
|
|
|
509
517
|
_encode_routes(routes),
|
|
510
518
|
tuple(sorted(escaped)),
|
|
511
519
|
template_calls,
|
|
520
|
+
project=tuple(sorted(analysable)),
|
|
512
521
|
),
|
|
513
522
|
)
|
|
514
523
|
for component in pending
|
|
@@ -734,6 +743,7 @@ def _analyse_managers(
|
|
|
734
743
|
seed: SummaryIndex,
|
|
735
744
|
plugins: tuple[Plugin, ...],
|
|
736
745
|
affected: Affected,
|
|
746
|
+
suffixes: SuffixIndex,
|
|
737
747
|
) -> dict[str, CachedModule]:
|
|
738
748
|
"""Analyse one component: iterate its summaries to a fixpoint over ``seed`` (§21),
|
|
739
749
|
then extract what the rest of the run needs from each module. Each member registers
|
|
@@ -762,7 +772,7 @@ def _analyse_managers(
|
|
|
762
772
|
for manager in managers.values():
|
|
763
773
|
manager.run(ProjectSummariesUpdated)
|
|
764
774
|
manager.provide(ProjectSummaries, index)
|
|
765
|
-
return {name: _analyse_module(manager, plugins, affected) for name, manager in managers.items()}
|
|
775
|
+
return {name: _analyse_module(manager, plugins, affected, suffixes) for name, manager in managers.items()}
|
|
766
776
|
|
|
767
777
|
|
|
768
778
|
@dataclass(frozen=True)
|
|
@@ -780,6 +790,9 @@ class _Batch:
|
|
|
780
790
|
routes: tuple[tuple[str, str, str, int], ...] = ()
|
|
781
791
|
escaped: tuple[str, ...] = ()
|
|
782
792
|
template_calls: TemplateCalls = field(default_factory=TemplateCalls)
|
|
793
|
+
# Every analysable module of the project, not only the component's: a curated
|
|
794
|
+
# entry-point suffix matches derived symbols of any project module.
|
|
795
|
+
project: tuple[str, ...] = ()
|
|
783
796
|
|
|
784
797
|
|
|
785
798
|
def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
@@ -796,7 +809,9 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
|
796
809
|
continue
|
|
797
810
|
advisories, _ = _merge_advisories(_contributions(registry, batch.plugins, batch.root, file_advisories))
|
|
798
811
|
affected = affected_symbols(dependencies, advisories)
|
|
799
|
-
models = plugin_models(all_plugins, batch.root).extended(
|
|
812
|
+
models = plugin_models(all_plugins, batch.root).extended(
|
|
813
|
+
*advisory_sinks(affected), suffixes=advisory_suffix_sinks(affected)
|
|
814
|
+
)
|
|
800
815
|
routes = _decode_routes(batch.routes)
|
|
801
816
|
escaped = frozenset(batch.escaped)
|
|
802
817
|
members = models.members_by_class()
|
|
@@ -810,7 +825,8 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
|
|
|
810
825
|
manager.provide(ClearingAnalysis, models.clearing(escaped))
|
|
811
826
|
manager.provide(TemplateCallsAnalysis, batch.template_calls)
|
|
812
827
|
module_plugins = tuple(p for p in all_plugins if not isinstance(p, ProjectPlugin))
|
|
813
|
-
|
|
828
|
+
suffixes = suffix_index(affected, batch.project)
|
|
829
|
+
results = _analyse_managers(managers, decode_index(batch.seed), module_plugins, affected, suffixes)
|
|
814
830
|
return {name: encode(entry) for name, entry in results.items()}
|
|
815
831
|
|
|
816
832
|
|
|
@@ -860,7 +876,7 @@ def _configuration_key(
|
|
|
860
876
|
|
|
861
877
|
|
|
862
878
|
def _analyse_module(
|
|
863
|
-
manager: AnalysisManager, plugins: tuple[Plugin, ...], affected: Affected
|
|
879
|
+
manager: AnalysisManager, plugins: tuple[Plugin, ...], affected: Affected, suffixes: SuffixIndex
|
|
864
880
|
) -> CachedModule:
|
|
865
881
|
"""One module's findings, summaries, call sites and symbol reads: what the cache
|
|
866
882
|
keeps (§11)."""
|
|
@@ -875,8 +891,17 @@ def _analyse_module(
|
|
|
875
891
|
ssa = manager.get(SSAAnalysis, function)
|
|
876
892
|
defs = {i.result: i for block in ssa.blocks for i in block.instructions if i.result is not None}
|
|
877
893
|
urls = {flow: flow_url(flow, defs, strings) for flow in flows if flow.kinds & TaintKind.ADVISORY}
|
|
894
|
+
name = graph.name_of(function)
|
|
895
|
+
# A flow through a callee reaches the sink at another call: no receiver here.
|
|
896
|
+
prior = {
|
|
897
|
+
flow: graph.prior_calls_at(name, flow.location)
|
|
898
|
+
for flow in flows
|
|
899
|
+
if flow.kinds & TaintKind.ADVISORY and flow.through is None
|
|
900
|
+
}
|
|
878
901
|
correlated.extend(
|
|
879
|
-
correlate(
|
|
902
|
+
correlate(
|
|
903
|
+
name, flows, manager.get(RefutationAnalysis, function), affected, urls, suffixes, prior
|
|
904
|
+
)
|
|
880
905
|
)
|
|
881
906
|
return CachedModule(
|
|
882
907
|
manager.get(EntryPointAnalysis),
|
|
@@ -8,6 +8,7 @@ from coretrace_python.interprocedural.callgraph import (
|
|
|
8
8
|
ExternalSymbol,
|
|
9
9
|
KnownFunction,
|
|
10
10
|
ModuleFunction,
|
|
11
|
+
PriorCall,
|
|
11
12
|
Signature,
|
|
12
13
|
SignaturesAnalysis,
|
|
13
14
|
SymbolRead,
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
|
58
59
|
"ModuleGraph",
|
|
59
60
|
"Mutation",
|
|
60
61
|
"NonlocalWrite",
|
|
62
|
+
"PriorCall",
|
|
61
63
|
"ProjectSummaries",
|
|
62
64
|
"Signature",
|
|
63
65
|
"SignaturesAnalysis",
|
|
@@ -11,12 +11,13 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import re
|
|
13
13
|
from collections.abc import Mapping
|
|
14
|
-
from dataclasses import dataclass, field
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
15
|
from types import MappingProxyType
|
|
16
16
|
from typing import ClassVar
|
|
17
17
|
|
|
18
18
|
from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
|
|
19
|
-
from coretrace_python.cfg import CFGError
|
|
19
|
+
from coretrace_python.cfg import BlockId, CFGError
|
|
20
|
+
from coretrace_python.cfg.dominance import DominanceAnalysis, DominatorTree
|
|
20
21
|
from coretrace_python.hir import nodes
|
|
21
22
|
from coretrace_python.ir.defuse import DefUse, def_use
|
|
22
23
|
from coretrace_python.ir.lowering import (
|
|
@@ -27,6 +28,7 @@ from coretrace_python.ir.lowering import (
|
|
|
27
28
|
)
|
|
28
29
|
from coretrace_python.ir.model import (
|
|
29
30
|
Await,
|
|
31
|
+
BasicBlock,
|
|
30
32
|
BuildDict,
|
|
31
33
|
Call,
|
|
32
34
|
Constant,
|
|
@@ -93,12 +95,26 @@ class Arguments:
|
|
|
93
95
|
return None if self.unpacked else (False, None)
|
|
94
96
|
|
|
95
97
|
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class PriorCall:
|
|
100
|
+
"""Another call on the same receiver in this function; ``dominates`` says it surely
|
|
101
|
+
executes before this one."""
|
|
102
|
+
|
|
103
|
+
symbol: SymbolId
|
|
104
|
+
location: SourceSpan
|
|
105
|
+
arguments: Arguments
|
|
106
|
+
dominates: bool
|
|
107
|
+
|
|
108
|
+
|
|
96
109
|
@dataclass(frozen=True)
|
|
97
110
|
class CallSite:
|
|
98
111
|
caller: str
|
|
99
112
|
location: SourceSpan
|
|
100
113
|
target: Target
|
|
101
114
|
arguments: Arguments = field(default_factory=Arguments)
|
|
115
|
+
# None: no receiver, or one the engine cannot follow; a tuple is the complete set
|
|
116
|
+
# of other external calls on the receiver, in source order.
|
|
117
|
+
prior_calls: tuple[PriorCall, ...] | None = None
|
|
102
118
|
|
|
103
119
|
|
|
104
120
|
@dataclass(frozen=True)
|
|
@@ -201,6 +217,13 @@ class CallGraph:
|
|
|
201
217
|
site = self._at.get((caller, location))
|
|
202
218
|
return site.arguments if site is not None else Arguments()
|
|
203
219
|
|
|
220
|
+
def prior_calls_at(self, caller: str, location: SourceSpan) -> tuple[PriorCall, ...] | None:
|
|
221
|
+
"""The other calls on the receiver of the call at ``location`` in ``caller``;
|
|
222
|
+
None when there is no receiver, or one the engine cannot follow."""
|
|
223
|
+
|
|
224
|
+
site = self._at.get((caller, location))
|
|
225
|
+
return site.prior_calls if site is not None else None
|
|
226
|
+
|
|
204
227
|
def callees(self, caller: str) -> frozenset[str]:
|
|
205
228
|
return frozenset(
|
|
206
229
|
site.target.name for site in self.sites(caller) if isinstance(site.target, KnownFunction)
|
|
@@ -335,6 +358,105 @@ def _position(span: SourceSpan) -> tuple[int, int]:
|
|
|
335
358
|
return span.start_line, span.start_column
|
|
336
359
|
|
|
337
360
|
|
|
361
|
+
# A receiver carrying more calls than this is left unfollowed: the sibling pairing
|
|
362
|
+
# stays bounded, and such code is generated, not a client bound and used twice.
|
|
363
|
+
MAX_RECEIVER_CALLS = 12
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _relate_receivers(
|
|
367
|
+
sites: list[CallSite],
|
|
368
|
+
calls: list[tuple[Call, BlockId, int]],
|
|
369
|
+
function: FunctionIR,
|
|
370
|
+
defs: Mapping[Value, Instruction],
|
|
371
|
+
uses: DefUse,
|
|
372
|
+
symbols: Mapping[Value, SymbolId],
|
|
373
|
+
tree: DominatorTree,
|
|
374
|
+
) -> tuple[CallSite, ...]:
|
|
375
|
+
"""The ``sites`` with ``prior_calls`` filled wherever the receiver's whole lifetime
|
|
376
|
+
is visible, so the set of its other calls is complete and program order is decided
|
|
377
|
+
by dominance; any other receiver leaves ``None``. ``calls`` gives each site's
|
|
378
|
+
instruction with its block and index, aligned with ``sites``."""
|
|
379
|
+
|
|
380
|
+
groups: dict[Value, list[int]] = {}
|
|
381
|
+
for index, (call, _, _) in enumerate(calls):
|
|
382
|
+
callee = defs.get(call.callee)
|
|
383
|
+
if isinstance(callee, GetAttr):
|
|
384
|
+
groups.setdefault(callee.object, []).append(index)
|
|
385
|
+
related = list(sites)
|
|
386
|
+
blocks = {block.id: block for block in function.blocks}
|
|
387
|
+
for receiver, group in groups.items():
|
|
388
|
+
if not _followed(receiver, group, sites, blocks, defs, uses, symbols):
|
|
389
|
+
continue
|
|
390
|
+
ordered = sorted(group, key=lambda member: _position(sites[member].location))
|
|
391
|
+
for index in group:
|
|
392
|
+
_, block, at = calls[index]
|
|
393
|
+
related[index] = replace(
|
|
394
|
+
sites[index],
|
|
395
|
+
prior_calls=tuple(
|
|
396
|
+
PriorCall(
|
|
397
|
+
target.symbol,
|
|
398
|
+
sites[other].location,
|
|
399
|
+
sites[other].arguments,
|
|
400
|
+
_executes_before(calls[other], block, at, tree),
|
|
401
|
+
)
|
|
402
|
+
for other in ordered
|
|
403
|
+
if other != index and isinstance(target := sites[other].target, ExternalSymbol)
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
return tuple(related)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _followed(
|
|
410
|
+
receiver: Value,
|
|
411
|
+
group: list[int],
|
|
412
|
+
sites: list[CallSite],
|
|
413
|
+
blocks: Mapping[BlockId, BasicBlock],
|
|
414
|
+
defs: Mapping[Value, Instruction],
|
|
415
|
+
uses: DefUse,
|
|
416
|
+
symbols: Mapping[Value, SymbolId],
|
|
417
|
+
) -> bool:
|
|
418
|
+
"""Whether the receiver's whole lifetime is visible: bound once to the result of a
|
|
419
|
+
call or a ``with`` denoting a symbol, and read only to call external methods. Only
|
|
420
|
+
then is ``group`` complete enough to claim a prior call surely missing; a receiver
|
|
421
|
+
returned, passed to another call, mutated through an attribute or merged by a
|
|
422
|
+
``Phi`` may be called elsewhere, and stays unfollowed."""
|
|
423
|
+
|
|
424
|
+
if len(group) > MAX_RECEIVER_CALLS or receiver not in symbols:
|
|
425
|
+
return False
|
|
426
|
+
if any(not isinstance(sites[index].target, ExternalSymbol) for index in group):
|
|
427
|
+
return False
|
|
428
|
+
made = defs.get(receiver)
|
|
429
|
+
while isinstance(made, Await):
|
|
430
|
+
made = defs.get(made.value)
|
|
431
|
+
if not isinstance(made, Call | WithEnter):
|
|
432
|
+
return False
|
|
433
|
+
for use in uses.uses(receiver):
|
|
434
|
+
if use.index is None: # a terminator use, as a return, escapes
|
|
435
|
+
return False
|
|
436
|
+
read = blocks[use.block].instructions[use.index]
|
|
437
|
+
if not isinstance(read, GetAttr):
|
|
438
|
+
return False
|
|
439
|
+
for called in uses.uses(read.result):
|
|
440
|
+
if called.index is None:
|
|
441
|
+
return False
|
|
442
|
+
call = blocks[called.block].instructions[called.index]
|
|
443
|
+
if not isinstance(call, Call) or call.callee != read.result or read.result in call.argument_values():
|
|
444
|
+
return False
|
|
445
|
+
return True
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _executes_before(
|
|
449
|
+
other: tuple[Call, BlockId, int], block: BlockId, index: int, tree: DominatorTree
|
|
450
|
+
) -> bool:
|
|
451
|
+
"""Whether ``other`` surely executes before the call at ``index`` in ``block``: it
|
|
452
|
+
comes earlier in the same block, or its block dominates this one."""
|
|
453
|
+
|
|
454
|
+
_, other_block, other_index = other
|
|
455
|
+
if other_block == block:
|
|
456
|
+
return other_index < index
|
|
457
|
+
return tree.dominates(other_block, block)
|
|
458
|
+
|
|
459
|
+
|
|
338
460
|
def resolve_targets(
|
|
339
461
|
function: FunctionIR,
|
|
340
462
|
scopes: ScopeTable,
|
|
@@ -471,7 +593,7 @@ def _typed_with_module_classes(
|
|
|
471
593
|
class CallGraphAnalysis(Analysis[CallGraph]):
|
|
472
594
|
name: ClassVar[str] = "interprocedural.callgraph"
|
|
473
595
|
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset(
|
|
474
|
-
{SSAAnalysis, ScopeAnalysis, SymbolAnalysis, SignaturesAnalysis}
|
|
596
|
+
{SSAAnalysis, ScopeAnalysis, SymbolAnalysis, SignaturesAnalysis, DominanceAnalysis}
|
|
475
597
|
)
|
|
476
598
|
|
|
477
599
|
@classmethod
|
|
@@ -537,8 +659,9 @@ class CallGraphAnalysis(Analysis[CallGraph]):
|
|
|
537
659
|
defs = {i.result: i for block in ssa.blocks for i in block.instructions if i.result is not None}
|
|
538
660
|
uses = def_use(ssa)
|
|
539
661
|
found: list[CallSite] = []
|
|
662
|
+
calls: list[tuple[Call, BlockId, int]] = []
|
|
540
663
|
for block in ssa.blocks:
|
|
541
|
-
for instruction in block.instructions:
|
|
664
|
+
for index, instruction in enumerate(block.instructions):
|
|
542
665
|
if isinstance(instruction, Call):
|
|
543
666
|
found.append(
|
|
544
667
|
CallSite(
|
|
@@ -548,6 +671,9 @@ class CallGraphAnalysis(Analysis[CallGraph]):
|
|
|
548
671
|
_arguments(instruction, symbols[name], defs, uses),
|
|
549
672
|
)
|
|
550
673
|
)
|
|
551
|
-
|
|
674
|
+
calls.append((instruction, block.id, index))
|
|
675
|
+
sites[name] = _relate_receivers(
|
|
676
|
+
found, calls, ssa, defs, uses, symbols[name], ctx.get(DominanceAnalysis, function)
|
|
677
|
+
)
|
|
552
678
|
reads[name] = _reads(name, ssa, symbols[name])
|
|
553
679
|
return CallGraph(definitions, sites, frozenset(unsupported), symbols, reads)
|
coretrace_python/taint/models.py
CHANGED
|
@@ -356,20 +356,23 @@ class ModelTable:
|
|
|
356
356
|
return None
|
|
357
357
|
|
|
358
358
|
def sink(self, symbol: SymbolId) -> Sink | None:
|
|
359
|
+
# An exact sink and the matching suffix sinks compose: an advisory sink on a
|
|
360
|
+
# derived symbol must not shadow ``objects.extra``'s SQL kind, nor conversely.
|
|
359
361
|
found = self._by_symbol[Sink].get(symbol)
|
|
360
|
-
if isinstance(found, Sink)
|
|
361
|
-
return found
|
|
362
|
+
merged = found if isinstance(found, Sink) else None
|
|
362
363
|
for suffix in self.suffix_sinks:
|
|
363
364
|
if symbol.canonical_name.endswith(f".{suffix.suffix}"):
|
|
364
|
-
|
|
365
|
-
|
|
365
|
+
matched = Sink(symbol, suffix.kinds, suffix.positions)
|
|
366
|
+
merged = matched if merged is None else merged.merged(matched)
|
|
367
|
+
return merged
|
|
366
368
|
|
|
367
369
|
def route_registrar(self, symbol: SymbolId) -> RouteRegistrar | None:
|
|
368
370
|
found = self._by_symbol[RouteRegistrar].get(symbol)
|
|
369
371
|
return found if isinstance(found, RouteRegistrar) else None
|
|
370
372
|
|
|
371
|
-
def extended(self, *sinks: Sink) -> ModelTable:
|
|
372
|
-
"""A table with extra sinks; a sink already present gains the
|
|
373
|
+
def extended(self, *sinks: Sink, suffixes: tuple[SuffixSink, ...] = ()) -> ModelTable:
|
|
374
|
+
"""A table with extra sinks and suffix sinks; a sink already present gains the
|
|
375
|
+
new kinds, a suffix sink already present is kept once."""
|
|
373
376
|
|
|
374
377
|
merged = {sink.symbol: sink for sink in self.sinks}
|
|
375
378
|
for sink in sinks:
|
|
@@ -385,7 +388,7 @@ class ModelTable:
|
|
|
385
388
|
self.authorizations,
|
|
386
389
|
self.named_parameters,
|
|
387
390
|
self.route_registrars,
|
|
388
|
-
self.suffix_sinks,
|
|
391
|
+
self.suffix_sinks + tuple(s for s in suffixes if s not in self.suffix_sinks),
|
|
389
392
|
self.safe_arguments,
|
|
390
393
|
self.template_renders,
|
|
391
394
|
self.members,
|
{coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: coretrace-python-analyzer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.0
|
|
4
4
|
Summary: Static security analysis infrastructure for Python code
|
|
5
5
|
Project-URL: Homepage, https://github.com/CoreTrace/coretrace-python-analyzer
|
|
6
6
|
Project-URL: Repository, https://github.com/CoreTrace/coretrace-python-analyzer
|
{coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/RECORD
RENAMED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
coretrace_python/__init__.py,sha256=
|
|
1
|
+
coretrace_python/__init__.py,sha256=qxOYJ7AnuZXOGjD8kgEwDnqkaV4dOwzK_MuofIgTfc4,76
|
|
2
2
|
coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
|
|
3
|
-
coretrace_python/cache.py,sha256=
|
|
3
|
+
coretrace_python/cache.py,sha256=yrwI4echBNdOOJpfRMiZMeiUrnnP-dN4AHLwjUu6uSo,14559
|
|
4
4
|
coretrace_python/cli.py,sha256=G5jhb6g6RgSRSjjS7DBgziIjUM1vTFwalJGvFHVKxrg,11225
|
|
5
|
-
coretrace_python/engine.py,sha256=
|
|
5
|
+
coretrace_python/engine.py,sha256=NJZDfoEfPis0U4wm06vCqLFptdAv1s7V0IAvGdC6ujU,38676
|
|
6
6
|
coretrace_python/abstract/__init__.py,sha256=UK1yHTU4fldnW0W-e-nyPEOsqCnXqGPVv7LkxfyBMII,1021
|
|
7
7
|
coretrace_python/abstract/constants.py,sha256=z4-e7IzysxzGi58wFFff1MlEoV8l7AZsB9EL-q6b7vY,9071
|
|
8
8
|
coretrace_python/abstract/heap.py,sha256=uJAA0bYnN3-4fdESo6o4CzlYJSIpwlFLoyccacN6Uk8,9831
|
|
@@ -15,11 +15,11 @@ coretrace_python/analysis/provider.py,sha256=Q_OlEcoLzBAsNdAGWijbj_n3E2Ep5UQ4Z8_
|
|
|
15
15
|
coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py,sha256=_CJYHijv4DTgfdumcCqAVV5_MPWjfZOadxeM5pQdw_k,1845
|
|
16
16
|
coretrace_python/bundled/dependency/dependency_policy/plugin.toml,sha256=4w1TA-UKsa82WTnd1xy4bJuU3_G2RNCAWNICLPcZLMk,210
|
|
17
17
|
coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml,sha256=LuF9jQqZfplN_GBky69Xjq2pksaX1Fs8PYEir-7-vY8,275
|
|
18
|
-
coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py,sha256=
|
|
18
|
+
coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py,sha256=5hNA3UoCULDXgKC3gBIQOKD1oeEe_qZ3V-2y5vkmPsI,4362
|
|
19
19
|
coretrace_python/bundled/dependency/sample_advisories/plugin.toml,sha256=cME4pSFzkFsxmIJGGBSrmXqynAd0dg34X-rgEoZ_u6E,184
|
|
20
20
|
coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py,sha256=jw98GK6hh5RAx05t8QPcUzsFMfGigAL8418vZLprKlc,3710
|
|
21
21
|
coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml,sha256=4-OnbEb3lYVAoTfuz8mDPH4L9fHOPQ2KW1dDFAbClFk,267
|
|
22
|
-
coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py,sha256=
|
|
22
|
+
coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py,sha256=KT9L5GHUV6EsOb9sEnbKpwPx5hwpzxp2hC5RJ9G1ldc,3523
|
|
23
23
|
coretrace_python/bundled/models/aiohttp/aiohttp_models.py,sha256=X3ZYgbrezHbsP3D9rVt-qM1_Iecu6nT_Yz9H7-XGl4M,3422
|
|
24
24
|
coretrace_python/bundled/models/aiohttp/plugin.toml,sha256=YvU60RUBexYBtIxbXdmUhO83BoijmOe3MeQKwY8QDaM,220
|
|
25
25
|
coretrace_python/bundled/models/bottle/bottle_models.py,sha256=Y0SUMCNGiGhzcMj0uxBLhCbLFgM7sBc4XBB-C5LEuDw,2200
|
|
@@ -94,9 +94,9 @@ coretrace_python/dataflow/__init__.py,sha256=N5uY8VTNjEk6-ARCgDkxr6uGqQIz2NbF-U9
|
|
|
94
94
|
coretrace_python/dataflow/lattice.py,sha256=HgYGVcKVUlexvEc0yT47aQCudLxU_SWLpHhr17igkiE,1929
|
|
95
95
|
coretrace_python/dataflow/solver.py,sha256=iDsW0wujhM2DXplu2JMKC46BRsroXlNETHPEoikPeG4,3475
|
|
96
96
|
coretrace_python/dependency/__init__.py,sha256=BxkxnNwIEpAYtwbshCQovG-YXJ80QbwyzkmyJdbgsVg,1184
|
|
97
|
-
coretrace_python/dependency/advisories.py,sha256=
|
|
98
|
-
coretrace_python/dependency/correlation.py,sha256=
|
|
99
|
-
coretrace_python/dependency/graph.py,sha256=
|
|
97
|
+
coretrace_python/dependency/advisories.py,sha256=ktGkMWJifjCQotP3ulJ5xK2IasDmMoox2gsne-AwwHU,12649
|
|
98
|
+
coretrace_python/dependency/correlation.py,sha256=Z6GNsDK7Ji7E5xyZcJtinNdEQQToO9FbmV6ojDKwov0,16124
|
|
99
|
+
coretrace_python/dependency/graph.py,sha256=939KKNugO9ipdWlShOquwEefC5qif5JbaRNstj50XkE,19971
|
|
100
100
|
coretrace_python/dependency/policy.py,sha256=bsxZXL9xC0jnAzkzDh6ncnW8oKLG2H13jYmXF_q99Ng,2454
|
|
101
101
|
coretrace_python/dependency/sbom.py,sha256=rzJ-t0Mwoghej8nk0N63Dpjr_wYXm6JzuOPXjD8Kqs4,3234
|
|
102
102
|
coretrace_python/dependency/vex.py,sha256=bafWyziWWQP-Fjw1bvyOcOPsdFMwKy-sjMGWL7IPY3w,8048
|
|
@@ -113,8 +113,8 @@ coretrace_python/frontend/parser.py,sha256=6-CG7rWxUYeOGeDP206SJWy4BMQUP9Mr6Jf-t
|
|
|
113
113
|
coretrace_python/hir/__init__.py,sha256=xMpvj-RSp6bd78a_hhkqbe99-snjERkH-_Lv5o9ASbw,180
|
|
114
114
|
coretrace_python/hir/nodes.py,sha256=4BI0f3UhKbnz9ZIxMoKXM7ujav0BmRpD0OXaoblRxIk,12364
|
|
115
115
|
coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nahYNg,885
|
|
116
|
-
coretrace_python/interprocedural/__init__.py,sha256=
|
|
117
|
-
coretrace_python/interprocedural/callgraph.py,sha256=
|
|
116
|
+
coretrace_python/interprocedural/__init__.py,sha256=Wq0ipLA3pqoKoHgCSqYJPMJtLKkJdhrQQGhEVbBG5jE,1636
|
|
117
|
+
coretrace_python/interprocedural/callgraph.py,sha256=1vpzDscTI2mq0l3Xq7Cqgv7QEoZ49ILdPpDkaO_EJBE,29667
|
|
118
118
|
coretrace_python/interprocedural/modulegraph.py,sha256=xKlO_L2mcZi0RoG9h2ulacYecqUhM5BG1RrdC6_HUk8,13036
|
|
119
119
|
coretrace_python/interprocedural/summaries.py,sha256=6lybEynjtptZGGBw_3WKXx9ZZZqN96xL-r-tzo8fbMI,33862
|
|
120
120
|
coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
|
|
@@ -145,13 +145,13 @@ coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857Tp
|
|
|
145
145
|
coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
|
|
146
146
|
coretrace_python/taint/__init__.py,sha256=Da0nmvrxm9uKsmQogtHdc-FRrWo_xxpnLBDCSQ-xp9g,1867
|
|
147
147
|
coretrace_python/taint/engine.py,sha256=NvGhkBpBviCKE0BGHlgEgKacKC55fSxjwUwkaBz7pkk,54415
|
|
148
|
-
coretrace_python/taint/models.py,sha256=
|
|
148
|
+
coretrace_python/taint/models.py,sha256=Rzi6fSVEfNC913NDB9y5sR4kraZPKR7D4qRLAURMm_c,20827
|
|
149
149
|
coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
|
|
150
150
|
coretrace_python/taint/templates.py,sha256=gUpUvVk_UIVY2a3T5ENm2TfBuPCDhnu2lu8h50srZ4Q,43963
|
|
151
151
|
coretrace_python/taint/urls.py,sha256=kb0-uOT12ww7R3Jzs_lPKg9lFs74JI-bpa9pXX105xM,3166
|
|
152
|
-
coretrace_python_analyzer-0.
|
|
153
|
-
coretrace_python_analyzer-0.
|
|
154
|
-
coretrace_python_analyzer-0.
|
|
155
|
-
coretrace_python_analyzer-0.
|
|
156
|
-
coretrace_python_analyzer-0.
|
|
157
|
-
coretrace_python_analyzer-0.
|
|
152
|
+
coretrace_python_analyzer-0.21.0.dist-info/METADATA,sha256=znaywXU-dm6Y2uyWG9vgT4CGZFC-V25w9k5fIxzsFDE,3217
|
|
153
|
+
coretrace_python_analyzer-0.21.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
154
|
+
coretrace_python_analyzer-0.21.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
|
|
155
|
+
coretrace_python_analyzer-0.21.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
156
|
+
coretrace_python_analyzer-0.21.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
|
|
157
|
+
coretrace_python_analyzer-0.21.0.dist-info/RECORD,,
|
{coretrace_python_analyzer-0.20.0.dist-info → coretrace_python_analyzer-0.21.0.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|