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

## Unreleased

### Dependencies

- Advisories distinguish the `affected_symbols` a fix changed from the public `entry_points` through which they are reached, each justified and carrying `conditions` (checkable argument values, or `semantic` ones kept as pending review); `modules` names what the package installs. Every dependency finding records its evidence `level`: `declared`, `imported`, `reachable` or `exploitable`.
- A call to an API affected by several advisories of the pinned release reports every one of them, not the first listed.
- One advisory per identifier and package: a later contributor replaces an earlier one, so a curated feed refines the bundled sample, and the sample names the modules its packages install.

### Plugins

- Every plugin module is registered in `sys.modules` before it runs, so a single-file plugin may define a dataclass under `from __future__ import annotations` (#105).
Expand Down
34 changes: 33 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,45 @@ affected APIs that feed reachability and correlation:
"vulnerable": "<5.4",
"summary": "yaml.load can execute arbitrary code from untrusted documents",
"severity": "critical",
"affected_symbols": ["python.yaml.load", "python.yaml.full_load"],
"affected_symbols": ["python.yaml.constructor.FullConstructor.construct_python_object_apply"],
"entry_points": [
{
"symbol": "python.yaml.full_load",
"justification": "FullLoader uses FullConstructor (commit 5080ba5)",
"conditions": [
{"kind": "semantic", "text": "the document carries a python/object/apply tag"}
]
},
{
"symbol": "python.yaml.load",
"justification": "same path when Loader is FullLoader",
"conditions": [
{"kind": "argument", "text": "Loader is FullLoader", "argument": "Loader", "values": ["python.yaml.FullLoader"]},
{"kind": "semantic", "text": "the document carries a python/object/apply tag"}
]
}
],
"modules": ["yaml"],
"aliases": ["GHSA-8q59-q68h-6hv4"]
}
]
}
```

`affected_symbols` are the functions the fix changed; `entry_points` the public APIs
through which a project reaches them, each with the justification that ties it to the
fix — the commit, or the call path — and the `conditions` under which it is affected: an
argument value the engine can check, or a `semantic` condition it cannot yet, kept on the
finding as pending review rather than dropped. A call to either kind of symbol is
reachable. `modules` names the top-level modules the package installs, so the analyzer
can tell an imported package from a merely required one; it defaults to the package name.

Every dependency finding records the highest level of evidence established in its
`level` metadata: `declared` (the requirement allows a vulnerable version), `imported`
(a module of the package is imported somewhere), `reachable` (an entry point or affected
symbol is called) or `exploitable` (attacker input reaches it), with `entry_point`,
`justification`, `conditions` and `conditions_pending_review` on the last two.

A `coretrace-policy.toml` at the root, or the file passed with `--policy`, denies
packages, requires pins and lists accepted advisories whose findings are dropped:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from coretrace_python.analysis import AnyAnalysis
from coretrace_python.dependency import DependencyAnalysis
from coretrace_python.dependency.correlation import affected_symbols, evidence
from coretrace_python.findings import Confidence, Finding
from coretrace_python.interprocedural import CallGraphAnalysis, ExternalSymbol
from coretrace_python.plugins import ProjectContext, ProjectPlugin
Expand All @@ -17,12 +18,7 @@ class ReachableVulnerabilityPlugin(ProjectPlugin):
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({DependencyAnalysis, CallGraphAnalysis})

def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
affected = {}
for requirement in ctx.dependencies.requirements:
for advisory in ctx.advisories:
if advisory.affects(requirement):
for symbol in advisory.affected_symbols:
affected.setdefault(symbol, advisory)
affected = affected_symbols(ctx.dependencies, ctx.advisories)
if not affected:
return ()
findings: list[Finding] = []
Expand All @@ -32,25 +28,19 @@ def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
for site in graph.sites(function):
if not isinstance(site.target, ExternalSymbol):
continue
advisory = affected.get(site.target.symbol)
if advisory is None:
continue
findings.append(
Finding(
rule_id="reachable-vulnerability",
message=(
f"{advisory.id}: {site.target.symbol} is affected in the required "
f"{advisory.package} {advisory.vulnerable}: {advisory.summary}"
),
severity=advisory.severity,
confidence=Confidence.HIGH,
span=site.location,
function=function,
metadata={
"advisory": advisory.id,
"package": advisory.package,
"symbol": str(site.target.symbol),
},
for advisory in affected.get(site.target.symbol, ()):
findings.append(
Finding(
rule_id="reachable-vulnerability",
message=(
f"{advisory.id}: {site.target.symbol} is affected in the required "
f"{advisory.package} {advisory.vulnerable}: {advisory.summary}"
),
severity=advisory.severity,
confidence=Confidence.HIGH,
span=site.location,
function=function,
metadata=evidence(advisory, site.target.symbol, "reachable"),
)
)
)
return findings
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class SampleAdvisories(ModelPlugin):
"yaml.load and full_load can execute arbitrary code from untrusted documents",
Severity.CRITICAL,
(_sym("yaml.load"), _sym("yaml.full_load"), _sym("yaml.unsafe_load")),
modules=("yaml",),
),
Advisory(
"CVE-2018-18074",
Expand Down Expand Up @@ -105,5 +106,6 @@ class SampleAdvisories(ModelPlugin):
Severity.HIGH,
(_sym("PIL.Image.open"),),
("GHSA-cfh3-3jmp-rvhc",),
modules=("PIL",),
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ class VulnerableDependencyPlugin(ProjectPlugin):

def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
findings: list[Finding] = []
imported = [s for module in ctx.modules for s in ctx.imports(module).all_symbols()]
for requirement in ctx.dependencies.requirements:
for advisory in ctx.advisories:
if not advisory.affects(requirement):
continue
pinned = requirement.pinned is not None
level = "imported" if advisory.imported_by(imported) else "declared"
findings.append(
Finding(
rule_id="vulnerable-dependency",
Expand All @@ -37,6 +39,7 @@ def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
"advisory": advisory.id,
"package": advisory.package,
"specifier": requirement.specifier,
"level": level,
},
)
)
Expand Down
4 changes: 4 additions & 0 deletions src/coretrace_python/dependency/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from coretrace_python.dependency.graph import (
DEPENDENCY_FILES,
Advisory,
AdvisoryEntryPoint,
Condition,
DependencyAnalysis,
DependencyGraph,
Requirement,
Expand All @@ -26,7 +28,9 @@
"DEPENDENCY_FILES",
"POLICY_FILE",
"Advisory",
"AdvisoryEntryPoint",
"AdvisoryFileError",
"Condition",
"DependencyAnalysis",
"DependencyGraph",
"Policy",
Expand Down
42 changes: 39 additions & 3 deletions src/coretrace_python/dependency/advisories.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
events into a version specifier; ``dump_advisories`` writes them as a small JSON file
that a project keeps at its root as ``advisories.json`` or passes with ``--advisories``.
OSV records name no affected APIs, so imported advisories feed the requirement checks
and the SBOM; a file completed by hand with ``affected_symbols`` also feeds the
reachability and correlation checks.
and the SBOM; a file completed by hand with ``affected_symbols``, ``entry_points`` and
their ``conditions`` also feeds the reachability and correlation checks.
"""

from __future__ import annotations
Expand All @@ -17,7 +17,7 @@
from pathlib import Path
from typing import Any

from coretrace_python.dependency.graph import Advisory, normalize
from coretrace_python.dependency.graph import Advisory, AdvisoryEntryPoint, Condition, normalize
from coretrace_python.findings import Severity
from coretrace_python.semantic.symbols import SymbolId

Expand Down Expand Up @@ -137,13 +137,31 @@ def dump_advisories(advisories: Iterable[Advisory]) -> str:
"severity": a.severity.value,
"affected_symbols": [str(s) for s in a.affected_symbols],
"aliases": list(a.aliases),
"entry_points": [
{
"symbol": str(e.symbol),
"justification": e.justification,
"conditions": [_condition_entry(c) for c in e.conditions],
}
for e in a.entry_points
],
"modules": list(a.modules),
}
for a in advisories
],
}
return json.dumps(document, indent=2) + "\n"


def _condition_entry(condition: Condition) -> dict[str, Any]:
entry: dict[str, Any] = {"kind": condition.kind, "text": condition.text}
if condition.argument is not None:
entry["argument"] = condition.argument
if condition.values:
entry["values"] = list(condition.values)
return entry


def load_advisories(path: Path) -> tuple[Advisory, ...]:
try:
document = json.loads(path.read_text(encoding="utf-8"))
Expand All @@ -165,4 +183,22 @@ def _advisory(entry: Mapping[str, Any]) -> Advisory:
Severity(entry["severity"]),
tuple(SymbolId(str(s)) for s in entry.get("affected_symbols") or []),
tuple(str(a) for a in entry.get("aliases") or []),
tuple(_entry_point(e) for e in entry.get("entry_points") or []),
tuple(str(m) for m in entry.get("modules") or []),
)


def _entry_point(entry: Mapping[str, Any]) -> AdvisoryEntryPoint:
return AdvisoryEntryPoint(
SymbolId(str(entry["symbol"])),
str(entry["justification"]),
tuple(
Condition(
str(c["kind"]),
str(c["text"]),
None if c.get("argument") is None else str(c["argument"]),
tuple(str(v) for v in c.get("values") or []),
)
for c in entry.get("conditions") or []
),
)
Loading
Loading