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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,25 @@ shipped `dependency/` plugins report a requirement that allows a vulnerable vers
its line, and every call in the project to an API the advisory affects. When
attacker-controlled data reaches such a call, the engine correlates the four facts into
one critical `exploitable-vulnerability` finding, judged like any other flow. The shipped
advisory database is a small offline sample; a live OSV feed is future work.
advisory database is a small offline sample. A real feed stays offline too:
`--import-advisories SRC OUT` converts a public OSV dump (a JSON file, a directory of
them or a zip archive) into a local advisory file once, and a directory check reads
`advisories.json` at the project root plus the files passed with `--advisories`, the
local file winning over a plugin for the same advisory. OSV records name no affected
APIs, so imported advisories feed the requirement checks and the SBOM; add
`affected_symbols` by hand to a local entry and it feeds reachability and correlation
too. A `coretrace-policy.toml` at the root, or the file passed with `--policy`, denies
packages (`denied-dependency`), requires pins (`unpinned-dependency`) and lists accepted
advisories whose findings are dropped:

```toml
[dependencies]
deny = ["pycrypto"]
require_pinned = true

[advisories]
ignore = ["CVE-2020-1747"]
```

`--sbom PATH` writes a CycloneDX 1.5 bill of materials of the dependency graph, one
component per requirement with its package URL, and the advisories affecting them as
Expand Down
46 changes: 46 additions & 0 deletions plugins/dependency/dependency_policy/dependency_policy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Requirements the project's policy denies or leaves unpinned."""

from __future__ import annotations

from collections.abc import Sequence
from typing import ClassVar

from coretrace_python.analysis import AnyAnalysis
from coretrace_python.dependency import DependencyAnalysis
from coretrace_python.findings import Confidence, Finding, Severity
from coretrace_python.plugins import ProjectContext, ProjectPlugin


class DependencyPolicyPlugin(ProjectPlugin):
name: ClassVar[str] = "dependency-policy"
requires: ClassVar[frozenset[AnyAnalysis]] = frozenset({DependencyAnalysis})

def analyze_project(self, ctx: ProjectContext) -> Sequence[Finding]:
findings: list[Finding] = []
for requirement in ctx.dependencies.requirements:
metadata = {"package": requirement.name, "specifier": requirement.specifier}
if ctx.policy.denies(requirement.name):
findings.append(
Finding(
"denied-dependency",
f"{requirement.name} is denied by the dependency policy",
Severity.HIGH,
Confidence.HIGH,
requirement.span,
None,
metadata,
)
)
elif ctx.policy.require_pinned and requirement.pinned is None:
findings.append(
Finding(
"unpinned-dependency",
f"{requirement.name}{requirement.specifier} is not pinned to one version",
Severity.LOW,
Confidence.HIGH,
requirement.span,
None,
metadata,
)
)
return findings
9 changes: 9 additions & 0 deletions plugins/dependency/dependency_policy/plugin.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name = "dependency-policy"
version = "1.0.0"
plugin_api = ">=1,<2"
requires = ["dependency.graph"]
provides = ["policy.dependencies"]

[entrypoint]
module = "dependency_policy"
class = "DependencyPolicyPlugin"
57 changes: 54 additions & 3 deletions src/coretrace_python/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import argparse
import sys
import zipfile
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 render_sbom
from coretrace_python.dependency import dump_advisories, import_osv, read_osv, render_sbom
from coretrace_python.frontend import HIRBuildError, ParseError, build_hir
from coretrace_python.ir.lowering import LoweringError, lower_module
from coretrace_python.ir.printer import format_module
Expand Down Expand Up @@ -42,7 +43,9 @@ def build_parser() -> argparse.ArgumentParser:
prog=engine.TOOL_NAME,
description="Analyze Python source with CoreTrace.",
)
parser.add_argument("path", type=Path, help="Python source file, or a directory for --check")
parser.add_argument(
"path", type=Path, nargs="?", help="Python source file, or a directory for --check"
)
parser.add_argument(
"--emit-ir",
action="store_true",
Expand Down Expand Up @@ -94,14 +97,51 @@ def build_parser() -> argparse.ArgumentParser:
metavar="PATH",
help="with --check on a directory, write a CycloneDX bill of materials to PATH",
)
parser.add_argument(
"--advisories",
action="append",
type=Path,
default=[],
metavar="FILE",
help="with --check on a directory, a local advisory file to read in addition to "
"advisories.json at the root (repeatable)",
)
parser.add_argument(
"--policy",
type=Path,
default=None,
metavar="FILE",
help="with --check on a directory, the dependency policy to apply instead of "
"coretrace-policy.toml at the root",
)
parser.add_argument(
"--import-advisories",
nargs=2,
type=Path,
default=None,
metavar=("SRC", "OUT"),
help="convert an OSV dump (a JSON file, a directory of them or a zip archive) "
"into the local advisory file OUT, then exit",
)
return parser


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.import_advisories is not None:
source, out = args.import_advisories
try:
out.write_text(dump_advisories(import_osv(read_osv(source))), encoding="utf-8")
except (OSError, ValueError, zipfile.BadZipFile) as error:
print(f"error: {error}", file=sys.stderr)
return EXIT_ERROR
return EXIT_CLEAN
if not (args.emit_ir or args.check):
print("error: no action selected; pass --emit-ir or --check", file=sys.stderr)
return EXIT_ERROR
if args.path is None:
print("error: a path is required", file=sys.stderr)
return EXIT_ERROR
if args.format is not None and not args.check:
print("error: --format only applies to --check", file=sys.stderr)
return EXIT_ERROR
Expand All @@ -117,6 +157,12 @@ 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.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
if args.policy is not None and not (args.check and args.path.is_dir()):
print("error: --policy only applies to --check on a directory", file=sys.stderr)
return EXIT_ERROR

if args.path.is_dir() and args.emit_ir:
print(f"error: {args.path} is a directory; --emit-ir needs a file", file=sys.stderr)
Expand All @@ -130,7 +176,12 @@ def main(argv: list[str] | None = None) -> int:
if args.path.is_dir():
cache = None if args.cache is None else ProjectCache(args.cache)
analysis = engine.analyze_project(
args.path, args.plugins, cache=cache, jobs=args.jobs or 1
args.path,
args.plugins,
cache=cache,
jobs=args.jobs or 1,
advisory_files=args.advisories,
policy_file=args.policy,
)
findings = analysis.findings
if args.sbom is not None:
Expand Down
19 changes: 19 additions & 0 deletions src/coretrace_python/dependency/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
"""Dependency resolution and advisories (architecture §26)."""

from coretrace_python.dependency.advisories import (
ADVISORY_FILE,
AdvisoryFileError,
dump_advisories,
import_osv,
load_advisories,
read_osv,
)
from coretrace_python.dependency.graph import (
DEPENDENCY_FILES,
Advisory,
Expand All @@ -10,16 +18,27 @@
normalize,
parse_dependencies,
)
from coretrace_python.dependency.policy import POLICY_FILE, Policy, apply_policy, load_policy
from coretrace_python.dependency.sbom import render_sbom

__all__ = [
"ADVISORY_FILE",
"DEPENDENCY_FILES",
"POLICY_FILE",
"Advisory",
"AdvisoryFileError",
"DependencyAnalysis",
"DependencyGraph",
"Policy",
"Requirement",
"Version",
"apply_policy",
"dump_advisories",
"import_osv",
"load_advisories",
"load_policy",
"normalize",
"parse_dependencies",
"read_osv",
"render_sbom",
]
Loading
Loading