Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/cli/quality.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub struct QualityConfig {
pub show_recommendations: bool,
pub show_metrics: bool,
pub min_score: Option<f32>,
/// Exit non-zero when the compliance verdict is non-compliant (opt-in;
/// the default gate is `--min-score` only, so existing scripts are
/// unaffected).
pub fail_on_noncompliant: bool,
pub no_color: bool,
/// Optional CRA sidecar metadata path (auto-discovered next to the SBOM
/// when None). Supplements the embedded compliance check used by the
Expand All @@ -47,6 +51,7 @@ pub fn run_quality(
show_recommendations: bool,
show_metrics: bool,
min_score: Option<f32>,
fail_on_noncompliant: bool,
no_color: bool,
cra_sidecar_path: Option<PathBuf>,
cra_product_class: Option<String>,
Expand All @@ -60,6 +65,7 @@ pub fn run_quality(
show_recommendations,
show_metrics,
min_score,
fail_on_noncompliant,
no_color,
cra_sidecar_path,
cra_product_class,
Expand Down Expand Up @@ -157,6 +163,18 @@ fn run_quality_impl(config: QualityConfig) -> Result<i32> {
return Ok(exit_codes::QUALITY_BELOW_THRESHOLD);
}

// Opt-in: fail the command when the compliance verdict is non-compliant,
// so the printed "NON-COMPLIANT" cannot be paired with a success exit.
// Off by default, so `quality --min-score` keeps its score-only contract.
if config.fail_on_noncompliant && !report.compliance.is_compliant {
tracing::error!(
"SBOM is non-compliant with {} ({} error(s))",
report.compliance.level.name(),
report.compliance.error_count
);
return Ok(exit_codes::COMPLIANCE_ERRORS);
}

Ok(exit_codes::SUCCESS)
}

Expand Down Expand Up @@ -684,6 +702,7 @@ mod tests {
show_recommendations: true,
show_metrics: true,
min_score,
fail_on_noncompliant: false,
no_color: true,
cra_sidecar_path: None,
cra_product_class: None,
Expand Down
11 changes: 9 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -721,14 +721,15 @@ struct MatrixArgs {
/// Arguments for the `quality` subcommand
#[derive(Parser)]
#[command(after_help = "EXIT CODES:
0 Score meets the --min-score threshold (or no threshold set)
1 Overall score below --min-score
0 Score meets --min-score (or no threshold) and, with --fail-on-noncompliant, the SBOM is compliant
1 Overall score below --min-score, OR (with --fail-on-noncompliant) the SBOM is non-compliant
3 Error occurred

EXAMPLES:
sbom-tools quality app.cdx.json # Score overview
sbom-tools quality app.cdx.json --profile security --metrics # Detailed metrics
sbom-tools quality app.cdx.json --min-score 70 -o json # CI gate with export
sbom-tools quality app.cdx.json --profile cra --fail-on-noncompliant # gate on compliance
sbom-tools quality app.cdx.json --recommendations # Improvement suggestions")]
struct QualityArgs {
/// Path to the SBOM file
Expand Down Expand Up @@ -758,6 +759,11 @@ struct QualityArgs {
#[arg(long)]
min_score: Option<f32>,

/// Exit non-zero when the SBOM is non-compliant with the report's
/// compliance standard (the score gate `--min-score` ignores compliance).
#[arg(long)]
fail_on_noncompliant: bool,

/// Path to a CRA sidecar metadata file (JSON or YAML).
/// Consulted by the embedded CRA compliance check when running
/// `--profile cra`. Auto-discovered next to the SBOM if omitted.
Expand Down Expand Up @@ -1880,6 +1886,7 @@ fn main() -> Result<()> {
args.recommendations,
args.metrics,
args.min_score,
args.fail_on_noncompliant,
resolve_bool(cli.no_color, app.output.no_color),
args.cra_sidecar,
args.cra_product_class,
Expand Down
20 changes: 20 additions & 0 deletions src/model/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,26 @@ impl AlgorithmProperties {
self.primitive == CryptoPrimitive::Combiner
}

/// Returns `true` if this is a CLASSICAL public-key algorithm broken by a
/// cryptographically-relevant quantum computer (Shor's algorithm): RSA,
/// finite-field / elliptic-curve Diffie-Hellman, DSA/ECDSA/EdDSA, ElGamal.
///
/// These are quantum-vulnerable regardless of key size or a declared
/// `nistQuantumSecurityLevel` — the family alone is authoritative. Matched
/// on `algorithm_family` (case-insensitive); the PQC families (ML-KEM,
/// ML-DSA, SLH-DSA, …) are not in the list and correctly return `false`.
#[must_use]
pub fn is_classical_quantum_vulnerable(&self) -> bool {
const CLASSICAL_PK: &[&str] = &[
"RSA", "DSA", "DH", "DHE", "ECDH", "ECDHE", "ECDSA", "EDDSA", "ED25519", "ED448",
"X25519", "X448", "ELGAMAL", "ECIES", "ECMQV",
];
self.algorithm_family.as_deref().is_some_and(|f| {
let upper = f.to_uppercase();
CLASSICAL_PK.iter().any(|c| upper == *c)
})
}

/// Returns `true` if the algorithm is considered broken or weak.
/// Checks `algorithm_family` first, then falls back to matching
/// common weak names in the `parameter_set_identifier`.
Expand Down
15 changes: 10 additions & 5 deletions src/model/license.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,23 @@ impl LicenseExpression {
/// Create from an SPDX license ID
#[must_use]
pub fn from_spdx_id(id: &str) -> Self {
Self {
expression: id.to_string(),
is_valid_spdx: true,
}
// Validate rather than trusting the caller: scoring now relies on
// `is_valid_spdx`, so a hardcoded `true` would be a footgun.
Self::new(id.to_string())
}

/// Validate an SPDX expression using the spdx crate.
///
/// Uses lax parsing mode to accept common non-standard expressions
/// (e.g., "Apache2" instead of "Apache-2.0", "/" instead of "OR").
fn validate_spdx(expr: &str) -> bool {
if expr.is_empty() || expr.contains("NOASSERTION") || expr.contains("NONE") {
// Reject expressions with a NOASSERTION/NONE clause (no license
// information), matching whole tokens so that legitimate ids like
// `LicenseRef-NONEXCLUSIVE` are not caught by a substring test.
let has_no_info_token = expr
.split(|c: char| c.is_whitespace() || c == '(' || c == ')')
.any(|tok| tok == "NOASSERTION" || tok == "NONE");
if expr.is_empty() || has_no_info_token {
return false;
}
spdx::Expression::parse_mode(expr, spdx::ParseMode::LAX).is_ok()
Expand Down
23 changes: 20 additions & 3 deletions src/quality/compliance/bsi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,26 @@ impl ComplianceChecker {
});
}

// §5.3 — Component name (mandatory) — already enforced globally;
// we add a BSI-specific message only if many components are
// missing names (extreme case).
// §5.3 — Component name (mandatory). The BsiTr03183_2 level dispatches
// ONLY to this checker (not the generic component check), so the name
// requirement must be enforced here — it is not "already enforced
// globally" for this standard.
let nameless = sbom
.components
.values()
.filter(|c| c.name.trim().is_empty())
.count();
if nameless > 0 {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::ComponentIdentification,
message: format!("{nameless} component(s) missing a name (BSI §5.3 mandatory)"),
element: None,
requirement: "BSI TR-03183-2 §5.3: Component name".to_string(),
rule_id: "SBOM-BSI-TR-03183-2-5-3",
standard_refs: Vec::new(),
});
}

// §5.3 — Component identifier: PURL or other recognised ID (mandatory).
// Stricter than CRA: BSI requires a PURL where the ecosystem applies.
Expand Down
29 changes: 16 additions & 13 deletions src/quality/compliance/cra.rs
Original file line number Diff line number Diff line change
Expand Up @@ -941,24 +941,27 @@ impl ComplianceChecker {
// a vulnerability-disclosure URL on the document or a SecurityContact
// / Advisories external reference on at least one component, OR
// sidecar-supplied PSIRT URL.
let has_vuln_handling = sbom.components.values().any(|c| {
c.external_refs.iter().any(|r| {
matches!(
r.ref_type,
crate::model::ExternalRefType::SecurityContact
| crate::model::ExternalRefType::Advisories
| crate::model::ExternalRefType::VulnerabilityAssertion
)
let has_vuln_handling = sbom.document.vulnerability_disclosure_url.is_some()
|| sbom.document.security_contact.is_some()
|| sbom.components.values().any(|c| {
c.external_refs.iter().any(|r| {
matches!(
r.ref_type,
crate::model::ExternalRefType::SecurityContact
| crate::model::ExternalRefType::Advisories
| crate::model::ExternalRefType::VulnerabilityAssertion
)
})
})
}) || self
.sidecar
.as_ref()
.is_some_and(|s| s.psirt_url.is_some() || s.vulnerability_disclosure_url.is_some());
|| self
.sidecar
.as_ref()
.is_some_and(|s| s.psirt_url.is_some() || s.vulnerability_disclosure_url.is_some());
if !has_vuln_handling {
violations.push(Violation {
severity: ViolationSeverity::Error,
category: ViolationCategory::SecurityInfo,
message: "[CRA Art. 24 / Annex I Part II] OSS steward must operate a vulnerability-handling process — declare a SecurityContact / Advisories external reference, or set psirt_url / vulnerability_disclosure_url in the sidecar".to_string(),
message: "[CRA Art. 24 / Annex I Part II] OSS steward must operate a vulnerability-handling process — set a document-level security contact or vulnerability-disclosure URL, declare a SecurityContact / Advisories external reference, or set psirt_url / vulnerability_disclosure_url in the sidecar".to_string(),
element: None,
requirement: "CRA Art. 24: Vulnerability-handling process (steward floor)"
.to_string(),
Expand Down
Loading
Loading