cbomctl 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.
- cbomctl/__init__.py +0 -0
- cbomctl/cli.py +242 -0
- cbomctl/config.py +102 -0
- cbomctl/loader/__init__.py +25 -0
- cbomctl/loader/cyclonedx.py +124 -0
- cbomctl/loader/sbom_tools.py +101 -0
- cbomctl/models.py +311 -0
- cbomctl/normalize/__init__.py +0 -0
- cbomctl/normalize/identity.py +165 -0
- cbomctl/normalize/oids.py +73 -0
- cbomctl/normalize/purpose.py +270 -0
- cbomctl/packs/anssi-fr.yaml +137 -0
- cbomctl/packs/asd-au.yaml +167 -0
- cbomctl/packs/bsi-de.yaml +165 -0
- cbomctl/packs/cnsa-2.0.yaml +288 -0
- cbomctl/packs/eu-roadmap.yaml +158 -0
- cbomctl/packs/nist-ir8547.yaml +105 -0
- cbomctl/packs/us-eo14412.yaml +105 -0
- cbomctl/plan.py +121 -0
- cbomctl/policy/__init__.py +0 -0
- cbomctl/policy/engine.py +209 -0
- cbomctl/policy/schema.py +156 -0
- cbomctl/report/__init__.py +0 -0
- cbomctl/report/json_out.py +79 -0
- cbomctl/report/markdown.py +67 -0
- cbomctl/report/matrix_text.py +123 -0
- cbomctl/report/sarif.py +121 -0
- cbomctl/scoring/__init__.py +0 -0
- cbomctl/scoring/mosca.py +112 -0
- cbomctl/verdict/__init__.py +0 -0
- cbomctl/verdict/conflicts.py +249 -0
- cbomctl/verdict/matrix.py +145 -0
- cbomctl-0.1.0.dist-info/METADATA +164 -0
- cbomctl-0.1.0.dist-info/RECORD +37 -0
- cbomctl-0.1.0.dist-info/WHEEL +4 -0
- cbomctl-0.1.0.dist-info/entry_points.txt +2 -0
- cbomctl-0.1.0.dist-info/licenses/LICENSE +202 -0
cbomctl/__init__.py
ADDED
|
File without changes
|
cbomctl/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""CLI. Subcommands only — no logic lives here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from cbomctl.config import Config
|
|
13
|
+
from cbomctl.loader import CbomParseError, read_assets
|
|
14
|
+
from cbomctl.models import Interpretation, RuleStatus
|
|
15
|
+
from cbomctl.policy.schema import available, load_pack, load_packs
|
|
16
|
+
from cbomctl.report import json_out, markdown, matrix_text, sarif
|
|
17
|
+
from cbomctl.scoring import mosca
|
|
18
|
+
from cbomctl.verdict.conflicts import detect
|
|
19
|
+
from cbomctl.verdict.matrix import build
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
add_completion=False,
|
|
23
|
+
help=("Evaluate a CycloneDX CBOM against several national post-quantum "
|
|
24
|
+
"policies at once and report where their verdicts contradict."),
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
DEFAULT_JURISDICTIONS = "bsi-de,anssi-fr,asd-au,cnsa-2.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fail(msg: str) -> None:
|
|
32
|
+
typer.secho(f"error: {msg}", fg=typer.colors.RED, err=True)
|
|
33
|
+
raise typer.Exit(2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def verdict(
|
|
38
|
+
cbom: Annotated[str, typer.Argument(help="CBOM JSON path, or - for stdin.")],
|
|
39
|
+
jurisdictions: Annotated[str, typer.Option("--jurisdictions", "-j")] = DEFAULT_JURISDICTIONS,
|
|
40
|
+
config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
|
|
41
|
+
fmt: Annotated[str, typer.Option("--format", "-f", help="matrix|json|md|sarif")] = "matrix",
|
|
42
|
+
source_format: Annotated[str, typer.Option("--from", help="auto|cyclonedx|sbom-tools")] = "auto",
|
|
43
|
+
crqc_year: Annotated[int, typer.Option("--crqc-year")] = mosca.DEFAULT_CRQC_YEAR,
|
|
44
|
+
migration_years: Annotated[float | None, typer.Option("--migration-years")] = None,
|
|
45
|
+
strict: Annotated[bool, typer.Option("--strict", help=(
|
|
46
|
+
"Unverified rules return INDETERMINATE rather than asserting a verdict, "
|
|
47
|
+
"and indeterminate findings exit 3."))] = False,
|
|
48
|
+
require_verified: Annotated[bool, typer.Option("--require-verified-policy", help=(
|
|
49
|
+
"Refuse to run against any pack containing an unverified rule."))] = False,
|
|
50
|
+
fail_on_warn: Annotated[bool, typer.Option("--fail-on-warn")] = False,
|
|
51
|
+
cnsa_acquisition_gate: Annotated[bool, typer.Option("--cnsa-acquisition-gate", help=(
|
|
52
|
+
"Include the CNSA 2.0 January 2027 acquisition gate. It is a "
|
|
53
|
+
"procurement condition on new NSS acquisitions, not an algorithm "
|
|
54
|
+
"deadline, so it is off by default."))] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Per-asset × jurisdiction verdicts, plus the conflicts between them."""
|
|
57
|
+
try:
|
|
58
|
+
assets, detected = read_assets(cbom, source_format)
|
|
59
|
+
except CbomParseError as exc:
|
|
60
|
+
_fail(str(exc))
|
|
61
|
+
if not assets:
|
|
62
|
+
_fail("no cryptographic assets found — is this a CBOM?")
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
packs = load_packs(jurisdictions.split(","))
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
_fail(str(exc))
|
|
68
|
+
|
|
69
|
+
if require_verified:
|
|
70
|
+
offenders = {p.id: [r.id for r in p.unverified_rules]
|
|
71
|
+
for p in packs if not p.is_verified}
|
|
72
|
+
if offenders:
|
|
73
|
+
detail = "; ".join(f"{k} ({len(v)} rules)" for k, v in offenders.items())
|
|
74
|
+
_fail(f"--require-verified-policy given, but these packs contain "
|
|
75
|
+
f"unverified rules: {detail}. See docs/policy-sources.md.")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
cfg = Config.load(config)
|
|
79
|
+
except FileNotFoundError as exc:
|
|
80
|
+
_fail(str(exc))
|
|
81
|
+
|
|
82
|
+
matrix = build(
|
|
83
|
+
assets, packs, cfg,
|
|
84
|
+
crqc_year=crqc_year, migration_years=migration_years,
|
|
85
|
+
include_acquisition_gate=cnsa_acquisition_gate, strict=strict,
|
|
86
|
+
)
|
|
87
|
+
conflicts = detect(matrix, packs)
|
|
88
|
+
|
|
89
|
+
renderer = {"matrix": matrix_text, "json": json_out,
|
|
90
|
+
"md": markdown, "markdown": markdown, "sarif": sarif}.get(fmt)
|
|
91
|
+
if renderer is None:
|
|
92
|
+
_fail(f"unknown format {fmt!r}; use matrix, json, md or sarif")
|
|
93
|
+
typer.echo(renderer.render(matrix, conflicts))
|
|
94
|
+
|
|
95
|
+
code = matrix.exit_code
|
|
96
|
+
if fail_on_warn and code == 0:
|
|
97
|
+
from cbomctl.models import Verdict
|
|
98
|
+
if any(r.worst is Verdict.WARN for r in matrix.rows):
|
|
99
|
+
code = 1
|
|
100
|
+
raise typer.Exit(code)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command()
|
|
104
|
+
def prioritize(
|
|
105
|
+
cbom: Annotated[str, typer.Argument(help="CBOM JSON path, or - for stdin.")],
|
|
106
|
+
config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
|
|
107
|
+
source_format: Annotated[str, typer.Option("--from")] = "auto",
|
|
108
|
+
crqc_year: Annotated[int, typer.Option("--crqc-year")] = mosca.DEFAULT_CRQC_YEAR,
|
|
109
|
+
migration_years: Annotated[float | None, typer.Option("--migration-years")] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Rank findings by Mosca's inequality. Answers *when must I move*."""
|
|
112
|
+
try:
|
|
113
|
+
assets, _ = read_assets(cbom, source_format)
|
|
114
|
+
cfg = Config.load(config)
|
|
115
|
+
except (CbomParseError, FileNotFoundError) as exc:
|
|
116
|
+
_fail(str(exc))
|
|
117
|
+
|
|
118
|
+
matrix = build(assets, [], cfg, crqc_year=crqc_year, migration_years=migration_years)
|
|
119
|
+
scored = [r for r in matrix.rows if r.risk]
|
|
120
|
+
scored.sort(key=lambda r: -r.risk.exposure_years)
|
|
121
|
+
|
|
122
|
+
for i, r in enumerate(scored, 1):
|
|
123
|
+
typer.echo(f"{i:>3}. {r.risk.band.upper():<9} {r.display:<20} {r.purpose:<15} "
|
|
124
|
+
f"exposure {r.risk.exposure_years:>5}y ({r.risk.rationale})")
|
|
125
|
+
if r.locations:
|
|
126
|
+
typer.echo(f" {r.locations[0]}")
|
|
127
|
+
|
|
128
|
+
unresolved = [r for r in matrix.rows if r.unscored]
|
|
129
|
+
if unresolved:
|
|
130
|
+
typer.echo(f"\nUNRESOLVED ({len(unresolved)}) — not scored, because the "
|
|
131
|
+
f"CBOM does not say what they are for:")
|
|
132
|
+
for r in unresolved:
|
|
133
|
+
span = " · ".join(f"{k.replace('_', ' ')} → {v}"
|
|
134
|
+
for k, v in r.unscored.range_if_guessed.items())
|
|
135
|
+
typer.echo(f" {r.display:<20} {r.unscored.reason}"
|
|
136
|
+
+ (f" [{span}]" if span else ""))
|
|
137
|
+
typer.echo(f" {r.unscored.would_resolve}")
|
|
138
|
+
|
|
139
|
+
a = matrix.assumptions
|
|
140
|
+
typer.echo(f"\nAssumptions: CRQC {a['crqc_year']} ({a['crqc_note']}) · "
|
|
141
|
+
f"migration {a['migration_years']}y")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command()
|
|
145
|
+
def plan(
|
|
146
|
+
cbom: Annotated[str, typer.Argument(help="CBOM JSON path, or - for stdin.")],
|
|
147
|
+
jurisdictions: Annotated[str, typer.Option("--jurisdictions", "-j")] = DEFAULT_JURISDICTIONS,
|
|
148
|
+
config: Annotated[Path | None, typer.Option("--config", "-c")] = None,
|
|
149
|
+
source_format: Annotated[str, typer.Option("--from")] = "auto",
|
|
150
|
+
crqc_year: Annotated[int, typer.Option("--crqc-year")] = mosca.DEFAULT_CRQC_YEAR,
|
|
151
|
+
strict: Annotated[bool, typer.Option("--strict")] = False,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Ordered migration plan: what to fix first, to what, by whose deadline."""
|
|
154
|
+
try:
|
|
155
|
+
assets, _ = read_assets(cbom, source_format)
|
|
156
|
+
packs = load_packs(jurisdictions.split(","))
|
|
157
|
+
cfg = Config.load(config)
|
|
158
|
+
except (CbomParseError, FileNotFoundError) as exc:
|
|
159
|
+
_fail(str(exc))
|
|
160
|
+
|
|
161
|
+
from cbomctl import plan as plan_mod
|
|
162
|
+
|
|
163
|
+
matrix = build(assets, packs, cfg, crqc_year=crqc_year, strict=strict)
|
|
164
|
+
conflicts = detect(matrix, packs)
|
|
165
|
+
typer.echo(plan_mod.render(matrix, conflicts, packs))
|
|
166
|
+
raise typer.Exit(matrix.exit_code)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def normalize(
|
|
171
|
+
cbom: Annotated[str, typer.Argument(help="CBOM JSON path, or - for stdin.")],
|
|
172
|
+
source_format: Annotated[str, typer.Option("--from")] = "auto",
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Show purpose resolution and which CBOM field decided it."""
|
|
175
|
+
try:
|
|
176
|
+
assets, detected = read_assets(cbom, source_format)
|
|
177
|
+
except CbomParseError as exc:
|
|
178
|
+
_fail(str(exc))
|
|
179
|
+
typer.echo(f"# read as {detected}, {len(assets)} assets\n")
|
|
180
|
+
typer.echo(f"{'ASSET':<22}{'PURPOSE':<15}{'VIA':<18}{'CONSTRUCTION':<12}QUANTUM")
|
|
181
|
+
for a in assets:
|
|
182
|
+
typer.echo(f"{a.display:<22}{a.purpose.value:<15}{a.purpose_signal.value:<18}"
|
|
183
|
+
f"{a.construction.value:<12}{a.quantum_status.value}")
|
|
184
|
+
for c in a.purpose_conflicts:
|
|
185
|
+
typer.echo(f" ! {c.note}")
|
|
186
|
+
for ctx in a.corroborating[:1]:
|
|
187
|
+
typer.echo(f" ~ corroborating (never decides): {ctx}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
policies = typer.Typer(help="Inspect policy packs.", no_args_is_help=True)
|
|
191
|
+
app.add_typer(policies, name="policies")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@policies.command("list")
|
|
195
|
+
def policies_list() -> None:
|
|
196
|
+
"""List packs and their verification state."""
|
|
197
|
+
for pid in available():
|
|
198
|
+
p = load_pack(pid)
|
|
199
|
+
unverified = len(p.unverified_rules)
|
|
200
|
+
state = "VERIFIED" if not unverified else f"{unverified}/{len(p.rules)} UNVERIFIED"
|
|
201
|
+
typer.echo(f"{p.id:<14} {state:<18} {p.name}")
|
|
202
|
+
typer.echo("\nNo pack is verified. Rules were assembled from secondary "
|
|
203
|
+
"reporting; see docs/policy-sources.md.")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@policies.command("show")
|
|
207
|
+
def policies_show(pack_id: str) -> None:
|
|
208
|
+
"""Show a pack's rules, sources and open questions."""
|
|
209
|
+
try:
|
|
210
|
+
p = load_pack(pack_id)
|
|
211
|
+
except FileNotFoundError as exc:
|
|
212
|
+
_fail(str(exc))
|
|
213
|
+
typer.echo(f"{p.name} ({p.id} v{p.pack_version})")
|
|
214
|
+
typer.echo(f"authority: {p.authority}\n")
|
|
215
|
+
typer.echo(f"applicability: {p.applicability.strip()}\n")
|
|
216
|
+
for r in p.rules:
|
|
217
|
+
mark = "✓" if r.status is RuleStatus.VERIFIED else "✗"
|
|
218
|
+
typer.echo(f" {mark} {r.id}")
|
|
219
|
+
typer.echo(f" {r.description.strip()}")
|
|
220
|
+
typer.echo(f" binding={r.binding.value} verdict={r.effective_verdict.value}"
|
|
221
|
+
+ (f" hybrid={r.hybrid.value}" if r.hybrid else "")
|
|
222
|
+
+ (f" rationale={r.rationale.value}" if r.rationale else "")
|
|
223
|
+
+ (f" deadline={r.deadline}" if r.deadline else ""))
|
|
224
|
+
typer.echo(f" source: {r.source_title} — {r.source_url}")
|
|
225
|
+
if r.interpretation.value == "contested":
|
|
226
|
+
alt = r.alt_reading.value if r.alt_reading else "unspecified"
|
|
227
|
+
typer.secho(f" ⚖ CONTESTED ENCODING — alternative reading: {alt}",
|
|
228
|
+
fg=typer.colors.YELLOW)
|
|
229
|
+
for line in (r.interpretation_note or "").strip().split("\n"):
|
|
230
|
+
if line.strip():
|
|
231
|
+
typer.echo(f" {line.strip()}")
|
|
232
|
+
if r.open_question:
|
|
233
|
+
typer.echo(f" OPEN: {r.open_question.strip()}")
|
|
234
|
+
typer.echo("")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main() -> None: # pragma: no cover
|
|
238
|
+
app()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__": # pragma: no cover
|
|
242
|
+
main()
|
cbomctl/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""User-supplied facts a CBOM cannot contain.
|
|
2
|
+
|
|
3
|
+
The load-bearing one is data lifetime. No inventory tool can derive how long
|
|
4
|
+
data must stay confidential, and without it there is nothing to rank by.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from cbomctl.models import CryptoAsset, Purpose
|
|
17
|
+
|
|
18
|
+
CONFIG_PROPERTY_PREFIX = "cbomctl:"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AssetRule(BaseModel):
|
|
22
|
+
match: dict[str, str] = Field(default_factory=dict)
|
|
23
|
+
data_lifetime_years: float | None = None
|
|
24
|
+
verification_lifetime_years: float | None = None
|
|
25
|
+
purpose: Purpose | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Defaults(BaseModel):
|
|
29
|
+
data_lifetime_years: float = 5.0
|
|
30
|
+
migration_years: float = 3.0
|
|
31
|
+
verification_lifetime_years: float | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Config(BaseModel):
|
|
35
|
+
version: int = 1
|
|
36
|
+
defaults: Defaults = Field(default_factory=Defaults)
|
|
37
|
+
system_category: str | None = None
|
|
38
|
+
assets: list[AssetRule] = Field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def load(cls, path: str | Path | None) -> "Config":
|
|
42
|
+
if path is None:
|
|
43
|
+
return cls()
|
|
44
|
+
p = Path(path)
|
|
45
|
+
if not p.is_file():
|
|
46
|
+
raise FileNotFoundError(f"config not found: {path}")
|
|
47
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
48
|
+
return cls.model_validate(data)
|
|
49
|
+
|
|
50
|
+
def _matches(self, rule: AssetRule, asset: CryptoAsset) -> bool:
|
|
51
|
+
for key, pattern in rule.match.items():
|
|
52
|
+
if key == "location":
|
|
53
|
+
files = [loc.file or "" for loc in asset.locations]
|
|
54
|
+
if not any(fnmatch.fnmatch(f, pattern) for f in files):
|
|
55
|
+
return False
|
|
56
|
+
elif key == "algorithm":
|
|
57
|
+
if (asset.algorithm or "").upper() != pattern.upper():
|
|
58
|
+
return False
|
|
59
|
+
elif key == "bom_ref":
|
|
60
|
+
if asset.bom_ref != pattern:
|
|
61
|
+
return False
|
|
62
|
+
elif key == "purpose":
|
|
63
|
+
if asset.purpose.value != pattern:
|
|
64
|
+
return False
|
|
65
|
+
else:
|
|
66
|
+
return False
|
|
67
|
+
return bool(rule.match)
|
|
68
|
+
|
|
69
|
+
def for_asset(self, asset: CryptoAsset) -> tuple[float, str, Purpose | None]:
|
|
70
|
+
"""Return (lifetime_years, source, purpose_override).
|
|
71
|
+
|
|
72
|
+
Signatures use verification lifetime where one is supplied: what matters
|
|
73
|
+
for a signature is how long it stays trusted, not how long the data it
|
|
74
|
+
covers stays secret.
|
|
75
|
+
"""
|
|
76
|
+
wants_verification = asset.purpose is Purpose.SIGNATURE
|
|
77
|
+
for i, rule in enumerate(self.assets):
|
|
78
|
+
if not self._matches(rule, asset):
|
|
79
|
+
continue
|
|
80
|
+
if wants_verification and rule.verification_lifetime_years is not None:
|
|
81
|
+
return rule.verification_lifetime_years, f"config:assets[{i}].verification_lifetime_years", rule.purpose
|
|
82
|
+
if rule.data_lifetime_years is not None:
|
|
83
|
+
return rule.data_lifetime_years, f"config:assets[{i}].data_lifetime_years", rule.purpose
|
|
84
|
+
if rule.purpose is not None:
|
|
85
|
+
break
|
|
86
|
+
if wants_verification and self.defaults.verification_lifetime_years is not None:
|
|
87
|
+
return self.defaults.verification_lifetime_years, "config:defaults.verification_lifetime_years", None
|
|
88
|
+
return self.defaults.data_lifetime_years, "config:defaults.data_lifetime_years", None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def lifetime_from_properties(component: dict[str, Any]) -> float | None:
|
|
92
|
+
"""Read `cbomctl:data_lifetime_years` from CycloneDX component properties."""
|
|
93
|
+
for prop in component.get("properties") or []:
|
|
94
|
+
if not isinstance(prop, dict):
|
|
95
|
+
continue
|
|
96
|
+
name = prop.get("name") or ""
|
|
97
|
+
if name == f"{CONFIG_PROPERTY_PREFIX}data_lifetime_years":
|
|
98
|
+
try:
|
|
99
|
+
return float(prop.get("value"))
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return None
|
|
102
|
+
return None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Input detection. Raw CycloneDX is primary; the sbom-tools adapter is opt-in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cbomctl.loader import cyclonedx, sbom_tools
|
|
8
|
+
from cbomctl.loader.cyclonedx import CbomParseError, load
|
|
9
|
+
from cbomctl.models import CryptoAsset
|
|
10
|
+
|
|
11
|
+
__all__ = ["CbomParseError", "load", "read_assets"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_assets(source: str | Path, fmt: str = "auto") -> tuple[list[CryptoAsset], str]:
|
|
15
|
+
doc = load(source)
|
|
16
|
+
if fmt == "sbom-tools" or (fmt == "auto" and sbom_tools.is_sbom_tools(doc)):
|
|
17
|
+
return sbom_tools.to_assets(doc), "sbom-tools"
|
|
18
|
+
if fmt in ("auto", "cyclonedx"):
|
|
19
|
+
if fmt == "auto" and not cyclonedx.is_cyclonedx(doc):
|
|
20
|
+
raise CbomParseError(
|
|
21
|
+
"input is neither a CycloneDX document nor recognisable "
|
|
22
|
+
"sbom-tools output; pass --from to force a reader"
|
|
23
|
+
)
|
|
24
|
+
return cyclonedx.to_assets(doc), "cyclonedx"
|
|
25
|
+
raise CbomParseError(f"unknown input format: {fmt}")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Read a CycloneDX 1.6 / 1.7 CBOM.
|
|
2
|
+
|
|
3
|
+
1.7 is a superset of 1.6 in the crypto subtree, so one reader serves both.
|
|
4
|
+
Unrecognised enum values degrade to UNKNOWN rather than raising: a future 1.8
|
|
5
|
+
adding a primitive must not crash a CI gate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from cbomctl.models import CryptoAsset, Location
|
|
15
|
+
from cbomctl.normalize import identity, purpose as purpose_mod
|
|
16
|
+
from cbomctl.normalize.oids import lookup as oid_lookup
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CbomParseError(ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(source: str | Path) -> dict[str, Any]:
|
|
24
|
+
raw = _read(source)
|
|
25
|
+
try:
|
|
26
|
+
doc = json.loads(raw)
|
|
27
|
+
except json.JSONDecodeError as exc: # pragma: no cover - message path
|
|
28
|
+
raise CbomParseError(f"not valid JSON: {exc}") from exc
|
|
29
|
+
if not isinstance(doc, dict):
|
|
30
|
+
raise CbomParseError("top level of a CBOM must be an object")
|
|
31
|
+
return doc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read(source: str | Path) -> str:
|
|
35
|
+
if source == "-":
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
return sys.stdin.read()
|
|
39
|
+
p = Path(source)
|
|
40
|
+
if not p.is_file():
|
|
41
|
+
raise CbomParseError(f"no such file: {source}")
|
|
42
|
+
return p.read_text(encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_cyclonedx(doc: dict[str, Any]) -> bool:
|
|
46
|
+
return doc.get("bomFormat") == "CycloneDX" or "specVersion" in doc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _locations(component: dict[str, Any]) -> list[Location]:
|
|
50
|
+
occurrences = (component.get("evidence") or {}).get("occurrences") or []
|
|
51
|
+
out: list[Location] = []
|
|
52
|
+
for o in occurrences:
|
|
53
|
+
if not isinstance(o, dict):
|
|
54
|
+
continue
|
|
55
|
+
out.append(Location(
|
|
56
|
+
file=o.get("location"),
|
|
57
|
+
line=o.get("line"),
|
|
58
|
+
context=o.get("additionalContext"),
|
|
59
|
+
))
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def to_assets(doc: dict[str, Any]) -> list[CryptoAsset]:
|
|
64
|
+
spec = str(doc.get("specVersion") or "")
|
|
65
|
+
assets: list[CryptoAsset] = []
|
|
66
|
+
|
|
67
|
+
for comp in doc.get("components") or []:
|
|
68
|
+
if not isinstance(comp, dict):
|
|
69
|
+
continue
|
|
70
|
+
cp = comp.get("cryptoProperties")
|
|
71
|
+
if not isinstance(cp, dict):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
algo = cp.get("algorithmProperties") or {}
|
|
75
|
+
raw_name = comp.get("name") or comp.get("bom-ref") or "<unnamed>"
|
|
76
|
+
primitive = algo.get("primitive")
|
|
77
|
+
functions = algo.get("cryptoFunctions") or []
|
|
78
|
+
oid = cp.get("oid")
|
|
79
|
+
|
|
80
|
+
res = purpose_mod.resolve(
|
|
81
|
+
raw_name=raw_name,
|
|
82
|
+
crypto_functions=list(functions) if isinstance(functions, list) else None,
|
|
83
|
+
primitive=primitive,
|
|
84
|
+
oid=oid,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
entry = oid_lookup(oid)
|
|
88
|
+
# 1.7 renamed `curve` to `ellipticCurve`; both may appear.
|
|
89
|
+
curve = identity.parse_curve(
|
|
90
|
+
raw_name, algo.get("ellipticCurve") or algo.get("curve"))
|
|
91
|
+
param = algo.get("parameterSetIdentifier")
|
|
92
|
+
|
|
93
|
+
locations = _locations(comp)
|
|
94
|
+
corroborating = [loc.context for loc in locations if loc.context]
|
|
95
|
+
|
|
96
|
+
assets.append(CryptoAsset(
|
|
97
|
+
bom_ref=comp.get("bom-ref") or raw_name,
|
|
98
|
+
raw_name=raw_name,
|
|
99
|
+
# `algorithmFamily` is 1.7-only; fall back to the OID's canonical
|
|
100
|
+
# name, then to parsing the free-text name.
|
|
101
|
+
algorithm=(algo.get("algorithmFamily")
|
|
102
|
+
or (entry.algorithm if entry else None)
|
|
103
|
+
or identity.family(raw_name)),
|
|
104
|
+
purpose=res.purpose,
|
|
105
|
+
purpose_signal=res.signal,
|
|
106
|
+
purpose_conflicts=res.conflicts,
|
|
107
|
+
construction=identity.classify_construction(raw_name, primitive),
|
|
108
|
+
quantum_status=identity.classify_quantum(raw_name),
|
|
109
|
+
key_size=identity.parse_key_size(raw_name, param),
|
|
110
|
+
security_strength=identity.security_strength(
|
|
111
|
+
raw_name,
|
|
112
|
+
declared=algo.get("classicalSecurityLevel"),
|
|
113
|
+
key_size=identity.parse_key_size(raw_name, param),
|
|
114
|
+
curve=curve,
|
|
115
|
+
),
|
|
116
|
+
parameter_set=param if param and not str(param).isdigit() else None,
|
|
117
|
+
curve=curve,
|
|
118
|
+
oid=oid,
|
|
119
|
+
asset_type=cp.get("assetType") or "algorithm",
|
|
120
|
+
locations=locations,
|
|
121
|
+
corroborating=sorted(set(corroborating)),
|
|
122
|
+
spec_version=spec or None,
|
|
123
|
+
))
|
|
124
|
+
return assets
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Optional adapter for `sbom-tools` normalized JSON.
|
|
2
|
+
|
|
3
|
+
Best-effort and clearly labelled as such. Their `Component` derives `Serialize`
|
|
4
|
+
with no serde renames, so fields arrive snake_cased — but that is an
|
|
5
|
+
implementation detail read from source, not a documented contract. We have
|
|
6
|
+
asked upstream whether the payload is stable; until they answer this adapter
|
|
7
|
+
may break, and it says so rather than failing silently.
|
|
8
|
+
|
|
9
|
+
Known lossy point: their parser maps an absent `primitive` and an explicit
|
|
10
|
+
`primitive: "unknown"` to the same value, so this path cannot tell a generator
|
|
11
|
+
that omitted the field from one that said it did not know. Both still resolve
|
|
12
|
+
to Purpose.UNKNOWN, so no verdict changes — but the diagnostic is gone.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from cbomctl.models import CryptoAsset
|
|
20
|
+
from cbomctl.normalize import identity, purpose as purpose_mod
|
|
21
|
+
from cbomctl.normalize.oids import lookup as oid_lookup
|
|
22
|
+
|
|
23
|
+
#: snake_case -> CycloneDX camelCase for the fields we consume.
|
|
24
|
+
_PRIMITIVE_FROM_RUST = {
|
|
25
|
+
"Ae": "ae", "BlockCipher": "block-cipher", "StreamCipher": "stream-cipher",
|
|
26
|
+
"Hash": "hash", "Mac": "mac", "Signature": "signature", "Pke": "pke",
|
|
27
|
+
"Kem": "kem", "Kdf": "kdf", "KeyAgree": "key-agree", "Xof": "xof",
|
|
28
|
+
"Drbg": "drbg", "Combiner": "combiner", "Unknown": "unknown",
|
|
29
|
+
}
|
|
30
|
+
_FUNCTION_FROM_RUST = {
|
|
31
|
+
"Encrypt": "encrypt", "Decrypt": "decrypt", "Sign": "sign", "Verify": "verify",
|
|
32
|
+
"Encapsulate": "encapsulate", "Decapsulate": "decapsulate", "Digest": "digest",
|
|
33
|
+
"Tag": "tag", "Keyderive": "keyderive", "KeyDerive": "keyderive",
|
|
34
|
+
"Keygen": "keygen", "Generate": "generate", "Other": "other", "Unknown": "unknown",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_sbom_tools(doc: dict[str, Any]) -> bool:
|
|
39
|
+
comps = doc.get("components")
|
|
40
|
+
if not isinstance(comps, list) or not comps:
|
|
41
|
+
return False
|
|
42
|
+
return any(isinstance(c, dict) and "crypto_properties" in c for c in comps)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _norm(value: Any, table: dict[str, str]) -> str | None:
|
|
46
|
+
if value is None:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(value, dict): # externally tagged enum, e.g. {"Other": "..."}
|
|
49
|
+
value = next(iter(value), None)
|
|
50
|
+
return table.get(str(value), str(value).lower())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_assets(doc: dict[str, Any]) -> list[CryptoAsset]:
|
|
54
|
+
assets: list[CryptoAsset] = []
|
|
55
|
+
for comp in doc.get("components") or []:
|
|
56
|
+
if not isinstance(comp, dict):
|
|
57
|
+
continue
|
|
58
|
+
cp = comp.get("crypto_properties")
|
|
59
|
+
if not isinstance(cp, dict):
|
|
60
|
+
continue
|
|
61
|
+
algo = cp.get("algorithm_properties") or {}
|
|
62
|
+
raw_name = comp.get("name") or "<unnamed>"
|
|
63
|
+
primitive = _norm(algo.get("primitive"), _PRIMITIVE_FROM_RUST)
|
|
64
|
+
functions = [_norm(f, _FUNCTION_FROM_RUST)
|
|
65
|
+
for f in (algo.get("crypto_functions") or [])]
|
|
66
|
+
oid = cp.get("oid")
|
|
67
|
+
|
|
68
|
+
curve = identity.parse_curve(raw_name, algo.get("elliptic_curve"))
|
|
69
|
+
res = purpose_mod.resolve(
|
|
70
|
+
raw_name=raw_name,
|
|
71
|
+
crypto_functions=[f for f in functions if f],
|
|
72
|
+
primitive=primitive,
|
|
73
|
+
oid=oid,
|
|
74
|
+
)
|
|
75
|
+
entry = oid_lookup(oid)
|
|
76
|
+
param = algo.get("parameter_set_identifier")
|
|
77
|
+
assets.append(CryptoAsset(
|
|
78
|
+
bom_ref=comp.get("bom_ref") or comp.get("bom-ref") or raw_name,
|
|
79
|
+
raw_name=raw_name,
|
|
80
|
+
algorithm=(algo.get("algorithm_family")
|
|
81
|
+
or (entry.algorithm if entry else None)
|
|
82
|
+
or identity.family(raw_name)),
|
|
83
|
+
purpose=res.purpose,
|
|
84
|
+
purpose_signal=res.signal,
|
|
85
|
+
purpose_conflicts=res.conflicts,
|
|
86
|
+
construction=identity.classify_construction(raw_name, primitive),
|
|
87
|
+
quantum_status=identity.classify_quantum(raw_name),
|
|
88
|
+
key_size=identity.parse_key_size(raw_name, param),
|
|
89
|
+
security_strength=identity.security_strength(
|
|
90
|
+
raw_name,
|
|
91
|
+
declared=algo.get("classical_security_level"),
|
|
92
|
+
key_size=identity.parse_key_size(raw_name, param),
|
|
93
|
+
curve=curve,
|
|
94
|
+
),
|
|
95
|
+
parameter_set=param if param and not str(param).isdigit() else None,
|
|
96
|
+
curve=curve,
|
|
97
|
+
oid=oid,
|
|
98
|
+
asset_type=str(cp.get("asset_type") or "algorithm").lower(),
|
|
99
|
+
spec_version="sbom-tools",
|
|
100
|
+
))
|
|
101
|
+
return assets
|