cbom-scan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbom_scan/__init__.py +3 -0
- cbom_scan/ai/__init__.py +0 -0
- cbom_scan/ai/roadmap_agent.py +118 -0
- cbom_scan/classifier/__init__.py +0 -0
- cbom_scan/classifier/risk_classifier.py +58 -0
- cbom_scan/classifier/rules.py +121 -0
- cbom_scan/cli.py +259 -0
- cbom_scan/formatters/__init__.py +0 -0
- cbom_scan/formatters/html_formatter.py +89 -0
- cbom_scan/formatters/json_formatter.py +8 -0
- cbom_scan/formatters/sarif_formatter.py +57 -0
- cbom_scan/formatters/summary_formatter.py +64 -0
- cbom_scan/generator/__init__.py +0 -0
- cbom_scan/generator/cbom_generator.py +103 -0
- cbom_scan/models/__init__.py +0 -0
- cbom_scan/models/cyclonedx.py +98 -0
- cbom_scan/models/finding.py +40 -0
- cbom_scan/scanners/__init__.py +11 -0
- cbom_scan/scanners/base.py +51 -0
- cbom_scan/scanners/go_scanner.py +64 -0
- cbom_scan/scanners/java_scanner.py +62 -0
- cbom_scan/scanners/javascript_scanner.py +165 -0
- cbom_scan/scanners/manifest_scanner.py +100 -0
- cbom_scan/scanners/python_scanner.py +121 -0
- cbom_scan-0.1.0.dist-info/METADATA +54 -0
- cbom_scan-0.1.0.dist-info/RECORD +28 -0
- cbom_scan-0.1.0.dist-info/WHEEL +4 -0
- cbom_scan-0.1.0.dist-info/entry_points.txt +2 -0
cbom_scan/__init__.py
ADDED
cbom_scan/ai/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from cbom_scan.models.cyclonedx import CycloneDXBom
|
|
9
|
+
|
|
10
|
+
_log = logging.getLogger("cbom_scan.roadmap")
|
|
11
|
+
|
|
12
|
+
_SYSTEM = """You are a post-quantum cryptography migration expert.
|
|
13
|
+
Given a CycloneDX 1.7 CBOM (Cryptography Bill of Materials) from a real codebase,
|
|
14
|
+
produce a prioritized migration roadmap.
|
|
15
|
+
|
|
16
|
+
Return ONLY valid JSON (no markdown):
|
|
17
|
+
{
|
|
18
|
+
"executive_summary": "2-sentence CISO-level summary",
|
|
19
|
+
"tasks": [
|
|
20
|
+
{
|
|
21
|
+
"rank": 1,
|
|
22
|
+
"algorithm": "RSA",
|
|
23
|
+
"location": "src/auth/jwt.py:34",
|
|
24
|
+
"why_first": "one sentence — why this is the highest priority",
|
|
25
|
+
"effort_days": 3.0,
|
|
26
|
+
"migration_diff": "# BEFORE\\nfrom Crypto.PublicKey import RSA\\nkey = RSA.generate(2048)\\n\\n# AFTER\\nfrom oqs import KeyEncapsulation\\nkem = KeyEncapsulation('ML-KEM-768')\\npub = kem.generate_keypair()",
|
|
27
|
+
"nist_replacement": "ML-KEM-768 (FIPS 203)"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"total_effort_days": 5.5
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
Rules:
|
|
34
|
+
- Rank by impact: signing keys first (break auth), then encryption, then hashing
|
|
35
|
+
- Show top 5 tasks only
|
|
36
|
+
- migration_diff must be actual code for the detected language (infer from file extension)
|
|
37
|
+
- Be specific: use the exact file path and line number from the CBOM evidence"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MigrationTask:
|
|
42
|
+
rank: int
|
|
43
|
+
algorithm: str
|
|
44
|
+
location: str
|
|
45
|
+
why_first: str
|
|
46
|
+
effort_days: float
|
|
47
|
+
migration_diff: str
|
|
48
|
+
nist_replacement: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RoadmapResult:
|
|
53
|
+
executive_summary: str
|
|
54
|
+
tasks: list[MigrationTask]
|
|
55
|
+
total_effort_days: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RoadmapAgent:
|
|
59
|
+
"""
|
|
60
|
+
Calls Claude with the CBOM JSON and returns a prioritized migration roadmap.
|
|
61
|
+
Returns None gracefully when api_key is missing or Claude call fails.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
65
|
+
self._api_key = api_key
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def available(self) -> bool:
|
|
69
|
+
return bool(self._api_key)
|
|
70
|
+
|
|
71
|
+
def generate(self, bom: CycloneDXBom) -> RoadmapResult | None:
|
|
72
|
+
if not self.available:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
import anthropic
|
|
76
|
+
except ImportError:
|
|
77
|
+
_log.warning("anthropic package not installed. Run: pip install cbom-scan[ai]")
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
cbom_json = bom.to_json(indent=2)
|
|
81
|
+
prompt = (
|
|
82
|
+
f"Here is the CBOM for a software project:\n\n```json\n{cbom_json}\n```\n\n"
|
|
83
|
+
"Produce the migration roadmap JSON."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
client = anthropic.Anthropic(api_key=self._api_key)
|
|
88
|
+
message = client.messages.create(
|
|
89
|
+
model = "claude-sonnet-4-6",
|
|
90
|
+
max_tokens = 1500,
|
|
91
|
+
system = _SYSTEM,
|
|
92
|
+
messages = [{"role": "user", "content": prompt}],
|
|
93
|
+
)
|
|
94
|
+
raw = message.content[0].text
|
|
95
|
+
m = re.search(r"\{.*\}", raw, re.DOTALL)
|
|
96
|
+
if not m:
|
|
97
|
+
return None
|
|
98
|
+
data = json.loads(m.group(0))
|
|
99
|
+
tasks = [
|
|
100
|
+
MigrationTask(
|
|
101
|
+
rank = t.get("rank", i + 1),
|
|
102
|
+
algorithm = t.get("algorithm", ""),
|
|
103
|
+
location = t.get("location", ""),
|
|
104
|
+
why_first = t.get("why_first", ""),
|
|
105
|
+
effort_days = float(t.get("effort_days", 1.0)),
|
|
106
|
+
migration_diff = t.get("migration_diff", ""),
|
|
107
|
+
nist_replacement = t.get("nist_replacement", ""),
|
|
108
|
+
)
|
|
109
|
+
for i, t in enumerate(data.get("tasks", [])[:5])
|
|
110
|
+
]
|
|
111
|
+
return RoadmapResult(
|
|
112
|
+
executive_summary = data.get("executive_summary", ""),
|
|
113
|
+
tasks = tasks,
|
|
114
|
+
total_effort_days = float(data.get("total_effort_days", 0.0)),
|
|
115
|
+
)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
_log.warning(f"RoadmapAgent failed: {exc}")
|
|
118
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from cbom_scan.classifier.rules import NIST_RULES, RuleEntry
|
|
6
|
+
from cbom_scan.models.finding import CryptoFinding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class RiskProfile:
|
|
11
|
+
risk_label: str
|
|
12
|
+
nist_quantum_security_level: int
|
|
13
|
+
primitive: str
|
|
14
|
+
nist_replacement: str
|
|
15
|
+
effort_days: float
|
|
16
|
+
crypto_functions: list[str]
|
|
17
|
+
certification_level: list[str]
|
|
18
|
+
parameter_set_identifier: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_UNKNOWN_RULE: RuleEntry = {
|
|
22
|
+
"primitive": "unknown",
|
|
23
|
+
"nist_quantum_security_level": 0,
|
|
24
|
+
"nist_replacement": "Review manually",
|
|
25
|
+
"effort_days": 1.0,
|
|
26
|
+
"risk_label": "HIGH",
|
|
27
|
+
"crypto_functions": [],
|
|
28
|
+
"certification_level": ["none"],
|
|
29
|
+
"parameter_set_identifier": "unknown",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RiskClassifier:
|
|
34
|
+
"""
|
|
35
|
+
Pure function classifier. No I/O, no state.
|
|
36
|
+
Maps a CryptoFinding to a RiskProfile using the NIST_RULES table.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def classify(self, finding: CryptoFinding) -> RiskProfile:
|
|
40
|
+
rule = NIST_RULES.get(finding.algorithm.upper(), _UNKNOWN_RULE)
|
|
41
|
+
return RiskProfile(
|
|
42
|
+
risk_label = rule["risk_label"],
|
|
43
|
+
nist_quantum_security_level = rule["nist_quantum_security_level"],
|
|
44
|
+
primitive = rule["primitive"],
|
|
45
|
+
nist_replacement = rule["nist_replacement"],
|
|
46
|
+
effort_days = rule["effort_days"],
|
|
47
|
+
crypto_functions = list(rule["crypto_functions"]),
|
|
48
|
+
certification_level = list(rule["certification_level"]),
|
|
49
|
+
parameter_set_identifier = rule["parameter_set_identifier"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def classify_all(self, findings: list[CryptoFinding]) -> dict[str, RiskProfile]:
|
|
53
|
+
seen: dict[str, RiskProfile] = {}
|
|
54
|
+
for f in findings:
|
|
55
|
+
algo = f.algorithm.upper()
|
|
56
|
+
if algo not in seen:
|
|
57
|
+
seen[algo] = self.classify(f)
|
|
58
|
+
return seen
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NIST PQC status database.
|
|
3
|
+
Source: NIST IR 8547 (2024), FIPS 203/204/205, NSA CNSA 2.0.
|
|
4
|
+
|
|
5
|
+
nistQuantumSecurityLevel meanings:
|
|
6
|
+
0 = broken by quantum (Shor's algorithm) — RSA, ECC, DH, DSA
|
|
7
|
+
0 = classically weak — MD5, SHA-1
|
|
8
|
+
1 = acceptable short-term (classical only)
|
|
9
|
+
3 = 128-bit post-quantum security (Grover halves symmetric key strength)
|
|
10
|
+
5 = 192-bit post-quantum security
|
|
11
|
+
6 = 256-bit post-quantum security (max level)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
from typing import TypedDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RuleEntry(TypedDict):
|
|
19
|
+
primitive: str
|
|
20
|
+
nist_quantum_security_level: int
|
|
21
|
+
nist_replacement: str
|
|
22
|
+
effort_days: float
|
|
23
|
+
risk_label: str # "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"
|
|
24
|
+
crypto_functions: list[str]
|
|
25
|
+
certification_level: list[str]
|
|
26
|
+
parameter_set_identifier: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
NIST_RULES: dict[str, RuleEntry] = {
|
|
30
|
+
"RSA": {
|
|
31
|
+
"primitive": "pke",
|
|
32
|
+
"nist_quantum_security_level": 0,
|
|
33
|
+
"nist_replacement": "ML-KEM-768 (FIPS 203) for KEM, ML-DSA-65 (FIPS 204) for signing",
|
|
34
|
+
"effort_days": 3.0,
|
|
35
|
+
"risk_label": "CRITICAL",
|
|
36
|
+
"crypto_functions": ["keygen", "encrypt", "decrypt", "sign", "verify"],
|
|
37
|
+
"certification_level": ["none"],
|
|
38
|
+
"parameter_set_identifier": "2048",
|
|
39
|
+
},
|
|
40
|
+
"ECDSA": {
|
|
41
|
+
"primitive": "signature",
|
|
42
|
+
"nist_quantum_security_level": 0,
|
|
43
|
+
"nist_replacement": "ML-DSA-65 (FIPS 204) for signing, ML-KEM-768 (FIPS 203) for KEM",
|
|
44
|
+
"effort_days": 2.5,
|
|
45
|
+
"risk_label": "CRITICAL",
|
|
46
|
+
"crypto_functions": ["keygen", "sign", "verify"],
|
|
47
|
+
"certification_level": ["none"],
|
|
48
|
+
"parameter_set_identifier": "P-256",
|
|
49
|
+
},
|
|
50
|
+
"DH": {
|
|
51
|
+
"primitive": "pke",
|
|
52
|
+
"nist_quantum_security_level": 0,
|
|
53
|
+
"nist_replacement": "ML-KEM-768 (FIPS 203)",
|
|
54
|
+
"effort_days": 2.0,
|
|
55
|
+
"risk_label": "CRITICAL",
|
|
56
|
+
"crypto_functions": ["keygen", "keyagreement"],
|
|
57
|
+
"certification_level": ["none"],
|
|
58
|
+
"parameter_set_identifier": "2048",
|
|
59
|
+
},
|
|
60
|
+
"DSA": {
|
|
61
|
+
"primitive": "signature",
|
|
62
|
+
"nist_quantum_security_level": 0,
|
|
63
|
+
"nist_replacement": "ML-DSA-65 (FIPS 204)",
|
|
64
|
+
"effort_days": 2.0,
|
|
65
|
+
"risk_label": "CRITICAL",
|
|
66
|
+
"crypto_functions": ["keygen", "sign", "verify"],
|
|
67
|
+
"certification_level": ["none"],
|
|
68
|
+
"parameter_set_identifier": "2048",
|
|
69
|
+
},
|
|
70
|
+
"MD5": {
|
|
71
|
+
"primitive": "hash",
|
|
72
|
+
"nist_quantum_security_level": 0,
|
|
73
|
+
"nist_replacement": "SHA-256 or SHA3-256",
|
|
74
|
+
"effort_days": 0.5,
|
|
75
|
+
"risk_label": "HIGH",
|
|
76
|
+
"crypto_functions": ["digest"],
|
|
77
|
+
"certification_level": ["none"],
|
|
78
|
+
"parameter_set_identifier": "128",
|
|
79
|
+
},
|
|
80
|
+
"SHA1": {
|
|
81
|
+
"primitive": "hash",
|
|
82
|
+
"nist_quantum_security_level": 0,
|
|
83
|
+
"nist_replacement": "SHA-256 or SHA3-256",
|
|
84
|
+
"effort_days": 0.5,
|
|
85
|
+
"risk_label": "HIGH",
|
|
86
|
+
"crypto_functions": ["digest"],
|
|
87
|
+
"certification_level": ["none"],
|
|
88
|
+
"parameter_set_identifier": "160",
|
|
89
|
+
},
|
|
90
|
+
# Quantum-safe references (for completeness / future detection)
|
|
91
|
+
"AES-128": {
|
|
92
|
+
"primitive": "ae",
|
|
93
|
+
"nist_quantum_security_level": 1,
|
|
94
|
+
"nist_replacement": "AES-256 recommended (Grover reduces to 64-bit security)",
|
|
95
|
+
"effort_days": 1.0,
|
|
96
|
+
"risk_label": "MEDIUM",
|
|
97
|
+
"crypto_functions": ["encrypt", "decrypt"],
|
|
98
|
+
"certification_level": ["fips140-2"],
|
|
99
|
+
"parameter_set_identifier": "128",
|
|
100
|
+
},
|
|
101
|
+
"AES-256": {
|
|
102
|
+
"primitive": "ae",
|
|
103
|
+
"nist_quantum_security_level": 3,
|
|
104
|
+
"nist_replacement": "Retain — 128-bit post-quantum security",
|
|
105
|
+
"effort_days": 0.0,
|
|
106
|
+
"risk_label": "LOW",
|
|
107
|
+
"crypto_functions": ["encrypt", "decrypt"],
|
|
108
|
+
"certification_level": ["fips140-2"],
|
|
109
|
+
"parameter_set_identifier": "256",
|
|
110
|
+
},
|
|
111
|
+
"SHA-256": {
|
|
112
|
+
"primitive": "hash",
|
|
113
|
+
"nist_quantum_security_level": 3,
|
|
114
|
+
"nist_replacement": "Retain — 128-bit post-quantum security",
|
|
115
|
+
"effort_days": 0.0,
|
|
116
|
+
"risk_label": "LOW",
|
|
117
|
+
"crypto_functions": ["digest"],
|
|
118
|
+
"certification_level": ["fips180-4"],
|
|
119
|
+
"parameter_set_identifier": "256",
|
|
120
|
+
},
|
|
121
|
+
}
|
cbom_scan/cli.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cbom-scan CLI — CycloneDX 1.7 CBOM generator for post-quantum compliance.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
cbom-scan scan ./src
|
|
6
|
+
cbom-scan scan ./src --output cbom.json --format json
|
|
7
|
+
cbom-scan scan ./src --format summary
|
|
8
|
+
cbom-scan scan ./src --format html --output report.html
|
|
9
|
+
cbom-scan scan ./src --ai-roadmap --key $ANTHROPIC_KEY
|
|
10
|
+
cbom-scan validate cbom.json
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from cbom_scan import __version__
|
|
22
|
+
|
|
23
|
+
# Trigger scanner self-registration
|
|
24
|
+
import cbom_scan.scanners # noqa: F401
|
|
25
|
+
|
|
26
|
+
from cbom_scan.ai.roadmap_agent import RoadmapAgent
|
|
27
|
+
from cbom_scan.formatters.html_formatter import HtmlFormatter
|
|
28
|
+
from cbom_scan.formatters.json_formatter import JsonFormatter
|
|
29
|
+
from cbom_scan.formatters.sarif_formatter import SarifFormatter
|
|
30
|
+
from cbom_scan.formatters.summary_formatter import SummaryFormatter
|
|
31
|
+
from cbom_scan.generator.cbom_generator import CbomGenerator
|
|
32
|
+
from cbom_scan.models.cyclonedx import CycloneDXBom
|
|
33
|
+
from cbom_scan.models.finding import CryptoFinding
|
|
34
|
+
from cbom_scan.scanners.base import ScannerRegistry
|
|
35
|
+
|
|
36
|
+
_SKIP_DIRS = {
|
|
37
|
+
".git", "node_modules", "__pycache__", ".mypy_cache",
|
|
38
|
+
".pytest_cache", "venv", ".venv", "dist", "build", ".tox",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _walk_files(path: Path, exclude: tuple[str, ...]) -> list[Path]:
|
|
43
|
+
files: list[Path] = []
|
|
44
|
+
if path.is_file():
|
|
45
|
+
return [path]
|
|
46
|
+
for p in path.rglob("*"):
|
|
47
|
+
if any(part in _SKIP_DIRS for part in p.parts):
|
|
48
|
+
continue
|
|
49
|
+
if any(p.match(pat) for pat in exclude):
|
|
50
|
+
continue
|
|
51
|
+
if p.is_file():
|
|
52
|
+
files.append(p)
|
|
53
|
+
return sorted(files)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _collect_findings(files: list[Path], source_dir: Path) -> list[CryptoFinding]:
|
|
57
|
+
all_findings: list[CryptoFinding] = []
|
|
58
|
+
registry = ScannerRegistry
|
|
59
|
+
for fp in files:
|
|
60
|
+
scanner = registry.get_scanner(str(fp))
|
|
61
|
+
if scanner is None:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
source = fp.read_text(encoding="utf-8", errors="replace")
|
|
65
|
+
findings = scanner.scan(source, str(fp))
|
|
66
|
+
all_findings.extend(findings)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return all_findings
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@click.group()
|
|
73
|
+
@click.version_option(__version__, prog_name="cbom-scan")
|
|
74
|
+
def cli() -> None:
|
|
75
|
+
"""cbom-scan — Generate CycloneDX 1.7 CBOM for post-quantum compliance."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@cli.command("scan")
|
|
79
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
80
|
+
@click.option("--output", "-o", default=None, help="Output file path (default: stdout)")
|
|
81
|
+
@click.option(
|
|
82
|
+
"--format", "-f", "fmt",
|
|
83
|
+
type=click.Choice(["json", "html", "sarif", "summary"]),
|
|
84
|
+
default="json",
|
|
85
|
+
show_default=True,
|
|
86
|
+
help="Output format",
|
|
87
|
+
)
|
|
88
|
+
@click.option("--ai-roadmap", is_flag=True, default=False, help="Add Claude AI migration roadmap (paid)")
|
|
89
|
+
@click.option("--key", default=None, envvar="ANTHROPIC_API_KEY", help="Anthropic API key for --ai-roadmap")
|
|
90
|
+
@click.option("--exclude", multiple=True, help="Glob patterns to exclude (repeatable)")
|
|
91
|
+
@click.option(
|
|
92
|
+
"--fail-on",
|
|
93
|
+
type=click.Choice(["findings", "critical", "never"]),
|
|
94
|
+
default="findings",
|
|
95
|
+
show_default=True,
|
|
96
|
+
help="Exit code policy",
|
|
97
|
+
)
|
|
98
|
+
@click.option("--min-confidence", default=0.5, type=float, show_default=True, help="Minimum ai_confidence (0–1)")
|
|
99
|
+
def scan_cmd(
|
|
100
|
+
path: str,
|
|
101
|
+
output: str | None,
|
|
102
|
+
fmt: str,
|
|
103
|
+
ai_roadmap: bool,
|
|
104
|
+
key: str | None,
|
|
105
|
+
exclude: tuple[str, ...],
|
|
106
|
+
fail_on: str,
|
|
107
|
+
min_confidence: float,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Scan PATH for quantum-vulnerable cryptography and output a CycloneDX 1.7 CBOM."""
|
|
110
|
+
source_dir = Path(path).resolve()
|
|
111
|
+
files = _walk_files(source_dir, exclude)
|
|
112
|
+
|
|
113
|
+
if not files:
|
|
114
|
+
click.echo("cbom-scan: No scannable files found.", err=True)
|
|
115
|
+
sys.exit(0)
|
|
116
|
+
|
|
117
|
+
findings = _collect_findings(files, source_dir)
|
|
118
|
+
findings = [f for f in findings if f.ai_confidence >= min_confidence]
|
|
119
|
+
|
|
120
|
+
generator = CbomGenerator()
|
|
121
|
+
bom = generator.generate(findings, str(source_dir))
|
|
122
|
+
|
|
123
|
+
roadmap = None
|
|
124
|
+
if ai_roadmap:
|
|
125
|
+
agent = RoadmapAgent(api_key=key or os.environ.get("ANTHROPIC_API_KEY"))
|
|
126
|
+
roadmap = agent.generate(bom)
|
|
127
|
+
if roadmap is None and ai_roadmap:
|
|
128
|
+
click.echo(
|
|
129
|
+
"cbom-scan: AI roadmap unavailable (set --key or ANTHROPIC_API_KEY, "
|
|
130
|
+
"install with pip install cbom-scan[ai]).",
|
|
131
|
+
err=True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Format output
|
|
135
|
+
if fmt == "json":
|
|
136
|
+
rendered = JsonFormatter().render(bom)
|
|
137
|
+
# Append roadmap as extra field if present (outside CycloneDX schema)
|
|
138
|
+
if roadmap is not None:
|
|
139
|
+
import json as _json
|
|
140
|
+
data = _json.loads(rendered)
|
|
141
|
+
data["x-cbom-scan-roadmap"] = {
|
|
142
|
+
"executive_summary": roadmap.executive_summary,
|
|
143
|
+
"total_effort_days": roadmap.total_effort_days,
|
|
144
|
+
"tasks": [
|
|
145
|
+
{
|
|
146
|
+
"rank": t.rank,
|
|
147
|
+
"algorithm": t.algorithm,
|
|
148
|
+
"location": t.location,
|
|
149
|
+
"why_first": t.why_first,
|
|
150
|
+
"effort_days": t.effort_days,
|
|
151
|
+
"nist_replacement": t.nist_replacement,
|
|
152
|
+
"migration_diff": t.migration_diff,
|
|
153
|
+
}
|
|
154
|
+
for t in roadmap.tasks
|
|
155
|
+
],
|
|
156
|
+
}
|
|
157
|
+
rendered = _json.dumps(data, indent=2)
|
|
158
|
+
elif fmt == "html":
|
|
159
|
+
rendered = HtmlFormatter().render(bom)
|
|
160
|
+
if roadmap is not None:
|
|
161
|
+
# Inject roadmap section before </body>
|
|
162
|
+
roadmap_html = _roadmap_html(roadmap)
|
|
163
|
+
rendered = rendered.replace("</body>", f"{roadmap_html}\n</body>")
|
|
164
|
+
elif fmt == "sarif":
|
|
165
|
+
rendered = SarifFormatter().render(bom)
|
|
166
|
+
else:
|
|
167
|
+
rendered = SummaryFormatter().render(bom)
|
|
168
|
+
if roadmap is not None:
|
|
169
|
+
rendered += _roadmap_text(roadmap)
|
|
170
|
+
|
|
171
|
+
if output:
|
|
172
|
+
Path(output).write_text(rendered, encoding="utf-8")
|
|
173
|
+
click.echo(f"cbom-scan: Wrote {fmt.upper()} to {output}", err=True)
|
|
174
|
+
else:
|
|
175
|
+
click.echo(rendered)
|
|
176
|
+
|
|
177
|
+
# Exit code
|
|
178
|
+
if fail_on == "never":
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
critical = bom.critical_count
|
|
181
|
+
if critical and fail_on in ("findings", "critical"):
|
|
182
|
+
sys.exit(2)
|
|
183
|
+
if bom.components and fail_on == "findings":
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
sys.exit(0)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli.command("validate")
|
|
189
|
+
@click.argument("cbom_file", type=click.Path(exists=True))
|
|
190
|
+
def validate_cmd(cbom_file: str) -> None:
|
|
191
|
+
"""Validate an existing CBOM JSON file against the CycloneDX 1.7 schema."""
|
|
192
|
+
import json
|
|
193
|
+
from pydantic import ValidationError
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(Path(cbom_file).read_text(encoding="utf-8"))
|
|
196
|
+
CycloneDXBom.model_validate(data)
|
|
197
|
+
click.echo(f"VALID — {cbom_file} conforms to CycloneDX 1.7 CBOM schema.")
|
|
198
|
+
sys.exit(0)
|
|
199
|
+
except (json.JSONDecodeError, ValidationError) as exc:
|
|
200
|
+
click.echo(f"INVALID — {cbom_file}: {exc}", err=True)
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@cli.command("report")
|
|
205
|
+
@click.argument("cbom_file", type=click.Path(exists=True))
|
|
206
|
+
@click.option(
|
|
207
|
+
"--format", "-f", "fmt",
|
|
208
|
+
type=click.Choice(["summary", "html", "sarif"]),
|
|
209
|
+
default="summary",
|
|
210
|
+
)
|
|
211
|
+
@click.option("--output", "-o", default=None, help="Output file path (default: stdout)")
|
|
212
|
+
def report_cmd(cbom_file: str, fmt: str, output: str | None) -> None:
|
|
213
|
+
"""Re-render an existing CBOM JSON in a different format."""
|
|
214
|
+
import json
|
|
215
|
+
data = json.loads(Path(cbom_file).read_text(encoding="utf-8"))
|
|
216
|
+
bom = CycloneDXBom.model_validate(data)
|
|
217
|
+
if fmt == "html":
|
|
218
|
+
rendered = HtmlFormatter().render(bom)
|
|
219
|
+
elif fmt == "sarif":
|
|
220
|
+
rendered = SarifFormatter().render(bom)
|
|
221
|
+
else:
|
|
222
|
+
rendered = SummaryFormatter().render(bom)
|
|
223
|
+
|
|
224
|
+
if output:
|
|
225
|
+
Path(output).write_text(rendered, encoding="utf-8")
|
|
226
|
+
click.echo(f"Wrote {fmt.upper()} to {output}", err=True)
|
|
227
|
+
else:
|
|
228
|
+
click.echo(rendered)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _roadmap_text(roadmap) -> str: # type: ignore[no-untyped-def]
|
|
232
|
+
lines = ["\n AI Migration Roadmap\n " + "─" * 40]
|
|
233
|
+
lines.append(f"\n {roadmap.executive_summary}\n")
|
|
234
|
+
for t in roadmap.tasks:
|
|
235
|
+
lines.append(f" [{t.rank}] {t.algorithm} @ {t.location}")
|
|
236
|
+
lines.append(f" Why first: {t.why_first}")
|
|
237
|
+
lines.append(f" Effort: {t.effort_days}d → {t.nist_replacement}")
|
|
238
|
+
lines.append(f"\n Total migration effort: {roadmap.total_effort_days} engineer-days\n")
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _roadmap_html(roadmap) -> str: # type: ignore[no-untyped-def]
|
|
243
|
+
tasks_html = ""
|
|
244
|
+
for t in roadmap.tasks:
|
|
245
|
+
diff_escaped = t.migration_diff.replace("<", "<").replace(">", ">")
|
|
246
|
+
tasks_html += f"""
|
|
247
|
+
<div style="border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin-bottom:12px">
|
|
248
|
+
<div style="font-weight:700;margin-bottom:4px">[{t.rank}] {t.algorithm} → {t.nist_replacement}</div>
|
|
249
|
+
<div style="color:#64748b;font-size:.85rem;margin-bottom:6px">{t.location} • {t.effort_days}d</div>
|
|
250
|
+
<div style="font-size:.85rem;margin-bottom:8px">{t.why_first}</div>
|
|
251
|
+
<pre style="background:#f8fafc;padding:10px;border-radius:6px;font-size:.8rem;overflow-x:auto">{diff_escaped}</pre>
|
|
252
|
+
</div>"""
|
|
253
|
+
return f"""
|
|
254
|
+
<div class="card" style="margin-top:20px">
|
|
255
|
+
<h2 style="font-size:1.1rem;margin:0 0 8px">AI Migration Roadmap</h2>
|
|
256
|
+
<p style="color:#475569;font-size:.88rem;margin:0 0 16px">{roadmap.executive_summary}</p>
|
|
257
|
+
{tasks_html}
|
|
258
|
+
<div style="color:#64748b;font-size:.82rem">Total effort: {roadmap.total_effort_days} engineer-days</div>
|
|
259
|
+
</div>"""
|
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from cbom_scan.models.cyclonedx import CycloneDXBom
|
|
4
|
+
|
|
5
|
+
_RISK_COLOR = {0: "#dc2626", 1: "#d97706", 2: "#ca8a04", 3: "#16a34a", 5: "#059669", 6: "#059669"}
|
|
6
|
+
_RISK_LABEL = {0: "CRITICAL", 1: "HIGH", 2: "MEDIUM", 3: "ACCEPTABLE", 5: "GOOD", 6: "SAFE"}
|
|
7
|
+
|
|
8
|
+
_CSS = """
|
|
9
|
+
body{font-family:system-ui,sans-serif;background:#f8fafc;color:#0f172a;margin:0;padding:24px}
|
|
10
|
+
h1{font-size:1.4rem;font-weight:700;margin:0 0 4px}
|
|
11
|
+
.meta{color:#64748b;font-size:.85rem;margin-bottom:24px}
|
|
12
|
+
.card{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:20px;margin-bottom:16px}
|
|
13
|
+
.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:.78rem;font-weight:700;color:#fff}
|
|
14
|
+
table{width:100%;border-collapse:collapse;font-size:.88rem}
|
|
15
|
+
th{text-align:left;padding:8px 10px;background:#f1f5f9;color:#475569;font-weight:600;border-bottom:2px solid #e2e8f0}
|
|
16
|
+
td{padding:8px 10px;border-bottom:1px solid #f1f5f9;vertical-align:top}
|
|
17
|
+
.algo{font-weight:700;font-family:monospace}
|
|
18
|
+
.loc{font-family:monospace;font-size:.82rem;color:#64748b}
|
|
19
|
+
.repl{color:#059669;font-size:.82rem}
|
|
20
|
+
.summary-bar{display:flex;gap:20px;margin-bottom:20px}
|
|
21
|
+
.stat{background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:12px 18px;min-width:100px}
|
|
22
|
+
.stat-val{font-size:1.6rem;font-weight:800}
|
|
23
|
+
.stat-lbl{font-size:.78rem;color:#64748b;text-transform:uppercase;letter-spacing:.06em}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HtmlFormatter:
|
|
28
|
+
"""Self-contained HTML report — no CDN, no JS, no external fonts."""
|
|
29
|
+
|
|
30
|
+
def render(self, bom: CycloneDXBom) -> str:
|
|
31
|
+
total = len(bom.components)
|
|
32
|
+
critical = bom.critical_count
|
|
33
|
+
safe = total - critical
|
|
34
|
+
|
|
35
|
+
rows = ""
|
|
36
|
+
for comp in sorted(bom.components, key=lambda x: x.cryptoProperties.nistQuantumSecurityLevel):
|
|
37
|
+
level = comp.cryptoProperties.nistQuantumSecurityLevel
|
|
38
|
+
color = _RISK_COLOR.get(level, "#94a3b8")
|
|
39
|
+
label = _RISK_LABEL.get(level, "UNKNOWN")
|
|
40
|
+
props = comp.cryptoProperties.algorithmProperties
|
|
41
|
+
repl = comp.cryptoProperties.algorithmProperties
|
|
42
|
+
locs = "".join(
|
|
43
|
+
f'<div class="loc">{o.location}:{o.line}</div>'
|
|
44
|
+
for o in comp.evidence.occurrences
|
|
45
|
+
)
|
|
46
|
+
rows += f"""<tr>
|
|
47
|
+
<td class="algo">{comp.name}</td>
|
|
48
|
+
<td><span class="badge" style="background:{color}">{label}</span></td>
|
|
49
|
+
<td>{level}</td>
|
|
50
|
+
<td>{locs}</td>
|
|
51
|
+
</tr>"""
|
|
52
|
+
|
|
53
|
+
return f"""<!DOCTYPE html>
|
|
54
|
+
<html lang="en">
|
|
55
|
+
<head><meta charset="UTF-8"><title>CBOM Scan Report</title>
|
|
56
|
+
<style>{_CSS}</style></head>
|
|
57
|
+
<body>
|
|
58
|
+
<h1>CBOM Scan Report</h1>
|
|
59
|
+
<div class="meta">
|
|
60
|
+
CycloneDX 1.7 | {bom.metadata.timestamp} |
|
|
61
|
+
<code style="font-size:.8rem">{bom.serialNumber}</code>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="summary-bar">
|
|
65
|
+
<div class="stat">
|
|
66
|
+
<div class="stat-val" style="color:#dc2626">{critical}</div>
|
|
67
|
+
<div class="stat-lbl">Quantum-Broken</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="stat">
|
|
70
|
+
<div class="stat-val">{total}</div>
|
|
71
|
+
<div class="stat-lbl">Total Assets</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="stat">
|
|
74
|
+
<div class="stat-val" style="color:#059669">{safe}</div>
|
|
75
|
+
<div class="stat-lbl">Acceptable</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="card">
|
|
80
|
+
<table>
|
|
81
|
+
<thead><tr><th>Algorithm</th><th>Risk</th><th>NIST Level</th><th>Locations</th></tr></thead>
|
|
82
|
+
<tbody>{rows}</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{'<div class="card" style="border-color:#fca5a5"><strong style="color:#dc2626">ACTION REQUIRED</strong> — ' + str(critical) + ' quantum-broken cryptographic asset(s) detected. Migrate to ML-KEM-768 (FIPS 203), ML-DSA-65 (FIPS 204), or SLH-DSA (FIPS 205) before 2030 NIST mandate.</div>' if critical else ''}
|
|
87
|
+
|
|
88
|
+
<div style="color:#94a3b8;font-size:.78rem;margin-top:24px">Generated by cbom-scan 0.1.0 — CycloneDX 1.7 CBOM</div>
|
|
89
|
+
</body></html>"""
|