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