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.
Files changed (28) hide show
  1. coretrace_python/__init__.py +1 -1
  2. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +52 -19
  3. coretrace_python/bundled/models/django/django_models.py +9 -0
  4. coretrace_python/bundled/models/http_clients/http_client_models.py +22 -13
  5. coretrace_python/bundled/models/python_stdlib/python_stdlib.py +25 -1
  6. coretrace_python/cache.py +16 -6
  7. coretrace_python/cli.py +33 -1
  8. coretrace_python/dependency/__init__.py +2 -0
  9. coretrace_python/dependency/advisories.py +16 -8
  10. coretrace_python/dependency/correlation.py +1 -13
  11. coretrace_python/dependency/graph.py +76 -9
  12. coretrace_python/dependency/policy.py +6 -2
  13. coretrace_python/dependency/vex.py +182 -0
  14. coretrace_python/engine.py +36 -4
  15. coretrace_python/findings/refutation.py +70 -5
  16. coretrace_python/interprocedural/__init__.py +2 -0
  17. coretrace_python/interprocedural/callgraph.py +50 -2
  18. coretrace_python/interprocedural/summaries.py +18 -8
  19. coretrace_python/taint/__init__.py +7 -0
  20. coretrace_python/taint/engine.py +49 -9
  21. coretrace_python/taint/models.py +73 -15
  22. coretrace_python/taint/templates.py +250 -0
  23. {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/METADATA +1 -1
  24. {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/RECORD +28 -26
  25. {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/WHEEL +0 -0
  26. {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/entry_points.txt +0 -0
  27. {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/licenses/LICENSE +0 -0
  28. {coretrace_python_analyzer-0.5.0.dist-info → coretrace_python_analyzer-0.7.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
4
4
 
@@ -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 affected_symbols, check_conditions, evidence
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
- check = check_conditions(advisory.entry_point(site.target.symbol), site.arguments)
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
- Finding(
37
- rule_id="reachable-vulnerability",
38
- message=(
39
- f"{advisory.id}: {site.target.symbol} is affected in the required "
40
- f"{advisory.package} {advisory.vulnerable}: {advisory.summary}"
41
- ),
42
- severity=advisory.severity,
43
- confidence=Confidence.HIGH,
44
- span=site.location,
45
- function=function,
46
- metadata=evidence(advisory, site.target.symbol, "reachable", check),
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, and what it returns
2
- is an ``http-response`` source, so data fetched from a remote server is untrusted."""
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
- _REQUEST_FUNCTIONS = (
24
- *(f"{caller}.{method}" for caller in _CALLERS for method in _METHODS),
25
- "requests.Session.send",
26
- "httpx.stream",
27
- "httpx.Client.stream",
28
- "httpx.AsyncClient.stream",
29
- "httpx.Client.send",
30
- "httpx.AsyncClient.send",
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
- *(Sink(_sym(function), TaintKind.SSRF) for function in _REQUEST_FUNCTIONS),
42
- *(Source(_sym(function), "http-response") for function in _REQUEST_FUNCTIONS),
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 Model, Sanitizer, Sink, Source, TaintKind, Validator
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 serve
8
- the project plugins and its findings are reported as they were. Entries are JSON, so a
9
- tampered or foreign file can never execute anything; an unreadable entry is a miss.
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 = 7
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 dump_advisories, import_osv, read_osv, render_sbom
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 = _given(condition, arguments) if condition.checkable and arguments is not None else None
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``. Versions are
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__(self, requirements: Mapping[str, Requirement] | None = None, errors: tuple[str, ...] = ()) -> None:
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
- return DependencyGraph(merged, self.errors + other.errors)
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
- ignored = set(policy.ignore)
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))