1use crate::lockfile::{LockfileModel, Package};
13use crate::report::RustinelReport;
14use crate::signals::{RiskSignal, Severity};
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17
18fn 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#[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
63fn purl(pkg: &Package) -> String {
65 format!("pkg:cargo/{}@{}", pkg.id.name, pkg.id.version)
66}
67
68fn 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
91pub 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
106fn 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
118fn 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 if let Some(sum) = &p.checksum {
144 c["hashes"] = json!([{ "alg": "SHA-256", "content": sum }]);
145 }
146 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 "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
196pub 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 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 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 "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
295fn 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
315pub fn osv(_lock: &LockfileModel, report: &RustinelReport) -> Value {
318 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 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 Value::Array(records)
350}
351
352pub 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 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 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 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 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 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 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 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 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 assert!(v["statements"][0]["justification"].is_null());
572 }
573
574 #[test]
575 fn sbom_embeds_checksums() {
576 let (mut lock, report) = fixture();
577 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 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}