coretrace-python-analyzer 0.8.0__py3-none-any.whl → 0.10.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.8.0"
3
+ __version__ = "0.10.0"
4
4
 
coretrace_python/cli.py CHANGED
@@ -242,9 +242,12 @@ def main(argv: list[str] | None = None) -> int:
242
242
  findings = analysis.findings
243
243
  coverage = analysis.coverage
244
244
  suppressed = analysis.suppressed
245
+ components = analysis.components
245
246
  if args.sbom is not None:
246
247
  args.sbom.write_text(
247
- render_sbom(analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__),
248
+ render_sbom(
249
+ analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__, analysis.components
250
+ ),
248
251
  encoding="utf-8",
249
252
  )
250
253
  if args.vex is not None:
@@ -258,6 +261,7 @@ def main(argv: list[str] | None = None) -> int:
258
261
  engine.TOOL_NAME,
259
262
  __version__,
260
263
  datetime.now(UTC),
264
+ analysis.components,
261
265
  ),
262
266
  encoding="utf-8",
263
267
  )
@@ -265,6 +269,7 @@ def main(argv: list[str] | None = None) -> int:
265
269
  file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots)
266
270
  findings, coverage = file_analysis.findings, file_analysis.coverage
267
271
  suppressed = file_analysis.suppressed
272
+ components = ()
268
273
  root = args.path.resolve() if args.path.is_dir() else args.path.resolve().parent
269
274
  baselined = None
270
275
  if args.baseline is not None:
@@ -273,7 +278,7 @@ def main(argv: list[str] | None = None) -> int:
273
278
  else:
274
279
  Baseline.of(findings, root).save(args.baseline)
275
280
  findings, baselined = (), findings
276
- report = engine.report(findings, coverage, root, suppressed, baselined)
281
+ report = engine.report(findings, coverage, root, suppressed, baselined, components)
277
282
  print(render(args.format or "text", report), end="")
278
283
  threshold = Severity(args.fail_on).rank if args.fail_on is not None else 0
279
284
  failing = any(finding.severity.rank >= threshold for finding in findings)
@@ -12,6 +12,7 @@ from coretrace_python.dependency.graph import (
12
12
  DEPENDENCY_FILES,
13
13
  Advisory,
14
14
  AdvisoryEntryPoint,
15
+ AttackerArgument,
15
16
  Condition,
16
17
  DependencyAnalysis,
17
18
  DependencyGraph,
@@ -31,6 +32,7 @@ __all__ = [
31
32
  "Advisory",
32
33
  "AdvisoryEntryPoint",
33
34
  "AdvisoryFileError",
35
+ "AttackerArgument",
34
36
  "Condition",
35
37
  "DependencyAnalysis",
36
38
  "DependencyGraph",
@@ -17,7 +17,13 @@ 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, AdvisoryEntryPoint, Condition, normalize
20
+ from coretrace_python.dependency.graph import (
21
+ Advisory,
22
+ AdvisoryEntryPoint,
23
+ AttackerArgument,
24
+ Condition,
25
+ normalize,
26
+ )
21
27
  from coretrace_python.findings import Severity
22
28
  from coretrace_python.semantic.symbols import SymbolId
23
29
 
@@ -155,6 +161,19 @@ def _entry_point_entry(entry_point: AdvisoryEntryPoint) -> dict[str, Any]:
155
161
  }
156
162
  if entry_point.read:
157
163
  entry["read"] = True
164
+ if entry_point.attacker_arguments:
165
+ entry["attacker_arguments"] = [_attacker_argument_entry(a) for a in entry_point.attacker_arguments]
166
+ return entry
167
+
168
+
169
+ def _attacker_argument_entry(argument: AttackerArgument) -> dict[str, Any]:
170
+ entry: dict[str, Any] = {}
171
+ if argument.argument is not None:
172
+ entry["argument"] = argument.argument
173
+ if argument.position is not None:
174
+ entry["position"] = argument.position
175
+ if argument.variadic:
176
+ entry["variadic"] = True
158
177
  return entry
159
178
 
160
179
 
@@ -206,9 +225,25 @@ def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
206
225
  str(entry["justification"]),
207
226
  tuple(_condition(c) for c in entry.get("conditions") or []),
208
227
  read,
228
+ tuple(_attacker_argument(a) for a in entry.get("attacker_arguments") or []),
209
229
  )
210
230
 
211
231
 
232
+ def _attacker_argument(entry: Mapping[str, Any]) -> AttackerArgument:
233
+ argument, position, variadic = entry.get("argument"), entry.get("position"), entry.get("variadic", False)
234
+ if argument is not None and not isinstance(argument, str):
235
+ raise TypeError(f"attacker argument name must be a string, got {argument!r}")
236
+ if position is not None and (not isinstance(position, int) or isinstance(position, bool) or position < 0):
237
+ raise TypeError(f"attacker argument position must be a non-negative integer, got {position!r}")
238
+ if not isinstance(variadic, bool):
239
+ raise TypeError(f"attacker argument variadic must be true or false, got {variadic!r}")
240
+ if argument is None and position is None:
241
+ raise ValueError("an attacker argument needs a name, a position or both")
242
+ if variadic and position is None:
243
+ raise ValueError("a variadic attacker argument needs the position it starts at")
244
+ return AttackerArgument(argument, position, variadic)
245
+
246
+
212
247
  def _condition(entry: Mapping[str, Any]) -> Condition:
213
248
  position = entry.get("position")
214
249
  if position is not None and (not isinstance(position, int) or isinstance(position, bool) or position < 0):
@@ -133,7 +133,10 @@ def correlate(
133
133
  continue
134
134
  hotspot = verdict is not None and verdict.status is Status.HOTSPOT
135
135
  for advisory in affected.get(flow.sink.symbol, ()):
136
- check = check_conditions(advisory.entry_point(flow.sink.symbol), flow.sink_arguments)
136
+ entry = advisory.entry_point(flow.sink.symbol)
137
+ if entry is not None and not any(entry.exploitable_through(p, k) for p, k in flow.passed_as):
138
+ continue
139
+ check = check_conditions(entry, flow.sink_arguments)
137
140
  if check.contradicted is None:
138
141
  findings.append(_exploitable(function, flow, advisory, verdict, hotspot, check))
139
142
  return tuple(findings)
@@ -166,6 +166,31 @@ class Condition:
166
166
  return self.kind == "argument"
167
167
 
168
168
 
169
+ @dataclass(frozen=True)
170
+ class AttackerArgument:
171
+ """An argument of an entry point that must carry the attacker's input: passed by
172
+ keyword ``argument``, or at ``position`` (from 0, a method's receiver excluded) when it
173
+ may be passed positionally; a ``variadic`` one, such as ``*pathnames``, also takes every
174
+ later position."""
175
+
176
+ argument: str | None = None
177
+ position: int | None = None
178
+ variadic: bool = False
179
+
180
+ def receives(self, position: int | None, keyword: str | None) -> bool:
181
+ """Whether the argument passed at ``position`` or as ``keyword`` is this one. An
182
+ argument unpacked with ``*`` or ``**`` has neither and may fill this one: like an
183
+ argument condition it leaves undecided, it does not rule the call out, so it is."""
184
+
185
+ if keyword is not None:
186
+ return keyword == self.argument
187
+ if position is None:
188
+ return True
189
+ if self.position is None:
190
+ return False
191
+ return position == self.position or (self.variadic and position > self.position)
192
+
193
+
169
194
  @dataclass(frozen=True)
170
195
  class AdvisoryEntryPoint:
171
196
  """A public API through which a project reaches an affected symbol, justified by the
@@ -173,12 +198,20 @@ class AdvisoryEntryPoint:
173
198
 
174
199
  An entry point is reached by a call, or — when ``read`` is set, for an attribute whose
175
200
  getter runs the affected code, such as a lazily parsed request body — by any read of
176
- it or of an attribute of it."""
201
+ it or of an attribute of it. When it names ``attacker_arguments``, attacker input
202
+ exploits it only through one of them; otherwise through any argument."""
177
203
 
178
204
  symbol: SymbolId
179
205
  justification: str
180
206
  conditions: tuple[Condition, ...] = ()
181
207
  read: bool = False
208
+ attacker_arguments: tuple[AttackerArgument, ...] = ()
209
+
210
+ def exploitable_through(self, position: int | None, keyword: str | None) -> bool:
211
+ """Whether attacker input in the argument at ``position`` or ``keyword`` can
212
+ exploit the vulnerability."""
213
+
214
+ return not self.attacker_arguments or any(a.receives(position, keyword) for a in self.attacker_arguments)
182
215
 
183
216
 
184
217
  DIRECT = "affected symbol, changed by the fix"
@@ -3,9 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from collections.abc import Iterable
6
+ from collections.abc import Iterable, Sequence
7
7
 
8
8
  from coretrace_python.dependency.graph import Advisory, DependencyGraph, Requirement
9
+ from coretrace_python.findings import PLUGIN, Component
9
10
 
10
11
  SPEC_VERSION = "1.5"
11
12
 
@@ -33,8 +34,22 @@ def _component(requirement: Requirement) -> dict[str, object]:
33
34
  return component
34
35
 
35
36
 
37
+ def _tool(component: Component) -> dict[str, object]:
38
+ """A plugin as an application, an advisory file as data, each with its SHA-256."""
39
+
40
+ tool: dict[str, object] = {"type": "application" if component.kind == PLUGIN else "data", "name": component.name}
41
+ if component.version is not None:
42
+ tool["version"] = component.version
43
+ tool["hashes"] = [{"alg": "SHA-256", "content": component.digest.removeprefix("sha256:")}]
44
+ return tool
45
+
46
+
36
47
  def render_sbom(
37
- dependencies: DependencyGraph, advisories: Iterable[Advisory], tool_name: str, tool_version: str
48
+ dependencies: DependencyGraph,
49
+ advisories: Iterable[Advisory],
50
+ tool_name: str,
51
+ tool_version: str,
52
+ components: Sequence[Component] = (),
38
53
  ) -> str:
39
54
  """A CycloneDX JSON document: one component per requirement, and the advisories
40
55
  affecting them as vulnerabilities. Deterministic for a given graph."""
@@ -57,7 +72,12 @@ def render_sbom(
57
72
  "specVersion": SPEC_VERSION,
58
73
  "version": 1,
59
74
  "metadata": {
60
- "tools": {"components": [{"type": "application", "name": tool_name, "version": tool_version}]}
75
+ "tools": {
76
+ "components": [
77
+ {"type": "application", "name": tool_name, "version": tool_version},
78
+ *(_tool(component) for component in components),
79
+ ]
80
+ }
61
81
  },
62
82
  "components": [_component(requirement) for requirement in requirements],
63
83
  "vulnerabilities": vulnerabilities,
@@ -23,7 +23,7 @@ from urllib.parse import quote
23
23
 
24
24
  from coretrace_python.dependency.graph import Advisory, DependencyGraph, Requirement
25
25
  from coretrace_python.dependency.sbom import purl
26
- from coretrace_python.findings import Finding
26
+ from coretrace_python.findings import Component, Finding
27
27
  from coretrace_python.findings.coverage import Coverage
28
28
 
29
29
  CONTEXT = "https://openvex.dev/ns/v0.2.0"
@@ -44,6 +44,7 @@ def render_vex(
44
44
  tool_name: str,
45
45
  tool_version: str,
46
46
  timestamp: datetime,
47
+ components: Sequence[Component] = (),
47
48
  ) -> str:
48
49
  """The OpenVEX document of the project at ``root``. ``evidence`` is every finding of
49
50
  the check, those suppressed or accepted by the policy included. The document's
@@ -69,7 +70,12 @@ def render_vex(
69
70
  **_status(advisory, requirement, about, dependencies, coverage, root),
70
71
  }
71
72
  )
72
- tooling = f"{tool_name} {tool_version}"
73
+ tooling = "; ".join(
74
+ (
75
+ f"{tool_name} {tool_version}",
76
+ *(" ".join(filter(None, (c.name, c.version, f"({c.digest})"))) for c in components),
77
+ )
78
+ )
73
79
  content = {
74
80
  "@context": CONTEXT,
75
81
  "author": AUTHOR,
@@ -7,9 +7,10 @@ plugins and analyses never import it.
7
7
  from __future__ import annotations
8
8
 
9
9
  import concurrent.futures
10
+ import hashlib
10
11
  import multiprocessing
11
12
  from collections.abc import Callable, Iterable, Mapping, Sequence
12
- from dataclasses import dataclass, field
13
+ from dataclasses import dataclass, field, replace
13
14
  from pathlib import Path
14
15
  from types import MappingProxyType
15
16
  from typing import Any, ClassVar
@@ -55,7 +56,10 @@ from coretrace_python.dependency.correlation import (
55
56
  correlate,
56
57
  )
57
58
  from coretrace_python.findings import (
59
+ ADVISORIES,
58
60
  FINDING_SCHEMA_VERSION,
61
+ PLUGIN,
62
+ Component,
59
63
  Confidence,
60
64
  Coverage,
61
65
  FileCoverage,
@@ -217,6 +221,8 @@ class ProjectAnalysis:
217
221
  suppressed: tuple[Finding, ...] = ()
218
222
  # Findings about an advisory the policy accepts, still evidence of what is reached.
219
223
  accepted: tuple[Finding, ...] = ()
224
+ # The plugins and advisory files the result was produced with.
225
+ components: tuple[Component, ...] = ()
220
226
 
221
227
 
222
228
  @dataclass(frozen=True)
@@ -358,10 +364,10 @@ def analyze_project(
358
364
  for error in dependencies.errors:
359
365
  findings.append(_note("syntax-error", error, sources.add_source(error.split(":")[0], ""), 1))
360
366
  advisory_paths = _advisory_paths(root, advisory_files)
361
- file_advisories: list[Advisory] = []
367
+ file_advisories: list[tuple[Path, tuple[Advisory, ...]]] = []
362
368
  for path in advisory_paths:
363
369
  try:
364
- file_advisories.extend(load_advisories(path))
370
+ file_advisories.append((path, load_advisories(path)))
365
371
  except AdvisoryFileError as error:
366
372
  findings.append(_note("syntax-error", str(error), sources.add_source(str(path), ""), 1))
367
373
  policy = Policy()
@@ -392,9 +398,8 @@ def analyze_project(
392
398
  probe = next(iter(managers.values()), None) or _register_all(build_hir(sources.add_source("<empty>", "")))
393
399
  registry = load_plugins(plugin_roots, probe)
394
400
  all_plugins: tuple[Plugin, ...] = (*(loaded.plugin for loaded in registry), *plugins)
395
- advisories = _merge_advisories(
396
- (a for plugin in all_plugins for a in plugin.advisories), file_advisories
397
- )
401
+ components = _components(registry, root, (path for path, _ in file_advisories))
402
+ advisories, origins = _merge_advisories(_contributions(registry, plugins, root, file_advisories))
398
403
  affected = affected_symbols(dependencies, advisories)
399
404
  models = plugin_models(all_plugins, root).extended(*advisory_sinks(affected))
400
405
  for manager in managers.values():
@@ -425,7 +430,7 @@ def analyze_project(
425
430
  manager.provide(EscapedTemplates, escaped)
426
431
  manager.provide(ClearingAnalysis, clearing)
427
432
 
428
- configuration = _configuration_key(registry, plugins, models, advisories, dependencies, routes, escaped)
433
+ configuration = _configuration_key(components, plugins, models, advisories, dependencies, routes, escaped)
429
434
  keys = module_keys(
430
435
  graph,
431
436
  {name: fingerprint(configuration, str(files[name].source_id), name, files[name].text) for name in analysable},
@@ -511,6 +516,7 @@ def analyze_project(
511
516
  for plugin in all_plugins:
512
517
  if isinstance(plugin, ProjectPlugin):
513
518
  findings = list(apply_refinement(plugin, findings, plugin.refine(context, tuple(findings))))
519
+ findings = [_sourced(finding, origins) for finding in findings]
514
520
  accepted = tuple(f for f in findings if policy.accepts(f))
515
521
  kept, suppressed = partition(apply_policy(policy, findings), _text_of(sources))
516
522
  return ProjectAnalysis(
@@ -524,20 +530,78 @@ def analyze_project(
524
530
  Coverage(tuple(sorted(coverage, key=lambda c: c.path))),
525
531
  suppressed,
526
532
  accepted,
533
+ components,
534
+ )
535
+
536
+
537
+ Origins = Mapping[tuple[str, str], str]
538
+
539
+
540
+ def _merge_advisories(contributions: Iterable[tuple[str, Iterable[Advisory]]]) -> tuple[tuple[Advisory, ...], Origins]:
541
+ """One advisory per identifier and package, with the contributor it comes from: a
542
+ later contributor replaces an earlier one, so a curated plugin refines a bundled
543
+ sample and a local file, the project's own feed, wins over every plugin."""
544
+
545
+ merged: dict[tuple[str, str], tuple[str, Advisory]] = {}
546
+ for source, advisories in contributions:
547
+ for advisory in advisories:
548
+ merged[(advisory.id, advisory.package)] = (source, advisory)
549
+ return (
550
+ tuple(advisory for _, advisory in merged.values()),
551
+ MappingProxyType({key: source for key, (source, _) in merged.items()}),
527
552
  )
528
553
 
529
554
 
530
- def _merge_advisories(
531
- from_plugins: Iterable[Advisory], from_files: Iterable[Advisory]
532
- ) -> tuple[Advisory, ...]:
533
- """One advisory per identifier and package: a later contributor replaces an earlier
534
- one, so a curated plugin refines a bundled sample and a local file, the project's
535
- own feed, wins over every plugin."""
555
+ def _contributions(
556
+ registry: PluginRegistry,
557
+ plugins: Sequence[Plugin],
558
+ root: Path,
559
+ files: Iterable[tuple[Path, tuple[Advisory, ...]]],
560
+ ) -> list[tuple[str, Iterable[Advisory]]]:
561
+ """The advisories of each plugin, then of each advisory file, each under the name a
562
+ finding cites it by: ``name@version`` for a loaded plugin, the path for a file."""
563
+
564
+ return [
565
+ *((f"{loaded.manifest.name}@{loaded.manifest.version}", loaded.plugin.advisories) for loaded in registry),
566
+ *((plugin.name, plugin.advisories) for plugin in plugins),
567
+ *((_located(path, root), advisories) for path, advisories in files),
568
+ ]
536
569
 
537
- merged: dict[tuple[str, str], Advisory] = {}
538
- for advisory in (*from_plugins, *from_files):
539
- merged[(advisory.id, advisory.package)] = advisory
540
- return tuple(merged.values())
570
+
571
+ def _components(registry: PluginRegistry, root: Path, files: Iterable[Path]) -> tuple[Component, ...]:
572
+ """The plugins and advisory files a project result is produced with, each with the
573
+ digest of its content: the plugin directory as the cache fingerprints it, the file's
574
+ bytes."""
575
+
576
+ return (
577
+ *(
578
+ Component(PLUGIN, loaded.manifest.name, f"sha256:{directory_fingerprint(loaded.directory)}", loaded.manifest.version)
579
+ for loaded in registry
580
+ ),
581
+ *(
582
+ Component(ADVISORIES, _located(path, root), f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}")
583
+ for path in files
584
+ ),
585
+ )
586
+
587
+
588
+ def _located(path: Path, root: Path) -> str:
589
+ """``path`` relative to ``root``, POSIX style, when it lies under it."""
590
+
591
+ try:
592
+ return path.resolve().relative_to(root.resolve()).as_posix()
593
+ except ValueError:
594
+ return str(path)
595
+
596
+
597
+ def _sourced(finding: Finding, origins: Origins) -> Finding:
598
+ """``finding`` with the plugin or advisory file its advisory comes from, when it
599
+ cites one."""
600
+
601
+ origin = origins.get((finding.metadata.get("advisory", ""), finding.metadata.get("package", "")))
602
+ if origin is None:
603
+ return finding
604
+ return replace(finding, metadata={**finding.metadata, "advisory_source": origin})
541
605
 
542
606
 
543
607
  def _advisory_paths(root: Path, advisory_files: Sequence[Path]) -> tuple[Path, ...]:
@@ -623,15 +687,13 @@ def _analyse_batch(batch: _Batch) -> dict[str, dict[str, Any]]:
623
687
  registry = load_plugins(batch.plugin_roots, next(iter(managers.values())))
624
688
  all_plugins: tuple[Plugin, ...] = (*(loaded.plugin for loaded in registry), *batch.plugins)
625
689
  dependencies = resolve_dependencies(batch.root, sources)
626
- file_advisories: list[Advisory] = []
690
+ file_advisories: list[tuple[Path, tuple[Advisory, ...]]] = []
627
691
  for path in batch.advisory_paths:
628
692
  try:
629
- file_advisories.extend(load_advisories(path))
693
+ file_advisories.append((path, load_advisories(path)))
630
694
  except AdvisoryFileError:
631
695
  continue
632
- advisories = _merge_advisories(
633
- (a for plugin in all_plugins for a in plugin.advisories), file_advisories
634
- )
696
+ advisories, _ = _merge_advisories(_contributions(registry, batch.plugins, batch.root, file_advisories))
635
697
  affected = affected_symbols(dependencies, advisories)
636
698
  models = plugin_models(all_plugins, batch.root).extended(*advisory_sinks(affected))
637
699
  routes = _decode_routes(batch.routes)
@@ -663,7 +725,7 @@ def _decode_routes(data: tuple[tuple[str, str, str, int], ...]) -> dict[SymbolId
663
725
 
664
726
 
665
727
  def _configuration_key(
666
- registry: PluginRegistry,
728
+ components: Sequence[Component],
667
729
  plugins: Sequence[Plugin],
668
730
  models: ModelTable,
669
731
  advisories: tuple[Advisory, ...],
@@ -679,10 +741,7 @@ def _configuration_key(
679
741
  str(PYIR_SCHEMA_VERSION),
680
742
  str(FINDING_SCHEMA_VERSION),
681
743
  str(PLUGIN_API_VERSION),
682
- *(
683
- f"{loaded.manifest.name}={loaded.manifest.version}:{directory_fingerprint(loaded.directory)}"
684
- for loaded in registry
685
- ),
744
+ *(f"{c.name}={c.version}:{c.digest}" for c in components if c.kind == PLUGIN),
686
745
  *(f"{type(p).__module__}.{type(p).__qualname__}" for p in plugins),
687
746
  repr(models),
688
747
  repr(advisories),
@@ -776,6 +835,7 @@ def report(
776
835
  root: Path | None = None,
777
836
  suppressed: Sequence[Finding] = (),
778
837
  baselined: Sequence[Finding] | None = None,
838
+ components: Sequence[Component] = (),
779
839
  ) -> Report:
780
840
  """The report of a check; paths under ``root`` are rendered relative to it.
781
841
  ``baselined`` is the findings a baseline accounts for, ``None`` when none applied."""
@@ -789,4 +849,5 @@ def report(
789
849
  tuple(suppressed),
790
850
  tuple(baselined or ()),
791
851
  baselined is not None,
852
+ tuple(components),
792
853
  )
@@ -2,9 +2,13 @@
2
2
 
3
3
  from coretrace_python.findings.coverage import Coverage, FileCoverage
4
4
  from coretrace_python.findings.model import FINDING_SCHEMA_VERSION, Confidence, Finding, Severity
5
+ from coretrace_python.findings.provenance import ADVISORIES, PLUGIN, Component
5
6
 
6
7
  __all__ = [
8
+ "ADVISORIES",
7
9
  "FINDING_SCHEMA_VERSION",
10
+ "PLUGIN",
11
+ "Component",
8
12
  "Confidence",
9
13
  "Coverage",
10
14
  "FileCoverage",
@@ -0,0 +1,26 @@
1
+ """What a result was produced with, besides the engine and the analysed sources.
2
+
3
+ Advisories decide the dependency findings and the VEX statements, and they come from
4
+ plugins and advisory files that change on their own schedule. A report names each of
5
+ them with the digest of its content, so a result can be traced to its data and
6
+ reproduced.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ PLUGIN = "plugin"
14
+ ADVISORIES = "advisories"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Component:
19
+ """A loaded plugin, by its manifest ``name`` and ``version``, or an advisory file, by
20
+ its path as ``name``. ``digest`` is ``sha256:`` and the hex digest of its content:
21
+ the plugin directory as the cache fingerprints it, or the file's bytes."""
22
+
23
+ kind: str
24
+ name: str
25
+ digest: str
26
+ version: str | None = None
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
 
7
- from coretrace_python.findings import FINDING_SCHEMA_VERSION, Finding
7
+ from coretrace_python.findings import FINDING_SCHEMA_VERSION, Component, Finding
8
8
  from coretrace_python.reporters.report import Report
9
9
 
10
10
 
@@ -28,11 +28,21 @@ def finding_record(finding: Finding, report: Report | None = None) -> dict[str,
28
28
  }
29
29
 
30
30
 
31
+ def component_record(component: Component) -> dict[str, str]:
32
+ """A plugin or advisory file the result was produced with; a file has no version."""
33
+
34
+ record = {"kind": component.kind, "name": component.name}
35
+ if component.version is not None:
36
+ record["version"] = component.version
37
+ record["digest"] = component.digest
38
+ return record
39
+
40
+
31
41
  def render_json(report: Report) -> str:
32
- document: dict[str, object] = {
33
- "schema_version": FINDING_SCHEMA_VERSION,
34
- "tool": {"name": report.tool_name, "version": report.tool_version},
35
- }
42
+ tool: dict[str, object] = {"name": report.tool_name, "version": report.tool_version}
43
+ if report.components:
44
+ tool["components"] = [component_record(component) for component in report.components]
45
+ document: dict[str, object] = {"schema_version": FINDING_SCHEMA_VERSION, "tool": tool}
36
46
  if report.root is not None:
37
47
  document["root"] = str(report.root)
38
48
  document["findings"] = [finding_record(finding, report) for finding in report.findings]
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path, PurePosixPath
7
7
 
8
- from coretrace_python.findings import Coverage, Finding
8
+ from coretrace_python.findings import Component, Coverage, Finding
9
9
 
10
10
 
11
11
  def _order(finding: Finding) -> tuple[str, int, int, str]:
@@ -27,6 +27,8 @@ class Report:
27
27
  baselined: tuple[Finding, ...] = ()
28
28
  # Whether a baseline was applied, so new findings can be marked as such.
29
29
  with_baseline: bool = False
30
+ # The plugins and advisory files the result was produced with.
31
+ components: tuple[Component, ...] = ()
30
32
 
31
33
  def __post_init__(self) -> None:
32
34
  for name in ("findings", "suppressed", "baselined"):
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
 
7
- from coretrace_python.findings import Finding, Severity
7
+ from coretrace_python.findings import Component, Finding, Severity
8
8
  from coretrace_python.reporters.report import Report
9
9
 
10
10
  SARIF_VERSION = "2.1.0"
@@ -59,6 +59,16 @@ def _result(
59
59
  return result
60
60
 
61
61
 
62
+ def _extension(component: Component) -> dict[str, object]:
63
+ """A plugin or advisory file as a SARIF tool extension, its kind and digest as properties."""
64
+
65
+ extension: dict[str, object] = {"name": component.name}
66
+ if component.version is not None:
67
+ extension["version"] = component.version
68
+ extension["properties"] = {"kind": component.kind, "digest": component.digest}
69
+ return extension
70
+
71
+
62
72
  def render_sarif(report: Report) -> str:
63
73
  rule_ids: list[str] = []
64
74
  for finding in (*report.findings, *report.suppressed, *report.baselined):
@@ -78,7 +88,8 @@ def render_sarif(report: Report) -> str:
78
88
  {"id": rule_id, "shortDescription": {"text": rule_id}}
79
89
  for rule_id in rule_ids
80
90
  ],
81
- }
91
+ },
92
+ **({"extensions": [_extension(c) for c in report.components]} if report.components else {}),
82
93
  },
83
94
  "results": [
84
95
  *(_result(report, f, rule_ids.index(f.rule_id), False, new) for f in report.findings),
@@ -9,7 +9,7 @@ argument reaching a sink whose kinds it still carries is reported as a ``TaintFl
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- from collections.abc import Callable, Mapping
12
+ from collections.abc import Callable, Iterable, Mapping
13
13
  from dataclasses import dataclass, replace
14
14
  from types import MappingProxyType
15
15
  from typing import ClassVar
@@ -119,6 +119,9 @@ class TaintFlow:
119
119
  sink_location: SourceSpan | None = None
120
120
  # What the arguments of the sink call denote, to decide the conditions on them.
121
121
  sink_arguments: Arguments | None = None
122
+ # How the tainted value is passed to the sink call: at a position, by a keyword, or
123
+ # (None, None) unpacked with ``*`` or ``**``; ``f(x, key=x)`` passes it twice.
124
+ passed_as: frozenset[tuple[int | None, str | None]] = frozenset()
122
125
 
123
126
 
124
127
  Key = Value | HeapLocation
@@ -559,6 +562,7 @@ class _TaintProblem(DataflowProblem[State]):
559
562
  through,
560
563
  call.location if sink_location is None else sink_location,
561
564
  arguments,
565
+ frozenset({(position, keyword)}),
562
566
  )
563
567
  )
564
568
 
@@ -819,7 +823,18 @@ def propagate_taint(
819
823
  state, found = problem.evaluate(block, solution.incoming(block.id))
820
824
  taints.update(state)
821
825
  flows.extend(found)
822
- return TaintFacts(taints, tuple(dict.fromkeys(flows)))
826
+ return TaintFacts(taints, _merged(flows))
827
+
828
+
829
+ def _merged(flows: Iterable[TaintFlow]) -> tuple[TaintFlow, ...]:
830
+ """One flow per source, sink, kinds and tainted value, however many arguments of the
831
+ sink call it is passed as."""
832
+
833
+ passed: dict[TaintFlow, frozenset[tuple[int | None, str | None]]] = {}
834
+ for flow in flows:
835
+ key = replace(flow, passed_as=frozenset())
836
+ passed[key] = passed.get(key, frozenset()) | flow.passed_as
837
+ return tuple(replace(flow, passed_as=ways) for flow, ways in passed.items())
823
838
 
824
839
 
825
840
  class TaintAnalysis(FunctionAnalysis[TaintFacts]):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: coretrace-python-analyzer
3
- Version: 0.8.0
3
+ Version: 0.10.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=u9j9n2ZvaBeVUS33TRJ3MIoLUWwaBdpCAainRMXI4ok,75
1
+ coretrace_python/__init__.py,sha256=dpPkJxTU8YFTQytkIzuK1AwDQYRpHrAJPKFeSC3Beps,76
2
2
  coretrace_python/__main__.py,sha256=V8DnYtRboISK2VCKm7DGHLREKvqLbqApyWlKBOs26wU,65
3
3
  coretrace_python/cache.py,sha256=-pZv-ELm4jMCTXQWSZIobHaT9UHIjFu7IlMkOM0i4LI,13607
4
- coretrace_python/cli.py,sha256=86rTb3Kcezwk9DBTRg-G3G38ypx8zH4lZbCR467xvpE,11008
5
- coretrace_python/engine.py,sha256=VWTUTgm9k0DrPYhYrgJQHzXi_L00pxnH8fGfo8insMc,29340
4
+ coretrace_python/cli.py,sha256=G5jhb6g6RgSRSjjS7DBgziIjUM1vTFwalJGvFHVKxrg,11225
5
+ coretrace_python/engine.py,sha256=-E4z-HvKffI0WMUXPKdQxm61anu-r-cYXHGqnppalYk,31953
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
@@ -88,17 +88,18 @@ 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=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
91
+ coretrace_python/dependency/__init__.py,sha256=BxkxnNwIEpAYtwbshCQovG-YXJ80QbwyzkmyJdbgsVg,1184
92
+ coretrace_python/dependency/advisories.py,sha256=zti7MrojcfxfYWJuGpyLXXJU3pffpT4SJnQUa_g34Lw,10274
93
+ coretrace_python/dependency/correlation.py,sha256=GLtrMn5yYr4NcKYNYZTqIFpwoXOrgsqI9t6lggXxFbg,7388
94
+ coretrace_python/dependency/graph.py,sha256=HtvKeWWG9cy9TzJQ7lYMGPfHjPBxoSVgwzoj71SegaE,18445
95
95
  coretrace_python/dependency/policy.py,sha256=bsxZXL9xC0jnAzkzDh6ncnW8oKLG2H13jYmXF_q99Ng,2454
96
- coretrace_python/dependency/sbom.py,sha256=h4K3Gb9O_D6uZyfpwpC5rPcNjH_Ib-3uvFxix6OKlfs,2520
97
- coretrace_python/dependency/vex.py,sha256=ZPASwL6Var269WifrU7g1NRlDf-jxKKs8LzMo8dUkxI,7237
98
- coretrace_python/findings/__init__.py,sha256=gnJI-uB2CkgZ8I5aFYP_N5yZb7FGNIy7LirVDh4NLwo,372
96
+ coretrace_python/dependency/sbom.py,sha256=rzJ-t0Mwoghej8nk0N63Dpjr_wYXm6JzuOPXjD8Kqs4,3234
97
+ coretrace_python/dependency/vex.py,sha256=YddBuXsv7UgEnHdM9Jfjpo_A700IuIqRMjQz7AHSLX4,7437
98
+ coretrace_python/findings/__init__.py,sha256=yFKXSFEsAnFSxF1E_ptF2OH4YlUcCaSeIlWSvqMgaHo,500
99
99
  coretrace_python/findings/baseline.py,sha256=fbGQuULRsCjhxSNdwuLfwsvql9ZUfF3jqCXrRmIgOoQ,3468
100
100
  coretrace_python/findings/coverage.py,sha256=U8URLEz6EN4B_TTQlLFCOtEmUbINt7a0Bexb-6OUFlI,1203
101
101
  coretrace_python/findings/model.py,sha256=zRK1FDuLgiVY4ch6H_AN9rDxC1orNW4wc8YN3cfVFfc,1424
102
+ coretrace_python/findings/provenance.py,sha256=vP3nbyDPMlRlxVcVlurF0mSGNuxO3B7kzDDDd_BVws0,838
102
103
  coretrace_python/findings/refutation.py,sha256=UeYCz70LwyL8AMimhs3ys9cH0j1_YbdHVu7y3VvmmUg,22026
103
104
  coretrace_python/findings/suppressions.py,sha256=kN4q0CA1tzeAubeYe4rUv_aRXda4PAsMZY0r8M5cuQI,2633
104
105
  coretrace_python/frontend/__init__.py,sha256=ULI2tf_IRTMboaKM6uzTkGGh4Agy3cFjtw2H1IPrz4g,627
@@ -125,9 +126,9 @@ coretrace_python/plugins/manifest.py,sha256=F24pgQSwApRUL6ZihRSLv9wL_M03A_pZjrj9
125
126
  coretrace_python/plugins/registry.py,sha256=ixKcT2YDky96TWCFckdzKnGRmFn1dbBVMSZLPvdxvY8,1177
126
127
  coretrace_python/plugins/secrets.py,sha256=fpCxXpz3u7a-p1mAabC99FK_DpC1RSg62lLPe96pGy8,15162
127
128
  coretrace_python/reporters/__init__.py,sha256=MHTEfIj5q1KvFeIm6aAzt4mwL8oCZMkpYlaAGaq_cdo,764
128
- coretrace_python/reporters/json_format.py,sha256=-eP6feOYFjJFVbP98-hxRZTK8m4OYYvtohdPie88Jx0,2198
129
- coretrace_python/reporters/report.py,sha256=fUlxvxbyZYherJpU7Kl9v55i0Opoipc80dNDMDMIf-w,1656
130
- coretrace_python/reporters/sarif.py,sha256=CfHfe9hTC9gzW408z-k3qbs0nB695gInqQs1Cxxu7Ow,3113
129
+ coretrace_python/reporters/json_format.py,sha256=x1XRw6UOOX624LRw2rJFiZDBbIX-LVzQ0SqHwG4BdAw,2699
130
+ coretrace_python/reporters/report.py,sha256=hEQPDtnEgDF0djXfg74vBM1B0uajZ_AWtNtu3eH7QI4,1777
131
+ coretrace_python/reporters/sarif.py,sha256=0HF47hZ6F2WtrDDb2uICLPqYI0gLRiUgxBX69C5lbnU,3649
131
132
  coretrace_python/reporters/text.py,sha256=bHsHkMheyL0AB1iWtxBO5H9S14k1AqJvKkNsFro71xo,1043
132
133
  coretrace_python/semantic/__init__.py,sha256=_tjT9CgXfPbOMs8EFguhWrLeSmpyrqOHU_OSoQPiWYs,411
133
134
  coretrace_python/semantic/identity.py,sha256=uQBdqEXrOtYmGIiR9hZu0Pimv52iW1rOOOvIdG0InNY,1472
@@ -138,13 +139,13 @@ coretrace_python/source/__init__.py,sha256=0Y8fvtbkETnNCJR9Zq7RS5TSgF9c22pz3SSk1
138
139
  coretrace_python/source/manager.py,sha256=JRmMOcWfS1nocbVrdcvfQTqEfIkmnb5JP857TpGywN4,2736
139
140
  coretrace_python/source/model.py,sha256=FZnN5mlir3tzbIpWoxnn6YtRi9UcJ_y0sZM60tKr8U4,1727
140
141
  coretrace_python/taint/__init__.py,sha256=kfqCEXE5wKFLwz2XrT42_yEv9pW4vPfgg5yYBVVSr2I,1371
141
- coretrace_python/taint/engine.py,sha256=lB6ysdzYNuYhIiRUaxfI4F3cAnaUK55d4lF9AzPWfrU,39742
142
+ coretrace_python/taint/engine.py,sha256=_uVUc2m29aljN_YR2iCQtEdvZFc-LJ5CDWiZF1Tz3WU,40524
142
143
  coretrace_python/taint/models.py,sha256=B84caPxmQvTdB9H_80nZOqcFlpNd9ofpKI2YL5Uc0UA,15269
143
144
  coretrace_python/taint/routes.py,sha256=UAJKXcw_sM9shf58FfDroxVO9bNSm4qrQdZknwUB0lo,6110
144
145
  coretrace_python/taint/templates.py,sha256=8Ojb43VXuWxOVifeXRpi3_2mjZfnvIC7GQ-AW80GUYk,11148
145
- coretrace_python_analyzer-0.8.0.dist-info/METADATA,sha256=6iUDBdRu9mMtAf4EaeD4sVOr4iEdxzT7tkfOBlaNNT8,3216
146
- coretrace_python_analyzer-0.8.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
147
- coretrace_python_analyzer-0.8.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
148
- coretrace_python_analyzer-0.8.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
149
- coretrace_python_analyzer-0.8.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
150
- coretrace_python_analyzer-0.8.0.dist-info/RECORD,,
146
+ coretrace_python_analyzer-0.10.0.dist-info/METADATA,sha256=g7Y7gs7Dsb2oZY33Iz2_aG6SQNI6KLkWuwtpcfij1i4,3217
147
+ coretrace_python_analyzer-0.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
148
+ coretrace_python_analyzer-0.10.0.dist-info/entry_points.txt,sha256=wdozemiraOkbt65g_Y_zgspuZpoLxJdaCVMJt1Ft3c0,72
149
+ coretrace_python_analyzer-0.10.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
150
+ coretrace_python_analyzer-0.10.0.dist-info/licenses/NOTICE,sha256=s3VHj5ngV0ozjW_kZJpg_35ne6JwoBK0ysOjbqVtA0w,113
151
+ coretrace_python_analyzer-0.10.0.dist-info/RECORD,,