coretrace-python-analyzer 0.2.0__py3-none-any.whl → 0.4.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 (25) hide show
  1. coretrace_python/__init__.py +1 -1
  2. coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py +16 -26
  3. coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py +2 -0
  4. coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py +3 -0
  5. coretrace_python/cache.py +11 -8
  6. coretrace_python/dependency/__init__.py +4 -0
  7. coretrace_python/dependency/advisories.py +42 -5
  8. coretrace_python/dependency/correlation.py +70 -44
  9. coretrace_python/dependency/graph.py +74 -5
  10. coretrace_python/engine.py +18 -8
  11. coretrace_python/interprocedural/callgraph.py +9 -2
  12. coretrace_python/interprocedural/modulegraph.py +4 -1
  13. coretrace_python/ir/lowering.py +57 -3
  14. coretrace_python/plugins/__init__.py +4 -0
  15. coretrace_python/plugins/api.py +47 -1
  16. coretrace_python/plugins/loader.py +18 -2
  17. coretrace_python/semantic/scopes.py +3 -1
  18. coretrace_python/taint/engine.py +25 -5
  19. coretrace_python/taint/models.py +33 -12
  20. {coretrace_python_analyzer-0.2.0.dist-info → coretrace_python_analyzer-0.4.0.dist-info}/METADATA +1 -1
  21. {coretrace_python_analyzer-0.2.0.dist-info → coretrace_python_analyzer-0.4.0.dist-info}/RECORD +25 -25
  22. {coretrace_python_analyzer-0.2.0.dist-info → coretrace_python_analyzer-0.4.0.dist-info}/WHEEL +1 -1
  23. {coretrace_python_analyzer-0.2.0.dist-info → coretrace_python_analyzer-0.4.0.dist-info}/entry_points.txt +0 -0
  24. {coretrace_python_analyzer-0.2.0.dist-info → coretrace_python_analyzer-0.4.0.dist-info}/licenses/LICENSE +0 -0
  25. {coretrace_python_analyzer-0.2.0.dist-info → coretrace_python_analyzer-0.4.0.dist-info}/licenses/NOTICE +0 -0
@@ -1,4 +1,4 @@
1
1
  """CoreTrace's Python static analysis frontend."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
4
4
 
@@ -7,6 +7,7 @@ from typing import ClassVar
7
7
 
8
8
  from coretrace_python.analysis import AnyAnalysis
9
9
  from coretrace_python.dependency import DependencyAnalysis
10
+ from coretrace_python.dependency.correlation import affected_symbols, evidence
10
11
  from coretrace_python.findings import Confidence, Finding
11
12
  from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
12
13
  from coretrace_python.plugins import ProjectContext, ProjectPlugin
@@ -17,12 +18,7 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
17
18
  requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({DependencyAnalysis, CallGraphAnalysis})
18
19
 
19
20
  def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
20
- affected = {}
21
- for requirement in ctx.dependencies.requirements:
22
- for advisory in ctx.advisories:
23
- if advisory.affects(requirement):
24
- for symbol in advisory.affected_symbols:
25
- affected.setdefault(symbol, advisory)
21
+ affected = affected_symbols(ctx.dependencies, ctx.advisories)
26
22
  if not affected:
27
23
  return ()
28
24
  findings: list[Finding] = []
@@ -32,25 +28,19 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
32
28
  for site in graph.sites(function):
33
29
  if not isinstance(site.target, ExternalSymbol):
34
30
  continue
35
- advisory = affected.get(site.target.symbol)
36
- if advisory is None:
37
- continue
38
- findings.append(
39
- Finding(
40
- rule_id="reachable-vulnerability",
41
- message=(
42
- f"{advisory.id}: {site.target.symbol} is affected in the required "
43
- f"{advisory.package} {advisory.vulnerable}: {advisory.summary}"
44
- ),
45
- severity=advisory.severity,
46
- confidence=Confidence.HIGH,
47
- span=site.location,
48
- function=function,
49
- metadata={
50
- "advisory": advisory.id,
51
- "package": advisory.package,
52
- "symbol": str(site.target.symbol),
53
- },
31
+ for advisory in affected.get(site.target.symbol, ()):
32
+ findings.append(
33
+ Finding(
34
+ rule_id="reachable-vulnerability",
35
+ message=(
36
+ f"{advisory.id}: {site.target.symbol} is affected in the required "
37
+ f"{advisory.package} {advisory.vulnerable}: {advisory.summary}"
38
+ ),
39
+ severity=advisory.severity,
40
+ confidence=Confidence.HIGH,
41
+ span=site.location,
42
+ function=function,
43
+ metadata=evidence(advisory, site.target.symbol, "reachable"),
44
+ )
54
45
  )
55
- )
56
46
  return findings
@@ -28,6 +28,7 @@ class SampleAdvisories(ModelPlugin):
28
28
  "yaml.load and full_load can execute arbitrary code from untrusted documents",
29
29
  Severity.CRITICAL,
30
30
  (_sym("yaml.load"), _sym("yaml.full_load"), _sym("yaml.unsafe_load")),
31
+ modules=("yaml",),
31
32
  ),
32
33
  Advisory(
33
34
  "CVE-2018-18074",
@@ -105,5 +106,6 @@ class SampleAdvisories(ModelPlugin):
105
106
  Severity.HIGH,
106
107
  (_sym("PIL.Image.open"),),
107
108
  ("GHSA-cfh3-3jmp-rvhc",),
109
+ modules=("PIL",),
108
110
  ),
109
111
  )
@@ -17,11 +17,13 @@ class VulnerableDependencyPlugin(ProjectPlugin):
17
17
 
18
18
  def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
19
19
  findings: list[Finding] = []
20
+ imported = [s for module in ctx.modules for s in ctx.imports(module).all_symbols()]
20
21
  for requirement in ctx.dependencies.requirements:
21
22
  for advisory in ctx.advisories:
22
23
  if not advisory.affects(requirement):
23
24
  continue
24
25
  pinned = requirement.pinned is not None
26
+ level = "imported" if advisory.imported_by(imported) else "declared"
25
27
  findings.append(
26
28
  Finding(
27
29
  rule_id="vulnerable-dependency",
@@ -37,6 +39,7 @@ class VulnerableDependencyPlugin(ProjectPlugin):
37
39
  "advisory": advisory.id,
38
40
  "package": advisory.package,
39
41
  "specifier": requirement.specifier,
42
+ "level": level,
40
43
  },
41
44
  )
42
45
  )
coretrace_python/cache.py CHANGED
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  import hashlib
15
15
  import json
16
16
  import os
17
- from collections.abc import Iterable, Mapping
17
+ from collections.abc import Mapping
18
18
  from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
  from types import MappingProxyType
@@ -37,7 +37,7 @@ from coretrace_python.interprocedural import (
37
37
  from coretrace_python.semantic.symbols import SymbolId
38
38
  from coretrace_python.source import SourceId, SourceSpan
39
39
 
40
- CACHE_FORMAT = 4
40
+ CACHE_FORMAT = 5
41
41
 
42
42
 
43
43
  @dataclass(frozen=True)
@@ -64,15 +64,18 @@ def fingerprint(*parts: str) -> str:
64
64
  return digest.hexdigest()
65
65
 
66
66
 
67
- def directory_fingerprint(directory: Path, suffixes: Iterable[str] = (".py", ".toml")) -> str:
68
- """A digest of the source files under ``directory``, so edited plugin code misses."""
67
+ def directory_fingerprint(directory: Path) -> str:
68
+ """A digest of every file under ``directory``, so an edited plugin misses: its code,
69
+ its manifest and the rule or advisory data it ships in any format. Bytecode and
70
+ hidden files are not part of what the plugin does."""
69
71
 
70
- wanted = tuple(suffixes)
71
72
  parts: list[str] = []
72
73
  for path in sorted(directory.rglob("*")):
73
- if path.is_file() and path.suffix in wanted:
74
- parts.append(str(path.relative_to(directory)))
75
- parts.append(path.read_text(encoding="utf-8", errors="replace"))
74
+ relative = path.relative_to(directory)
75
+ if not path.is_file() or any(p == "__pycache__" or p.startswith(".") for p in relative.parts):
76
+ continue
77
+ parts.append(str(relative))
78
+ parts.append(path.read_bytes().hex())
76
79
  return fingerprint(*parts)
77
80
 
78
81
 
@@ -11,6 +11,8 @@ from coretrace_python.dependency.advisories import (
11
11
  from coretrace_python.dependency.graph import (
12
12
  DEPENDENCY_FILES,
13
13
  Advisory,
14
+ AdvisoryEntryPoint,
15
+ Condition,
14
16
  DependencyAnalysis,
15
17
  DependencyGraph,
16
18
  Requirement,
@@ -26,7 +28,9 @@ __all__ = [
26
28
  "DEPENDENCY_FILES",
27
29
  "POLICY_FILE",
28
30
  "Advisory",
31
+ "AdvisoryEntryPoint",
29
32
  "AdvisoryFileError",
33
+ "Condition",
30
34
  "DependencyAnalysis",
31
35
  "DependencyGraph",
32
36
  "Policy",
@@ -5,8 +5,8 @@ OSV dump into ``Advisory`` values, keeping the PyPI ecosystem and turning each r
5
5
  events into a version specifier; ``dump_advisories`` writes them as a small JSON file
6
6
  that a project keeps at its root as ``advisories.json`` or passes with ``--advisories``.
7
7
  OSV records name no affected APIs, so imported advisories feed the requirement checks
8
- and the SBOM; a file completed by hand with ``affected_symbols`` also feeds the
9
- reachability and correlation checks.
8
+ and the SBOM; a file completed by hand with ``affected_symbols``, ``entry_points`` and
9
+ their ``conditions`` also feeds the reachability and correlation checks.
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -17,7 +17,7 @@ from collections.abc import Iterable, Iterator, Mapping
17
17
  from pathlib import Path
18
18
  from typing import Any
19
19
 
20
- from coretrace_python.dependency.graph import Advisory, normalize
20
+ from coretrace_python.dependency.graph import Advisory, AdvisoryEntryPoint, Condition, normalize
21
21
  from coretrace_python.findings import Severity
22
22
  from coretrace_python.semantic.symbols import SymbolId
23
23
 
@@ -84,8 +84,9 @@ def import_osv(records: Iterable[Mapping[str, Any]]) -> tuple[Advisory, ...]:
84
84
  if str(package.get("ecosystem", "")).lower() != "pypi" or not package.get("name"):
85
85
  continue
86
86
  name = normalize(str(package["name"]))
87
- for specifier in _specifiers(affected.get("ranges") or []):
88
- advisories.append(Advisory(identifier, name, specifier, summary, severity, (), aliases))
87
+ specifiers = _specifiers(affected.get("ranges") or [])
88
+ if specifiers:
89
+ advisories.append(Advisory(identifier, name, " || ".join(specifiers), summary, severity, (), aliases))
89
90
  return tuple(advisories)
90
91
 
91
92
 
@@ -137,6 +138,15 @@ def dump_advisories(advisories: Iterable[Advisory]) -> str:
137
138
  "severity": a.severity.value,
138
139
  "affected_symbols": [str(s) for s in a.affected_symbols],
139
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
+ ],
149
+ "modules": list(a.modules),
140
150
  }
141
151
  for a in advisories
142
152
  ],
@@ -144,6 +154,15 @@ def dump_advisories(advisories: Iterable[Advisory]) -> str:
144
154
  return json.dumps(document, indent=2) + "\n"
145
155
 
146
156
 
157
+ def _condition_entry(condition: Condition) -> dict[str, Any]:
158
+ entry: dict[str, Any] = {"kind": condition.kind, "text": condition.text}
159
+ if condition.argument is not None:
160
+ entry["argument"] = condition.argument
161
+ if condition.values:
162
+ entry["values"] = list(condition.values)
163
+ return entry
164
+
165
+
147
166
  def load_advisories(path: Path) -> tuple[Advisory, ...]:
148
167
  try:
149
168
  document = json.loads(path.read_text(encoding="utf-8"))
@@ -165,4 +184,22 @@ def _advisory(entry: Mapping[str, Any]) -> Advisory:
165
184
  Severity(entry["severity"]),
166
185
  tuple(SymbolId(str(s)) for s in entry.get("affected_symbols") or []),
167
186
  tuple(str(a) for a in entry.get("aliases") or []),
187
+ tuple(_entry_point(e) for e in entry.get("entry_points") or []),
188
+ tuple(str(m) for m in entry.get("modules") or []),
189
+ )
190
+
191
+
192
+ def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
193
+ return AdvisoryEntryPoint(
194
+ SymbolId(str(entry["symbol"])),
195
+ str(entry["justification"]),
196
+ tuple(
197
+ Condition(
198
+ str(c["kind"]),
199
+ str(c["text"]),
200
+ None if c.get("argument") is None else str(c["argument"]),
201
+ tuple(str(v) for v in c.get("values") or []),
202
+ )
203
+ for c in entry.get("conditions") or []
204
+ ),
168
205
  )
@@ -11,77 +11,103 @@ from __future__ import annotations
11
11
 
12
12
  from collections.abc import Iterable, Mapping
13
13
 
14
- from coretrace_python.dependency.graph import Advisory, DependencyGraph
14
+ from coretrace_python.dependency.graph import DIRECT, Advisory, DependencyGraph
15
15
  from coretrace_python.findings import Confidence, Finding, Severity
16
- from coretrace_python.findings.refutation import Status, Verdicts
16
+ from coretrace_python.findings.refutation import Status, Verdict, Verdicts
17
17
  from coretrace_python.semantic.symbols import SymbolId
18
18
  from coretrace_python.taint import Sink, TaintFlow, TaintKind
19
19
 
20
+ Affected = Mapping[SymbolId, tuple[Advisory, ...]]
20
21
 
21
- def affected_symbols(
22
- dependencies: DependencyGraph, advisories: Iterable[Advisory]
23
- ) -> Mapping[SymbolId, Advisory]:
24
- """The APIs affected by advisories whose package is required in a vulnerable version."""
25
22
 
26
- affected: dict[SymbolId, Advisory] = {}
23
+ def affected_symbols(dependencies: DependencyGraph, advisories: Iterable[Advisory]) -> Affected:
24
+ """The APIs affected by advisories whose package is required in a vulnerable version,
25
+ each with every such advisory: a call to ``yaml.load`` reaches every CVE of the
26
+ pinned release, not the first one listed."""
27
+
28
+ affected: dict[SymbolId, list[Advisory]] = {}
27
29
  for requirement in dependencies.requirements:
28
30
  for advisory in advisories:
29
31
  if advisory.affects(requirement):
30
- for symbol in advisory.affected_symbols:
31
- affected.setdefault(symbol, advisory)
32
- return affected
32
+ for symbol in advisory.reachable_symbols:
33
+ found = affected.setdefault(symbol, [])
34
+ if advisory not in found:
35
+ found.append(advisory)
36
+ return {symbol: tuple(found) for symbol, found in affected.items()}
33
37
 
34
38
 
35
- def advisory_sinks(affected: Mapping[SymbolId, Advisory]) -> tuple[Sink, ...]:
39
+ def advisory_sinks(affected: Affected) -> tuple[Sink, ...]:
36
40
  return tuple(Sink(symbol, TaintKind.ADVISORY) for symbol in affected)
37
41
 
38
42
 
43
+ def evidence(advisory: Advisory, symbol: SymbolId, level: str) -> dict[str, str]:
44
+ """What a finding keeps of the advisory for ``symbol``: the level of evidence
45
+ established, how the symbol relates to the vulnerability and under which
46
+ conditions, the ones the engine could not check listed as pending review."""
47
+
48
+ metadata = {"advisory": advisory.id, "package": advisory.package, "symbol": str(symbol), "level": level}
49
+ entry = advisory.entry_point(symbol)
50
+ if entry is None:
51
+ metadata["justification"] = DIRECT
52
+ return metadata
53
+ metadata["entry_point"] = str(entry.symbol)
54
+ metadata["justification"] = entry.justification
55
+ if entry.conditions:
56
+ metadata["conditions"] = "; ".join(c.text for c in entry.conditions)
57
+ # ponytail: no condition is checked yet, so every one awaits review; argument
58
+ # conditions get checked at the call site once call sites carry argument symbols.
59
+ metadata["conditions_pending_review"] = "; ".join(c.text for c in entry.conditions)
60
+ return metadata
61
+
62
+
39
63
  def correlate(
40
64
  function: str,
41
65
  flows: Iterable[TaintFlow],
42
66
  verdicts: Verdicts | None,
43
- affected: Mapping[SymbolId, Advisory],
67
+ affected: Affected,
44
68
  ) -> tuple[Finding, ...]:
45
- """Exploitable-vulnerability findings for the non-refuted ADVISORY flows of a function."""
69
+ """Exploitable-vulnerability findings for the non-refuted ADVISORY flows of a function,
70
+ one per advisory the sink is affected by."""
46
71
 
47
72
  findings: list[Finding] = []
48
73
  for flow in flows:
49
74
  if not flow.kinds & TaintKind.ADVISORY:
50
75
  continue
51
- advisory = affected.get(flow.sink.symbol)
52
- if advisory is None:
53
- continue
54
76
  verdict = verdicts.verdict(flow) if verdicts is not None else None
55
77
  if verdict is not None and verdict.status is Status.REFUTED:
56
78
  continue
57
79
  hotspot = verdict is not None and verdict.status is Status.HOTSPOT
58
- message = (
59
- f"{advisory.id}: {flow.source.label} input reaches {flow.sink.symbol}, affected in "
60
- f"the required {advisory.package} {advisory.vulnerable}: {advisory.summary}"
61
- )
62
- metadata = {
63
- "advisory": advisory.id,
64
- "package": advisory.package,
65
- "symbol": str(flow.sink.symbol),
66
- "source": str(flow.source.symbol),
67
- "source_label": flow.source.label,
68
- "verdict": "hotspot" if hotspot else "vulnerability",
69
- }
70
- if verdict is not None:
71
- metadata["evidence"] = verdict.evidence
72
- if flow.through is not None and flow.sink_location is not None:
73
- message += f" through {flow.through}"
74
- metadata["through"] = flow.through
75
- metadata["sink_line"] = str(flow.sink_location.start_line)
76
- findings.append(
77
- Finding(
78
- rule_id="exploitable-vulnerability",
79
- message=message,
80
- severity=Severity.CRITICAL,
81
- confidence=Confidence.MEDIUM if hotspot else Confidence.HIGH,
82
- span=flow.location,
83
- function=function,
84
- metadata=metadata,
85
- )
80
+ findings.extend(
81
+ _exploitable(function, flow, advisory, verdict, hotspot) for advisory in affected.get(flow.sink.symbol, ())
86
82
  )
87
83
  return tuple(findings)
84
+
85
+
86
+ def _exploitable(
87
+ function: str, flow: TaintFlow, advisory: Advisory, verdict: Verdict | None, hotspot: bool
88
+ ) -> Finding:
89
+ message = (
90
+ f"{advisory.id}: {flow.source.label} input reaches {flow.sink.symbol}, affected in "
91
+ f"the required {advisory.package} {advisory.vulnerable}: {advisory.summary}"
92
+ )
93
+ metadata = {
94
+ **evidence(advisory, flow.sink.symbol, "exploitable"),
95
+ "source": str(flow.source.symbol),
96
+ "source_label": flow.source.label,
97
+ "verdict": "hotspot" if hotspot else "vulnerability",
98
+ }
99
+ if verdict is not None:
100
+ metadata["evidence"] = verdict.evidence
101
+ if flow.through is not None and flow.sink_location is not None:
102
+ message += f" through {flow.through}"
103
+ metadata["through"] = flow.through
104
+ metadata["sink_line"] = str(flow.sink_location.start_line)
105
+ return Finding(
106
+ rule_id="exploitable-vulnerability",
107
+ message=message,
108
+ severity=Severity.CRITICAL,
109
+ confidence=Confidence.MEDIUM if hotspot else Confidence.HIGH,
110
+ span=flow.location,
111
+ function=function,
112
+ metadata=metadata,
113
+ )
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import re
13
13
  import tomllib
14
- from collections.abc import Mapping
14
+ from collections.abc import Iterable, Mapping
15
15
  from dataclasses import dataclass
16
16
  from pathlib import PurePath
17
17
  from types import MappingProxyType
@@ -50,6 +50,13 @@ class Version:
50
50
  return self.parts + (0,) * (length - len(self.parts))
51
51
 
52
52
  def satisfies(self, specifier: str) -> bool:
53
+ """Whether this version is in ``specifier``: comma-separated clauses all hold, and
54
+ ``||`` separates alternatives, as an advisory over several release series needs
55
+ (``>=4.2,<4.2.28 || >=5.2,<5.2.11``)."""
56
+
57
+ return any(self._satisfies_all(alternative) for alternative in _alternatives(specifier))
58
+
59
+ def _satisfies_all(self, specifier: str) -> bool:
53
60
  for clause in [c.strip() for c in specifier.split(",") if c.strip()]:
54
61
  match = _CLAUSE.match(clause)
55
62
  if match is None:
@@ -91,12 +98,17 @@ class Version:
91
98
  return True
92
99
 
93
100
 
101
+ def _alternatives(specifier: str) -> list[str]:
102
+ return [a for a in specifier.split("||") if a.strip()] or [""]
103
+
104
+
94
105
  def _lower_bounds(specifier: str) -> list[Version]:
95
106
  bounds: list[Version] = []
96
- for clause in [c.strip() for c in specifier.split(",") if c.strip()]:
97
- match = _CLAUSE.match(clause)
98
- if match is not None and match.group(1) in (">=", ">", "~=", "^", "==", "==="):
99
- bounds.append(Version.parse(match.group(2)))
107
+ for alternative in _alternatives(specifier):
108
+ for clause in [c.strip() for c in alternative.split(",") if c.strip()]:
109
+ match = _CLAUSE.match(clause)
110
+ if match is not None and match.group(1) in (">=", ">", "~=", "^", "==", "==="):
111
+ bounds.append(Version.parse(match.group(2)))
100
112
  return bounds
101
113
 
102
114
 
@@ -130,6 +142,37 @@ class Requirement:
130
142
  return any(v.satisfies(self.specifier) and v.satisfies(vulnerable) for v in candidates)
131
143
 
132
144
 
145
+ @dataclass(frozen=True)
146
+ class Condition:
147
+ """What must hold for an entry point to reach, or exploit, the affected code: the
148
+ value of an argument, the loader or algorithm used, the format decoded, the kind of
149
+ input, a configuration. ``kind`` says whether the engine can check it: an
150
+ ``argument`` condition names the argument and the values that satisfy it; a
151
+ ``semantic`` condition cannot be checked yet and is reported as pending review."""
152
+
153
+ kind: str
154
+ text: str
155
+ argument: str | None = None
156
+ values: tuple[str, ...] = ()
157
+
158
+ @property
159
+ def checkable(self) -> bool:
160
+ return self.kind == "argument"
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class AdvisoryEntryPoint:
165
+ """A public API through which a project reaches an affected symbol, justified by the
166
+ fixing commit or by a call path, with the conditions under which it is affected."""
167
+
168
+ symbol: SymbolId
169
+ justification: str
170
+ conditions: tuple[Condition, ...] = ()
171
+
172
+
173
+ DIRECT = "affected symbol, changed by the fix"
174
+
175
+
133
176
  @dataclass(frozen=True)
134
177
  class Advisory:
135
178
  id: str
@@ -139,10 +182,36 @@ class Advisory:
139
182
  severity: Severity
140
183
  affected_symbols: tuple[SymbolId, ...] = ()
141
184
  aliases: tuple[str, ...] = ()
185
+ entry_points: tuple[AdvisoryEntryPoint, ...] = ()
186
+ # Top-level modules the package installs, to tell an imported package from a merely
187
+ # required one; defaults to the package name (``pyyaml`` installs ``yaml``: say so).
188
+ modules: tuple[str, ...] = ()
189
+
190
+ def __post_init__(self) -> None:
191
+ if not self.modules:
192
+ object.__setattr__(self, "modules", (normalize(self.package).replace("-", "_"),))
142
193
 
143
194
  def affects(self, requirement: Requirement) -> bool:
144
195
  return requirement.name == normalize(self.package) and requirement.may_match(self.vulnerable)
145
196
 
197
+ @property
198
+ def reachable_symbols(self) -> tuple[SymbolId, ...]:
199
+ """Every symbol a call to which reaches the vulnerability: the entry points and
200
+ the affected symbols themselves."""
201
+
202
+ return (*(e.symbol for e in self.entry_points), *self.affected_symbols)
203
+
204
+ def entry_point(self, symbol: SymbolId) -> AdvisoryEntryPoint | None:
205
+ return next((e for e in self.entry_points if e.symbol == symbol), None)
206
+
207
+ def imported_by(self, symbols: Iterable[SymbolId]) -> bool:
208
+ """Whether any of the ``symbols`` a project imports belongs to this package."""
209
+
210
+ prefixes = tuple(f"python.{m}" for m in self.modules)
211
+ return any(
212
+ s.canonical_name == p or s.canonical_name.startswith(p + ".") for s in symbols for p in prefixes
213
+ )
214
+
146
215
 
147
216
  class DependencyGraph:
148
217
  def __init__(self, requirements: Mapping[str, Requirement] | None = None, errors: tuple[str, ...] = ()) -> None:
@@ -48,7 +48,12 @@ from coretrace_python.dependency import (
48
48
  load_policy,
49
49
  parse_dependencies,
50
50
  )
51
- from coretrace_python.dependency.correlation import advisory_sinks, affected_symbols, correlate
51
+ from coretrace_python.dependency.correlation import (
52
+ Affected,
53
+ advisory_sinks,
54
+ affected_symbols,
55
+ correlate,
56
+ )
52
57
  from coretrace_python.findings import (
53
58
  FINDING_SCHEMA_VERSION,
54
59
  Confidence,
@@ -89,6 +94,7 @@ from coretrace_python.plugins import (
89
94
  PluginRegistry,
90
95
  ProjectContext,
91
96
  ProjectPlugin,
97
+ apply_refinement,
92
98
  discover_plugins,
93
99
  run_plugins,
94
100
  )
@@ -257,7 +263,7 @@ def load_plugins(plugin_roots: Sequence[Path], manager: AnalysisManager) -> Plug
257
263
  def plugin_models(plugins: Iterable[Plugin]) -> ModelTable:
258
264
  models = SecurityModelRegistry()
259
265
  for plugin in plugins:
260
- models.register(*plugin.models)
266
+ models.register(*plugin.models, origin=plugin.name)
261
267
  return models.freeze()
262
268
 
263
269
 
@@ -460,6 +466,9 @@ def analyze_project(
460
466
  for plugin in all_plugins:
461
467
  if isinstance(plugin, ProjectPlugin):
462
468
  findings.extend(plugin.analyze_project(context))
469
+ for plugin in all_plugins:
470
+ if isinstance(plugin, ProjectPlugin):
471
+ findings = list(apply_refinement(plugin, findings, plugin.refine(context, tuple(findings))))
463
472
  kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources))
464
473
  return ProjectAnalysis(
465
474
  graph,
@@ -477,12 +486,13 @@ def analyze_project(
477
486
  def _merge_advisories(
478
487
  from_plugins: Iterable[Advisory], from_files: Iterable[Advisory]
479
488
  ) -> tuple[Advisory, ...]:
480
- """One advisory per identifier, package and range; a local file's version wins over
481
- a plugin's, since the file is the project's own curated feed."""
489
+ """One advisory per identifier and package: a later contributor replaces an earlier
490
+ one, so a curated plugin refines a bundled sample and a local file, the project's
491
+ own feed, wins over every plugin."""
482
492
 
483
- merged: dict[tuple[str, str, str], Advisory] = {}
493
+ merged: dict[tuple[str, str], Advisory] = {}
484
494
  for advisory in (*from_plugins, *from_files):
485
- merged[(advisory.id, advisory.package, advisory.vulnerable)] = advisory
495
+ merged[(advisory.id, advisory.package)] = advisory
486
496
  return tuple(merged.values())
487
497
 
488
498
 
@@ -520,7 +530,7 @@ def _analyse_managers(
520
530
  managers: Mapping[str, AnalysisManager],
521
531
  seed: SummaryIndex,
522
532
  plugins: tuple[Plugin, ...],
523
- affected: Mapping[SymbolId, Advisory],
533
+ affected: Affected,
524
534
  ) -> dict[str, CachedModule]:
525
535
  """Analyse one component: iterate its summaries to a fixpoint over ``seed`` (§21),
526
536
  then extract what the rest of the run needs from each module."""
@@ -634,7 +644,7 @@ def _configuration_key(
634
644
 
635
645
 
636
646
  def _analyse_module(
637
- manager: AnalysisManager, plugins: tuple[Plugin, ...], affected: Mapping[SymbolId, Advisory]
647
+ manager: AnalysisManager, plugins: tuple[Plugin, ...], affected: Affected
638
648
  ) -> CachedModule:
639
649
  """One module's findings, summaries and call sites: what the cache keeps (§11)."""
640
650
 
@@ -16,7 +16,12 @@ from typing import ClassVar
16
16
  from coretrace_python.analysis import Analysis, AnalysisContext, AnyAnalysis
17
17
  from coretrace_python.cfg import CFGError
18
18
  from coretrace_python.hir import nodes
19
- from coretrace_python.ir.lowering import LoweringError, analyzable_functions, qualified_name
19
+ from coretrace_python.ir.lowering import (
20
+ MODULE_BODY,
21
+ LoweringError,
22
+ analyzable_functions,
23
+ qualified_name,
24
+ )
20
25
  from coretrace_python.ir.model import (
21
26
  Await,
22
27
  Call,
@@ -198,7 +203,9 @@ def resolve_targets(
198
203
  for block in function.blocks:
199
204
  for instruction in block.instructions:
200
205
  if isinstance(instruction, MakeFunction):
201
- qualified = f"{function.name}.{instruction.name}"
206
+ # A lambda or def made by the module body is named without a prefix, as
207
+ # its scope's parent is the module.
208
+ qualified = instruction.name if function.name == MODULE_BODY else f"{function.name}.{instruction.name}"
202
209
  if qualified in nested:
203
210
  targets[instruction.result] = KnownFunction(qualified)
204
211
  elif isinstance(instruction, Global):
@@ -73,7 +73,10 @@ def project_symbol(module_name: str, qualified_name: str) -> SymbolId:
73
73
  symbols: invalid characters become underscores."""
74
74
 
75
75
  module = ".".join(_component(part) for part in module_name.split("."))
76
- return SymbolId(f"python.{module}.{qualified_name}")
76
+ # A synthetic body function is ``<module>`` or ``Cls.<body>``: valid as a name, not
77
+ # as a symbol component. Nothing calls a body, so its symbol only needs to be one.
78
+ qualified = ".".join(_component(part) for part in qualified_name.split("."))
79
+ return SymbolId(f"python.{module}.{qualified}")
77
80
 
78
81
 
79
82
  def _component(part: str) -> str:
@@ -120,7 +120,19 @@ class _FunctionLowerer:
120
120
  def resolve(self, name: str) -> Resolution:
121
121
  if name in self.cfg.synthetic_locals:
122
122
  return Resolution(ResolutionKind.LOCAL, self.scope.id)
123
- return self.scopes.resolve(self.scope.id, name)
123
+ resolution = self.scopes.resolve(self.scope.id, name)
124
+ # The module body runs as a script: the variables it assigns are its locals, so
125
+ # ``x = input(); os.system(x)`` connects in SSA and ``for x in ...`` has a target.
126
+ # Its functions, classes and imports stay globals, which is how the call graph
127
+ # and the symbol resolution find them.
128
+ if (
129
+ resolution.kind is ResolutionKind.GLOBAL
130
+ and self.scope.kind is ScopeKind.MODULE
131
+ and (binding := self.scope.bindings.get(name)) is not None
132
+ and binding.kind is BindingKind.LOCAL
133
+ ):
134
+ return Resolution(ResolutionKind.LOCAL, self.scope.id)
135
+ return resolution
124
136
 
125
137
  # ------------------------------------------------------------------ expressions
126
138
 
@@ -360,6 +372,9 @@ class _FunctionLowerer:
360
372
  # records that the import runs here (§39 rule 3).
361
373
  module = "." * node.level + (node.module or "") if isinstance(node, nodes.ImportFrom) else ""
362
374
  for alias in node.names:
375
+ if alias.name == "*":
376
+ # ``from m import *`` binds through the scope's wildcards, not a name.
377
+ continue
363
378
  bound = alias.as_name or alias.name.partition(".")[0]
364
379
  symbol = self.symbols.resolve(self.scope.id, bound)
365
380
  if symbol is None:
@@ -563,8 +578,9 @@ _FUNCTIONS_CACHE_SIZE = 32
563
578
 
564
579
 
565
580
  def analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]:
566
- """Top-level functions, the methods of top-level classes, and the functions and
567
- lambdas nested inside them, in source order. Computed once per module: the result is
581
+ """The module body, top-level functions, top-level classes' bodies and methods, and
582
+ the functions and lambdas nested inside them, in source order. Computed once per
583
+ module: the result is
568
584
  memoised for the last few modules seen, keyed by identity, so every consumer shares
569
585
  one tuple and the synthesized lambda functions it holds."""
570
586
 
@@ -581,16 +597,54 @@ def analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]:
581
597
 
582
598
  def _analyzable_functions(module: nodes.Module) -> tuple[nodes.Function, ...]:
583
599
  functions: list[nodes.Function] = []
600
+ body = module_function(module)
601
+ if body is not None:
602
+ _collect(body, functions)
584
603
  for statement in module.body:
585
604
  if isinstance(statement, nodes.Function):
586
605
  _collect(statement, functions)
587
606
  elif isinstance(statement, nodes.Class):
607
+ body = class_body_function(statement)
608
+ if body is not None:
609
+ _collect(body, functions)
588
610
  for member in statement.body:
589
611
  if isinstance(member, nodes.Function):
590
612
  _collect(member, functions)
591
613
  return tuple(functions)
592
614
 
593
615
 
616
+ MODULE_BODY = "<module>"
617
+ CLASS_BODY = ".<body>"
618
+
619
+
620
+ def module_function(module: nodes.Module) -> nodes.Function | None:
621
+ """The module's statements outside its definitions, as a function named ``<module>``
622
+ with the module's span, so its names resolve in the module scope; ``None`` when the
623
+ module only defines things."""
624
+
625
+ statements = tuple(s for s in module.body if not isinstance(s, nodes.Function | nodes.Class))
626
+ if not statements:
627
+ return None
628
+ return nodes.Function(MODULE_BODY, (), statements, False, module.span)
629
+
630
+
631
+ def class_body_function(cls: nodes.Class) -> nodes.Function | None:
632
+ """A top-level class's statements outside its methods (``field = Field()``), as a
633
+ function named ``Cls.<body>`` with the class's span, so its names resolve in the
634
+ class scope."""
635
+
636
+ statements = tuple(s for s in cls.body if not isinstance(s, nodes.Function))
637
+ if not statements:
638
+ return None
639
+ return nodes.Function(f"{cls.name}{CLASS_BODY}", (), statements, False, cls.span)
640
+
641
+
642
+ def is_body_function(function: nodes.Function) -> bool:
643
+ """Whether ``function`` is a synthetic module or class body rather than a definition."""
644
+
645
+ return function.name == MODULE_BODY or function.name.endswith(CLASS_BODY)
646
+
647
+
594
648
  def _collect(function: nodes.Function, into: list[nodes.Function]) -> None:
595
649
  into.append(function)
596
650
 
@@ -7,6 +7,8 @@ from coretrace_python.plugins.api import (
7
7
  PluginContext,
8
8
  ProjectContext,
9
9
  ProjectPlugin,
10
+ RefinementError,
11
+ apply_refinement,
10
12
  run_plugins,
11
13
  )
12
14
  from coretrace_python.plugins.detectors import SymbolCallDetector, TaintDetector
@@ -44,11 +46,13 @@ __all__ = [
44
46
  "PluginRegistry",
45
47
  "ProjectContext",
46
48
  "ProjectPlugin",
49
+ "RefinementError",
47
50
  "SecretDetector",
48
51
  "SecretPattern",
49
52
  "SymbolCallDetector",
50
53
  "TaintDetector",
51
54
  "VersionRange",
55
+ "apply_refinement",
52
56
  "discover_plugins",
53
57
  "literals",
54
58
  "load_manifest",
@@ -8,6 +8,7 @@ room for out-of-process isolation later.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import dataclasses
11
12
  from abc import ABC, abstractmethod
12
13
  from collections.abc import Iterable, Mapping, Sequence
13
14
  from pathlib import Path
@@ -99,6 +100,50 @@ class ProjectPlugin(Plugin):
99
100
  def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
100
101
  raise NotImplementedError
101
102
 
103
+ def refine(self, ctx: ProjectContext, findings: tuple[Finding, ...]) -> Sequence[Finding]:
104
+ """Reclassify the findings of the run, once every plugin has reported.
105
+
106
+ A refinement returns the same findings in the same order and may change only
107
+ their severity, their confidence and add metadata: what a finding is, where it
108
+ is and the evidence behind it are not the refiner's to touch. The engine records
109
+ who reclassified a finding and what it was before, and rejects anything else."""
110
+
111
+ return findings
112
+
113
+
114
+ class RefinementError(Exception):
115
+ """A refinement did more than reclassify a finding."""
116
+
117
+
118
+ REFINABLE = ("severity", "confidence")
119
+
120
+
121
+ def apply_refinement(plugin: Plugin, original: Sequence[Finding], refined: Sequence[Finding]) -> tuple[Finding, ...]:
122
+ """The refined findings, checked against the contract of ``ProjectPlugin.refine`` and
123
+ stamped with a trace of the reclassification."""
124
+
125
+ if len(refined) != len(original):
126
+ raise RefinementError(f"plugin {plugin.name!r} returned {len(refined)} findings for {len(original)}")
127
+ kept: list[Finding] = []
128
+ for before, after in zip(original, refined, strict=True):
129
+ for attribute in ("rule_id", "message", "span", "function"):
130
+ if getattr(before, attribute) != getattr(after, attribute):
131
+ raise RefinementError(f"plugin {plugin.name!r} changed the {attribute} of a finding")
132
+ lost = [k for k, v in before.metadata.items() if after.metadata.get(k) != v]
133
+ if lost:
134
+ raise RefinementError(f"plugin {plugin.name!r} changed the evidence {lost} of a finding")
135
+ changed = [a for a in REFINABLE if getattr(before, a) != getattr(after, a)]
136
+ if not changed:
137
+ kept.append(after)
138
+ continue
139
+ trace = dict(after.metadata)
140
+ for attribute in changed:
141
+ trace.setdefault(f"original_{attribute}", getattr(before, attribute).value)
142
+ refiners = trace.get("refined_by", "")
143
+ trace["refined_by"] = f"{refiners},{plugin.name}" if refiners else plugin.name
144
+ kept.append(dataclasses.replace(after, metadata=trace))
145
+ return tuple(kept)
146
+
102
147
 
103
148
  class PluginContext:
104
149
  """What one plugin may see while analysing one module."""
@@ -118,7 +163,8 @@ class PluginContext:
118
163
  return self._manager.module
119
164
 
120
165
  def functions(self) -> tuple[nodes.Function, ...]:
121
- """Top-level functions and methods the engine can analyse."""
166
+ """The functions the engine analyses: the module body (``<module>``), top-level
167
+ functions, class bodies (``Cls.<body>``) and methods."""
122
168
 
123
169
  return self._functions
124
170
 
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import importlib.util
7
+ import sys
7
8
  from dataclasses import dataclass
8
9
  from pathlib import Path
9
10
 
@@ -63,16 +64,31 @@ def discover_plugins(root: Path, manager: AnalysisManager) -> tuple[LoadedPlugin
63
64
 
64
65
 
65
66
  def _import_entrypoint(directory: Path, manifest: PluginManifest) -> type[Plugin]:
66
- module_path = directory / f"{manifest.entrypoint.module}.py"
67
+ """Import the entrypoint, a module ``<name>.py`` or a package ``<name>/__init__.py``.
68
+
69
+ The module name derives from the path, so two plugins may both call their module
70
+ ``models``. The module is registered in ``sys.modules`` under that name before it
71
+ runs: a package's relative imports resolve through it, and so does ``dataclasses``
72
+ when it evaluates postponed annotations."""
73
+
74
+ package_path = directory / manifest.entrypoint.module / "__init__.py"
75
+ is_package = package_path.is_file()
76
+ module_path = package_path if is_package else directory / f"{manifest.entrypoint.module}.py"
67
77
  if not module_path.is_file():
68
78
  raise ManifestError(
69
79
  f"{directory / MANIFEST_FILENAME}: entrypoint module"
70
80
  f" {manifest.entrypoint.module!r} not found"
71
81
  )
72
82
  unique = hashlib.sha1(str(module_path.resolve()).encode()).hexdigest()[:12]
73
- spec = importlib.util.spec_from_file_location(f"coretrace_plugin_{unique}", module_path)
83
+ name = f"coretrace_plugin_{unique}"
84
+ spec = importlib.util.spec_from_file_location(
85
+ name,
86
+ module_path,
87
+ submodule_search_locations=[str(module_path.parent)] if is_package else None,
88
+ )
74
89
  assert spec is not None and spec.loader is not None
75
90
  module = importlib.util.module_from_spec(spec)
91
+ sys.modules[name] = module
76
92
  spec.loader.exec_module(module)
77
93
 
78
94
  candidate = getattr(module, manifest.entrypoint.class_name, None)
@@ -218,7 +218,9 @@ class _Collector:
218
218
  ScopeId(str(module.span.source_id)), ScopeKind.MODULE, "<module>", None, module.span
219
219
  )
220
220
  self.builders: list[_ScopeBuilder] = [self.module]
221
- self.spans: dict[SourceSpan, ScopeId] = {}
221
+ # The module scope is found by the module's span, as the synthetic ``<module>``
222
+ # function carries it.
223
+ self.spans: dict[SourceSpan, ScopeId] = {module.span: self.module.id}
222
224
  self.body(module.body, self.module)
223
225
 
224
226
  def body(self, statements: tuple[nodes.Statement, ...], scope: _ScopeBuilder) -> None:
@@ -38,7 +38,7 @@ from coretrace_python.interprocedural import (
38
38
  SummaryTable,
39
39
  project_symbol,
40
40
  )
41
- from coretrace_python.ir.lowering import analyzable_functions
41
+ from coretrace_python.ir.lowering import analyzable_functions, is_body_function
42
42
  from coretrace_python.ir.model import (
43
43
  BasicBlock,
44
44
  Branch,
@@ -147,8 +147,10 @@ class _TaintProblem(DataflowProblem[State]):
147
147
  project: SummaryIndex | None = None,
148
148
  heap: HeapFacts | None = None,
149
149
  seeds: Mapping[HeapLocation, Taint] | None = None,
150
+ module: str = "",
150
151
  ) -> None:
151
152
  self.name = name
153
+ self.module = module
152
154
  self.function = function
153
155
  self.models = models
154
156
  self.graph = graph
@@ -294,9 +296,10 @@ class _TaintProblem(DataflowProblem[State]):
294
296
  through = target.symbol.canonical_name.removeprefix("python.")
295
297
  bound = () if project.static else self.receiver(call, project.name)
296
298
  arguments = (*(self.deep(r, state) for r in bound), *arguments)
297
- return self.known(
299
+ result = self.known(
298
300
  project, through, arguments, keywords, everything, call, flows, state, (), bound
299
301
  )
302
+ return self.declared_clean(target.symbol, result)
300
303
  symbol = target.symbol
301
304
  if not self.modelled(symbol):
302
305
  # ``get_conn().execute`` derived ``app.database.get_conn.execute``; what
@@ -312,9 +315,10 @@ class _TaintProblem(DataflowProblem[State]):
312
315
  *arguments,
313
316
  *(self.deep(value, state) for value in captured),
314
317
  )
315
- return self.known(
318
+ result = self.known(
316
319
  summary, target.name, arguments, keywords, everything, call, flows, state, captured, bound
317
320
  )
321
+ return self.declared_clean(project_symbol(self.module, target.name), result)
318
322
  returned = self.returned_symbol(call)
319
323
  if returned is not None:
320
324
  return self.external(returned, everything, call, state, flows)
@@ -369,6 +373,13 @@ class _TaintProblem(DataflowProblem[State]):
369
373
  made = self.defs.get(call.callee)
370
374
  return made.captured if isinstance(made, MakeFunction) else ()
371
375
 
376
+ def declared_clean(self, symbol: SymbolId, result: Taint) -> Taint:
377
+ """A ``Sanitizer`` declared on a project function is the project's own knowledge
378
+ of what its result carries; it wins over what the summary derived from the body."""
379
+
380
+ sanitizer = self.models.sanitizer(symbol)
381
+ return result if sanitizer is None else result.without(sanitizer.kinds)
382
+
372
383
  def modelled(self, symbol: SymbolId) -> bool:
373
384
  return (
374
385
  self.models.sink(symbol) is not None
@@ -582,7 +593,12 @@ def local_instances(function: nodes.Function, scopes: ScopeTable, symbols: Symbo
582
593
 
583
594
 
584
595
  def _enclosing(module: nodes.Module, function: nodes.Function) -> nodes.Function | None:
585
- """The function whose body defines ``function``, if it is nested."""
596
+ """The function whose body defines ``function``, if it is nested. A module or class
597
+ body is nested in nothing, and encloses nothing: a lambda at module level reads
598
+ globals, not the locals of a function."""
599
+
600
+ if is_body_function(function):
601
+ return None
586
602
 
587
603
  def search(
588
604
  body: tuple[nodes.Statement, ...], parent: nodes.Function | None
@@ -607,6 +623,8 @@ def _enclosing(module: nodes.Module, function: nodes.Function) -> nodes.Function
607
623
  # analysable function whose span contains theirs.
608
624
  innermost: nodes.Function | None = None
609
625
  for candidate in analyzable_functions(module):
626
+ if is_body_function(candidate):
627
+ continue
610
628
  if candidate.span != function.span and _contains(candidate.span, function.span):
611
629
  innermost = candidate
612
630
  return innermost
@@ -730,8 +748,9 @@ def propagate_taint(
730
748
  project: SummaryIndex | None = None,
731
749
  heap: HeapFacts | None = None,
732
750
  seeds: Mapping[HeapLocation, Taint] | None = None,
751
+ module: str = "",
733
752
  ) -> TaintFacts:
734
- problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds)
753
+ problem = _TaintProblem(name, function, models, graph, summaries, parameters, project, heap, seeds, module)
735
754
  solution = solve(problem, cfg)
736
755
  taints: dict[Key, Taint] = {}
737
756
  flows: list[TaintFlow] = []
@@ -794,6 +813,7 @@ class TaintAnalysis(FunctionAnalysis[TaintFacts]):
794
813
  ctx.get(ProjectSummaries),
795
814
  heap,
796
815
  seeds,
816
+ ctx.module.name,
797
817
  )
798
818
 
799
819
 
@@ -30,10 +30,14 @@ class TaintKind(Flag):
30
30
  ADVISORY = auto()
31
31
  DESERIALIZATION = auto()
32
32
  REDIRECT = auto()
33
- ALL = SQL | COMMAND | HTML | PATH | SSRF | CODE | ADVISORY | DESERIALIZATION | REDIRECT
34
- # Outside ALL on purpose: only credential-named parameters carry it, so a database
35
- # write reached by ordinary input is not a plaintext credential.
33
+ NOSQL = auto()
34
+ ALL = SQL | COMMAND | HTML | PATH | SSRF | CODE | ADVISORY | DESERIALIZATION | REDIRECT | NOSQL
35
+ # Outside ALL on purpose: only the values a model marks carry them, so a database
36
+ # write reached by ordinary input is not a plaintext credential, and ordinary input
37
+ # reaching a logger is neither log forging nor a personal-data leak.
36
38
  CREDENTIAL = auto()
39
+ LOG = auto()
40
+ PII = auto()
37
41
 
38
42
 
39
43
  class ModelError(Exception):
@@ -69,6 +73,11 @@ class Sink:
69
73
  kinds &= ~restricted
70
74
  return kinds
71
75
 
76
+ def merged(self, other: Sink) -> Sink:
77
+ """This sink with ``other``'s kinds and positions added."""
78
+
79
+ return Sink(self.symbol, self.kinds | other.kinds, self.positions + other.positions)
80
+
72
81
 
73
82
  @dataclass(frozen=True)
74
83
  class Sanitizer:
@@ -251,11 +260,7 @@ class ModelTable:
251
260
  merged = {sink.symbol: sink for sink in self.sinks}
252
261
  for sink in sinks:
253
262
  current = merged.get(sink.symbol)
254
- merged[sink.symbol] = (
255
- Sink(sink.symbol, current.kinds | sink.kinds, current.positions + sink.positions)
256
- if current is not None
257
- else sink
258
- )
263
+ merged[sink.symbol] = sink if current is None else current.merged(sink)
259
264
  return ModelTable(
260
265
  self.sources,
261
266
  tuple(merged.values()),
@@ -279,15 +284,31 @@ class SecurityModelRegistry:
279
284
 
280
285
  def __init__(self) -> None:
281
286
  self._models: dict[tuple[type[Model], SymbolId], Model] = {}
287
+ self._origins: dict[tuple[type[Model], SymbolId], str | None] = {}
288
+
289
+ def register(self, *models: Model, origin: str | None = None) -> None:
290
+ """Add models; ``origin`` names the plugin so a conflict can name both sides.
291
+
292
+ Two plugins may describe the same symbol: an identical model is ignored, sinks
293
+ merge their kinds and positions, any other difference is a conflict."""
282
294
 
283
- def register(self, *models: Model) -> None:
284
295
  for model in models:
285
296
  key = (type(model), model.symbol)
286
- if key in self._models:
297
+ current = self._models.get(key)
298
+ if current is None:
299
+ self._models[key] = model
300
+ self._origins[key] = origin
301
+ elif current == model:
302
+ continue
303
+ elif isinstance(model, Sink) and isinstance(current, Sink):
304
+ self._models[key] = current.merged(model)
305
+ else:
306
+ registered = self._origins[key]
307
+ sides = f" by {registered!r} and {origin!r}" if registered and origin else ""
287
308
  raise ModelError(
288
- f"{type(model).__name__.lower()} model for {model.symbol} is already registered"
309
+ f"{type(model).__name__.lower()} model for {model.symbol}"
310
+ f" is registered twice{sides} with different values"
289
311
  )
290
- self._models[key] = model
291
312
 
292
313
  def freeze(self) -> ModelTable:
293
314
  models = list(self._models.values())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coretrace-python-analyzer
3
- Version: 0.2.0
3
+ Version: 0.4.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=Aitn8kEWC3OxAz_1TliVDkeY0-Dz9mZf-S09r7oSD4M,75
1
+ coretrace_python/__init__.py,sha256=uJFWBDpap961PWbZ01zYoAZ0LTF4lnI0UH11pEl6GEw,75
2
2
  coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
3
- coretrace_python/cache.py,sha256=267JaeUore7nmYwCRQ5dv4VIESyeFkQg4MqLPXOyUcc,11194
3
+ coretrace_python/cache.py,sha256=Aj6o-PvOHoTjLRuD-rJqYZv-fnPnp7NmbNFmfWssOvk,11316
4
4
  coretrace_python/cli.py,sha256=ZE6F8wqNcE-ogfldii-UgUq4nOcEMnHuMCzz0l--yuw,9792
5
- coretrace_python/engine.py,sha256=RDuwxwMOV1pT7fX2T9WU9EfoBn_9dNXOcY-7ztNpb5U,27154
5
+ coretrace_python/engine.py,sha256=FGMR22fXmBnt_iafkM7-xSh9vYhw15OPKGvTTJu-xiQ,27403
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,11 +14,11 @@ 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=jMNw7-ly4Bhjh3gaUDW7z43HOoQPH9f17M3sZ8HtK2M,2507
17
+ coretrace_python/bundled/dependency/reachable_vulnerability/reachable_vulnerability.py,sha256=nPwOlbebAhMNmk4BHldNe-UMrsK6qUUs1bL4z8fObvY,2186
18
18
  coretrace_python/bundled/dependency/sample_advisories/plugin.toml,sha256=cME4pSFzkFsxmIJGGBSrmXqynAd0dg34X-rgEoZ_u6E,184
19
- coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py,sha256=uViz4fvg2-u7jFKPtPY2G0yPhr_8Ba09exZc-fcZ2w8,3649
19
+ coretrace_python/bundled/dependency/sample_advisories/sample_advisories.py,sha256=jw98GK6hh5RAx05t8QPcUzsFMfGigAL8418vZLprKlc,3710
20
20
  coretrace_python/bundled/dependency/vulnerable_dependency/plugin.toml,sha256=FbmiXLEqtPUZEsDc5q_UqCzn6AecL5HCSdGVy7_2dy0,238
21
- coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py,sha256=Mu4pOhNrfxds-qsFr9_DZOAQHaW2QAKxwelQEpm41vU,1871
21
+ coretrace_python/bundled/dependency/vulnerable_dependency/vulnerable_dependency.py,sha256=ruwruejQ_NqYy7osxfaiPFRlus1DsMEluJ2obhxATkM,2092
22
22
  coretrace_python/bundled/models/aiohttp/aiohttp_models.py,sha256=EraiXpZih8yPRk5t0OKe2TMJO9iATv4Kj_lKLp_KpfA,2946
23
23
  coretrace_python/bundled/models/aiohttp/plugin.toml,sha256=YvU60RUBexYBtIxbXdmUhO83BoijmOe3MeQKwY8QDaM,220
24
24
  coretrace_python/bundled/models/bottle/bottle_models.py,sha256=0yt8-7g-Pxtas3qQcIdORkWLiqJu4O3JzCrSnhzbgHs,1885
@@ -88,10 +88,10 @@ 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=JOa2OupSyMe6COiSF9Cah6kcdqYJewFprWi9-E1Je6Q,983
92
- coretrace_python/dependency/advisories.py,sha256=BDZatgUvxeW8vOLLnGax49gSPBO3yiRlUHcPpaZKeMc,6333
93
- coretrace_python/dependency/correlation.py,sha256=psRKsca9W8Mr790VfFHIjGiblE0k4onKIW-Y2nlt8I0,3562
94
- coretrace_python/dependency/graph.py,sha256=LIH8PdBBzpqNsic8tQHQmO8LuTJXEHSDHDGnW0mKjwc,10812
91
+ coretrace_python/dependency/__init__.py,sha256=sZdigKRbM4NkV0Dr86FDF3rFRQz35pICVX6obgO2Rxo,1065
92
+ coretrace_python/dependency/advisories.py,sha256=RMEQ_9bvZ7OThjBncM5CbKzsmySPvQYuNMZqQVcrVnc,7765
93
+ coretrace_python/dependency/correlation.py,sha256=nyysSoXPz9jg_X8zeweybDpJRXLmj4eH0Z0_WQw5rd0,4916
94
+ coretrace_python/dependency/graph.py,sha256=Z0_u4v0hAYMCAyXEWMh7NwcG2FesIHsXUd3Fl8C1MF8,13617
95
95
  coretrace_python/dependency/policy.py,sha256=u6nUw2nX-kTuqmw_vooxN8eXZoC31ZMoMaxC9pCcwec,2317
96
96
  coretrace_python/dependency/sbom.py,sha256=h4K3Gb9O_D6uZyfpwpC5rPcNjH_Ib-3uvFxix6OKlfs,2520
97
97
  coretrace_python/findings/__init__.py,sha256=gnJI-uB2CkgZ8I5aFYP_N5yZb7FGNIy7LirVDh4NLwo,372
@@ -107,19 +107,19 @@ coretrace_python/hir/__init__.py,sha256=xMpvj-RSp6bd78a_hhkqbe99-snjERkH-_Lv5o9A
107
107
  coretrace_python/hir/nodes.py,sha256=4BI0f3UhKbnz9ZIxMoKXM7ujav0BmRpD0OXaoblRxIk,12364
108
108
  coretrace_python/hir/visitors.py,sha256=JFnX09hK6trLyRcWgZ5ehOG2ywX3VvkqQ1h82nahYNg,885
109
109
  coretrace_python/interprocedural/__init__.py,sha256=57nLUicdsnX0uIub--EUcLt3TRazzZxy2LQ8TRwlOS0,1054
110
- coretrace_python/interprocedural/callgraph.py,sha256=J6XnAPOO8VL4aJJUmSDvhIITjxIOp2mq5veacvXAuhs,15004
111
- coretrace_python/interprocedural/modulegraph.py,sha256=0UIKtxxAfTV7WW4B0xcJgVnD-Z6KRS3hpOTMshj1E_U,8595
110
+ coretrace_python/interprocedural/callgraph.py,sha256=5YJ3W5JsKf-uSHkwDvSPBRS1X1ZTyjPaz-wJ8aJV3zE,15232
111
+ coretrace_python/interprocedural/modulegraph.py,sha256=4sB7OKjBYfxtm7aMtHm2jRlqFVUNUsdnLhnv9UZ1BWA,8846
112
112
  coretrace_python/interprocedural/summaries.py,sha256=6JFrTaMkG34g_nLhfmckjxKWvexCGvXuXozLliw6xQ8,20427
113
113
  coretrace_python/ir/__init__.py,sha256=ShBPYYqoQFePP-mgBjGezQ2acel0-ReOHF9vCC-G_4Q,232
114
114
  coretrace_python/ir/defuse.py,sha256=CZEPnlgntRp4MqwR7kLldd2ZlIlBnr-V9VJAxXxwm0c,2616
115
- coretrace_python/ir/lowering.py,sha256=YFSXKgsjcr1Ul33C8jMM_yxeFpGsAzFCte1mXNIoatw,29112
115
+ coretrace_python/ir/lowering.py,sha256=V7r_4tEOi801NBOsPdG6pwrRHZIeVTahygYENneLQ4U,31428
116
116
  coretrace_python/ir/model.py,sha256=i7LvHEnl5pmWYUcSui17ZGS1rdPVavMpnjt7Tog7P4c,11125
117
117
  coretrace_python/ir/printer.py,sha256=otBIhZUvB_UfB68gid_FbPcSjBUSmxH3v7vBMZZQVkg,9125
118
118
  coretrace_python/ir/ssa.py,sha256=cIYbcv90PI6AOeMrw2ZWp88fy7E2ZO4P3tqjtPHra7g,11116
119
- coretrace_python/plugins/__init__.py,sha256=Jh5oLVI_Rc7ZzibsMjJzg69LGeNcRRjLcY5XaVOhjgA,1308
120
- coretrace_python/plugins/api.py,sha256=y1bicTdPypstWs7aJ4JDIOSVKX-MwFq5NSjGntTZhDw,5309
119
+ coretrace_python/plugins/__init__.py,sha256=xRKqkvkvQMmfQACHHoBioYhIyyK4HoMAVogy6EZG8tg,1398
120
+ coretrace_python/plugins/api.py,sha256=Fjjy3FjnkS9OoQ55eLQZ-YJgykd4TgKNAYXySb0uSXY,7588
121
121
  coretrace_python/plugins/detectors.py,sha256=ej5Z_MS0CBqrR4b9xycjTVUIo5arpArpdbr7nL732iI,5700
122
- coretrace_python/plugins/loader.py,sha256=e7S6l3VhMpku0HpnAl5qbBRFiOlsSf66P7low7RsRvU,3174
122
+ coretrace_python/plugins/loader.py,sha256=Use-lHTL4-IJYQBZmODC89zhJbYKGvDSLZHWjZa5NaI,3885
123
123
  coretrace_python/plugins/manifest.py,sha256=F24pgQSwApRUL6ZihRSLv9wL_M03A_pZjrj9Zi4Ku3Q,3438
124
124
  coretrace_python/plugins/registry.py,sha256=ixKcT2YDky96TWCFckdzKnGRmFn1dbBVMSZLPvdxvY8,1177
125
125
  coretrace_python/plugins/secrets.py,sha256=fpCxXpz3u7a-p1mAabC99FK_DpC1RSg62lLPe96pGy8,15162
@@ -131,18 +131,18 @@ coretrace_python/reporters/text.py,sha256=bHsHkMheyL0AB1iWtxBO5H9S14k1AqJvKkNsFr
131
131
  coretrace_python/semantic/__init__.py,sha256=_tjT9CgXfPbOMs8EFguhWrLeSmpyrqOHU_OSoQPiWYs,411
132
132
  coretrace_python/semantic/identity.py,sha256=uQBdqEXrOtYmGIiR9hZu0Pimv52iW1rOOOvIdG0InNY,1472
133
133
  coretrace_python/semantic/imports.py,sha256=389MZs8iTUc3CyUHG0VAgWFsypd7VPFKkLHmFz9L-N0,5673
134
- coretrace_python/semantic/scopes.py,sha256=wLnXs81gCs9M_HNXp4FZUVAydYWNkdeJFgq6z8atL0k,20095
134
+ coretrace_python/semantic/scopes.py,sha256=zg8ClITSAUbOMvLOV5BsfdG1oC4cd0VweUb9S5Jc4js,20241
135
135
  coretrace_python/semantic/symbols.py,sha256=3JptnnX5_KBlSd00L_wcGlYZQdnmBvSBAhfivBF6DLo,3513
136
136
  coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1KdAXF0,301
137
137
  coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
138
138
  coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
139
139
  coretrace_python/taint/__init__.py,sha256=D-NH3kx9wKTNeWASewawGAx7ChOkGMGhmjf-E4crHl0,1111
140
- coretrace_python/taint/engine.py,sha256=QedazdxW-5jwiYbZWFI7AyQycSQWNDVOVbkPIKkI0i8,34667
141
- coretrace_python/taint/models.py,sha256=s7luO5vMaowxPcWoThkLTEdYuyw0JZZVkaKYW5o_xOI,10930
140
+ coretrace_python/taint/engine.py,sha256=tj28lfqWZSyddXsJ2c0Q7zbVSmk8ceZ-FccZDoBuhd4,35605
141
+ coretrace_python/taint/models.py,sha256=H-Q_nChzwqm-MEGfbfw5jMegpXlIfwUIunFiRXbE90I,12005
142
142
  coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
143
- coretrace_python_analyzer-0.2.0.dist-info/METADATA,sha256=Cztxh-bWrr4a_Ttxusa8jsRqA1i5knoRD9gbF_X4jlU,3216
144
- coretrace_python_analyzer-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
145
- coretrace_python_analyzer-0.2.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
146
- coretrace_python_analyzer-0.2.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
147
- coretrace_python_analyzer-0.2.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
148
- coretrace_python_analyzer-0.2.0.dist-info/RECORD,,
143
+ coretrace_python_analyzer-0.4.0.dist-info/METADATA,sha256=4CTPKMus5dgilu4I3lpROK-gLTL9HiQC5P75aZu8Kj8,3216
144
+ coretrace_python_analyzer-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
145
+ coretrace_python_analyzer-0.4.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
146
+ coretrace_python_analyzer-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
147
+ coretrace_python_analyzer-0.4.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
148
+ coretrace_python_analyzer-0.4.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any