Skip to main content

rustinel_core/
sbom.rs

1//! Standards-based interchange output: SBOM (CycloneDX, SPDX), OSV and OpenVEX.
2//!
3//! These let rustinel plug into the wider ecosystem and compliance tooling
4//! (e.g. EU Cyber Resilience Act / US EO 14028 SBOM requirements, osv.dev,
5//! code-scanning dashboards).
6//!
7//! All builders are pure and deterministic: inputs are sorted, no randomness or
8//! wall-clock is introduced here (timestamps flow in via the report), and every
9//! string is emitted through `serde_json`, so untrusted package names/titles are
10//! safely JSON-encoded with no injection surface.
11
12use crate::lockfile::{LockfileModel, Package};
13use crate::report::RustinelReport;
14use crate::signals::{RiskSignal, Severity};
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17
18/// Per-package declared license (SPDX expression), recovered from the
19/// `license_detected` findings produced when source was scanned. Enables the
20/// per-component "License" field required by the 2025 CISA SBOM minimum elements.
21fn license_map(report: &RustinelReport) -> BTreeMap<String, String> {
22    let mut m = BTreeMap::new();
23    for f in &report.findings {
24        if f.id == "license_detected" {
25            if let Some(lic) = f
26                .evidence
27                .iter()
28                .find_map(|e| e.summary.strip_prefix("declared license: "))
29            {
30                m.insert(f.package.clone(), lic.to_string());
31            }
32        }
33    }
34    m
35}
36
37/// Interchange formats understood by the `export` command.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ExportFormat {
40    CycloneDx,
41    Spdx,
42    Osv,
43    OpenVex,
44}
45
46impl ExportFormat {
47    pub fn as_str(&self) -> &'static str {
48        match self {
49            ExportFormat::CycloneDx => "cyclonedx",
50            ExportFormat::Spdx => "spdx",
51            ExportFormat::Osv => "osv",
52            ExportFormat::OpenVex => "openvex",
53        }
54    }
55}
56
57const TOOL: &str = "rustinel";
58
59fn tool_version() -> &'static str {
60    env!("CARGO_PKG_VERSION")
61}
62
63/// Package URL for a Cargo crate (Package URL spec, `cargo` type).
64fn purl(pkg: &Package) -> String {
65    format!("pkg:cargo/{}@{}", pkg.id.name, pkg.id.version)
66}
67
68/// Advisory findings only (the ones representing real, identified vulnerabilities).
69fn advisory_findings(report: &RustinelReport) -> Vec<&RiskSignal> {
70    let mut v: Vec<&RiskSignal> = report
71        .findings
72        .iter()
73        .filter(|f| f.id.starts_with("advisory_"))
74        .collect();
75    v.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.package.cmp(&b.package)));
76    v
77}
78
79fn advisory_id(finding: &RiskSignal) -> &str {
80    finding.id.strip_prefix("advisory_").unwrap_or(&finding.id)
81}
82
83fn finding_summary(finding: &RiskSignal) -> String {
84    finding
85        .evidence
86        .first()
87        .map(|e| e.summary.clone())
88        .unwrap_or_else(|| finding.id.clone())
89}
90
91/// Render the requested interchange format as pretty JSON.
92pub fn render(
93    format: ExportFormat,
94    lock: &LockfileModel,
95    report: &RustinelReport,
96) -> Result<String, serde_json::Error> {
97    let value = match format {
98        ExportFormat::CycloneDx => cyclonedx(lock, report),
99        ExportFormat::Spdx => spdx(lock, report),
100        ExportFormat::Osv => osv(lock, report),
101        ExportFormat::OpenVex => openvex(lock, report),
102    };
103    serde_json::to_string_pretty(&value)
104}
105
106/// Registry dependencies in deterministic order (the local root is handled
107/// separately by each exporter, e.g. CycloneDX `metadata.component` / SPDX root).
108fn sorted_components(lock: &LockfileModel) -> Vec<&Package> {
109    let mut comps: Vec<&Package> = lock.registry_packages().collect();
110    comps.sort_by(|a, b| a.id.cmp(&b.id));
111    comps
112}
113
114fn root_component(lock: &LockfileModel) -> Option<&Package> {
115    lock.packages.iter().find(|p| p.id.is_local())
116}
117
118// --- CycloneDX 1.5 -----------------------------------------------------------
119
120fn cdx_severity(sev: Severity) -> &'static str {
121    match sev {
122        Severity::Critical => "critical",
123        Severity::High => "high",
124        Severity::Medium => "medium",
125        Severity::Low => "low",
126        Severity::Info => "info",
127    }
128}
129
130pub fn cyclonedx(lock: &LockfileModel, report: &RustinelReport) -> Value {
131    let licenses = license_map(report);
132    let components: Vec<Value> = sorted_components(lock)
133        .iter()
134        .map(|p| {
135            let mut c = json!({
136                "type": "library",
137                "name": p.id.name,
138                "version": p.id.version,
139                "purl": purl(p),
140                "bom-ref": purl(p),
141            });
142            // Cargo records SHA-256 checksums — emit them (NTIA/CRA "component hash").
143            if let Some(sum) = &p.checksum {
144                c["hashes"] = json!([{ "alg": "SHA-256", "content": sum }]);
145            }
146            // Per-component license (CISA 2025 minimum element), as an SPDX expression.
147            if let Some(lic) = licenses.get(&p.id.to_string()) {
148                c["licenses"] = json!([{ "expression": lic }]);
149            }
150            c
151        })
152        .collect();
153
154    let vulnerabilities: Vec<Value> = advisory_findings(report)
155        .iter()
156        .map(|f| {
157            json!({
158                "id": advisory_id(f),
159                "source": { "name": "RustSec" },
160                "ratings": [ { "severity": cdx_severity(f.severity) } ],
161                "description": finding_summary(f),
162                "recommendation": f.recommendation,
163                "affects": [ { "ref": format!("pkg:cargo/{}", f.package) } ],
164            })
165        })
166        .collect();
167
168    let mut metadata = json!({
169        "tools": [ { "vendor": TOOL, "name": TOOL, "version": tool_version() } ],
170        // SDLC "generation context" (CISA 2025): this SBOM is produced from a
171        // resolved Cargo.lock, i.e. a build-time artifact.
172        "lifecycles": [ { "phase": "build" } ],
173    });
174    if let Some(root) = root_component(lock) {
175        metadata["component"] = json!({
176            "type": "application",
177            "name": root.id.name,
178            "version": root.id.version,
179            "bom-ref": purl(root),
180        });
181    }
182    if let Some(ts) = &report.analysis.generated_at {
183        metadata["timestamp"] = json!(ts);
184    }
185
186    json!({
187        "bomFormat": "CycloneDX",
188        "specVersion": "1.5",
189        "version": 1,
190        "metadata": metadata,
191        "components": components,
192        "vulnerabilities": vulnerabilities,
193    })
194}
195
196// --- SPDX 2.3 ----------------------------------------------------------------
197
198pub fn spdx(lock: &LockfileModel, report: &RustinelReport) -> Value {
199    let created = report
200        .analysis
201        .generated_at
202        .clone()
203        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
204
205    let root = root_component(lock);
206    let root_name = root
207        .map(|p| p.id.name.clone())
208        .unwrap_or_else(|| "rustinel-scan-target".to_string());
209
210    let licenses = license_map(report);
211    let comps = sorted_components(lock);
212    let mut packages = Vec::with_capacity(comps.len() + 1);
213    let mut relationships = Vec::new();
214
215    // The analyzed project (local root), when present, is the document's primary
216    // subject: DOCUMENT DESCRIBES it, and it DEPENDS_ON each resolved dependency.
217    const ROOT_ID: &str = "SPDXRef-Package-root";
218    if let Some(rp) = root {
219        packages.push(json!({
220            "name": rp.id.name,
221            "SPDXID": ROOT_ID,
222            "versionInfo": rp.id.version,
223            "downloadLocation": "NOASSERTION",
224            "filesAnalyzed": false,
225        }));
226        relationships.push(json!({
227            "spdxElementId": "SPDXRef-DOCUMENT",
228            "relatedSpdxElement": ROOT_ID,
229            "relationshipType": "DESCRIBES",
230        }));
231    }
232
233    for (i, p) in comps.iter().enumerate() {
234        let spdx_id = format!("SPDXRef-Package-{i}");
235        let lic = licenses
236            .get(&p.id.to_string())
237            .cloned()
238            .unwrap_or_else(|| "NOASSERTION".to_string());
239        let mut entry = json!({
240            "name": p.id.name,
241            "SPDXID": spdx_id,
242            "versionInfo": p.id.version,
243            "downloadLocation": "NOASSERTION",
244            "filesAnalyzed": false,
245            "licenseConcluded": "NOASSERTION",
246            "licenseDeclared": lic,
247            "externalRefs": [ {
248                "referenceCategory": "PACKAGE-MANAGER",
249                "referenceType": "purl",
250                "referenceLocator": purl(p),
251            } ],
252        });
253        if let Some(sum) = &p.checksum {
254            entry["checksums"] = json!([{ "algorithm": "SHA256", "checksumValue": sum }]);
255        }
256        packages.push(entry);
257        // With a root present, dependencies hang off it (DEPENDS_ON); without one
258        // (a lockfile-only scan) the document describes each dependency directly.
259        if root.is_some() {
260            relationships.push(json!({
261                "spdxElementId": ROOT_ID,
262                "relatedSpdxElement": spdx_id,
263                "relationshipType": "DEPENDS_ON",
264            }));
265        } else {
266            relationships.push(json!({
267                "spdxElementId": "SPDXRef-DOCUMENT",
268                "relatedSpdxElement": spdx_id,
269                "relationshipType": "DESCRIBES",
270            }));
271        }
272    }
273
274    json!({
275        "spdxVersion": "SPDX-2.3",
276        "dataLicense": "CC0-1.0",
277        "SPDXID": "SPDXRef-DOCUMENT",
278        "name": format!("{root_name}-sbom"),
279        // Unique per document (SPDX 2.3 MUST): a deterministic content fingerprint
280        // distinguishes distinct package sets and avoids the rootless-scan collision.
281        "documentNamespace": format!(
282            "https://rustinel.dev/spdx/{root_name}/{}",
283            content_fingerprint(&comps)
284        ),
285        "creationInfo": {
286            "created": created,
287            "creators": [ format!("Tool: {TOOL}-{}", tool_version()) ],
288            "comment": "Generation context: produced from a resolved Cargo.lock (build-time artifact).",
289        },
290        "packages": packages,
291        "relationships": relationships,
292    })
293}
294
295/// Deterministic content fingerprint of the component set (FNV-1a over the sorted
296/// `name@version:checksum` list). Stable for identical input, differs across
297/// package sets — makes the SPDX documentNamespace unique without a clock/random.
298fn content_fingerprint(comps: &[&Package]) -> String {
299    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
300    let mut feed = |s: &str| {
301        for b in s.bytes() {
302            h ^= b as u64;
303            h = h.wrapping_mul(0x0000_0100_0000_01b3);
304        }
305    };
306    for p in comps {
307        feed(&p.id.to_string());
308        feed(":");
309        feed(p.checksum.as_deref().unwrap_or(""));
310        feed("\n");
311    }
312    format!("{h:016x}")
313}
314
315// --- OSV (osv.dev schema) ----------------------------------------------------
316
317pub fn osv(_lock: &LockfileModel, report: &RustinelReport) -> Value {
318    // OSV requires `modified`; keep it deterministic (the analysis timestamp, or a
319    // fixed epoch when timestamps are suppressed for byte-identical output).
320    let modified = report
321        .analysis
322        .generated_at
323        .clone()
324        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
325    let records: Vec<Value> = advisory_findings(report)
326        .iter()
327        .map(|f| {
328            // `f.package` is `name@version`.
329            let (name, version) = split_pkg(&f.package);
330            json!({
331                "schema_version": "1.6.0",
332                "id": advisory_id(f),
333                "modified": modified,
334                "summary": finding_summary(f),
335                "affected": [ {
336                    "package": {
337                        "ecosystem": "crates.io",
338                        "name": name,
339                        "purl": format!("pkg:cargo/{name}@{version}"),
340                    },
341                    "versions": [ version ],
342                } ],
343            })
344        })
345        .collect();
346    // A JSON array of OSV vulnerability records, each a valid osv.dev object — not
347    // a non-standard `{ "results": [...] }` envelope (which is neither the OSV
348    // vulnerability schema nor the OSV-Scanner results shape).
349    Value::Array(records)
350}
351
352// --- OpenVEX -----------------------------------------------------------------
353
354pub fn openvex(_lock: &LockfileModel, report: &RustinelReport) -> Value {
355    let timestamp = report
356        .analysis
357        .generated_at
358        .clone()
359        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
360
361    // Each advisory finding becomes a VEX statement. Advisories waived via
362    // policy (`advisories.ignore`) are emitted as `not_affected` with a
363    // justification, turning rustinel's "ignore with a reason" into a
364    // standards-compliant VEX assertion; the rest are `affected`.
365    let ignored = &report.policy.ignored_advisories;
366    let statements: Vec<Value> = advisory_findings(report)
367        .iter()
368        .map(|f| {
369            let (name, version) = split_pkg(&f.package);
370            let id = advisory_id(f);
371            let product = format!("pkg:cargo/{name}@{version}");
372            if ignored.iter().any(|i| i == id) {
373                // OpenVEX `not_affected` needs a justification OR an impact
374                // statement. We cannot assert a specific machine-readable
375                // justification (the operator may have ignored the advisory for any
376                // reason), so state the waiver as a free-text impact statement
377                // rather than claiming, falsely, that the code is unreachable.
378                json!({
379                    "vulnerability": { "name": id },
380                    "products": [ { "@id": product } ],
381                    "status": "not_affected",
382                    "impact_statement": "waived by rustinel policy (advisories.ignore)",
383                })
384            } else {
385                json!({
386                    "vulnerability": { "name": id },
387                    "products": [ { "@id": product } ],
388                    "status": "affected",
389                })
390            }
391        })
392        .collect();
393
394    json!({
395        "@context": "https://openvex.dev/ns/v0.2.0",
396        "@id": "https://rustinel.dev/vex/scan",
397        "author": format!("{TOOL}-{}", tool_version()),
398        "timestamp": timestamp,
399        "version": 1,
400        "statements": statements,
401    })
402}
403
404fn split_pkg(pkg: &str) -> (&str, &str) {
405    match pkg.rsplit_once('@') {
406        Some((name, version)) => (name, version),
407        None => (pkg, ""),
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::policy::{Decision, PolicyDecision};
415    use crate::report::{AnalysisInfo, RustinelReport, ToolInfo, SCHEMA_VERSION, TOOL_NAME};
416    use crate::risk::{level_for_score, ProjectRisk};
417    use crate::signals::{Evidence, RiskSignal};
418    use std::path::PathBuf;
419
420    fn pkg(name: &str, version: &str, local: bool) -> Package {
421        Package {
422            id: crate::lockfile::PackageId {
423                name: name.into(),
424                version: version.into(),
425                source: if local {
426                    None
427                } else {
428                    Some("registry+https://github.com/rust-lang/crates.io-index".into())
429                },
430            },
431            checksum: None,
432            dependencies: vec![],
433        }
434    }
435
436    fn fixture() -> (LockfileModel, RustinelReport) {
437        let lock = LockfileModel {
438            path: PathBuf::from("Cargo.lock"),
439            version: Some(3),
440            packages: vec![
441                pkg("demo", "0.1.0", true),
442                pkg("time", "0.2.22", false),
443                pkg("serde", "1.0.0", false),
444            ],
445        };
446        let report = RustinelReport {
447            schema_version: SCHEMA_VERSION.into(),
448            tool: ToolInfo {
449                name: TOOL_NAME.into(),
450                version: "0.1.0".into(),
451            },
452            analysis: AnalysisInfo {
453                mode: "check".into(),
454                generated_at: None,
455                offline: true,
456            },
457            project: ProjectRisk {
458                score: 30,
459                level: level_for_score(30),
460                max_package_score: 30,
461                packages: vec![],
462            },
463            diff: None,
464            policy: PolicyDecision {
465                decision: Decision::Fail,
466                profile: "balanced".into(),
467                violations: vec![],
468                warnings: vec![],
469                review_items: vec![],
470                ignored_advisories: vec![],
471            },
472            packages_count: 3,
473            findings: vec![RiskSignal {
474                id: "advisory_RUSTSEC-2020-0071".into(),
475                package: "time@0.2.22".into(),
476                severity: Severity::High,
477                weight: 30,
478                confidence: 1.0,
479                evidence: vec![Evidence::new("advisory", "RUSTSEC-2020-0071: segfault")],
480                recommendation: "Update to >= 0.2.23".into(),
481            }],
482        };
483        (lock, report)
484    }
485
486    #[test]
487    fn cyclonedx_shape() {
488        let (lock, report) = fixture();
489        let v = cyclonedx(&lock, &report);
490        assert_eq!(v["bomFormat"], "CycloneDX");
491        assert_eq!(v["specVersion"], "1.5");
492        // 2 registry components (time, serde), sorted.
493        assert_eq!(v["components"].as_array().unwrap().len(), 2);
494        assert_eq!(v["components"][0]["purl"], "pkg:cargo/serde@1.0.0");
495        assert_eq!(v["vulnerabilities"][0]["id"], "RUSTSEC-2020-0071");
496        assert_eq!(v["vulnerabilities"][0]["ratings"][0]["severity"], "high");
497        assert_eq!(v["metadata"]["component"]["name"], "demo");
498    }
499
500    #[test]
501    fn spdx_shape() {
502        let (lock, report) = fixture();
503        let v = spdx(&lock, &report);
504        assert_eq!(v["spdxVersion"], "SPDX-2.3");
505        assert_eq!(v["SPDXID"], "SPDXRef-DOCUMENT");
506        // The analyzed project (root) plus its two registry dependencies.
507        let pkgs = v["packages"].as_array().unwrap();
508        assert_eq!(pkgs.len(), 3);
509        assert_eq!(pkgs[0]["SPDXID"], "SPDXRef-Package-root");
510        assert_eq!(pkgs[0]["name"], "demo");
511        // A dependency carries its purl externalRef (serde sorts first -> Package-0).
512        assert_eq!(pkgs[1]["SPDXID"], "SPDXRef-Package-0");
513        assert_eq!(
514            pkgs[1]["externalRefs"][0]["referenceLocator"],
515            "pkg:cargo/serde@1.0.0"
516        );
517        // DOCUMENT DESCRIBES the root; the root DEPENDS_ON its deps.
518        let rels = v["relationships"].as_array().unwrap();
519        assert!(rels.iter().any(|r| r["spdxElementId"] == "SPDXRef-DOCUMENT"
520            && r["relatedSpdxElement"] == "SPDXRef-Package-root"
521            && r["relationshipType"] == "DESCRIBES"));
522        assert!(rels
523            .iter()
524            .any(|r| r["spdxElementId"] == "SPDXRef-Package-root"
525                && r["relationshipType"] == "DEPENDS_ON"));
526        // documentNamespace is unique per content (carries a fingerprint suffix).
527        let ns = v["documentNamespace"].as_str().unwrap();
528        let prefix = "https://rustinel.dev/spdx/demo/";
529        assert!(
530            ns.starts_with(prefix) && ns.len() > prefix.len(),
531            "ns: {ns}"
532        );
533    }
534
535    #[test]
536    fn osv_shape() {
537        let (lock, report) = fixture();
538        let v = osv(&lock, &report);
539        // A JSON array of OSV vulnerability records, no `results` envelope.
540        let recs = v.as_array().unwrap();
541        assert_eq!(recs[0]["id"], "RUSTSEC-2020-0071");
542        assert_eq!(recs[0]["modified"], "1970-01-01T00:00:00Z");
543        assert_eq!(recs[0]["affected"][0]["package"]["name"], "time");
544        assert_eq!(recs[0]["affected"][0]["package"]["ecosystem"], "crates.io");
545        assert_eq!(recs[0]["affected"][0]["versions"][0], "0.2.22");
546    }
547
548    #[test]
549    fn openvex_shape() {
550        let (lock, report) = fixture();
551        let v = openvex(&lock, &report);
552        assert_eq!(v["@context"], "https://openvex.dev/ns/v0.2.0");
553        assert_eq!(
554            v["statements"][0]["vulnerability"]["name"],
555            "RUSTSEC-2020-0071"
556        );
557        assert_eq!(v["statements"][0]["status"], "affected");
558    }
559
560    #[test]
561    fn openvex_marks_policy_ignored_as_not_affected() {
562        let (lock, mut report) = fixture();
563        report.policy.ignored_advisories = vec!["RUSTSEC-2020-0071".into()];
564        let v = openvex(&lock, &report);
565        assert_eq!(v["statements"][0]["status"], "not_affected");
566        assert_eq!(
567            v["statements"][0]["impact_statement"],
568            "waived by rustinel policy (advisories.ignore)"
569        );
570        // Must not assert a specific (and likely false) machine-readable justification.
571        assert!(v["statements"][0]["justification"].is_null());
572    }
573
574    #[test]
575    fn sbom_embeds_checksums() {
576        let (mut lock, report) = fixture();
577        // give `time` a checksum and confirm it surfaces in both SBOM formats
578        for p in lock.packages.iter_mut() {
579            if p.id.name == "time" {
580                p.checksum = Some("abc123".into());
581            }
582        }
583        let cdx = cyclonedx(&lock, &report);
584        let time = cdx["components"]
585            .as_array()
586            .unwrap()
587            .iter()
588            .find(|c| c["name"] == "time")
589            .unwrap();
590        assert_eq!(time["hashes"][0]["alg"], "SHA-256");
591        assert_eq!(time["hashes"][0]["content"], "abc123");
592
593        let spdx_doc = spdx(&lock, &report);
594        let has_checksum = spdx_doc["packages"]
595            .as_array()
596            .unwrap()
597            .iter()
598            .any(|p| p["checksums"][0]["checksumValue"] == "abc123");
599        assert!(has_checksum);
600    }
601
602    #[test]
603    fn sbom_embeds_per_component_license_and_context() {
604        let (lock, mut report) = fixture();
605        report.findings.push(RiskSignal {
606            id: "license_detected".into(),
607            package: "time@0.2.22".into(),
608            severity: Severity::Info,
609            weight: 0,
610            confidence: 1.0,
611            evidence: vec![Evidence::new(
612                "manifest",
613                "declared license: MIT OR Apache-2.0",
614            )],
615            recommendation: String::new(),
616        });
617        let cdx = cyclonedx(&lock, &report);
618        // generation context (CISA 2025)
619        assert_eq!(cdx["metadata"]["lifecycles"][0]["phase"], "build");
620        let time = cdx["components"]
621            .as_array()
622            .unwrap()
623            .iter()
624            .find(|c| c["name"] == "time")
625            .unwrap();
626        assert_eq!(time["licenses"][0]["expression"], "MIT OR Apache-2.0");
627
628        let spdx_doc = spdx(&lock, &report);
629        assert!(spdx_doc["creationInfo"]["comment"]
630            .as_str()
631            .unwrap()
632            .contains("Generation context"));
633        let time_pkg = spdx_doc["packages"]
634            .as_array()
635            .unwrap()
636            .iter()
637            .find(|p| p["name"] == "time")
638            .unwrap();
639        assert_eq!(time_pkg["licenseDeclared"], "MIT OR Apache-2.0");
640    }
641
642    #[test]
643    fn deterministic_render() {
644        let (lock, report) = fixture();
645        let a = render(ExportFormat::CycloneDx, &lock, &report).unwrap();
646        let b = render(ExportFormat::CycloneDx, &lock, &report).unwrap();
647        assert_eq!(a, b);
648    }
649}