coretrace-python-analyzer 0.5.0__py3-none-any.whl → 0.7.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 +52 -19
- coretrace_python/bundled/models/django/django_models.py +9 -0
- coretrace_python/bundled/models/http_clients/http_client_models.py +22 -13
- coretrace_python/bundled/models/python_stdlib/python_stdlib.py +25 -1
- coretrace_python/cache.py +16 -6
- coretrace_python/cli.py +33 -1
- coretrace_python/dependency/__init__.py +2 -0
- coretrace_python/dependency/advisories.py +16 -8
- coretrace_python/dependency/correlation.py +1 -13
- coretrace_python/dependency/graph.py +76 -9
- coretrace_python/dependency/policy.py +6 -2
- coretrace_python/dependency/vex.py +182 -0
- coretrace_python/engine.py +36 -4
- coretrace_python/findings/refutation.py +70 -5
- coretrace_python/interprocedural/__init__.py +2 -0
- coretrace_python/interprocedural/callgraph.py +50 -2
- coretrace_python/interprocedural/summaries.py +18 -8
- coretrace_python/taint/__init__.py +7 -0
- coretrace_python/taint/engine.py +49 -9
- coretrace_python/taint/models.py +73 -15
- coretrace_python/taint/templates.py +250 -0
- {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/METADATA +1 -1
- {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/RECORD +28 -26
- {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/WHEEL +0 -0
- {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/entry_points.txt +0 -0
- {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/licenses/NOTICE +0 -0
coretrace_python/__init__.py
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
|
-
"""Calls, anywhere in the project, to an API affected by a vulnerable requirement
|
|
1
|
+
"""Calls, anywhere in the project, to an API affected by a vulnerable requirement, and
|
|
2
|
+
reads of an affected attribute whose getter runs the vulnerable code."""
|
|
2
3
|
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
|
-
from collections.abc import Sequence
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
6
7
|
from typing import ClassVar
|
|
7
8
|
|
|
8
9
|
from coretrace_python.analysis import AnyAnalysis
|
|
9
|
-
from coretrace_python.dependency import DependencyAnalysis
|
|
10
|
-
from coretrace_python.dependency.correlation import
|
|
10
|
+
from coretrace_python.dependency import Advisory, DependencyAnalysis
|
|
11
|
+
from coretrace_python.dependency.correlation import (
|
|
12
|
+
ConditionCheck,
|
|
13
|
+
affected_symbols,
|
|
14
|
+
check_conditions,
|
|
15
|
+
evidence,
|
|
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
|
|
20
|
+
from coretrace_python.semantic.symbols import SymbolId
|
|
21
|
+
from coretrace_python.source import SourceSpan
|
|
14
22
|
|
|
15
23
|
|
|
16
24
|
class ReachableVulnerabilityPlugin(ProjectPlugin):
|
|
@@ -29,21 +37,46 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
|
|
|
29
37
|
if not isinstance(site.target, ExternalSymbol):
|
|
30
38
|
continue
|
|
31
39
|
for advisory in affected.get(site.target.symbol, ()):
|
|
32
|
-
|
|
40
|
+
entry = advisory.entry_point(site.target.symbol)
|
|
41
|
+
if entry is not None and entry.read:
|
|
42
|
+
continue # a call reads its callee first: reported with the reads
|
|
43
|
+
check = check_conditions(entry, site.arguments)
|
|
33
44
|
if check.contradicted is not None:
|
|
34
45
|
continue
|
|
35
|
-
findings.append(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
)
|
|
46
|
+
findings.append(_reached(advisory, site.target.symbol, site.location, function, check))
|
|
47
|
+
# Reading ``request.form.get`` reads ``request.form``: one finding per entry
|
|
48
|
+
# point and function, where the function first reads it.
|
|
49
|
+
reported: set[tuple[Advisory, SymbolId]] = set()
|
|
50
|
+
for read in graph.reads(function):
|
|
51
|
+
for symbol in _read_through(read.symbol):
|
|
52
|
+
for advisory in affected.get(symbol, ()):
|
|
53
|
+
entry = advisory.entry_point(symbol)
|
|
54
|
+
if entry is None or not entry.read or (advisory, symbol) in reported:
|
|
55
|
+
continue
|
|
56
|
+
reported.add((advisory, symbol))
|
|
57
|
+
check = check_conditions(entry, None)
|
|
58
|
+
findings.append(_reached(advisory, symbol, read.location, function, check))
|
|
49
59
|
return findings
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_through(symbol: SymbolId) -> Iterator[SymbolId]:
|
|
63
|
+
"""``symbol`` and every symbol it is an attribute of, as reading it reads them all."""
|
|
64
|
+
|
|
65
|
+
components = symbol.canonical_name.split(".")
|
|
66
|
+
for end in range(len(components), 1, -1):
|
|
67
|
+
yield SymbolId(".".join(components[:end]))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _reached(advisory: Advisory, symbol: SymbolId, span: SourceSpan, function: str, check: ConditionCheck) -> Finding:
|
|
71
|
+
return Finding(
|
|
72
|
+
rule_id="reachable-vulnerability",
|
|
73
|
+
message=(
|
|
74
|
+
f"{advisory.id}: {symbol} is affected in the required "
|
|
75
|
+
f"{advisory.package} {advisory.vulnerable}: {advisory.summary}"
|
|
76
|
+
),
|
|
77
|
+
severity=advisory.severity,
|
|
78
|
+
confidence=Confidence.HIGH,
|
|
79
|
+
span=span,
|
|
80
|
+
function=function,
|
|
81
|
+
metadata=evidence(advisory, symbol, "reachable", check),
|
|
82
|
+
)
|
|
@@ -23,6 +23,7 @@ from coretrace_python.taint import (
|
|
|
23
23
|
Source,
|
|
24
24
|
SuffixSink,
|
|
25
25
|
TaintKind,
|
|
26
|
+
TemplateRender,
|
|
26
27
|
TypedParameter,
|
|
27
28
|
)
|
|
28
29
|
|
|
@@ -119,7 +120,15 @@ class DjangoModels(ModelPlugin):
|
|
|
119
120
|
Sink(_sym("django.http.HttpResponseRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
|
|
120
121
|
Sink(_sym("django.http.HttpResponsePermanentRedirect"), TaintKind.REDIRECT, _TARGET_ONLY),
|
|
121
122
|
Sanitizer(_sym("django.utils.html.escape"), TaintKind.HTML),
|
|
123
|
+
TemplateRender(_sym("django.template.loader.render_to_string")),
|
|
122
124
|
Sanitizer(_sym("django.utils.html.conditional_escape"), TaintKind.HTML),
|
|
125
|
+
# The masked CSRF secret: ASCII letters and digits only, a malformed cookie is
|
|
126
|
+
# replaced before it is used.
|
|
127
|
+
Sanitizer(_sym("django.middleware.csrf.get_token"), TaintKind.ALL),
|
|
128
|
+
# A path of the project's URL configuration, never scheme-relative: attacker data
|
|
129
|
+
# in its arguments or query reaches no other host through a redirect.
|
|
130
|
+
*(Sanitizer(_sym(f"django.urls.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")),
|
|
131
|
+
*(Sanitizer(_sym(f"django.urls.base.{name}"), TaintKind.REDIRECT) for name in ("reverse", "reverse_lazy")),
|
|
123
132
|
*(AuthorizationGuard(_sym(decorator), label) for decorator, label in _AUTHORIZATION_DECORATORS),
|
|
124
133
|
# ``urlpatterns = [path('login/', views.log_in)]``: the referenced view is an
|
|
125
134
|
# entry point wherever it is defined; routers register viewsets.
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
"""Requests and httpx models: every request function is a SSRF sink
|
|
2
|
-
is an ``http-response`` source, so data
|
|
1
|
+
"""Requests and httpx models: every request function is a SSRF sink for the argument that
|
|
2
|
+
chooses the destination, and what it returns is an ``http-response`` source, so data
|
|
3
|
+
fetched from a remote server is untrusted. The destination is the URL — first, or second
|
|
4
|
+
after the method, or ``url=`` — or the prepared request a ``send`` takes; the body, the
|
|
5
|
+
headers and the query parameters do not choose the host."""
|
|
3
6
|
|
|
4
7
|
from __future__ import annotations
|
|
5
8
|
|
|
@@ -20,15 +23,18 @@ _CALLERS = (
|
|
|
20
23
|
"httpx.AsyncClient",
|
|
21
24
|
)
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"httpx.
|
|
29
|
-
"httpx.Client.
|
|
30
|
-
"httpx.AsyncClient.
|
|
31
|
-
)
|
|
26
|
+
# Where each function takes its destination, by position (the receiver of a method
|
|
27
|
+
# excluded) and by keyword, as its signature declares it.
|
|
28
|
+
_DESTINATIONS = {
|
|
29
|
+
**{f"{caller}.{method}": (0, "url") for caller in _CALLERS for method in _METHODS if method != "request"},
|
|
30
|
+
**{f"{caller}.request": (1, "url") for caller in _CALLERS},
|
|
31
|
+
"httpx.stream": (1, "url"),
|
|
32
|
+
"httpx.Client.stream": (1, "url"),
|
|
33
|
+
"httpx.AsyncClient.stream": (1, "url"),
|
|
34
|
+
"requests.Session.send": (0, "request"),
|
|
35
|
+
"httpx.Client.send": (0, "request"),
|
|
36
|
+
"httpx.AsyncClient.send": (0, "request"),
|
|
37
|
+
}
|
|
32
38
|
|
|
33
39
|
|
|
34
40
|
def _sym(path: str) -> SymbolId:
|
|
@@ -38,6 +44,9 @@ def _sym(path: str) -> SymbolId:
|
|
|
38
44
|
class HttpClientModels(ModelPlugin):
|
|
39
45
|
name: ClassVar[str] = "http-client-models"
|
|
40
46
|
models: ClassVar[tuple[Model, ...]] = (
|
|
41
|
-
*(
|
|
42
|
-
|
|
47
|
+
*(
|
|
48
|
+
Sink(_sym(function), TaintKind.SSRF, ((TaintKind.SSRF, (position,)),), ((TaintKind.SSRF, (keyword,)),))
|
|
49
|
+
for function, (position, keyword) in _DESTINATIONS.items()
|
|
50
|
+
),
|
|
51
|
+
*(Source(_sym(function), "http-response") for function in _DESTINATIONS),
|
|
43
52
|
)
|
|
@@ -6,9 +6,28 @@ from typing import ClassVar
|
|
|
6
6
|
|
|
7
7
|
from coretrace_python.plugins import ModelPlugin
|
|
8
8
|
from coretrace_python.semantic.symbols import SymbolId
|
|
9
|
-
from coretrace_python.taint import
|
|
9
|
+
from coretrace_python.taint import (
|
|
10
|
+
Model,
|
|
11
|
+
SafeArgument,
|
|
12
|
+
Sanitizer,
|
|
13
|
+
Sink,
|
|
14
|
+
Source,
|
|
15
|
+
TaintKind,
|
|
16
|
+
Validator,
|
|
17
|
+
)
|
|
10
18
|
|
|
11
19
|
_ENVIRONMENT_KINDS = TaintKind.ALL & ~(TaintKind.COMMAND | TaintKind.PATH)
|
|
20
|
+
# SafeLoader and BaseLoader build plain data only, in Python and in C, under every spelling.
|
|
21
|
+
_SAFE_YAML_LOADERS = (
|
|
22
|
+
"python.yaml.SafeLoader",
|
|
23
|
+
"python.yaml.loader.SafeLoader",
|
|
24
|
+
"python.yaml.BaseLoader",
|
|
25
|
+
"python.yaml.loader.BaseLoader",
|
|
26
|
+
"python.yaml.CSafeLoader",
|
|
27
|
+
"python.yaml.cyaml.CSafeLoader",
|
|
28
|
+
"python.yaml.CBaseLoader",
|
|
29
|
+
"python.yaml.cyaml.CBaseLoader",
|
|
30
|
+
)
|
|
12
31
|
_PROCESS_OUTPUT_KINDS = TaintKind.ALL & ~TaintKind.PATH
|
|
13
32
|
|
|
14
33
|
|
|
@@ -66,6 +85,11 @@ class PythonStdlibModels(ModelPlugin):
|
|
|
66
85
|
Sink(_sym("dill.loads"), TaintKind.DESERIALIZATION),
|
|
67
86
|
Sink(_sym("jsonpickle.decode"), TaintKind.DESERIALIZATION),
|
|
68
87
|
Sink(_sym("yaml.load"), TaintKind.DESERIALIZATION),
|
|
88
|
+
Sink(_sym("yaml.load_all"), TaintKind.DESERIALIZATION),
|
|
89
|
+
Sink(_sym("yaml.full_load_all"), TaintKind.DESERIALIZATION),
|
|
90
|
+
Sink(_sym("yaml.unsafe_load_all"), TaintKind.DESERIALIZATION),
|
|
91
|
+
SafeArgument(_sym("yaml.load"), "Loader", _SAFE_YAML_LOADERS, position=1, kinds=TaintKind.DESERIALIZATION),
|
|
92
|
+
SafeArgument(_sym("yaml.load_all"), "Loader", _SAFE_YAML_LOADERS, position=1, kinds=TaintKind.DESERIALIZATION),
|
|
69
93
|
Sink(_sym("yaml.unsafe_load"), TaintKind.DESERIALIZATION),
|
|
70
94
|
Sink(_sym("yaml.full_load"), TaintKind.DESERIALIZATION),
|
|
71
95
|
Sanitizer(_sym("os.path.basename"), TaintKind.PATH),
|
coretrace_python/cache.py
CHANGED
|
@@ -4,9 +4,10 @@ A module's results are stored under a key derived from everything they depend on
|
|
|
4
4
|
source text and identity, the engine, schema and plugin API versions, the plugins and
|
|
5
5
|
their code, the security models, the advisories, the dependency graph, and the keys of
|
|
6
6
|
the project modules it imports transitively. A module whose key is unchanged on a later
|
|
7
|
-
run is served from the cache: its summaries seed the project index, its call sites
|
|
8
|
-
the project plugins and its findings are reported as they were.
|
|
9
|
-
tampered or foreign file can never execute anything; an
|
|
7
|
+
run is served from the cache: its summaries seed the project index, its call sites and
|
|
8
|
+
symbol reads serve the project plugins and its findings are reported as they were.
|
|
9
|
+
Entries are JSON, so a tampered or foreign file can never execute anything; an
|
|
10
|
+
unreadable entry is a miss.
|
|
10
11
|
"""
|
|
11
12
|
|
|
12
13
|
from __future__ import annotations
|
|
@@ -33,13 +34,14 @@ from coretrace_python.interprocedural import (
|
|
|
33
34
|
Mutation,
|
|
34
35
|
NonlocalWrite,
|
|
35
36
|
SummaryIndex,
|
|
37
|
+
SymbolRead,
|
|
36
38
|
Target,
|
|
37
39
|
UnknownTarget,
|
|
38
40
|
)
|
|
39
41
|
from coretrace_python.semantic.symbols import SymbolId
|
|
40
42
|
from coretrace_python.source import SourceId, SourceSpan
|
|
41
43
|
|
|
42
|
-
CACHE_FORMAT =
|
|
44
|
+
CACHE_FORMAT = 9
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
@dataclass(frozen=True)
|
|
@@ -50,6 +52,7 @@ class CachedModule:
|
|
|
50
52
|
summaries: Mapping[str, FunctionSummary]
|
|
51
53
|
sites: tuple[CallSite, ...]
|
|
52
54
|
findings: tuple[Finding, ...]
|
|
55
|
+
reads: tuple[SymbolRead, ...] = ()
|
|
53
56
|
|
|
54
57
|
def __post_init__(self) -> None:
|
|
55
58
|
object.__setattr__(self, "summaries", MappingProxyType(dict(self.summaries)))
|
|
@@ -134,6 +137,7 @@ def encode(module: CachedModule) -> dict[str, Any]:
|
|
|
134
137
|
"summaries": {name: _encode_summary(s) for name, s in module.summaries.items()},
|
|
135
138
|
"sites": [_encode_site(site) for site in module.sites],
|
|
136
139
|
"findings": [_encode_finding(finding) for finding in module.findings],
|
|
140
|
+
"reads": [[r.function, _encode_span(r.location), str(r.symbol)] for r in module.reads],
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
|
|
@@ -145,6 +149,7 @@ def decode(data: Mapping[str, Any]) -> CachedModule:
|
|
|
145
149
|
{_string(name): _decode_summary(s) for name, s in data["summaries"].items()},
|
|
146
150
|
tuple(_decode_site(site) for site in data["sites"]),
|
|
147
151
|
tuple(_decode_finding(finding) for finding in data["findings"]),
|
|
152
|
+
tuple(_decode_read(read) for read in data["reads"]),
|
|
148
153
|
)
|
|
149
154
|
|
|
150
155
|
|
|
@@ -194,6 +199,11 @@ def _decode_function(data: Any) -> ModuleFunction:
|
|
|
194
199
|
return ModuleFunction(_string(name), _decode_span(span), None if entry_point is None else _string(entry_point))
|
|
195
200
|
|
|
196
201
|
|
|
202
|
+
def _decode_read(data: Any) -> SymbolRead:
|
|
203
|
+
function, span, symbol = data
|
|
204
|
+
return SymbolRead(_string(function), _decode_span(span), SymbolId(_string(symbol)))
|
|
205
|
+
|
|
206
|
+
|
|
197
207
|
def _encode_finding(finding: Finding) -> dict[str, Any]:
|
|
198
208
|
return {
|
|
199
209
|
"rule": finding.rule_id,
|
|
@@ -223,7 +233,7 @@ def _encode_call(call: ExternalCall) -> dict[str, Any]:
|
|
|
223
233
|
return {
|
|
224
234
|
"symbol": str(call.symbol),
|
|
225
235
|
"arguments": [sorted(deps) for deps in call.argument_dependencies],
|
|
226
|
-
"keywords": sorted(call.keyword_dependencies
|
|
236
|
+
"keywords": [[name, sorted(deps)] for name, deps in call.keyword_dependencies],
|
|
227
237
|
"location": _encode_span(call.location),
|
|
228
238
|
"call_site": None if call.call_site is None else _encode_span(call.call_site),
|
|
229
239
|
"given": _encode_arguments(call.arguments),
|
|
@@ -235,7 +245,7 @@ def _decode_call(data: Mapping[str, Any]) -> ExternalCall:
|
|
|
235
245
|
return ExternalCall(
|
|
236
246
|
SymbolId(_string(data["symbol"])),
|
|
237
247
|
tuple(_indices(deps) for deps in data["arguments"]),
|
|
238
|
-
_indices(data["keywords"]),
|
|
248
|
+
tuple((None if name is None else _string(name), _indices(deps)) for name, deps in data["keywords"]),
|
|
239
249
|
_decode_span(data["location"]),
|
|
240
250
|
None if site is None else _decode_span(site),
|
|
241
251
|
_decode_arguments(data["given"]),
|
coretrace_python/cli.py
CHANGED
|
@@ -3,13 +3,20 @@ from __future__ import annotations
|
|
|
3
3
|
import argparse
|
|
4
4
|
import sys
|
|
5
5
|
import zipfile
|
|
6
|
+
from datetime import UTC, datetime
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from coretrace_python import __version__, engine
|
|
9
10
|
from coretrace_python.analysis import AnalysisError
|
|
10
11
|
from coretrace_python.cache import ProjectCache
|
|
11
12
|
from coretrace_python.cfg import CFGError
|
|
12
|
-
from coretrace_python.dependency import
|
|
13
|
+
from coretrace_python.dependency import (
|
|
14
|
+
dump_advisories,
|
|
15
|
+
import_osv,
|
|
16
|
+
read_osv,
|
|
17
|
+
render_sbom,
|
|
18
|
+
render_vex,
|
|
19
|
+
)
|
|
13
20
|
from coretrace_python.findings import Severity
|
|
14
21
|
from coretrace_python.findings.baseline import Baseline, BaselineError
|
|
15
22
|
from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
|
|
@@ -106,6 +113,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
106
113
|
metavar="PATH",
|
|
107
114
|
help="with --check on a directory, write a CycloneDX bill of materials to PATH",
|
|
108
115
|
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--vex",
|
|
118
|
+
type=Path,
|
|
119
|
+
default=None,
|
|
120
|
+
metavar="PATH",
|
|
121
|
+
help="with --check on a directory, write to PATH an OpenVEX document saying whether "
|
|
122
|
+
"each advisory affecting a requirement affects the project",
|
|
123
|
+
)
|
|
109
124
|
parser.add_argument(
|
|
110
125
|
"--advisories",
|
|
111
126
|
action="append",
|
|
@@ -182,6 +197,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
182
197
|
if args.sbom is not None and not (args.check and args.path.is_dir()):
|
|
183
198
|
print("error: --sbom only applies to --check on a directory", file=sys.stderr)
|
|
184
199
|
return EXIT_ERROR
|
|
200
|
+
if args.vex is not None and not (args.check and args.path.is_dir()):
|
|
201
|
+
print("error: --vex only applies to --check on a directory", file=sys.stderr)
|
|
202
|
+
return EXIT_ERROR
|
|
185
203
|
if args.advisories and not (args.check and args.path.is_dir()):
|
|
186
204
|
print("error: --advisories only applies to --check on a directory", file=sys.stderr)
|
|
187
205
|
return EXIT_ERROR
|
|
@@ -227,6 +245,20 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
227
245
|
render_sbom(analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__),
|
|
228
246
|
encoding="utf-8",
|
|
229
247
|
)
|
|
248
|
+
if args.vex is not None:
|
|
249
|
+
args.vex.write_text(
|
|
250
|
+
render_vex(
|
|
251
|
+
analysis.dependencies,
|
|
252
|
+
analysis.advisories,
|
|
253
|
+
(*analysis.findings, *analysis.suppressed, *analysis.accepted),
|
|
254
|
+
analysis.coverage,
|
|
255
|
+
args.path,
|
|
256
|
+
engine.TOOL_NAME,
|
|
257
|
+
__version__,
|
|
258
|
+
datetime.now(UTC),
|
|
259
|
+
),
|
|
260
|
+
encoding="utf-8",
|
|
261
|
+
)
|
|
230
262
|
else:
|
|
231
263
|
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots)
|
|
232
264
|
findings, coverage = file_analysis.findings, file_analysis.coverage
|
|
@@ -22,6 +22,7 @@ from coretrace_python.dependency.graph import (
|
|
|
22
22
|
)
|
|
23
23
|
from coretrace_python.dependency.policy import POLICY_FILE, Policy, apply_policy, load_policy
|
|
24
24
|
from coretrace_python.dependency.sbom import render_sbom
|
|
25
|
+
from coretrace_python.dependency.vex import render_vex
|
|
25
26
|
|
|
26
27
|
__all__ = [
|
|
27
28
|
"ADVISORY_FILE",
|
|
@@ -45,4 +46,5 @@ __all__ = [
|
|
|
45
46
|
"parse_dependencies",
|
|
46
47
|
"read_osv",
|
|
47
48
|
"render_sbom",
|
|
49
|
+
"render_vex",
|
|
48
50
|
]
|
|
@@ -138,14 +138,7 @@ def dump_advisories(advisories: Iterable[Advisory]) -> str:
|
|
|
138
138
|
"severity": a.severity.value,
|
|
139
139
|
"affected_symbols": [str(s) for s in a.affected_symbols],
|
|
140
140
|
"aliases": list(a.aliases),
|
|
141
|
-
"entry_points": [
|
|
142
|
-
{
|
|
143
|
-
"symbol": str(e.symbol),
|
|
144
|
-
"justification": e.justification,
|
|
145
|
-
"conditions": [_condition_entry(c) for c in e.conditions],
|
|
146
|
-
}
|
|
147
|
-
for e in a.entry_points
|
|
148
|
-
],
|
|
141
|
+
"entry_points": [_entry_point_entry(e) for e in a.entry_points],
|
|
149
142
|
"modules": list(a.modules),
|
|
150
143
|
}
|
|
151
144
|
for a in advisories
|
|
@@ -154,6 +147,17 @@ def dump_advisories(advisories: Iterable[Advisory]) -> str:
|
|
|
154
147
|
return json.dumps(document, indent=2) + "\n"
|
|
155
148
|
|
|
156
149
|
|
|
150
|
+
def _entry_point_entry(entry_point: AdvisoryEntryPoint) -> dict[str, Any]:
|
|
151
|
+
entry: dict[str, Any] = {
|
|
152
|
+
"symbol": str(entry_point.symbol),
|
|
153
|
+
"justification": entry_point.justification,
|
|
154
|
+
"conditions": [_condition_entry(c) for c in entry_point.conditions],
|
|
155
|
+
}
|
|
156
|
+
if entry_point.read:
|
|
157
|
+
entry["read"] = True
|
|
158
|
+
return entry
|
|
159
|
+
|
|
160
|
+
|
|
157
161
|
def _condition_entry(condition: Condition) -> dict[str, Any]:
|
|
158
162
|
entry: dict[str, Any] = {"kind": condition.kind, "text": condition.text}
|
|
159
163
|
if condition.argument is not None:
|
|
@@ -194,10 +198,14 @@ def _advisory(entry: Mapping[str, Any]) -> Advisory:
|
|
|
194
198
|
|
|
195
199
|
|
|
196
200
|
def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
|
|
201
|
+
read = entry.get("read", False)
|
|
202
|
+
if not isinstance(read, bool):
|
|
203
|
+
raise TypeError(f"entry point read must be true or false, got {read!r}")
|
|
197
204
|
return AdvisoryEntryPoint(
|
|
198
205
|
SymbolId(str(entry["symbol"])),
|
|
199
206
|
str(entry["justification"]),
|
|
200
207
|
tuple(_condition(c) for c in entry.get("conditions") or []),
|
|
208
|
+
read,
|
|
201
209
|
)
|
|
202
210
|
|
|
203
211
|
|
|
@@ -69,7 +69,7 @@ def check_conditions(entry: AdvisoryEntryPoint | None, arguments: Arguments | No
|
|
|
69
69
|
met: list[Condition] = []
|
|
70
70
|
pending: list[Condition] = []
|
|
71
71
|
for condition in entry.conditions:
|
|
72
|
-
given =
|
|
72
|
+
given = arguments.given(condition.argument, condition.position) if condition.checkable and arguments is not None else None
|
|
73
73
|
if given is None:
|
|
74
74
|
pending.append(condition)
|
|
75
75
|
continue
|
|
@@ -84,18 +84,6 @@ def check_conditions(entry: AdvisoryEntryPoint | None, arguments: Arguments | No
|
|
|
84
84
|
return ConditionCheck(tuple(met), tuple(pending))
|
|
85
85
|
|
|
86
86
|
|
|
87
|
-
def _given(condition: Condition, arguments: Arguments) -> tuple[bool, str | None] | None:
|
|
88
|
-
"""Whether the call gives the condition's argument, and what it denotes: ``(True,
|
|
89
|
-
value)`` when given, ``(False, None)`` when surely absent, None when it cannot tell."""
|
|
90
|
-
|
|
91
|
-
for name, value in arguments.keywords:
|
|
92
|
-
if name == condition.argument:
|
|
93
|
-
return True, value
|
|
94
|
-
if condition.position is not None and condition.position < len(arguments.positional):
|
|
95
|
-
return True, arguments.positional[condition.position]
|
|
96
|
-
return None if arguments.unpacked else (False, None)
|
|
97
|
-
|
|
98
|
-
|
|
99
87
|
def ruled_out(module: str, site: CallSite, check: ConditionCheck) -> str:
|
|
100
88
|
"""Which call a contradicted condition rules out, and why: ``app:12
|
|
101
89
|
python.yaml.load(Loader=python.yaml.SafeLoader)``."""
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Dependency resolution (architecture §26).
|
|
2
2
|
|
|
3
3
|
Requirements declared in ``requirements.txt``, ``pyproject.toml`` (PEP 621 and Poetry)
|
|
4
|
-
and pinned in ``poetry.lock`` or ``uv.lock`` become a ``DependencyGraph
|
|
4
|
+
and pinned in ``poetry.lock`` or ``uv.lock`` become a ``DependencyGraph``; a lock file
|
|
5
|
+
also tells which other packages require each package. Versions are
|
|
5
6
|
compared with a small PEP 440 subset (``==``, ``!=``, ``<``, ``<=``, ``>``, ``>=``,
|
|
6
7
|
``~=`` and Poetry's ``^``), enough to decide whether a requirement may allow a version an
|
|
7
8
|
advisory marks as vulnerable. Nothing is downloaded.
|
|
@@ -11,9 +12,9 @@ from __future__ import annotations
|
|
|
11
12
|
|
|
12
13
|
import re
|
|
13
14
|
import tomllib
|
|
14
|
-
from collections.abc import Iterable, Mapping
|
|
15
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
15
16
|
from dataclasses import dataclass
|
|
16
|
-
from pathlib import PurePath
|
|
17
|
+
from pathlib import PurePath, PurePosixPath
|
|
17
18
|
from types import MappingProxyType
|
|
18
19
|
from typing import Any, ClassVar
|
|
19
20
|
|
|
@@ -168,11 +169,16 @@ class Condition:
|
|
|
168
169
|
@dataclass(frozen=True)
|
|
169
170
|
class AdvisoryEntryPoint:
|
|
170
171
|
"""A public API through which a project reaches an affected symbol, justified by the
|
|
171
|
-
fixing commit or by a call path, with the conditions under which it is affected.
|
|
172
|
+
fixing commit or by a call path, with the conditions under which it is affected.
|
|
173
|
+
|
|
174
|
+
An entry point is reached by a call, or — when ``read`` is set, for an attribute whose
|
|
175
|
+
getter runs the affected code, such as a lazily parsed request body — by any read of
|
|
176
|
+
it or of an attribute of it."""
|
|
172
177
|
|
|
173
178
|
symbol: SymbolId
|
|
174
179
|
justification: str
|
|
175
180
|
conditions: tuple[Condition, ...] = ()
|
|
181
|
+
read: bool = False
|
|
176
182
|
|
|
177
183
|
|
|
178
184
|
DIRECT = "affected symbol, changed by the fix"
|
|
@@ -219,9 +225,15 @@ class Advisory:
|
|
|
219
225
|
|
|
220
226
|
|
|
221
227
|
class DependencyGraph:
|
|
222
|
-
def __init__(
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
requirements: Mapping[str, Requirement] | None = None,
|
|
231
|
+
errors: tuple[str, ...] = (),
|
|
232
|
+
dependents: Mapping[str, frozenset[str]] | None = None,
|
|
233
|
+
) -> None:
|
|
223
234
|
self._requirements = MappingProxyType(dict(sorted((requirements or {}).items())))
|
|
224
235
|
self.errors = errors
|
|
236
|
+
self._dependents = MappingProxyType(dict(dependents or {}))
|
|
225
237
|
|
|
226
238
|
@property
|
|
227
239
|
def names(self) -> tuple[str, ...]:
|
|
@@ -234,6 +246,12 @@ class DependencyGraph:
|
|
|
234
246
|
def requirement(self, name: str) -> Requirement | None:
|
|
235
247
|
return self._requirements.get(normalize(name))
|
|
236
248
|
|
|
249
|
+
def required_by(self, name: str) -> frozenset[str] | None:
|
|
250
|
+
"""The other packages a lock file shows requiring ``name`` — an empty set when
|
|
251
|
+
only the project does — or None when no lock file mentions it."""
|
|
252
|
+
|
|
253
|
+
return self._dependents.get(normalize(name))
|
|
254
|
+
|
|
237
255
|
def merge(self, other: DependencyGraph) -> DependencyGraph:
|
|
238
256
|
merged = dict(self._requirements)
|
|
239
257
|
for name, requirement in other._requirements.items():
|
|
@@ -248,7 +266,10 @@ class DependencyGraph:
|
|
|
248
266
|
requirement.pinned or current.pinned,
|
|
249
267
|
current.optional and requirement.optional,
|
|
250
268
|
)
|
|
251
|
-
|
|
269
|
+
dependents = dict(self._dependents)
|
|
270
|
+
for name, found in other._dependents.items():
|
|
271
|
+
dependents[name] = dependents.get(name, frozenset()) | found
|
|
272
|
+
return DependencyGraph(merged, self.errors + other.errors, dependents)
|
|
252
273
|
|
|
253
274
|
|
|
254
275
|
def parse_dependencies(source: SourceFile) -> DependencyGraph:
|
|
@@ -260,7 +281,7 @@ def parse_dependencies(source: SourceFile) -> DependencyGraph:
|
|
|
260
281
|
if name == "pyproject.toml":
|
|
261
282
|
return _parse_toml(source, _pyproject_requirements)
|
|
262
283
|
if name in ("poetry.lock", "uv.lock"):
|
|
263
|
-
return _parse_toml(source, _lock_requirements)
|
|
284
|
+
return _parse_toml(source, _lock_requirements, _lock_dependents)
|
|
264
285
|
return DependencyGraph()
|
|
265
286
|
|
|
266
287
|
|
|
@@ -276,7 +297,7 @@ def _parse_requirements_txt(source: SourceFile) -> DependencyGraph:
|
|
|
276
297
|
return DependencyGraph(found)
|
|
277
298
|
|
|
278
299
|
|
|
279
|
-
def _parse_toml(source: SourceFile, extract: Any) -> DependencyGraph:
|
|
300
|
+
def _parse_toml(source: SourceFile, extract: Any, dependents: Any = None) -> DependencyGraph:
|
|
280
301
|
try:
|
|
281
302
|
data = tomllib.loads(source.text)
|
|
282
303
|
except tomllib.TOMLDecodeError as error:
|
|
@@ -284,7 +305,7 @@ def _parse_toml(source: SourceFile, extract: Any) -> DependencyGraph:
|
|
|
284
305
|
found: dict[str, Requirement] = {}
|
|
285
306
|
for requirement in extract(data, source):
|
|
286
307
|
found[requirement.name] = requirement
|
|
287
|
-
return DependencyGraph(found)
|
|
308
|
+
return DependencyGraph(found, dependents=None if dependents is None else dependents(data))
|
|
288
309
|
|
|
289
310
|
|
|
290
311
|
def _line_of(source: SourceFile, key: str, default: int = 1) -> int:
|
|
@@ -335,6 +356,52 @@ def _lock_requirements(data: Mapping[str, Any], source: SourceFile) -> list[Requ
|
|
|
335
356
|
return found
|
|
336
357
|
|
|
337
358
|
|
|
359
|
+
def _lock_dependents(data: Mapping[str, Any]) -> dict[str, frozenset[str]]:
|
|
360
|
+
"""For every package a lock file lists or requires, the other locked packages that
|
|
361
|
+
require it, optional extras and development groups included. The project's own
|
|
362
|
+
packages — uv's editable or virtual sources inside the project — are not others."""
|
|
363
|
+
|
|
364
|
+
dependents: dict[str, set[str]] = {}
|
|
365
|
+
for package in data.get("package", []) or []:
|
|
366
|
+
name = package.get("name")
|
|
367
|
+
if not isinstance(name, str):
|
|
368
|
+
continue
|
|
369
|
+
dependents.setdefault(normalize(name), set())
|
|
370
|
+
own = _is_project_package(package)
|
|
371
|
+
for dependency in _locked_dependencies(package):
|
|
372
|
+
found = dependents.setdefault(dependency, set())
|
|
373
|
+
if not own:
|
|
374
|
+
found.add(normalize(name))
|
|
375
|
+
return {name: frozenset(found) for name, found in dependents.items()}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _locked_dependencies(package: Mapping[str, Any]) -> Iterator[str]:
|
|
379
|
+
declared = package.get("dependencies") or []
|
|
380
|
+
if isinstance(declared, Mapping): # poetry.lock: ``name = specifier``, optional ones too
|
|
381
|
+
yield from (normalize(str(name)) for name in declared)
|
|
382
|
+
return
|
|
383
|
+
groups = (
|
|
384
|
+
declared,
|
|
385
|
+
*(package.get("optional-dependencies") or {}).values(),
|
|
386
|
+
*(package.get("dev-dependencies") or {}).values(),
|
|
387
|
+
)
|
|
388
|
+
for group in groups:
|
|
389
|
+
for entry in group or []:
|
|
390
|
+
if isinstance(entry, Mapping) and isinstance(entry.get("name"), str):
|
|
391
|
+
yield normalize(entry["name"])
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _is_project_package(package: Mapping[str, Any]) -> bool:
|
|
395
|
+
source = package.get("source")
|
|
396
|
+
if not isinstance(source, Mapping):
|
|
397
|
+
return False
|
|
398
|
+
path = source.get("editable", source.get("virtual"))
|
|
399
|
+
if not isinstance(path, str):
|
|
400
|
+
return False
|
|
401
|
+
location = PurePosixPath(path)
|
|
402
|
+
return not location.is_absolute() and ".." not in location.parts
|
|
403
|
+
|
|
404
|
+
|
|
338
405
|
class DependencyAnalysis(Analysis[DependencyGraph]):
|
|
339
406
|
"""The project's dependency graph, provided by the engine; empty for a lone file."""
|
|
340
407
|
|
|
@@ -29,6 +29,11 @@ class Policy:
|
|
|
29
29
|
def denies(self, package: str) -> bool:
|
|
30
30
|
return normalize(package) in {normalize(name) for name in self.deny}
|
|
31
31
|
|
|
32
|
+
def accepts(self, finding: Finding) -> bool:
|
|
33
|
+
"""Whether ``finding`` is about an advisory the project accepts."""
|
|
34
|
+
|
|
35
|
+
return finding.metadata.get("advisory") in self.ignore
|
|
36
|
+
|
|
32
37
|
|
|
33
38
|
def load_policy(path: Path) -> Policy:
|
|
34
39
|
try:
|
|
@@ -61,5 +66,4 @@ def _boolean(value: Any, key: str) -> bool:
|
|
|
61
66
|
def apply_policy(policy: Policy, findings: Iterable[Finding]) -> tuple[Finding, ...]:
|
|
62
67
|
"""The findings minus those about an advisory the policy accepts."""
|
|
63
68
|
|
|
64
|
-
|
|
65
|
-
return tuple(f for f in findings if f.metadata.get("advisory") not in ignored)
|
|
69
|
+
return tuple(f for f in findings if not policy.accepts(f))
|