Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

### Reports

- A directory check names what its result was produced with, besides the engine and the sources. It lists each plugin it loaded, by manifest name and version, and each advisory file it read, by path, each with the SHA-256 digest of its content. The formats show them in these places:
- JSON: `tool.components`;
- SARIF: `tool.extensions`;
- CycloneDX SBOM: `metadata.tools.components`, with their hashes;
- OpenVEX: the document's `tooling`, so its `@id` changes with the advisory data.

Every finding about an advisory names the plugin (`name@version`) or advisory file it comes from, in its `advisory_source` metadata. The cache key and the reports identify a plugin by the same digest (#149).

## 0.9.0 (2026-09-25)

### Precision
Expand Down
34 changes: 31 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,34 @@ the directory of the checked file; a path outside it is printed as it is.

`root` is the directory the paths are relative to.

A directory check also lists, in `tool.components`, what the result was produced with
besides the engine and the sources: each plugin it loaded, by manifest name and version,
and each advisory file it read, by path (relative to the root when under it). Each
component carries the SHA-256 digest of its content, so a result can be traced to the
exact advisory data it used and reproduced. A plugin's digest covers its directory as the
cache fingerprints it, bytecode and hidden files left out; a file's digest covers its
bytes. A single-file check lists no components.

```json
"tool": {
"name": "coretrace-python-analyzer", "version": "0.10.0",
"components": [
{"kind": "plugin", "name": "curated-advisories", "version": "2026.09.25", "digest": "sha256:…"},
{"kind": "advisories", "name": "advisories.json", "digest": "sha256:…"}
]
}
```

A finding about an advisory names, in its `advisory_source` metadata, where the advisory
comes from. For a plugin this is `name@version`, and for an advisory file its path. When
several sources define the same advisory, it names the one whose definition is used, as
the next section describes.

`--format sarif` prints a SARIF 2.1.0 log, one run with the tool, its rules and one
result per finding. The root is declared once as the `SRCROOT` original URI base and
every location under it is relative to that base, which is what code scanning services
need to attach results to files:
need to attach results to files. The components of a directory check are the tool's
`extensions`, their kind and digest in `properties`:

```bash
coretrace-python-analyzer --check src/ --format sarif > report.sarif
Expand Down Expand Up @@ -385,7 +409,9 @@ ignore = ["CVE-2020-1747"]
```

`--sbom PATH` writes a CycloneDX 1.5 bill of materials: one component per requirement
with its package URL, and the advisories affecting them as vulnerabilities.
with its package URL, and the advisories affecting them as vulnerabilities. Its tools
list the analyzer and the components of the check, each with its SHA-256 hash: a plugin
as an `application`, an advisory file as `data`.

```bash
coretrace-python-analyzer --check src/ --sbom sbom.json --policy security/policy.toml
Expand All @@ -407,7 +433,9 @@ not the code of installed packages. A framework calling the vulnerable function
project's behalf would go unseen. Without a lock file, a package is never
`not_affected`. The document's `@id` derives from its statements, so two runs with the
same result share it; its `author` is `Unknown Author`, as OpenVEX tools write when they
cannot know it.
cannot know it. Its `tooling` names the analyzer and each component with its digest
(`coretrace-python-analyzer 0.10.0; curated-advisories 2026.09.25 (sha256:…)`), so the
`@id` changes with the advisory data a statement was decided with.

## Large projects

Expand Down
9 changes: 7 additions & 2 deletions src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,12 @@ def main(argv: list[str] | None = None) -> int:
findings = analysis.findings
coverage = analysis.coverage
suppressed = analysis.suppressed
components = analysis.components
if args.sbom is not None:
args.sbom.write_text(
render_sbom(analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__),
render_sbom(
analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__, analysis.components
),
encoding="utf-8",
)
if args.vex is not None:
Expand All @@ -258,13 +261,15 @@ def main(argv: list[str] | None = None) -> int:
engine.TOOL_NAME,
__version__,
datetime.now(UTC),
analysis.components,
),
encoding="utf-8",
)
else:
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots)
findings, coverage = file_analysis.findings, file_analysis.coverage
suppressed = file_analysis.suppressed
components = ()
root = args.path.resolve() if args.path.is_dir() else args.path.resolve().parent
baselined = None
if args.baseline is not None:
Expand All @@ -273,7 +278,7 @@ def main(argv: list[str] | None = None) -> int:
else:
Baseline.of(findings, root).save(args.baseline)
findings, baselined = (), findings
report = engine.report(findings, coverage, root, suppressed, baselined)
report = engine.report(findings, coverage, root, suppressed, baselined, components)
print(render(args.format or "text", report), end="")
threshold = Severity(args.fail_on).rank if args.fail_on is not None else 0
failing = any(finding.severity.rank >= threshold for finding in findings)
Expand Down
26 changes: 23 additions & 3 deletions src/coretrace_python/dependency/sbom.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from __future__ import annotations

import json
from collections.abc import Iterable
from collections.abc import Iterable, Sequence

from coretrace_python.dependency.graph import Advisory, DependencyGraph, Requirement
from coretrace_python.findings import PLUGIN, Component

SPEC_VERSION = "1.5"

Expand Down Expand Up @@ -33,8 +34,22 @@ def _component(requirement: Requirement) -> dict[str, object]:
return component


def _tool(component: Component) -> dict[str, object]:
"""A plugin as an application, an advisory file as data, each with its SHA-256."""

tool: dict[str, object] = {"type": "application" if component.kind == PLUGIN else "data", "name": component.name}
if component.version is not None:
tool["version"] = component.version
tool["hashes"] = [{"alg": "SHA-256", "content": component.digest.removeprefix("sha256:")}]
return tool


def render_sbom(
dependencies: DependencyGraph, advisories: Iterable[Advisory], tool_name: str, tool_version: str
dependencies: DependencyGraph,
advisories: Iterable[Advisory],
tool_name: str,
tool_version: str,
components: Sequence[Component] = (),
) -> str:
"""A CycloneDX JSON document: one component per requirement, and the advisories
affecting them as vulnerabilities. Deterministic for a given graph."""
Expand All @@ -57,7 +72,12 @@ def render_sbom(
"specVersion": SPEC_VERSION,
"version": 1,
"metadata": {
"tools": {"components": [{"type": "application", "name": tool_name, "version": tool_version}]}
"tools": {
"components": [
{"type": "application", "name": tool_name, "version": tool_version},
*(_tool(component) for component in components),
]
}
},
"components": [_component(requirement) for requirement in requirements],
"vulnerabilities": vulnerabilities,
Expand Down
10 changes: 8 additions & 2 deletions src/coretrace_python/dependency/vex.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from coretrace_python.dependency.graph import Advisory, DependencyGraph, Requirement
from coretrace_python.dependency.sbom import purl
from coretrace_python.findings import Finding
from coretrace_python.findings import Component, Finding
from coretrace_python.findings.coverage import Coverage

CONTEXT = "https://openvex.dev/ns/v0.2.0"
Expand All @@ -44,6 +44,7 @@ def render_vex(
tool_name: str,
tool_version: str,
timestamp: datetime,
components: Sequence[Component] = (),
) -> str:
"""The OpenVEX document of the project at ``root``. ``evidence`` is every finding of
the check, those suppressed or accepted by the policy included. The document's
Expand All @@ -69,7 +70,12 @@ def render_vex(
**_status(advisory, requirement, about, dependencies, coverage, root),
}
)
tooling = f"{tool_name} {tool_version}"
tooling = "; ".join(
(
f"{tool_name} {tool_version}",
*(" ".join(filter(None, (c.name, c.version, f"({c.digest})"))) for c in components),
)
)
content = {
"@context": CONTEXT,
"author": AUTHOR,
Expand Down
Loading
Loading