Skip to content

fix(quality): compliance false-passes, gate mappings, metric counting, score math#304

Open
matrosov wants to merge 4 commits into
mainfrom
fix/quality-crypto-compliance
Open

fix(quality): compliance false-passes, gate mappings, metric counting, score math#304
matrosov wants to merge 4 commits into
mainfrom
fix/quality-crypto-compliance

Conversation

@matrosov

Copy link
Copy Markdown
Member

Summary

Fixes all 4 clusters from the quality/compliance audit (32 confirmed findings, 12 high). The threat model throughout: a false regulatory claim — the tool telling a user they comply when they don't.

1. Crypto/CBOM compliance false-passes (17ae0b0)

  • validate pqc / validate cnsa2 no longer pass vacuously on SBOMs with no crypto inventory (new gating Errors SBOM-PQC-000 / SBOM-CNSA2-000)
  • Classical quantum-vulnerable crypto (RSA/ECDSA/ECDH/DH/DSA/EdDSA…) now fails NIST PQC on the family alone via is_classical_quantum_vulnerable() — an RSA-2048 CBOM previously passed PQC validation
  • Property-less Cryptographic components no longer score grade A on empty denominators
  • CNSA gate escapes closed (SHA-224/SHA-256 in either encoding, AES requires explicit 256); strong ECC keys (P-256/384/521) no longer false-failed as "<2048 bits" (512 deliberately excluded so factorable RSA-512 isn't passed as ECC)

2. Compliance gate mappings (3f996e5)

  • NTIA required Timestamp element now gates (was an explicit no-op)
  • EO 14028 §4(e) Supplier Name is a gating Error (was a Warning behind a >30% threshold)
  • BSI TR-03183-2 §5.3 component name enforced (was a comment-only no-op)
  • CRA Art. 24 honors document-level disclosure fields (fixes a false-fail)
  • New opt-in --fail-on-noncompliant flag: quality can no longer print NON-COMPLIANT while exiting 0 (default unchanged — score-only gate)

3. Metric counting (bbacf17)

  • NOASSERTION-only components no longer count as "licensed" (was 100% licensed for zero license data)
  • License/identifier counters are per-component: spdx_ratio ≤ 1.0 by construction; one multi-CPE component can't mask identifier-less components (was false 100% coverage)
  • The substring SPDX "validator" (any string containing OR passed) is deleted — metrics use the model's real spdx-crate validation; net more accepting of legitimate ids (Zlib, GPL-2.0+, LicenseRef-*)
  • Lifecycle deprecated/archived counted once, not twice

4. Score math (80818d9)

  • Disclosing a bare vulnerability no longer scores worse than stripping the data (40-pt disclosure baseline, quality gradient intact)
  • Freshness removed from the score: identical SBOM bytes now score identically across days (freshness stays as display metadata; future-dated docs no longer "fresh")
  • Cyclic dependency graphs can't absorb their own cycle penalty via >100% raw coverage
  • "Add VCS URLs" recommendation reads the VCS field (was keyed on hash coverage — fired exactly backwards)

Verification

Every cluster was ground-truth A/B'd against its parent commit and adversarially audited before commit (verdict-flip tables, over-correction checks: clean ML-KEM/ML-DSA CBOMs still pass, complete SBOMs not false-failed, no legitimate license form flips invalid, full-report JSON for a well-formed SBOM differs only in the intended provenance renormalization). 20 new regression tests. Full suite (1660), clippy -D warnings, fmt clean at every commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CQvKahGY6VMYWSGMPBh77L

alexm-anthropic and others added 4 commits July 6, 2026 08:48
… classical crypto, undocumented CBOM)

First cluster from the quality/compliance audit — the flagship problem: a
tool advertising CNSA 2.0 / NIST PQC checking green-lit SBOMs it should
reject, so a user could certify quantum-safety the tool never actually
verified. The audit's threat model is a false regulatory claim; every fix
here flips a false verdict in the correct direction, and the A/B confirms
no over-correction (legitimate PQC crypto still passes).

Vacuous pass on empty inventory:
- check_cnsa2 / check_nist_pqc iterated components, `continue`d past every
  non-Cryptographic one, and emitted nothing — so a plain application SBOM
  (no CBOM) reported COMPLIANT for CNSA 2.0 and NIST PQC. They now emit an
  Error (SBOM-CNSA2-000 / SBOM-PQC-000) when zero cryptographic assets were
  evaluated: a PQC-readiness verdict must not be "compliant" having checked
  nothing.

Classical quantum-vulnerable crypto passed PQC:
- PQC-001 fired only when nistQuantumSecurityLevel == Some(0), and the
  broken-family list had no classical asymmetric algorithm — so an RSA /
  ECDSA / ECDH / DH / DSA CBOM with an unset quantum level (the common
  real-world case) passed NIST PQC. A new
  AlgorithmProperties::is_classical_quantum_vulnerable() flags these on the
  family alone (broken by Shor's algorithm regardless of key size); PQC-001
  now fires on it, and the quantum_vulnerable_count metric counts it.

Grade-A for undocumented crypto:
- CryptographyMetrics incremented total_crypto_components BEFORE the
  crypto_properties guard, so a Cryptographic component with no
  cryptoProperties made has_data() true and the six CBOM sub-scores
  returned 100 on empty denominators (grade-A crypto for nothing
  documented). It now counts only documented assets, so the crypto score is
  N/A rather than 100.

CNSA gate escapes + ECC false-fail:
- The hash gate only matched family literally "SHA-2"/"SHA2" with param
  "256", so SHA-224 and family-form "SHA-256" escaped; it now catches both
  encodings. AES now requires an explicit 256 (via param or classical
  security level) instead of passing on an absent parameter.
- Conversely, the key-size check false-FAILED every ECC key (flat <2048),
  but an ECC key size is the curve bit-length (P-256 → 256 ≈ 128-bit
  security). Strong ECC curve sizes (255/256/384/448/521) are now adequate
  while RSA/finite-field still needs ≥2048; 512 is deliberately excluded so
  a factorable 512-bit RSA/DSA key is not passed as "strong ECC".

Verified: false-pass/false-fail A/B vs main (all four false-passes flip to
fail; ML-KEM-1024/ML-DSA-87/SHA-384/AES-256 clean CBOM stays compliant;
ECC P-256/P-384 no longer false-failed; RSA-1024 still flagged) and an
over-correction audit (no PQC family or hybrid trips the classical list;
it caught the 512-bit-RSA hole in the ECC allowlist, fixed here). 7 new
tests; two compliance TUI snapshots correctly flip a crypto-less fixture
from pass to fail. Full suite green (1649 tests), clippy -D warnings and
fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity exit code

Second cluster from the quality/compliance audit — gate mis-mappings where a
required element was declared mandatory but never actually failed the verdict,
plus a score/verdict exit-code inconsistency. Threat model is the same false
regulatory claim; each fix flips a false verdict in the correct direction, and
the A/B confirms a fully-complete SBOM is not newly false-failed.

Required elements now gate:
- NTIA "Timestamp" (one of the seven required minimum data fields) was a literal
  no-op: the check was skipped on the assumption "we always set a timestamp."
  But a missing/invalid source timestamp is stored as the UNIX_EPOCH sentinel,
  so it now gates on has_known_timestamp() with an Error (SBOM-NTIA-TIMESTAMP)
  for NtiaMinimum/Comprehensive.
- EO 14028 §4(e) Supplier Name — a required NTIA element — was a non-gating
  Warning behind a >30% missing threshold while version and unique-id were
  Errors. It is now a gating Error on any missing supplier, consistent with the
  other required elements.
- BSI TR-03183-2 §5.3 mandatory component name was a comment-only no-op that
  claimed the name was "already enforced globally" — but the dedicated BSI
  checker never runs the generic component check, so a nameless component
  passed. It now enforces the name (SBOM-BSI-TR-03183-2-5-3).

False-fail fixed:
- CRA Art. 24 OSS-steward vulnerability-handling gate only inspected component
  external refs and the sidecar, ignoring the document-level
  vulnerability_disclosure_url / security_contact that its own doc comment says
  satisfy it. Those document fields now count (a strict OR-widening).

Score/verdict exit code:
- `quality` printed the compliance verdict ("NON-COMPLIANT (N errors)") but its
  only exit gate was --min-score, so a grade-A but non-compliant SBOM exited 0.
  Added an opt-in --fail-on-noncompliant flag that exits COMPLIANCE_ERRORS when
  the verdict is non-compliant; the default stays score-only so existing scripts
  are unaffected. Exit-code help text updated.

N/A stays compliant (deliberate): a standard that does not apply to an SBOM
(e.g. EU AI Act on a non-AI SBOM) is not a violation, so is_compliant is left
true — unlike the crypto case, where no crypto inventory under a *crypto*
standard means readiness cannot be asserted. The CRA "Default product class
under-gates" behavior is the intended CRA-P3.2 severity-tiering-by-class, not a
bug, and is left as-is.

Also aligned two registry default_severity entries (EO14028-SUPPLIER,
BSI-5-3) to the Error severity actually emitted, and expanded the Art.24
remediation message to mention the document-level satisfiers.

Verified: verdict A/B vs the crypto-cluster commit (all four gates flip in the
correct direction with the expected rule_ids; a fully-complete SBOM stays
compliant under NTIA/EO/BSI; the flag gates only when set, confirmed through the
built binary) and an over-correction audit (the widenings cannot introduce a
false-fail; compliant golden fixtures still pass zero errors). 4 new tests; full
suite green (1653 tests), clippy -D warnings and fmt clean; no golden/snapshot
churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-entry

Third cluster from the quality audit — metric-counting bugs that inflated
scores by conflating per-entry counts with per-component denominators, plus
a fake SPDX validator. All fixes are in how metrics COUNT; the A/B confirms
a well-formed SBOM scores byte-identically before and after.

NOASSERTION counted as licensed:
- CompletenessMetrics and LicenseMetrics counted any non-empty declared list
  as "has license", but the CycloneDX parser emits declared=["NOASSERTION"]
  for empty license objects — so an SBOM with no real license data reported
  100% licensed. A component now needs at least one non-NOASSERTION entry
  (SPDX NONE still counts as documented: asserting no license exists is
  information; making no assertion is not). A/B: 4-of-5 NOASSERTION SBOM
  drops from 100% to 20% licensed.

SPDX validity was a substring check:
- is_valid_spdx_license accepted any string containing " OR "/" AND "/
  " WITH " — "GARBAGE OR NONSENSE" validated. Deleted; metrics now use the
  is_valid_spdx the model already computes in LicenseExpression::new via the
  spdx crate (LAX parse). Net direction is MORE accepting of real-world
  forms (GPL-2.0+, Zlib, MIT/Apache-2.0, case variants now valid; the old
  25-id list rejected them) while garbage operands finally fail.
- validate_spdx's NOASSERTION/NONE guard now matches whole tokens, so
  LicenseRef-NONEXCLUSIVE is no longer substring-rejected; and from_spdx_id
  validates instead of hardcoding is_valid_spdx=true now that scoring
  trusts the bit.

Per-entry counts against per-component denominators:
- valid_spdx_expressions counted per declared ENTRY but divided by the
  per-component with_declared, so one multi-licensed component pushed
  spdx_ratio to 3.0 and the 30-pt bonus through the 100 clamp (license
  score 100 at 50% coverage). All license counters are now per-component
  with valid+non_standard == with_declared, making the ratio <= 1.0 by
  construction. A/B: that SBOM now scores 60, not 100.
- IdentifierMetrics counted CPEs per entry and summed per-type counts, so
  one component with 3 CPEs masked two components with no identifier at all
  (coverage 100%). New components_with_valid_id field is the coverage
  numerator; per-type counts are per-component. A/B: the masking SBOM drops
  to 33%.

Lifecycle double-count:
- Deprecated/archived components were counted twice — once for the
  StalenessLevel and once for the boolean flag, which the enrichment sets
  together (the normal case, not an edge). Each state now counts a
  component once; the audit's state-matrix check confirms NEW <= OLD in
  every combination.

Verified: score A/B vs the gate-mapping commit (all five scenarios move by
the exact expected amounts; clean SBOM byte-identical; 7-fixture sweep shows
only one change, a 59.3->60.3 gain) and an adversarial audit (100+ license
forms LAX-vs-old — no legitimate form flips invalid; all construction paths
go through LicenseExpression::new; serde round-trip surfaces checked; the
concluded-NOASSERTION guard is load-bearing via the SPDX3 path). 5 new
tests; full suite green (1657), clippy -D warnings and fmt clean; zero
snapshot churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, freshness, coverage clamp)

Fourth and final cluster from the quality audit — score-math defects where
the arithmetic itself produced perverse or unstable results.

Disclosing a vulnerability lowered the score:
- A bare VulnerabilityRef (no CVSS/CWE/remediation) scored the VulnDocs
  category 0, while an SBOM with NO vulnerability data got N/A and its
  weight redistributed — so disclosing was worse than stripping the data,
  punishing exactly the transparency an SBOM exists to provide.
  documentation_score now grants a 40-point disclosure baseline plus up to
  60 for quality (CVSS 24 / CWE 18 / remediation 18 — the old 40:30:30
  proportions preserved; fully documented still lands exactly on 100).
  A/B: the perverse gap halves (6.74 → 3.27 pts on the probe SBOM); the
  quality gradient survives (bare 40 < partial < full 100). The residual
  gap is deliberate: full parity would require rewarding disclosure above
  the SBOM's own average.

Freshness made scores non-deterministic:
- is_fresh (created < 90 days, live wall clock) carried 12 points of the
  provenance checklist, so identical SBOM bytes scored differently across
  midnight, and a future-dated document always counted fresh. Freshness is
  now display-only metadata: the checklist item is removed (denominator
  105 → 93 for CycloneDX), making the quality score a pure function of the
  document — the same determinism contract as the parser content-hash
  work. Future-dated documents no longer read as fresh. NOTE: fresh
  documents lose a little provenance ((S+12)/105 → S/93), stale and
  timestamp-less ones gain — that redistribution is the point. The TUI
  score explanation no longer cites freshness, and the two quality
  snapshots move by exactly the renormalization (9→10 / 10%→11%).

Cyclic graphs absorbed their own penalty:
- Dependency coverage uses an N/(N-1) denominator, so a fully-cyclic graph
  reached ~125% raw coverage and swallowed the cycle penalty before the
  final clamp (ring graph scored a perfect 100). Coverage now clamps to
  100 before penalties: the same ring scores 95 with its cycle visible;
  acyclic graphs are bit-identical.

Wrong-field recommendation + hardcoded flag:
- The "Add VCS URLs" recommendation derived missing-VCS from HASH coverage:
  it stayed silent for SBOMs with no VCS refs (all hashed) and fired for
  fully-VCS'd SBOMs missing hashes. AuditabilityMetrics is now plumbed into
  generate_recommendations and the count uses components_with_vcs.
- CompletenessMetrics.has_timestamp was hardcoded true; it now reports
  has_known_timestamp() (score-neutral; TUI checklist and reports only).

Verified: score A/B vs the metric-counting commit (all four fixes move by
the measured amounts; a well-formed SBOM's full report JSON differs in
exactly two fields — the provenance renormalization) and an adversarial
audit (baseline-40 coefficients binary-exact at 100; no consumer
special-cases score 0; the one-fake-CVE gaming vector is bounded and
smaller than the pre-existing fully-documented-CVE one; both scorer paths
checked — score_ai_readiness builds its own recommendations). 4 new tests,
one prior test corrected (its "fresh" fixture was year-3999, silently
relying on the future-dating bug). Full suite green (1660), clippy -D
warnings and fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants