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