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 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