1use serde::Deserialize;
32
33use crate::adapter::{Adapter, AssetPaths, InstallHint, Invocation, NativeContext, RUST_TOOLCHAIN};
34use crate::guidance::{Guidance, Line};
35use crate::ingest::{NormalizedReport, REPORT_SCHEMA, ReportFinding};
36use crate::runner::ExecError;
37use rto_graph::{AdvisoryDb, Severity};
38
39pub const ANALYZER: &str = "cargo-audit";
41
42pub const ADVISORY_DB_ASSET: &str = "rustsec-advisory-db";
44
45const INSTALL_HINTS: &[InstallHint] = &[
61 InstallHint {
62 program: "cargo",
63 guidance: RUST_TOOLCHAIN,
64 },
65 InstallHint {
66 program: "cargo-audit",
67 guidance: Guidance::new(&[
68 Line::Note(&[
69 "Roteiro does not install analyzers, and has not installed this one.",
70 "`cargo audit` is a cargo subcommand, installed with:",
71 ]),
72 Line::Command("cargo install cargo-audit"),
73 Line::Note(&["Upstream: https://github.com/rustsec/rustsec/tree/main/cargo-audit"]),
74 ]),
75 },
76];
77
78pub const UNKNOWN_LOCKFILE: &str = "unknown-lockfile";
86
87#[derive(Debug, Clone, Copy)]
89pub struct CargoAudit;
90
91impl Adapter for CargoAudit {
92 fn analyzer(&self) -> &'static str {
93 ANALYZER
94 }
95
96 fn summary(&self) -> &'static str {
97 "RustSec advisories against Cargo.lock (Rust dependencies only)"
98 }
99
100 fn languages(&self) -> &'static [&'static str] {
101 &["rust"]
102 }
103
104 fn asset_ids(&self) -> &'static [&'static str] {
105 &[ADVISORY_DB_ASSET]
106 }
107
108 fn host_programs(&self) -> &'static [&'static str] {
109 &["cargo", "cargo-audit"]
122 }
123
124 fn install_hints(&self) -> &'static [InstallHint] {
125 INSTALL_HINTS
126 }
127
128 fn command(&self, assets: &AssetPaths<'_>) -> Invocation {
129 Invocation {
130 program: "cargo".to_owned(),
131 args: vec![
132 "audit".to_owned(),
133 "--json".to_owned(),
134 "--no-fetch".to_owned(),
138 "--db".to_owned(),
139 assets.arg(ADVISORY_DB_ASSET),
140 ],
141 success_statuses: vec![0, 1],
143 }
144 }
145
146 fn normalize(
147 &self,
148 native: &[u8],
149 ctx: &NativeContext<'_>,
150 ) -> Result<NormalizedReport, ExecError> {
151 let output: AuditOutput = serde_json::from_slice(native)?;
152 let Some(vulnerabilities) = output.vulnerabilities else {
153 return Err(ExecError::MalformedReport(
154 "not a cargo-audit report: no `vulnerabilities` object".to_owned(),
155 ));
156 };
157
158 let lockfile = ctx
159 .source
160 .lockfile_blob
161 .as_deref()
162 .filter(|b| !b.trim().is_empty())
163 .unwrap_or(UNKNOWN_LOCKFILE);
164
165 let mut findings = Vec::new();
166 for entry in &vulnerabilities.list {
167 findings.push(convert(entry, "vulnerability", lockfile)?);
168 }
169 let mut kinds: Vec<&String> = output.warnings.keys().collect();
173 kinds.sort();
174 for kind in kinds {
175 for entry in &output.warnings[kind] {
176 findings.push(convert(entry, kind, lockfile)?);
177 }
178 }
179
180 Ok(NormalizedReport {
181 schema: REPORT_SCHEMA.to_owned(),
182 analyzer: ANALYZER.to_owned(),
183 analyzer_version: ctx.version_or(None),
187 started_at: ctx.started_at.clone(),
188 ended_at: ctx.ended_at.clone(),
189 exit_status: ctx.exit_status,
190 rules_digest: None,
192 image_digest: None,
193 advisory_db: output
198 .database
199 .and_then(advisory_db)
200 .or_else(|| ctx.advisory_db.clone()),
201 source: ctx.source.clone(),
202 findings,
203 })
204 }
205}
206
207fn advisory_db(database: Database) -> Option<AdvisoryDb> {
211 let digest = database.last_commit?;
212 if digest.trim().is_empty() {
213 return None;
214 }
215 Some(AdvisoryDb {
216 digest,
217 published_at: database.last_updated.filter(|s| !s.trim().is_empty()),
218 })
219}
220
221fn convert(entry: &Entry, kind: &str, lockfile: &str) -> Result<ReportFinding, ExecError> {
223 let package = entry.package.as_ref().ok_or_else(|| {
224 ExecError::MalformedReport(format!("a cargo-audit {kind} entry has no `package`"))
225 })?;
226 if package.name.trim().is_empty() {
227 return Err(ExecError::MalformedReport(format!(
228 "a cargo-audit {kind} entry has an unnamed package"
229 )));
230 }
231
232 let advisory = entry.advisory.as_ref();
237 let rule = advisory
238 .map(|a| a.id.clone())
239 .filter(|id| !id.trim().is_empty())
240 .unwrap_or_else(|| kind.to_owned());
241 let version = if package.version.trim().is_empty() {
242 "unknown-version".to_owned()
243 } else {
244 package.version.clone()
245 };
246
247 let title = advisory
248 .map(|a| a.title.trim())
249 .filter(|t| !t.is_empty())
250 .map_or_else(
251 || format!("{} {version} is {kind}", package.name),
252 str::to_owned,
253 );
254
255 Ok(ReportFinding {
256 identity: vec![
258 rule.clone(),
259 package.name.clone(),
260 version.clone(),
261 lockfile.to_owned(),
262 ],
263 rule,
264 severity: severity(kind, advisory),
265 title,
266 message: advisory
267 .map(|a| a.description.trim().to_owned())
268 .unwrap_or_default(),
269 path: Some("Cargo.lock".to_owned()),
272 span: None,
273 meta: serde_json::json!({
274 "kind": kind,
275 "package": package.name,
276 "version": version,
277 "patched": entry.versions.as_ref().map(|v| v.patched.clone()).unwrap_or_default(),
278 "cvss": advisory.and_then(|a| a.cvss.clone()),
279 "aliases": advisory.map(|a| a.aliases.clone()).unwrap_or_default(),
280 "related": advisory.map(|a| a.related.clone()).unwrap_or_default(),
284 "categories": advisory.map(|a| a.categories.clone()).unwrap_or_default(),
285 "url": advisory.and_then(|a| a.url.clone()),
286 "advisory_date": advisory.and_then(|a| a.date.clone()),
287 }),
288 })
289}
290
291fn severity(kind: &str, advisory: Option<&Advisory>) -> Severity {
300 let kind = advisory
303 .and_then(|a| a.informational.as_deref())
304 .unwrap_or(kind);
305 match kind {
306 "vulnerability" => Severity::High,
307 "unsound" => Severity::Medium,
308 "unmaintained" | "yanked" => Severity::Low,
309 "notice" => Severity::Info,
310 other => Severity::from_token(other),
311 }
312}
313
314#[derive(Debug, Deserialize)]
317struct AuditOutput {
318 #[serde(default)]
319 database: Option<Database>,
320 #[serde(default)]
323 vulnerabilities: Option<Vulnerabilities>,
324 #[serde(default)]
325 warnings: std::collections::BTreeMap<String, Vec<Entry>>,
326}
327
328#[derive(Debug, Deserialize)]
329struct Database {
330 #[serde(default, rename = "last-commit")]
331 last_commit: Option<String>,
332 #[serde(default, rename = "last-updated")]
333 last_updated: Option<String>,
334}
335
336#[derive(Debug, Deserialize)]
337struct Vulnerabilities {
338 #[serde(default)]
339 list: Vec<Entry>,
340}
341
342#[derive(Debug, Deserialize)]
343struct Entry {
344 #[serde(default)]
345 advisory: Option<Advisory>,
346 #[serde(default)]
347 package: Option<Package>,
348 #[serde(default)]
349 versions: Option<Versions>,
350}
351
352#[derive(Debug, Deserialize)]
353struct Advisory {
354 #[serde(default)]
355 id: String,
356 #[serde(default)]
357 title: String,
358 #[serde(default)]
359 description: String,
360 #[serde(default)]
361 date: Option<String>,
362 #[serde(default)]
363 url: Option<String>,
364 #[serde(default)]
365 cvss: Option<String>,
366 #[serde(default)]
367 informational: Option<String>,
368 #[serde(default)]
369 aliases: Vec<String>,
370 #[serde(default)]
371 related: Vec<String>,
372 #[serde(default)]
373 categories: Vec<String>,
374}
375
376#[derive(Debug, Deserialize)]
377struct Package {
378 #[serde(default)]
379 name: String,
380 #[serde(default)]
381 version: String,
382}
383
384#[derive(Debug, Deserialize)]
385struct Versions {
386 #[serde(default)]
387 patched: Vec<String>,
388}
389
390#[cfg(test)]
391mod tests {
392 use super::{ADVISORY_DB_ASSET, ANALYZER, CargoAudit, UNKNOWN_LOCKFILE};
393 use crate::adapter::{Adapter, AssetPaths, NativeContext};
394 use crate::runner::ExecError;
395 use rto_graph::{Severity, SourceIdentity};
396
397 static SOURCE_WITH_LOCK: std::sync::LazyLock<SourceIdentity> =
398 std::sync::LazyLock::new(|| SourceIdentity {
399 lockfile_blob: Some("lock123".to_owned()),
400 ..SourceIdentity::default()
401 });
402 static SOURCE_BARE: std::sync::LazyLock<SourceIdentity> =
403 std::sync::LazyLock::new(SourceIdentity::default);
404
405 fn ctx(source: &'static SourceIdentity) -> NativeContext<'static> {
406 NativeContext {
407 started_at: "2026-08-15T09:00:00Z".to_owned(),
408 ended_at: "2026-08-15T09:00:02Z".to_owned(),
409 analyzer_version: Some("0.21.2".to_owned()),
410 exit_status: 1,
411 source,
412 rules_digest: None,
413 advisory_db: None,
414 worktree: None,
415 snippets: &crate::snippet::NoSnippets,
416 }
417 }
418
419 const NATIVE: &str = r#"{
420 "database": {
421 "advisory-count": 742,
422 "last-commit": "9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90",
423 "last-updated": "2026-06-01T04:12:00Z"
424 },
425 "lockfile": {"dependency-count": 412},
426 "vulnerabilities": {
427 "found": true,
428 "count": 1,
429 "list": [
430 {
431 "advisory": {
432 "id": "RUSTSEC-2026-0031",
433 "package": "openssl",
434 "title": "openssl `X509` use-after-free",
435 "description": "A crafted certificate chain can free memory still in use.",
436 "date": "2026-05-20",
437 "url": "https://rustsec.org/advisories/RUSTSEC-2026-0031",
438 "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
439 "aliases": ["CVE-2026-1234"],
440 "categories": ["memory-corruption"]
441 },
442 "versions": {"patched": [">=0.10.66"], "unaffected": []},
443 "package": {"name": "openssl", "version": "0.10.5"}
444 }
445 ]
446 },
447 "warnings": {
448 "unmaintained": [
449 {
450 "kind": "unmaintained",
451 "advisory": {
452 "id": "RUSTSEC-2024-0436",
453 "title": "paste is unmaintained",
454 "description": "The author has archived the repository.",
455 "informational": "unmaintained"
456 },
457 "versions": {"patched": []},
458 "package": {"name": "paste", "version": "1.0.15"}
459 }
460 ],
461 "yanked": [
462 {
463 "kind": "yanked",
464 "package": {"name": "half-baked", "version": "0.3.1"}
465 }
466 ]
467 }
468 }"#;
469
470 #[test]
471 fn normalizes_vulnerabilities_and_warnings_together() {
472 let report = CargoAudit
473 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
474 .expect("parse");
475 assert_eq!(report.analyzer, ANALYZER);
476 assert_eq!(report.analyzer_version, "0.21.2");
477 assert_eq!(report.findings.len(), 3, "one vuln, two warnings");
478 assert!(
479 report.rules_digest.is_none(),
480 "rules are not a cargo-audit thing"
481 );
482 }
483
484 #[test]
488 fn carries_the_advisory_database_identity_and_publication_date() {
489 let report = CargoAudit
490 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
491 .expect("parse");
492 let db = report.advisory_db.expect("an advisory database");
493 assert_eq!(db.digest, "9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90");
494 assert_eq!(db.published_at.as_deref(), Some("2026-06-01T04:12:00Z"));
495 }
496
497 #[test]
500 fn an_unidentifiable_database_is_recorded_as_none() {
501 let native = NATIVE.replace("\"9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90\"", "\" \"");
502 let report = CargoAudit
503 .normalize(native.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
504 .expect("parse");
505 assert!(report.advisory_db.is_none());
506 }
507
508 #[test]
514 fn falls_back_to_the_callers_pinned_database_when_the_report_names_none() {
515 let native = r#"{"database":{"advisory-count":1216,"last-commit":null,
516 "last-updated":null},"vulnerabilities":{"list":[]},"warnings":{}}"#;
517 let mut ctx = ctx(&SOURCE_WITH_LOCK);
518 ctx.advisory_db = Some(rto_graph::AdvisoryDb {
519 digest: "ec5f7ef066dd".to_owned(),
520 published_at: Some("2026-08-12T10:42:29Z".to_owned()),
521 });
522 let report = CargoAudit
523 .normalize(native.as_bytes(), &ctx)
524 .expect("parse");
525 let db = report
526 .advisory_db
527 .expect("the pinned database must stand in");
528 assert_eq!(db.digest, "ec5f7ef066dd");
529 assert_eq!(db.published_at.as_deref(), Some("2026-08-12T10:42:29Z"));
530 }
531
532 #[test]
535 fn the_reports_own_database_wins_over_the_callers() {
536 let mut ctx = ctx(&SOURCE_WITH_LOCK);
537 ctx.advisory_db = Some(rto_graph::AdvisoryDb {
538 digest: "from-the-cache".to_owned(),
539 published_at: None,
540 });
541 let report = CargoAudit
542 .normalize(NATIVE.as_bytes(), &ctx)
543 .expect("parse");
544 assert_eq!(
545 report.advisory_db.expect("db").digest,
546 "9f1e5c0a2b7d4e6f8a0c1b3d5e7f9a1c3e5d7f90"
547 );
548 }
549
550 #[test]
551 fn uses_the_advisory_package_version_lockfile_identity() {
552 let report = CargoAudit
553 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
554 .expect("parse");
555 let vuln = report
556 .findings
557 .iter()
558 .find(|f| f.rule == "RUSTSEC-2026-0031")
559 .expect("the vulnerability");
560 assert_eq!(
561 vuln.identity,
562 vec!["RUSTSEC-2026-0031", "openssl", "0.10.5", "lock123"]
563 );
564 assert_eq!(vuln.severity, Severity::High);
565 assert_eq!(vuln.path.as_deref(), Some("Cargo.lock"));
566 assert_eq!(
567 vuln.meta["cvss"],
568 "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"
569 );
570 assert_eq!(vuln.meta["aliases"][0], "CVE-2026-1234");
571 }
572
573 #[test]
576 fn a_warning_with_no_advisory_is_keyed_by_its_kind() {
577 let report = CargoAudit
578 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
579 .expect("parse");
580 let yanked = report
581 .findings
582 .iter()
583 .find(|f| f.rule == "yanked")
584 .expect("the yanked warning");
585 assert_eq!(
586 yanked.identity,
587 vec!["yanked", "half-baked", "0.3.1", "lock123"]
588 );
589 assert_eq!(yanked.severity, Severity::Low);
590 assert_eq!(yanked.title, "half-baked 0.3.1 is yanked");
591 }
592
593 #[test]
594 fn an_informational_advisory_is_graded_below_a_vulnerability() {
595 let report = CargoAudit
596 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
597 .expect("parse");
598 let unmaintained = report
599 .findings
600 .iter()
601 .find(|f| f.rule == "RUSTSEC-2024-0436")
602 .expect("the unmaintained warning");
603 assert_eq!(unmaintained.severity, Severity::Low);
604 }
605
606 #[test]
609 fn an_unknown_lockfile_is_named_not_blank() {
610 let report = CargoAudit
611 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_BARE))
612 .expect("parse");
613 assert!(
614 report
615 .findings
616 .iter()
617 .all(|f| f.identity[3] == UNKNOWN_LOCKFILE)
618 );
619 }
620
621 #[test]
624 fn a_different_lockfile_is_a_different_finding() {
625 let with_lock = CargoAudit
626 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
627 .expect("a");
628 let bare = CargoAudit
629 .normalize(NATIVE.as_bytes(), &ctx(&SOURCE_BARE))
630 .expect("b");
631 assert_ne!(with_lock.findings[0].identity, bare.findings[0].identity);
632 }
633
634 #[test]
635 fn a_clean_audit_is_a_valid_empty_report() {
636 let clean = br#"{"database":{"last-commit":"abc"},
637 "vulnerabilities":{"found":false,"count":0,"list":[]},"warnings":{}}"#;
638 let report = CargoAudit
639 .normalize(clean, &ctx(&SOURCE_WITH_LOCK))
640 .expect("parse");
641 assert!(report.findings.is_empty());
642 assert_eq!(report.advisory_db.expect("db").digest, "abc");
643 }
644
645 #[test]
646 fn refuses_output_that_is_not_a_cargo_audit_report() {
647 let err = CargoAudit
648 .normalize(br#"{"database":{}}"#, &ctx(&SOURCE_WITH_LOCK))
649 .expect_err("must be refused");
650 assert!(matches!(err, ExecError::MalformedReport(_)));
651 assert!(
652 err.to_string().contains("no `vulnerabilities` object"),
653 "{err}"
654 );
655
656 assert!(matches!(
657 CargoAudit.normalize(b"<html>", &ctx(&SOURCE_WITH_LOCK)),
658 Err(ExecError::Json(_))
659 ));
660 }
661
662 #[test]
663 fn refuses_an_entry_with_no_package() {
664 let native = r#"{"vulnerabilities":{"list":[{"advisory":{"id":"R-1"}}]},"warnings":{}}"#;
665 assert!(matches!(
666 CargoAudit.normalize(native.as_bytes(), &ctx(&SOURCE_WITH_LOCK)),
667 Err(ExecError::MalformedReport(_))
668 ));
669 }
670
671 #[test]
674 fn an_unknown_warning_kind_is_reported_verbatim() {
675 let native = r#"{"vulnerabilities":{"list":[]},"warnings":{
676 "future-hazard":[{"package":{"name":"x","version":"1.0.0"}}]}}"#;
677 let report = CargoAudit
678 .normalize(native.as_bytes(), &ctx(&SOURCE_WITH_LOCK))
679 .expect("parse");
680 assert_eq!(report.findings.len(), 1);
681 assert_eq!(report.findings[0].rule, "future-hazard");
682 assert_eq!(
683 report.findings[0].severity,
684 Severity::Other("future-hazard".to_owned())
685 );
686 }
687
688 #[test]
689 fn the_invocation_pins_the_database_and_refuses_to_refresh_it() {
690 let entries = [(ADVISORY_DB_ASSET, std::path::PathBuf::from("/cache/db"))];
691 let invocation = CargoAudit.command(&AssetPaths::new(&entries));
692 assert_eq!(invocation.program, "cargo");
693 assert_eq!(invocation.args[0], "audit");
694 assert!(invocation.args.contains(&"--no-fetch".to_owned()));
695 let db = invocation
696 .args
697 .iter()
698 .position(|a| a == "--db")
699 .map(|i| invocation.args[i + 1].clone())
700 .expect("a --db argument");
701 assert_eq!(db, "/cache/db");
702 assert_eq!(invocation.success_statuses, vec![0, 1]);
703 }
704
705 #[test]
706 fn covers_rust_dependencies_and_says_nothing_more() {
707 assert_eq!(CargoAudit.languages(), &["rust"]);
708 assert_eq!(CargoAudit.asset_ids(), &[ADVISORY_DB_ASSET]);
709 }
710}