Skip to main content

Open source Python CVE reachability and evidence-backed SCA triage

Project description

ca9 - evidence-backed Python CVE reachability triage

ca9

Evidence-backed CVE triage for Python SCA alerts.

Python 3.10+ License: MPL-2.0 PyPI Minimal Dependencies Skylos A+ (99)


The problem

Your SCA tool (Snyk, Dependabot, Trivy, pip-audit, OSV, or another scanner) flags every CVE in your dependency tree. You get 60 alerts. Your team scrambles. But many of those CVEs are in code your application never imports, never calls, and never executes.

You're patching vulnerabilities in functions you don't use, in packages you didn't know you had, in code paths your app will never reach.

That's wasted engineering time. That's alert fatigue. That's how real vulnerabilities get ignored.

What ca9 does

ca9 turns CVE alerts into evidence-backed fix, suppress, or investigate decisions.

It takes your CVE list and answers one question per vulnerability: is this code actually reachable from your application?

pip install ca9[cli]
ca9 scan --repo . --coverage coverage.json
CVE ID               Package   Severity  Verdict
--------------------------------------------------------------
GHSA-cpwx-vrp4-4pq7  Jinja2    high      REACHABLE
GHSA-frmv-pr5f-9mcr  Django    critical  UNREACHABLE (static)
GHSA-mrwq-x4v8-fh7p  Pygments  medium    UNREACHABLE (dynamic)
--------------------------------------------------------------
Total: 61  |  Reachable: 25  |  Unreachable: 36  |  Inconclusive: 0

59% of flagged CVEs are unreachable — only 25 of 61 require action

36 CVEs eliminated. No manual triage. No guessing.

How it works

ca9 combines repository evidence with advisory metadata to determine whether vulnerable code is reachable:

1. Static analysis (AST import tracing) — Parses every Python file in your repo and traces import statements. If a vulnerable package is never imported, it's unreachable.

2. Dependency inventory — Uses declared dependencies, report metadata, and local package metadata to separate direct, transitive, imported, and unused packages.

3. Dynamic analysis (coverage.py) — Checks whether vulnerable code was actually executed during your test suite. A package might be imported but the specific vulnerable function might never be called.

4. Advisory normalization — Preserves source, aliases, CWE/CPE IDs, timestamps, and cache freshness where input data provides them, so evidence can be traced back to the alert.

For each CVE:
  Is the affected version installed or declared?
  ├── NO  → UNREACHABLE (static)
  └── YES → Is the package, affected submodule, or known vulnerable API used?
      ├── NO, with enough graph/import evidence → UNREACHABLE (static)
      ├── YES, and runtime/coverage confirms execution → REACHABLE
      ├── YES, but coverage shows no affected execution → UNREACHABLE (dynamic)
      └── Not enough evidence → INCONCLUSIVE

ca9 is conservative — it only marks something unreachable when it can prove it. Every verdict comes with an evidence trail and a confidence score so you can see exactly why ca9 reached its conclusion.

Where ca9 fits

ca9 does not replace your SCA tool. It adds local, evidence-first reachability analysis to the vulnerability data you already have.

ca9 Alert-only SCA output Hosted reachability platforms
Local analysis Runs in your repo/CI Varies Often requires source upload or hosted project import
Direct OSV scan Yes — ca9 scan queries OSV.dev directly Not always Varies
SCA report parsing Snyk, Dependabot, Trivy, pip-audit Native to each tool Platform-specific
Static + dynamic evidence Imports, dependency graph, coverage, API usage Usually package-level alerts Varies by vendor and integration
Open outputs JSON, SARIF, OpenVEX, Markdown, HTML, remediation, action plan Vendor-specific Platform-specific
Confidence/evidence trail Structured evidence per verdict Limited Varies
Runtime dependencies packaging core dependency; optional CLI/MCP extras Varies Hosted service

Use ca9 when you want an open, local Python reachability layer for CVE triage, CI gates, SARIF upload, OpenVEX generation, or SBOM enrichment.

Real-world results

Django REST Framework — 37 CVEs, 19% noise

A focused library that genuinely uses most of its deps. Even here, ca9 found 7 CVEs in packages that are installed but never imported (redis, sentry-sdk, pip):

$ ca9 scan --repo /path/to/drf -v

GHSA-g92j-qhmh-64v2  sentry-sdk  low       UNREACHABLE (static)
                      -> 'sentry-sdk' is not imported and not a dependency of any imported package
GHSA-8fww-64cx-x8p5  redis       high      UNREACHABLE (static)
                      -> 'redis' is not imported and not a dependency of any imported package
...
Total: 37  |  Reachable: 0  |  Unreachable: 7  |  Inconclusive: 30

Flask app with bloated deps — 61 CVEs, 59% noise

A Flask app that imports 4 packages but has 19 pinned in requirements.txt (Django, tornado, Pygments added "just in case"):

$ ca9 scan --repo demo/ --coverage demo/coverage.json

Total: 61  |  Reachable: 25  |  Unreachable: 36  |  Inconclusive: 0

59% of flagged CVEs are unreachable — only 25 of 61 require action

Django alone brought 21 CVEs that were pure noise.

The pattern: ca9's value scales with how bloated your dependency list is — which in enterprise codebases is typically very.

Quick start

Scan declared or installed packages (no SCA tool needed)

pip install ca9[cli]
ca9 scan --repo .

This resolves dependency inventory from the target repository, queries OSV.dev, and falls back to the current Python environment when no resolvable manifest is available. No Snyk, no Dependabot, no config files.

Add dynamic analysis for better results

coverage run --source=.,$(python -c "import site; print(site.getsitepackages()[0])") -m pytest
coverage json -o coverage.json
ca9 scan --repo . --coverage coverage.json

Analyze an existing SCA report

ca9 check snyk.json --repo . --coverage coverage.json
ca9 check dependabot.json --repo .

Format is auto-detected. Supports Snyk, Dependabot, Trivy, and pip-audit:

ca9 check snyk.json --repo .
ca9 check dependabot.json --repo .
ca9 check trivy.json --repo .
ca9 check pip-audit.json --repo .

Verdicts

Verdict What it means What to do
REACHABLE Evidence shows the vulnerable package, component, or known API is reachable Fix this
UNREACHABLE (static) Package is never imported — not even transitively Suppress with confidence
UNREACHABLE (dynamic) Package is imported but vulnerable code was never executed Likely safe — monitor
INCONCLUSIVE Imported but no coverage data to prove execution Add coverage or review manually

Evidence and confidence

Every verdict is backed by structured evidence. Use --show-confidence to see scores in table output, or inspect the evidence object in JSON/SARIF output.

Signal What it checks
advisory Advisory source, ecosystem, aliases, CWE/CPE IDs, and cache freshness metadata when available.
version_in_range Is the installed version within the affected range (PEP 440)?
package_imported Is the package imported anywhere in the repo?
submodule_imported Is the specific vulnerable submodule imported?
coverage_seen Was the vulnerable code executed during tests?
api_call_sites_covered Were specific vulnerable API call sites executed in tests?
coverage_completeness_pct Overall test coverage percentage — weights dynamic absence signals
affected_component_source How was the vulnerable component identified (commit analysis, curated mapping, regex, class scan)?

Confidence scoring is verdict-directional — evidence that supports the verdict boosts the score, evidence that contradicts it lowers it. A high confidence UNREACHABLE is different from a high confidence REACHABLE.

Bucket Score Meaning
High 80-100 Strong evidence supports the verdict
Medium 60-79 Moderate evidence, reasonable certainty
Low 40-59 Weak evidence, treat with caution
Weak 0-39 Very little evidence, manual review recommended

CLI reference

ca9 scan [OPTIONS]              Scan declared or installed packages via OSV.dev
ca9 check SCA_REPORT [OPTIONS]  Analyze a Snyk/Dependabot/Trivy/pip-audit report

Common options:
  -r, --repo PATH                  Path to the project repository  [default: .]
  -c, --coverage PATH              Path to coverage.json for dynamic analysis
  -f, --format [table|json|sarif|vex|remediation|action-plan|markdown|html]
                                      Output format  [default: table]
  -o, --output PATH                Write output to file instead of stdout
  -v, --verbose                    Show reasoning trace for each verdict
  --no-auto-coverage               Disable automatic coverage discovery
  --show-confidence                Show confidence score in table output
  --show-evidence-source           Show evidence extraction source in table output
  --proof-standard [strict|balanced]
                                      Proof policy for suppressions
  --capabilities                   Attach AI capability blast radius
  --runtime-context PATH           Deployment-aware severity adjustment
  --trace-paths                    Trace exploit paths
  --threat-intel                   Enrich with EPSS and CISA KEV data
  --otel-traces PATH               Production runtime evidence from OTLP JSON
  --accepted-risks PATH            Accepted-risk TOML/JSON file
  --baseline PATH                  Previous ca9 JSON report for new-only gating
  --new-only                       Only gate on new reachable/inconclusive findings

Scan-only options:
  --offline                        Use only cached OSV data, no network requests
  --refresh-cache                  Clear OSV cache before fetching
  --max-osv-workers N              Max concurrent OSV detail fetches  [default: 8]

Exit codes:
  0  Clean — no reachable CVEs
  1  Reachable CVEs found — action needed
  2  Inconclusive only — need more coverage data

Config file

Create a .ca9.toml in your project root to set defaults:

repo = "src"
coverage = "coverage.json"
format = "json"
verbose = true
accepted_risks = "accepted-risks.toml"
baseline = "ca9-baseline.json"
new_only = true

Config is auto-discovered from the current directory upward. CLI flags override config values. Accepted-risk and baseline options keep ignored findings visible in report output while excluding them from exit-code decisions.

Caching and offline mode

ca9 caches OSV vulnerability details (~/.cache/ca9/osv/, 24h TTL) and GitHub commit file lists (~/.cache/ca9/commits/, 7-day TTL) to reduce API calls.

ca9 scan --repo . --offline           # use cached data only, no network
ca9 scan --repo . --refresh-cache     # clear cache and re-fetch

Set GITHUB_TOKEN to avoid GitHub API rate limits when ca9 fetches commit data for affected component analysis:

export GITHUB_TOKEN=ghp_...
ca9 check snyk.json --repo .

MCP server

ca9 ships an MCP server so LLM-powered tools (Claude Code, Cursor, etc.) can run reachability analysis directly.

pip install ca9[mcp]

Add to your MCP client config:

{
  "mcpServers": {
    "ca9": {
      "command": "ca9-mcp"
    }
  }
}

Available tools:

Tool What it does
check_reachability Analyze an SCA report (Snyk, Dependabot, Trivy, pip-audit)
scan_dependencies Scan declared or installed packages via OSV.dev
check_coverage_quality Assess how reliable your coverage data is
explain_verdict Deep-dive a specific CVE's verdict with full evidence
generate_vex Generate OpenVEX exploitability statements
generate_remediation_plan Generate prioritized remediation actions
scan_capabilities Scan AI capabilities and emit an AI-BOM
check_blast_radius Attach capability blast radius to reachable CVEs
trace_exploit_path Trace paths to vulnerable API call sites
lookup_threat_intel Look up EPSS and CISA KEV data
enrich_sbom Enrich CycloneDX or SPDX SBOM JSON

Library usage

import json
from pathlib import Path
from ca9.parsers.snyk import SnykParser
from ca9.engine import analyze

data = json.loads(Path("snyk.json").read_text())
vulns = SnykParser().parse(data)

report = analyze(
    vulnerabilities=vulns,
    repo_path=Path("./my-project"),
    coverage_path=Path("coverage.json"),
)

for result in report.results:
    print(f"{result.vulnerability.id}: {result.verdict.value} (confidence: {result.confidence_score})")
    print(f"  reason: {result.reason}")
    if result.evidence:
        print(f"  source: {result.evidence.affected_component_source}")

Zero heavy dependencies

ca9's core library depends only on packaging (PEP 440 version parsing) and the Python standard library. The click package is optional — only needed if you use the CLI. This means you can embed ca9 in CI pipelines, security toolchains, or other Python tools without pulling in a large dependency tree.

Limitations

  • Static analysis traces import statements and importlib.metadata dependency trees. Dynamic imports (importlib.import_module, __import__) are not detected.
  • Coverage quality directly impacts dynamic analysis. If your tests don't exercise a code path, ca9 can't detect it dynamically.
  • Transitive dependency resolution requires packages to be installed. Without installed deps, ca9 falls back to direct-import-only checking.
  • Python only (for now).

Development

git clone https://github.com/duriantaco/ca9.git
cd ca9
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest tests/ -v

License

MPL-2.0

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ca9-0.3.0.tar.gz (1.8 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ca9-0.3.0-py3-none-any.whl (118.3 kB view details)

Uploaded Python 3

File details

Details for the file ca9-0.3.0.tar.gz.

File metadata

  • Download URL: ca9-0.3.0.tar.gz
  • Upload date:
  • Size: 1.8 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ca9-0.3.0.tar.gz
Algorithm Hash digest
SHA256 52b967a2e08e993f6b60d2ef5aebba6bd171a3edc261c6a97f202c4b671f3b1c
MD5 6fdfd74a8d54d3bd203d7ad492a72a3d
BLAKE2b-256 80bebb28bb4b9b75d76649800a8d89f1ff1cb9c731ac0e78ee3e555777262c5e

See more details on using hashes here.

Provenance

The following attestation bundles were made for ca9-0.3.0.tar.gz:

Publisher: release.yml on duriantaco/ca9

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ca9-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: ca9-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 118.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for ca9-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fc234dff83cc07e117b16dce72a51b88b0cb0943c38c043c2176b0c1ed570fe5
MD5 b4346890a806c89f750d3c7a8b4eede1
BLAKE2b-256 4e10fc2cf73b618177cfc0cab05ae2d2a21d5518f6231f5c23fdd66669dd9371

See more details on using hashes here.

Provenance

The following attestation bundles were made for ca9-0.3.0-py3-none-any.whl:

Publisher: release.yml on duriantaco/ca9

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page