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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

- A sink call can be made safe by one of its arguments: a `SafeArgument` model names the argument and the values that take kinds off the sink, only when the call gives one explicitly. `yaml.load` with `SafeLoader`, `BaseLoader` or their C versions, under every spelling, is no longer an insecure deserialization; an absent loader, another loader, a variable or unpacked arguments still are (#121).

### Reports

- `--vex PATH` writes an OpenVEX document with one statement per advisory affecting a requirement. A statement is `affected` when the project's code reaches the vulnerability, including findings the policy accepts or a comment suppresses. It is `not_affected` (`vulnerable_code_not_in_execute_path`) only for an advisory with entry points that nothing reaches, in a complete analysis, and when the lock file shows no other package requiring the vulnerable one. Anything else is `under_investigation`, with the reason (#97).
- A lock file records which packages require which. `DependencyGraph.required_by(name)` gives the other packages requiring a package; the project's own packages are not counted. `ProjectAnalysis.accepted` keeps the findings of advisories the policy accepts (#97).

## 0.5.0 (2026-09-24)

### Dependencies
Expand Down
21 changes: 20 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ coretrace-python-analyzer [--check | --emit-ir [--ssa]] [options] [path]
| `--cache DIR` | Keep per-module results under `DIR` and reuse them for unchanged modules. |
| `--jobs N` | Analyse independent modules in `N` processes. |
| `--sbom PATH` | Write a CycloneDX bill of materials of the dependencies to `PATH`. |
| `--vex PATH` | Write an OpenVEX document to `PATH`: whether each advisory affecting a requirement affects the project. |
| `--advisories FILE` | Read a local advisory file in addition to `advisories.json` at the root. Repeatable. |
| `--policy FILE` | Apply this dependency policy instead of `coretrace-policy.toml` at the root. |
| `--import-advisories SRC OUT` | Convert an OSV dump into the local advisory file `OUT`, then exit. |
Expand All @@ -59,7 +60,7 @@ coretrace-python-analyzer [--check | --emit-ir [--ssa]] [options] [path]
| `--ssa` | With `--emit-ir`, print the static single assignment form. |
| `--help` | Show the options and exit. |

`--cache`, `--jobs`, `--sbom`, `--advisories` and `--policy` apply to a directory check.
`--cache`, `--jobs`, `--sbom`, `--vex`, `--advisories` and `--policy` apply to a directory check.

## What is analysed

Expand Down Expand Up @@ -332,6 +333,24 @@ with its package URL, and the advisories affecting them as vulnerabilities.
coretrace-python-analyzer --check src/ --sbom sbom.json --policy security/policy.toml
```

`--vex PATH` writes an OpenVEX 0.2.0 document with one statement per advisory affecting
a requirement. In each statement, the required package is a subcomponent of the project,
identified as `pkg:generic/<directory>`. The status follows from the evidence of the
check, including findings the policy accepts or a comment suppresses:

| Status | When |
|---|---|
| `affected` | The project's code reaches the vulnerable code. The notes give each place and its level, `reachable` or `exploitable`. |
| `not_affected` | Justified as `vulnerable_code_not_in_execute_path`, only when all of these hold: the advisory names entry points, no code of the project reaches them, every file and function was analysed, and a lock file (`uv.lock` or `poetry.lock`) shows that no other package requires the vulnerable one. The impact statement names the entry points and any calls ruled out by their arguments. |
| `under_investigation` | Anything else. The notes say which condition failed. |

`not_affected` needs the lock-file check because the analyzer reads the project's code,
not the code of installed packages. A framework calling the vulnerable function on the
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.

## Large projects

`--cache DIR` keeps the results of a directory check on disk, one entry per module,
Expand Down
34 changes: 33 additions & 1 deletion src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
import argparse
import sys
import zipfile
from datetime import UTC, datetime
from pathlib import Path

from coretrace_python import __version__, engine
from coretrace_python.analysis import AnalysisError
from coretrace_python.cache import ProjectCache
from coretrace_python.cfg import CFGError
from coretrace_python.dependency import dump_advisories, import_osv, read_osv, render_sbom
from coretrace_python.dependency import (
dump_advisories,
import_osv,
read_osv,
render_sbom,
render_vex,
)
from coretrace_python.findings import Severity
from coretrace_python.findings.baseline import Baseline, BaselineError
from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
Expand Down Expand Up @@ -106,6 +113,14 @@ def build_parser() -> argparse.ArgumentParser:
metavar="PATH",
help="with --check on a directory, write a CycloneDX bill of materials to PATH",
)
parser.add_argument(
"--vex",
type=Path,
default=None,
metavar="PATH",
help="with --check on a directory, write to PATH an OpenVEX document saying whether "
"each advisory affecting a requirement affects the project",
)
parser.add_argument(
"--advisories",
action="append",
Expand Down Expand Up @@ -182,6 +197,9 @@ def main(argv: list[str] | None = None) -> int:
if args.sbom is not None and not (args.check and args.path.is_dir()):
print("error: --sbom only applies to --check on a directory", file=sys.stderr)
return EXIT_ERROR
if args.vex is not None and not (args.check and args.path.is_dir()):
print("error: --vex only applies to --check on a directory", file=sys.stderr)
return EXIT_ERROR
if args.advisories and not (args.check and args.path.is_dir()):
print("error: --advisories only applies to --check on a directory", file=sys.stderr)
return EXIT_ERROR
Expand Down Expand Up @@ -227,6 +245,20 @@ def main(argv: list[str] | None = None) -> int:
render_sbom(analysis.dependencies, analysis.advisories, engine.TOOL_NAME, __version__),
encoding="utf-8",
)
if args.vex is not None:
args.vex.write_text(
render_vex(
analysis.dependencies,
analysis.advisories,
(*analysis.findings, *analysis.suppressed, *analysis.accepted),
analysis.coverage,
args.path,
engine.TOOL_NAME,
__version__,
datetime.now(UTC),
),
encoding="utf-8",
)
else:
file_analysis = engine.analyze_file(SourceManager().load_file(args.path), plugin_roots)
findings, coverage = file_analysis.findings, file_analysis.coverage
Expand Down
2 changes: 2 additions & 0 deletions src/coretrace_python/dependency/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from coretrace_python.dependency.policy import POLICY_FILE, Policy, apply_policy, load_policy
from coretrace_python.dependency.sbom import render_sbom
from coretrace_python.dependency.vex import render_vex

__all__ = [
"ADVISORY_FILE",
Expand All @@ -45,4 +46,5 @@
"parse_dependencies",
"read_osv",
"render_sbom",
"render_vex",
]
78 changes: 70 additions & 8 deletions src/coretrace_python/dependency/graph.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Dependency resolution (architecture §26).

Requirements declared in ``requirements.txt``, ``pyproject.toml`` (PEP 621 and Poetry)
and pinned in ``poetry.lock`` or ``uv.lock`` become a ``DependencyGraph``. Versions are
and pinned in ``poetry.lock`` or ``uv.lock`` become a ``DependencyGraph``; a lock file
also tells which other packages require each package. Versions are
compared with a small PEP 440 subset (``==``, ``!=``, ``<``, ``<=``, ``>``, ``>=``,
``~=`` and Poetry's ``^``), enough to decide whether a requirement may allow a version an
advisory marks as vulnerable. Nothing is downloaded.
Expand All @@ -11,9 +12,9 @@

import re
import tomllib
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Iterator, Mapping
from dataclasses import dataclass
from pathlib import PurePath
from pathlib import PurePath, PurePosixPath
from types import MappingProxyType
from typing import Any, ClassVar

Expand Down Expand Up @@ -219,9 +220,15 @@ def imported_by(self, symbols: Iterable[SymbolId]) -> bool:


class DependencyGraph:
def __init__(self, requirements: Mapping[str, Requirement] | None = None, errors: tuple[str, ...] = ()) -> None:
def __init__(
self,
requirements: Mapping[str, Requirement] | None = None,
errors: tuple[str, ...] = (),
dependents: Mapping[str, frozenset[str]] | None = None,
) -> None:
self._requirements = MappingProxyType(dict(sorted((requirements or {}).items())))
self.errors = errors
self._dependents = MappingProxyType(dict(dependents or {}))

@property
def names(self) -> tuple[str, ...]:
Expand All @@ -234,6 +241,12 @@ def requirements(self) -> tuple[Requirement, ...]:
def requirement(self, name: str) -> Requirement | None:
return self._requirements.get(normalize(name))

def required_by(self, name: str) -> frozenset[str] | None:
"""The other packages a lock file shows requiring ``name`` — an empty set when
only the project does — or None when no lock file mentions it."""

return self._dependents.get(normalize(name))

def merge(self, other: DependencyGraph) -> DependencyGraph:
merged = dict(self._requirements)
for name, requirement in other._requirements.items():
Expand All @@ -248,7 +261,10 @@ def merge(self, other: DependencyGraph) -> DependencyGraph:
requirement.pinned or current.pinned,
current.optional and requirement.optional,
)
return DependencyGraph(merged, self.errors + other.errors)
dependents = dict(self._dependents)
for name, found in other._dependents.items():
dependents[name] = dependents.get(name, frozenset()) | found
return DependencyGraph(merged, self.errors + other.errors, dependents)


def parse_dependencies(source: SourceFile) -> DependencyGraph:
Expand All @@ -260,7 +276,7 @@ def parse_dependencies(source: SourceFile) -> DependencyGraph:
if name == "pyproject.toml":
return _parse_toml(source, _pyproject_requirements)
if name in ("poetry.lock", "uv.lock"):
return _parse_toml(source, _lock_requirements)
return _parse_toml(source, _lock_requirements, _lock_dependents)
return DependencyGraph()


Expand All @@ -276,15 +292,15 @@ def _parse_requirements_txt(source: SourceFile) -> DependencyGraph:
return DependencyGraph(found)


def _parse_toml(source: SourceFile, extract: Any) -> DependencyGraph:
def _parse_toml(source: SourceFile, extract: Any, dependents: Any = None) -> DependencyGraph:
try:
data = tomllib.loads(source.text)
except tomllib.TOMLDecodeError as error:
return DependencyGraph(errors=(f"{source.source_id}: {error}",))
found: dict[str, Requirement] = {}
for requirement in extract(data, source):
found[requirement.name] = requirement
return DependencyGraph(found)
return DependencyGraph(found, dependents=None if dependents is None else dependents(data))


def _line_of(source: SourceFile, key: str, default: int = 1) -> int:
Expand Down Expand Up @@ -335,6 +351,52 @@ def _lock_requirements(data: Mapping[str, Any], source: SourceFile) -> list[Requ
return found


def _lock_dependents(data: Mapping[str, Any]) -> dict[str, frozenset[str]]:
"""For every package a lock file lists or requires, the other locked packages that
require it, optional extras and development groups included. The project's own
packages — uv's editable or virtual sources inside the project — are not others."""

dependents: dict[str, set[str]] = {}
for package in data.get("package", []) or []:
name = package.get("name")
if not isinstance(name, str):
continue
dependents.setdefault(normalize(name), set())
own = _is_project_package(package)
for dependency in _locked_dependencies(package):
found = dependents.setdefault(dependency, set())
if not own:
found.add(normalize(name))
return {name: frozenset(found) for name, found in dependents.items()}


def _locked_dependencies(package: Mapping[str, Any]) -> Iterator[str]:
declared = package.get("dependencies") or []
if isinstance(declared, Mapping): # poetry.lock: ``name = specifier``, optional ones too
yield from (normalize(str(name)) for name in declared)
return
groups = (
declared,
*(package.get("optional-dependencies") or {}).values(),
*(package.get("dev-dependencies") or {}).values(),
)
for group in groups:
for entry in group or []:
if isinstance(entry, Mapping) and isinstance(entry.get("name"), str):
yield normalize(entry["name"])


def _is_project_package(package: Mapping[str, Any]) -> bool:
source = package.get("source")
if not isinstance(source, Mapping):
return False
path = source.get("editable", source.get("virtual"))
if not isinstance(path, str):
return False
location = PurePosixPath(path)
return not location.is_absolute() and ".." not in location.parts


class DependencyAnalysis(Analysis[DependencyGraph]):
"""The project's dependency graph, provided by the engine; empty for a lone file."""

Expand Down
8 changes: 6 additions & 2 deletions src/coretrace_python/dependency/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ class Policy:
def denies(self, package: str) -> bool:
return normalize(package) in {normalize(name) for name in self.deny}

def accepts(self, finding: Finding) -> bool:
"""Whether ``finding`` is about an advisory the project accepts."""

return finding.metadata.get("advisory") in self.ignore


def load_policy(path: Path) -> Policy:
try:
Expand Down Expand Up @@ -61,5 +66,4 @@ def _boolean(value: Any, key: str) -> bool:
def apply_policy(policy: Policy, findings: Iterable[Finding]) -> tuple[Finding, ...]:
"""The findings minus those about an advisory the policy accepts."""

ignored = set(policy.ignore)
return tuple(f for f in findings if f.metadata.get("advisory") not in ignored)
return tuple(f for f in findings if not policy.accepts(f))
Loading
Loading