cbomscanner 0.2.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.
@@ -0,0 +1,3 @@
1
+ """cbom-scan — CycloneDX 1.7 CBOM generator for post-quantum compliance."""
2
+
3
+ __version__ = "0.1.0"
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 cbomscanner.models.cyclonedx import CycloneDXBom
9
+
10
+ _log = logging.getLogger("cbomscanner.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 cbomscanner.classifier.rules import NIST_RULES, RuleEntry
6
+ from cbomscanner.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
+ }
cbomscanner/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 cbomscanner import __version__
22
+
23
+ # Trigger scanner self-registration
24
+ import cbomscanner.scanners # noqa: F401
25
+
26
+ from cbomscanner.ai.roadmap_agent import RoadmapAgent
27
+ from cbomscanner.formatters.html_formatter import HtmlFormatter
28
+ from cbomscanner.formatters.json_formatter import JsonFormatter
29
+ from cbomscanner.formatters.sarif_formatter import SarifFormatter
30
+ from cbomscanner.formatters.summary_formatter import SummaryFormatter
31
+ from cbomscanner.generator.cbom_generator import CbomGenerator
32
+ from cbomscanner.models.cyclonedx import CycloneDXBom
33
+ from cbomscanner.models.finding import CryptoFinding
34
+ from cbomscanner.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("<", "&lt;").replace(">", "&gt;")
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} &rarr; {t.nist_replacement}</div>
249
+ <div style="color:#64748b;font-size:.85rem;margin-bottom:6px">{t.location} &bull; {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 cbomscanner.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 &nbsp;|&nbsp; {bom.metadata.timestamp} &nbsp;|&nbsp;
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>"""
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from cbomscanner.models.cyclonedx import CycloneDXBom
4
+
5
+
6
+ class JsonFormatter:
7
+ def render(self, bom: CycloneDXBom) -> str:
8
+ return bom.to_json(indent=2)