coretrace-python-analyzer 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.6.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
+ )
@@ -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 = 8
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,
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))
@@ -0,0 +1,182 @@
1
+ """OpenVEX statements from the evidence of a check (architecture §26).
2
+
3
+ One statement per advisory affecting a requirement, with the required package as the
4
+ subcomponent of the project:
5
+
6
+ - ``affected`` when the project's code reaches the vulnerable code: a reachable or
7
+ exploitable finding, whatever the policy or a suppression did with it;
8
+ - ``not_affected``, as ``vulnerable_code_not_in_execute_path``, only when the advisory is
9
+ curated (it names entry points), no code of the project reaches them, every file and
10
+ function was analysed, and a lock file shows that no other package requires the
11
+ vulnerable one, since the code of installed packages is not analysed;
12
+ - ``under_investigation`` otherwise, with the reason in its notes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from collections.abc import Iterable, Sequence
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path, PurePosixPath
22
+ from urllib.parse import quote
23
+
24
+ from coretrace_python.dependency.graph import Advisory, DependencyGraph, Requirement
25
+ from coretrace_python.dependency.sbom import purl
26
+ from coretrace_python.findings import Finding
27
+ from coretrace_python.findings.coverage import Coverage
28
+
29
+ CONTEXT = "https://openvex.dev/ns/v0.2.0"
30
+ # OpenVEX's shared namespace for documents without an IRI of their own, and the author
31
+ # OpenVEX tools such as govulncheck write when none is known.
32
+ NAMESPACE = "https://openvex.dev/docs/public/vex-"
33
+ AUTHOR = "Unknown Author"
34
+ # Evidence levels that put the vulnerable code in the project's execution path, lowest first.
35
+ REACHED = ("reachable", "exploitable")
36
+
37
+
38
+ def render_vex(
39
+ dependencies: DependencyGraph,
40
+ advisories: Iterable[Advisory],
41
+ evidence: Iterable[Finding],
42
+ coverage: Coverage,
43
+ root: Path,
44
+ tool_name: str,
45
+ tool_version: str,
46
+ timestamp: datetime,
47
+ ) -> str:
48
+ """The OpenVEX document of the project at ``root``. ``evidence`` is every finding of
49
+ the check, those suppressed or accepted by the policy included. The document's
50
+ identifier derives from its statements, not from ``timestamp``."""
51
+
52
+ root = root.resolve()
53
+ findings = tuple(evidence)
54
+ # The product must be an IRI; the check knows the project by its directory only.
55
+ product = f"pkg:generic/{quote(root.name, safe='')}"
56
+ statements: list[dict[str, object]] = []
57
+ for advisory in sorted(advisories, key=lambda a: (a.id, a.package)):
58
+ about = [
59
+ f
60
+ for f in findings
61
+ if f.metadata.get("advisory") == advisory.id and f.metadata.get("package") == advisory.package
62
+ ]
63
+ for requirement in dependencies.requirements:
64
+ if advisory.affects(requirement):
65
+ statements.append(
66
+ {
67
+ "vulnerability": _vulnerability(advisory),
68
+ "products": [{"@id": product, "subcomponents": [{"@id": purl(requirement)}]}],
69
+ **_status(advisory, requirement, about, dependencies, coverage, root),
70
+ }
71
+ )
72
+ tooling = f"{tool_name} {tool_version}"
73
+ content = {
74
+ "@context": CONTEXT,
75
+ "author": AUTHOR,
76
+ "version": 1,
77
+ "tooling": tooling,
78
+ "statements": statements,
79
+ }
80
+ digest = hashlib.sha256(json.dumps(content, sort_keys=True).encode("utf-8")).hexdigest()
81
+ document = {
82
+ "@context": CONTEXT,
83
+ "@id": NAMESPACE + digest,
84
+ "author": AUTHOR,
85
+ "timestamp": timestamp.astimezone(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
86
+ "version": 1,
87
+ "tooling": tooling,
88
+ "statements": statements,
89
+ }
90
+ return json.dumps(document, indent=2) + "\n"
91
+
92
+
93
+ def _vulnerability(advisory: Advisory) -> dict[str, object]:
94
+ vulnerability: dict[str, object] = {"name": advisory.id, "description": advisory.summary}
95
+ if advisory.aliases:
96
+ vulnerability["aliases"] = list(advisory.aliases)
97
+ return vulnerability
98
+
99
+
100
+ def _status(
101
+ advisory: Advisory,
102
+ requirement: Requirement,
103
+ findings: Sequence[Finding],
104
+ dependencies: DependencyGraph,
105
+ coverage: Coverage,
106
+ root: Path,
107
+ ) -> dict[str, str]:
108
+ package = requirement.name
109
+ reached = _reached(findings, root)
110
+ if reached:
111
+ return {
112
+ "status": "affected",
113
+ "action_statement": (
114
+ f"Update {package} to a version outside the vulnerable range {advisory.vulnerable}."
115
+ ),
116
+ "status_notes": f"Reached by the project's code at {reached}.",
117
+ }
118
+ if not advisory.entry_points:
119
+ return _investigating(
120
+ "The advisory names no entry point, so whether the project reaches the vulnerable "
121
+ "code is not known."
122
+ )
123
+ symbols = ", ".join(sorted(str(e.symbol) for e in advisory.entry_points))
124
+ entries = f"the entry points of {advisory.id} ({symbols})"
125
+ ruled_out = "; ".join(str(f.metadata["ruled_out"]) for f in findings if "ruled_out" in f.metadata)
126
+ ruled = f" Calls ruled out by their arguments: {ruled_out}." if ruled_out else ""
127
+ partial = sorted(
128
+ _relative(d.path, root)
129
+ for d in coverage.details
130
+ if d.status != "analysed" or d.analysed < d.functions
131
+ )
132
+ if partial:
133
+ return _investigating(
134
+ f"No analysed code reaches {entries}, but {', '.join(partial)} could not be fully "
135
+ f"analysed.{ruled}"
136
+ )
137
+ required_by = dependencies.required_by(package)
138
+ if required_by is None:
139
+ return _investigating(
140
+ f"No code of the project reaches {entries}, but no lock file shows which installed "
141
+ f"packages require {package}, and their code is not analysed.{ruled}"
142
+ )
143
+ if required_by:
144
+ return _investigating(
145
+ f"No code of the project reaches {entries}, but {package} is required by "
146
+ f"{', '.join(sorted(required_by))}, whose code is not analysed.{ruled}"
147
+ )
148
+ return {
149
+ "status": "not_affected",
150
+ "justification": "vulnerable_code_not_in_execute_path",
151
+ "impact_statement": (
152
+ f"No code of the project reaches {entries}, and no other locked package requires "
153
+ f"{package}.{ruled}"
154
+ ),
155
+ }
156
+
157
+
158
+ def _reached(findings: Sequence[Finding], root: Path) -> str:
159
+ """Where the project's code reaches the vulnerable code, each place at its highest
160
+ level of evidence; empty when nothing reaches it."""
161
+
162
+ levels: dict[tuple[str, int, str], str] = {}
163
+ for finding in findings:
164
+ level = finding.metadata.get("level")
165
+ if level in REACHED:
166
+ path = _relative(str(finding.span.source_id), root)
167
+ place = (path, finding.span.start_line, str(finding.metadata.get("symbol")))
168
+ levels[place] = max(level, levels.get(place, level), key=REACHED.index)
169
+ return "; ".join(
170
+ f"{path}:{line} {symbol} ({level})" for (path, line, symbol), level in sorted(levels.items())
171
+ )
172
+
173
+
174
+ def _investigating(notes: str) -> dict[str, str]:
175
+ return {"status": "under_investigation", "status_notes": notes}
176
+
177
+
178
+ def _relative(path: str, root: Path) -> str:
179
+ try:
180
+ return PurePosixPath(Path(path).relative_to(root)).as_posix()
181
+ except ValueError:
182
+ return path
@@ -76,6 +76,7 @@ from coretrace_python.interprocedural import (
76
76
  ProjectSummaries,
77
77
  SummaryAnalysis,
78
78
  SummaryIndex,
79
+ SymbolRead,
79
80
  build_module_graph,
80
81
  discover_sources,
81
82
  project_symbol,
@@ -201,6 +202,8 @@ class ProjectAnalysis:
201
202
  coverage: Coverage = field(default_factory=Coverage)
202
203
  # Findings silenced by an inline ``# coretrace: ignore`` comment.
203
204
  suppressed: tuple[Finding, ...] = ()
205
+ # Findings about an advisory the policy accepts, still evidence of what is reached.
206
+ accepted: tuple[Finding, ...] = ()
204
207
 
205
208
 
206
209
  @dataclass(frozen=True)
@@ -466,7 +469,15 @@ def analyze_project(
466
469
  sites: dict[str, list[CallSite]] = {function.name: [] for function in entry.functions}
467
470
  for site in entry.sites:
468
471
  sites.setdefault(site.caller, []).append(site)
469
- call_graphs[name] = CallGraph({}, {f: tuple(s) for f, s in sites.items()}, frozenset())
472
+ reads: dict[str, list[SymbolRead]] = {}
473
+ for read in entry.reads:
474
+ reads.setdefault(read.function, []).append(read)
475
+ call_graphs[name] = CallGraph(
476
+ {},
477
+ {f: tuple(s) for f, s in sites.items()},
478
+ frozenset(),
479
+ reads={f: tuple(r) for f, r in reads.items()},
480
+ )
470
481
  context = ProjectContext(graph, dependencies, advisories, analysable, call_graphs, policy, root, functions)
471
482
  for plugin in all_plugins:
472
483
  if isinstance(plugin, ProjectPlugin):
@@ -474,6 +485,7 @@ def analyze_project(
474
485
  for plugin in all_plugins:
475
486
  if isinstance(plugin, ProjectPlugin):
476
487
  findings = list(apply_refinement(plugin, findings, plugin.refine(context, tuple(findings))))
488
+ accepted = tuple(f for f in findings if policy.accepts(f))
477
489
  kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources))
478
490
  return ProjectAnalysis(
479
491
  graph,
@@ -485,6 +497,7 @@ def analyze_project(
485
497
  advisories,
486
498
  Coverage(tuple(sorted(coverage, key=lambda c: c.path))),
487
499
  suppressed,
500
+ accepted,
488
501
  )
489
502
 
490
503
 
@@ -651,7 +664,8 @@ def _configuration_key(
651
664
  def _analyse_module(
652
665
  manager: AnalysisManager, plugins: tuple[Plugin, ...], affected: Affected
653
666
  ) -> CachedModule:
654
- """One module's findings, summaries and call sites: what the cache keeps (§11)."""
667
+ """One module's findings, summaries, call sites and symbol reads: what the cache
668
+ keeps (§11)."""
655
669
 
656
670
  findings, supported = _check_module(manager, plugins)
657
671
  graph = manager.get(CallGraphAnalysis)
@@ -671,6 +685,7 @@ def _analyse_module(
671
685
  _summaries_of(manager),
672
686
  tuple(site for function in graph.functions for site in graph.sites(function)),
673
687
  (*findings, *correlated),
688
+ tuple(read for function in graph.functions for read in graph.reads(function)),
674
689
  )
675
690
 
676
691
 
@@ -8,6 +8,7 @@ from coretrace_python.interprocedural.callgraph import (
8
8
  ExternalSymbol,
9
9
  KnownFunction,
10
10
  ModuleFunction,
11
+ SymbolRead,
11
12
  Target,
12
13
  UnknownTarget,
13
14
  )
@@ -46,6 +47,7 @@ __all__ = [
46
47
  "SummaryAnalysis",
47
48
  "SummaryIndex",
48
49
  "SummaryTable",
50
+ "SymbolRead",
49
51
  "Target",
50
52
  "UnknownTarget",
51
53
  "build_module_graph",
@@ -3,7 +3,8 @@
3
3
  Every call site of every analysable function resolves, through the SSA form, to a
4
4
  ``KnownFunction`` defined at module level, an ``ExternalSymbol`` reached through imports
5
5
  or builtins, or ``UnknownTarget`` (parameters, attributes, methods) until type inference
6
- and framework models narrow it down.
6
+ and framework models narrow it down. The graph also keeps the symbols each function
7
+ reads, called or not: reading ``request.form`` runs its getter.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -72,6 +73,18 @@ class Arguments:
72
73
  keywords: tuple[tuple[str, str | None], ...] = ()
73
74
  unpacked: bool = False
74
75
 
76
+ def given(self, keyword: str | None, position: int | None = None) -> tuple[bool, str | None] | None:
77
+ """Whether the call gives an argument, by ``keyword`` or at ``position``, and what it
78
+ denotes: ``(True, value)`` when given, ``(False, None)`` when surely absent, None
79
+ when the call cannot tell because it unpacks arguments."""
80
+
81
+ for name, value in self.keywords:
82
+ if name == keyword:
83
+ return True, value
84
+ if position is not None and position < len(self.positional):
85
+ return True, self.positional[position]
86
+ return None if self.unpacked else (False, None)
87
+
75
88
 
76
89
  @dataclass(frozen=True)
77
90
  class CallSite:
@@ -81,6 +94,16 @@ class CallSite:
81
94
  arguments: Arguments = field(default_factory=Arguments)
82
95
 
83
96
 
97
+ @dataclass(frozen=True)
98
+ class SymbolRead:
99
+ """Where ``function`` first reads ``symbol``: an imported name or an attribute of a
100
+ value denoting a symbol (``request.form`` in ``request.form['name']``)."""
101
+
102
+ function: str
103
+ location: SourceSpan
104
+ symbol: SymbolId
105
+
106
+
84
107
  @dataclass(frozen=True)
85
108
  class ModuleFunction:
86
109
  """One function of a module as a project plugin sees it: the name the call graph
@@ -99,8 +122,10 @@ class CallGraph:
99
122
  sites: Mapping[str, tuple[CallSite, ...]],
100
123
  unsupported: frozenset[str],
101
124
  symbols: Mapping[str, Mapping[Value, SymbolId]] | None = None,
125
+ reads: Mapping[str, tuple[SymbolRead, ...]] | None = None,
102
126
  ) -> None:
103
127
  self._symbols = {name: MappingProxyType(dict(found)) for name, found in (symbols or {}).items()}
128
+ self._reads = MappingProxyType(dict(reads or {}))
104
129
  self.definitions: Mapping[str, nodes.Function] = MappingProxyType(dict(definitions))
105
130
  # A graph rebuilt from cached call sites has sites but no definitions.
106
131
  self.functions = tuple(dict.fromkeys((*definitions, *sites)))
@@ -126,6 +151,11 @@ class CallGraph:
126
151
  def sites(self, caller: str) -> tuple[CallSite, ...]:
127
152
  return self._sites.get(caller, ())
128
153
 
154
+ def reads(self, function: str) -> tuple[SymbolRead, ...]:
155
+ """The symbols ``function`` reads, each where it first reads it."""
156
+
157
+ return self._reads.get(function, ())
158
+
129
159
  def target_at(self, caller: str, location: SourceSpan) -> Target:
130
160
  site = self._at.get((caller, location))
131
161
  return site.target if site is not None else UnknownTarget()
@@ -207,6 +237,22 @@ def _arguments(call: Call, symbols: Mapping[Value, SymbolId], defs: Mapping[Valu
207
237
  )
208
238
 
209
239
 
240
+ def _reads(name: str, function: FunctionIR, symbols: Mapping[Value, SymbolId]) -> tuple[SymbolRead, ...]:
241
+ first: dict[SymbolId, SymbolRead] = {}
242
+ for block in function.blocks:
243
+ for instruction in block.instructions:
244
+ if isinstance(instruction, Symbol | GetAttr) and instruction.result in symbols:
245
+ read = SymbolRead(name, instruction.location, symbols[instruction.result])
246
+ known = first.get(read.symbol)
247
+ if known is None or _position(read.location) < _position(known.location):
248
+ first[read.symbol] = read
249
+ return tuple(sorted(first.values(), key=lambda read: (_position(read.location), str(read.symbol))))
250
+
251
+
252
+ def _position(span: SourceSpan) -> tuple[int, int]:
253
+ return span.start_line, span.start_column
254
+
255
+
210
256
  def resolve_targets(
211
257
  function: FunctionIR,
212
258
  scopes: ScopeTable,
@@ -359,6 +405,7 @@ class CallGraphAnalysis(Analysis[CallGraph]):
359
405
  break
360
406
  sites: dict[str, tuple[CallSite, ...]] = {}
361
407
  symbols: dict[str, Mapping[Value, SymbolId]] = {}
408
+ reads: dict[str, tuple[SymbolRead, ...]] = {}
362
409
  unsupported: set[str] = set()
363
410
  for name, function in definitions.items():
364
411
  try:
@@ -395,4 +442,5 @@ class CallGraphAnalysis(Analysis[CallGraph]):
395
442
  )
396
443
  )
397
444
  sites[name] = tuple(found)
398
- return CallGraph(definitions, sites, frozenset(unsupported), symbols)
445
+ reads[name] = _reads(name, ssa, symbols[name])
446
+ return CallGraph(definitions, sites, frozenset(unsupported), symbols, reads)
@@ -16,6 +16,7 @@ from coretrace_python.taint.models import (
16
16
  ModelTable,
17
17
  NamedParameter,
18
18
  RouteRegistrar,
19
+ SafeArgument,
19
20
  Sanitizer,
20
21
  SecurityModelAnalysis,
21
22
  SecurityModelRegistry,
@@ -39,6 +40,7 @@ __all__ = [
39
40
  "RegisteredRoutes",
40
41
  "RouteRegistrar",
41
42
  "Routes",
43
+ "SafeArgument",
42
44
  "Sanitizer",
43
45
  "SecurityModelAnalysis",
44
46
  "SecurityModelRegistry",
@@ -396,9 +396,9 @@ class _TaintProblem(DataflowProblem[State]):
396
396
  ) -> Taint:
397
397
  """Sinks, sanitizers and sources of a call to an external symbol."""
398
398
 
399
- sink = self.models.sink(symbol)
399
+ given = self.graph.arguments_at(self.name, call.location)
400
+ sink = self.guarded(self.models.sink(symbol), given)
400
401
  if sink is not None:
401
- given = self.graph.arguments_at(self.name, call.location)
402
402
  for position, argument in enumerate(call.arguments):
403
403
  self.report(flows, sink, self.rendered(argument, state), argument, call, None, None, position, given)
404
404
  for argument in (*call.starred, *(value for _, value in call.keywords)):
@@ -413,6 +413,19 @@ class _TaintProblem(DataflowProblem[State]):
413
413
  return everything.join(Taint(source.kinds, frozenset({source})))
414
414
  return everything
415
415
 
416
+ def guarded(self, sink: Sink | None, arguments: Arguments) -> Sink | None:
417
+ """The sink as this call makes it: an argument declared safe (``yaml.load`` with
418
+ ``Loader=SafeLoader``) takes off the kinds it covers, and no kind left, no sink."""
419
+
420
+ if sink is None:
421
+ return None
422
+ safe = self.models.safe_argument(sink.symbol)
423
+ given = arguments.given(safe.argument, safe.position) if safe is not None else None
424
+ if safe is None or given is None or not given[0] or given[1] not in safe.values:
425
+ return sink
426
+ kinds = sink.kinds & ~safe.kinds
427
+ return Sink(sink.symbol, kinds, sink.positions) if kinds else None
428
+
416
429
  def rendered(self, argument: Value, state: Mapping[Key, Taint]) -> Taint:
417
430
  """The taint an argument brings to a sink. A container literal is serialised by
418
431
  every framework (``self.write({...})`` answers JSON), never rendered as markup, so
@@ -481,7 +494,7 @@ class _TaintProblem(DataflowProblem[State]):
481
494
  return taint, witness
482
495
 
483
496
  for reached in summary.external_calls:
484
- sink = self.models.sink(reached.symbol)
497
+ sink = self.guarded(self.models.sink(reached.symbol), reached.arguments)
485
498
  if sink is None:
486
499
  continue
487
500
  positions: list[int | None] = [*range(len(reached.argument_dependencies)), None]
@@ -149,6 +149,21 @@ class SuffixSink:
149
149
  return SymbolId(f"python.suffix.{self.suffix.replace('.', '_')}")
150
150
 
151
151
 
152
+ @dataclass(frozen=True)
153
+ class SafeArgument:
154
+ """A call to the sink ``symbol`` whose ``argument`` (by keyword, or at ``position``)
155
+ denotes one of ``values`` is not a sink for ``kinds``: ``yaml.load`` with
156
+ ``Loader=SafeLoader``. Values are written as call sites record them: a symbol by its
157
+ canonical name, a constant as Python writes it. Only a value given explicitly makes
158
+ the call safe; an absent argument, a variable or unpacked arguments leave the sink."""
159
+
160
+ symbol: SymbolId
161
+ argument: str
162
+ values: tuple[str, ...]
163
+ position: int | None = None
164
+ kinds: TaintKind = TaintKind.ALL
165
+
166
+
152
167
  @dataclass(frozen=True)
153
168
  class Validator:
154
169
  """A callable whose truth proves its ``argument`` safe (refutation evidence, §24)."""
@@ -178,6 +193,7 @@ Model = (
178
193
  | NamedParameter
179
194
  | RouteRegistrar
180
195
  | SuffixSink
196
+ | SafeArgument
181
197
  )
182
198
 
183
199
 
@@ -193,6 +209,7 @@ class ModelTable:
193
209
  named_parameters: tuple[NamedParameter, ...] = ()
194
210
  route_registrars: tuple[RouteRegistrar, ...] = ()
195
211
  suffix_sinks: tuple[SuffixSink, ...] = ()
212
+ safe_arguments: tuple[SafeArgument, ...] = ()
196
213
  _by_symbol: dict[type[Model], dict[SymbolId, Model]] = field(
197
214
  init=False, repr=False, compare=False
198
215
  )
@@ -207,6 +224,7 @@ class ModelTable:
207
224
  Validator: {m.symbol: m for m in self.validators},
208
225
  AuthorizationGuard: {m.symbol: m for m in self.authorizations},
209
226
  RouteRegistrar: {m.symbol: m for m in self.route_registrars},
227
+ SafeArgument: {m.symbol: m for m in self.safe_arguments},
210
228
  }
211
229
  object.__setattr__(self, "_by_symbol", MappingProxyType(index))
212
230
 
@@ -272,12 +290,17 @@ class ModelTable:
272
290
  self.named_parameters,
273
291
  self.route_registrars,
274
292
  self.suffix_sinks,
293
+ self.safe_arguments,
275
294
  )
276
295
 
277
296
  def sanitizer(self, symbol: SymbolId) -> Sanitizer | None:
278
297
  found = self._by_symbol[Sanitizer].get(symbol)
279
298
  return found if isinstance(found, Sanitizer) else None
280
299
 
300
+ def safe_argument(self, symbol: SymbolId) -> SafeArgument | None:
301
+ found = self._by_symbol[SafeArgument].get(symbol)
302
+ return found if isinstance(found, SafeArgument) else None
303
+
281
304
 
282
305
  class SecurityModelRegistry:
283
306
  """Mutable collection point for the models plugins register."""
@@ -323,6 +346,7 @@ class SecurityModelRegistry:
323
346
  named_parameters=tuple(m for m in models if isinstance(m, NamedParameter)),
324
347
  route_registrars=tuple(m for m in models if isinstance(m, RouteRegistrar)),
325
348
  suffix_sinks=tuple(m for m in models if isinstance(m, SuffixSink)),
349
+ safe_arguments=tuple(m for m in models if isinstance(m, SafeArgument)),
326
350
  )
327
351
 
328
352
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coretrace-python-analyzer
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -1,8 +1,8 @@
1
- coretrace_python/__init__.py,sha256=utnxu8MbeZzYTN5zz5v2YJUPEiq63GzimvSef3LaKIo,75
1
+ coretrace_python/__init__.py,sha256=MPB7k3sSK3YPPbcgkTbDkNy8vJRChFlClF8INwVFbTw,75
2
2
  coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
3
- coretrace_python/cache.py,sha256=D14p9uzdise9HtkIEJVLZXICt5jUPcDYUMxo2iuh9g0,12427
4
- coretrace_python/cli.py,sha256=ZE6F8wqNcE-ogfldii-UgUq4nOcEMnHuMCzz0l--yuw,9792
5
- coretrace_python/engine.py,sha256=ZXEPnio1cRSMme9HP2kmHJaYpychCRDYpZSyFosEZx0,27625
3
+ coretrace_python/cache.py,sha256=0FgPpr5frJrZ3HndMMNJwSmXtkdWYv1yvkvCDMENt5E,12823
4
+ coretrace_python/cli.py,sha256=SwXYUR8DIgppkqom6kKNM701pb4p2omEy-hRXIM5yZE,10946
5
+ coretrace_python/engine.py,sha256=Ou1gEoHThoi7eAqv12kIV7Wy16t6hCNVqzAEplUXCCI,28202
6
6
  coretrace_python/abstract/__init__.py,sha256=n5FNkV8Z4LmwG0r06KWdLj4N2CPSgAKwY3896tdmRgM,999
7
7
  coretrace_python/abstract/constants.py,sha256=z4-e7IzysxzGi58wFFff1MlEoV8l7AZsB9EL-q6b7vY,9071
8
8
  coretrace_python/abstract/heap.py,sha256=OaESXFGC0qzyhAr76xrCu4skeDVzZM4wlenQqfMTkdk,9642
@@ -14,7 +14,7 @@ coretrace_python/analysis/provider.py,sha256=Q_OlEcoLzBAsNdAGWijbj_n3E2Ep5UQ4Z8_
14
14
  coretrace_python/bundled/dependency/dependency_policy/dependency_policy.py,sha256=_CJYHijv4DTgfdumcCqAVV5_MPWjfZOadxeM5pQdw_k,1845
15
15
  coretrace_python/bundled/dependency/dependency_policy/plugin.toml,sha256=4w1TA-UKsa82WTnd1xy4bJuU3_G2RNCAWNICLPcZLMk,210
16
16
  coretrace_python/bundled/dependency/reachable_vulnerability/plugin.toml,sha256=LuF9jQqZfplN_GBky69Xjq2pksaX1Fs8PYEir-7-vY8,275
17
- coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py,sha256=FwEqdDbkyYOUzF2piVteFfSzEm75tCnk2qRe8RvRNxA,2414
17
+ coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py,sha256=QFI8fj-k1xMtRDNEej9HvxEB9c40IME-ZTPzVwoCxKk,3838
18
18
  coretrace_python/bundled/dependency/sample_advisories/plugin.toml,sha256=cME4pSFzkFsxmIJGGBSrmXqynAd0dg34X-rgEoZ_u6E,184
19
19
  coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py,sha256=jw98GK6hh5RAx05t8QPcUzsFMfGigAL8418vZLprKlc,3710
20
20
  coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml,sha256=4-OnbEb3lYVAoTfuz8mDPH4L9fHOPQ2KW1dDFAbClFk,267
@@ -38,7 +38,7 @@ coretrace_python/bundled/models/flask/plugin.toml,sha256=kI53Xwf5aZKd2n3fEQEbBzN
38
38
  coretrace_python/bundled/models/http_clients/http_client_models.py,sha256=TmVIg66zRRQxx3C9RT2XX6xLhemm037hIIBFRSyKzU0,1273
39
39
  coretrace_python/bundled/models/http_clients/plugin.toml,sha256=vq9tWHgy8-m8LiU1StGhm9Lsy7vnj3w90P4AKDaKfc0,216
40
40
  coretrace_python/bundled/models/python_stdlib/plugin.toml,sha256=DWqjG68rx91HCJi_VEp6tUs4ujkzlR7Lq7saMvtK2HM,187
41
- coretrace_python/bundled/models/python_stdlib/python_stdlib.py,sha256=Ko5_Wbir8ZDo7NNO1S_4BV2aVh-LvYh1maZuCxQ8las,3757
41
+ coretrace_python/bundled/models/python_stdlib/python_stdlib.py,sha256=3LmjcFx3rUhM3Mpw86q8bS5Xj6gRJs_p6M-8yBGiK2k,4632
42
42
  coretrace_python/bundled/models/sqlalchemy/plugin.toml,sha256=2gRbglnMK5U2QWnzs2U2pDvmTeWmrlW7XpJ_8dRmIoY,182
43
43
  coretrace_python/bundled/models/sqlalchemy/sqlalchemy_models.py,sha256=Gm2qjyXUrjzALy-MigUhPQyyDxYdfWlQKDZLp1C6Jqk,1697
44
44
  coretrace_python/bundled/models/tornado/plugin.toml,sha256=u_XjJK2uKBR11zg4yGxsCDHcx2pKEQ1MBvvB6Mm0siA,222
@@ -88,12 +88,13 @@ coretrace_python/cfg/model.py,sha256=6EJiXFAGrsMgCdyxMJ6TClY8tQhJF6Hq2bX802Gc0yg
88
88
  coretrace_python/dataflow/__init__.py,sha256=N5uY8VTNjEk6-ARCgDkxr6uGqQIz2NbF-U9sC_v_lfA,547
89
89
  coretrace_python/dataflow/lattice.py,sha256=HgYGVcKVUlexvEc0yT47aQCudLxU_SWLpHhr17igkiE,1929
90
90
  coretrace_python/dataflow/solver.py,sha256=iDsW0wujhM2DXplu2JMKC46BRsroXlNETHPEoikPeG4,3475
91
- coretrace_python/dependency/__init__.py,sha256=sZdigKRbM4NkV0Dr86FDF3rFRQz35pICVX6obgO2Rxo,1065
92
- coretrace_python/dependency/advisories.py,sha256=9g9teb2bzkHmnR9tB86AmtLLlVPhfedP8wITFwmXEXI,8381
93
- coretrace_python/dependency/correlation.py,sha256=7a-i-1HbemzIfHFvdx0GcVuZ7gyI0o-NOT7-6uYoR_w,7797
94
- coretrace_python/dependency/graph.py,sha256=0w6bWq7YNb_qcF7H-S-YGClhjS7JsK9Fh1i-w1ONpBQ,13910
95
- coretrace_python/dependency/policy.py,sha256=u6nUw2nX-kTuqmw_vooxN8eXZoC31ZMoMaxC9pCcwec,2317
91
+ coretrace_python/dependency/__init__.py,sha256=CXE36X2d5rio90NWlpjLo8Hly-tmzDCvu05FZAj_WXQ,1138
92
+ coretrace_python/dependency/advisories.py,sha256=tfR-5BmjV4GYcsF3CSGA4jSXNwOVG6mS8AqPfy2fWms,8653
93
+ coretrace_python/dependency/correlation.py,sha256=pR9S0tcdumdk2DMfUXxz6Wdul_RZE8FBfVmCUEhcA7w,7231
94
+ coretrace_python/dependency/graph.py,sha256=6UpAsqbtr5EoVGp6qkaQ7Gi1iUbGtulyh0hfpK_RqLg,16875
95
+ coretrace_python/dependency/policy.py,sha256=bsxZXL9xC0jnAzkzDh6ncnW8oKLG2H13jYmXF_q99Ng,2454
96
96
  coretrace_python/dependency/sbom.py,sha256=h4K3Gb9O_D6uZyfpwpC5rPcNjH_Ib-3uvFxix6OKlfs,2520
97
+ coretrace_python/dependency/vex.py,sha256=ZPASwL6Var269WifrU7g1NRlDf-jxKKs8LzMo8dUkxI,7237
97
98
  coretrace_python/findings/__init__.py,sha256=gnJI-uB2CkgZ8I5aFYP_N5yZb7FGNIy7LirVDh4NLwo,372
98
99
  coretrace_python/findings/baseline.py,sha256=fbGQuULRsCjhxSNdwuLfwsvql9ZUfF3jqCXrRmIgOoQ,3468
99
100
  coretrace_python/findings/coverage.py,sha256=U8URLEz6EN4B_TTQlLFCOtEmUbINt7a0Bexb-6OUFlI,1203
@@ -106,8 +107,8 @@ coretrace_python/frontend/parser.py,sha256=6-CG7rWxUYeOGeDP206SJWy4BMQUP9Mr6Jf-t
106
107
  coretrace_python/hir/__init__.py,sha256=xMpvj-RSp6bd78a_hhkqbe99-snjERkH-_Lv5o9ASbw,180
107
108
  coretrace_python/hir/nodes.py,sha256=4BI0f3UhKbnz9ZIxMoKXM7ujav0BmRpD0OXaoblRxIk,12364
108
109
  coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nahYNg,885
109
- coretrace_python/interprocedural/__init__.py,sha256=TCX98DRbO0rXFcohwixpDZUVo_9Mdnkbn8wtlBeiu7Q,1128
110
- coretrace_python/interprocedural/callgraph.py,sha256=NuXogZcdcJwRB_UYiGHGsIss0REjfiQueel7Tfo_4fI,17565
110
+ coretrace_python/interprocedural/__init__.py,sha256=SXtIGE3DD4fADIXLDuEvyT_3oIsh7Vio3BL4U6oUm28,1162
111
+ coretrace_python/interprocedural/callgraph.py,sha256=E6enTawqcl4-TObdjvoFe6l1zEedvsWbnjEBD6omzy8,19827
111
112
  coretrace_python/interprocedural/modulegraph.py,sha256=4sB7OKjBYfxtm7aMtHm2jRlqFVUNUsdnLhnv9UZ1BWA,8846
112
113
  coretrace_python/interprocedural/summaries.py,sha256=q--xpp1euaf5utxbK4CZtS8CSyLJ6p3MLel5Iu80X3w,20747
113
114
  coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
@@ -136,13 +137,13 @@ coretrace_python/semantic/symbols.py,sha256=3JptnnX5_KBlSd00L_wcGlYZQdnmBvSBAhfi
136
137
  coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1KdAXF0,301
137
138
  coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
138
139
  coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
139
- coretrace_python/taint/__init__.py,sha256=BuZVlVE4h7Kr873dW3PZ7miBfHQvlRRcqvEaJVvJAas,1161
140
- coretrace_python/taint/engine.py,sha256=4IFD5uFXG6QhPRBM9lY87hVCpfPLTAd7oTJZbveY5ZI,38180
141
- coretrace_python/taint/models.py,sha256=H-Q_nChzwqm-MEGfbfw5jMegpXlIfwUIunFiRXbE90I,12005
140
+ coretrace_python/taint/__init__.py,sha256=UIJwnPnyYODsvnpe6-cmF8tOvKoGvWE7yVH3yIfthcM,1199
141
+ coretrace_python/taint/engine.py,sha256=UY59sb-sUWct3jzFHr6PBfJ_VFhZgb13tSyvD9nKqaM,38916
142
+ coretrace_python/taint/models.py,sha256=23TMzT149dCpRULO3RAyzbZWmayXWw4-a5iRjrqC0yo,13074
142
143
  coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
143
- coretrace_python_analyzer-0.5.0.dist-info/METADATA,sha256=v8A0jSjku33W6bQ9nd7aWtBRxGh0GQziPYycy8G4Igc,3216
144
- coretrace_python_analyzer-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
145
- coretrace_python_analyzer-0.5.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
146
- coretrace_python_analyzer-0.5.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
147
- coretrace_python_analyzer-0.5.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
148
- coretrace_python_analyzer-0.5.0.dist-info/RECORD,,
144
+ coretrace_python_analyzer-0.6.0.dist-info/METADATA,sha256=WW3JCb3v2qbdWowN5kzDA1NRREuGErf-QysFU7k2kIU,3216
145
+ coretrace_python_analyzer-0.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
146
+ coretrace_python_analyzer-0.6.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
147
+ coretrace_python_analyzer-0.6.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
148
+ coretrace_python_analyzer-0.6.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
149
+ coretrace_python_analyzer-0.6.0.dist-info/RECORD,,