1#![cfg_attr(docsrs, feature(doc_cfg))]
41#![warn(missing_docs)]
42#![warn(rust_2018_idioms)]
43
44use std::path::PathBuf;
45
46use dev_report::{CheckResult, Evidence, Report, Severity};
47use serde::{Deserialize, Serialize};
48
49mod audit;
50mod deny;
51mod producer;
52
53pub use producer::AuditProducer;
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum AuditScope {
63 Vulnerabilities,
65 Policy,
67 All,
69}
70
71impl AuditScope {
72 fn runs_audit(self) -> bool {
73 matches!(self, Self::Vulnerabilities | Self::All)
74 }
75
76 fn runs_deny(self) -> bool {
77 matches!(self, Self::Policy | Self::All)
78 }
79}
80
81#[derive(Debug, Clone)]
101pub struct AuditRun {
102 name: String,
103 version: String,
104 scope: AuditScope,
105 workdir: Option<PathBuf>,
106 deny_config: Option<PathBuf>,
107 allow_list: Vec<String>,
108 severity_threshold: Option<Severity>,
109}
110
111impl AuditRun {
112 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
117 Self {
118 name: name.into(),
119 version: version.into(),
120 scope: AuditScope::All,
121 workdir: None,
122 deny_config: None,
123 allow_list: Vec::new(),
124 severity_threshold: None,
125 }
126 }
127
128 pub fn scope(mut self, scope: AuditScope) -> Self {
130 self.scope = scope;
131 self
132 }
133
134 pub fn audit_scope(&self) -> AuditScope {
136 self.scope
137 }
138
139 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
141 self.workdir = Some(dir.into());
142 self
143 }
144
145 pub fn deny_config(mut self, path: impl Into<PathBuf>) -> Self {
148 self.deny_config = Some(path.into());
149 self
150 }
151
152 pub fn allow(mut self, id: impl Into<String>) -> Self {
157 self.allow_list.push(id.into());
158 self
159 }
160
161 pub fn allow_all<I, S>(mut self, ids: I) -> Self
163 where
164 I: IntoIterator<Item = S>,
165 S: Into<String>,
166 {
167 self.allow_list.extend(ids.into_iter().map(Into::into));
168 self
169 }
170
171 pub fn severity_threshold(mut self, threshold: Severity) -> Self {
176 self.severity_threshold = Some(threshold);
177 self
178 }
179
180 pub fn subject(&self) -> &str {
182 &self.name
183 }
184
185 pub fn subject_version(&self) -> &str {
187 &self.version
188 }
189
190 pub fn execute(&self) -> Result<AuditResult, AuditError> {
197 let mut findings: Vec<Finding> = Vec::new();
198 if self.scope.runs_audit() {
199 findings.extend(audit::run(self.workdir.as_deref())?);
200 }
201 if self.scope.runs_deny() {
202 findings.extend(deny::run(
203 self.workdir.as_deref(),
204 self.deny_config.as_deref(),
205 )?);
206 }
207
208 if !self.allow_list.is_empty() {
209 findings.retain(|f| !self.allow_list.iter().any(|id| id == &f.id));
210 }
211 if let Some(threshold) = self.severity_threshold {
212 findings.retain(|f| severity_ord(f.severity) >= severity_ord(threshold));
213 }
214 findings.sort_by(|a, b| {
215 a.id.cmp(&b.id)
216 .then_with(|| a.affected_crate.cmp(&b.affected_crate))
217 });
218 findings.dedup_by(|a, b| a.id == b.id && a.affected_crate == b.affected_crate);
219
220 Ok(AuditResult {
221 name: self.name.clone(),
222 version: self.version.clone(),
223 scope: self.scope,
224 findings,
225 })
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum FindingSource {
237 Audit,
239 Deny,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Finding {
265 pub id: String,
267 pub title: String,
269 pub severity: Severity,
271 pub affected_crate: String,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub affected_version: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub url: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub description: Option<String>,
282 pub source: FindingSource,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AuditResult {
293 pub name: String,
295 pub version: String,
297 pub scope: AuditScope,
299 pub findings: Vec<Finding>,
301}
302
303impl AuditResult {
304 pub fn count_at_or_above(&self, threshold: Severity) -> usize {
308 self.findings
309 .iter()
310 .filter(|f| severity_ord(f.severity) >= severity_ord(threshold))
311 .count()
312 }
313
314 pub fn count_from(&self, source: FindingSource) -> usize {
316 self.findings.iter().filter(|f| f.source == source).count()
317 }
318
319 pub fn worst_severity(&self) -> Option<Severity> {
321 self.findings
322 .iter()
323 .map(|f| f.severity)
324 .max_by_key(|s| severity_ord(*s))
325 }
326
327 pub fn into_report(self) -> Report {
336 let mut report = Report::new(&self.name, &self.version).with_producer("dev-security");
337 if self.findings.is_empty() {
338 report.push(
339 CheckResult::pass("security::audit")
340 .with_tag("security")
341 .with_detail(format!("{} scope: no findings", scope_label(self.scope))),
342 );
343 } else {
344 for f in &self.findings {
345 let source_label = match f.source {
346 FindingSource::Audit => "audit",
347 FindingSource::Deny => "deny",
348 };
349 let mut check =
350 CheckResult::fail(format!("security::{source_label}::{}", f.id), f.severity)
351 .with_detail(format!("{} (in {})", f.title, f.affected_crate))
352 .with_tag("security")
353 .with_tag(match f.source {
354 FindingSource::Audit => "cve",
355 FindingSource::Deny => "policy",
356 });
357
358 let mut kv: Vec<(String, String)> = vec![
359 ("crate".into(), f.affected_crate.clone()),
360 ("id".into(), f.id.clone()),
361 ];
362 if let Some(v) = &f.affected_version {
363 kv.push(("version".into(), v.clone()));
364 }
365 if let Some(u) = &f.url {
366 kv.push(("url".into(), u.clone()));
367 }
368 check = check.with_evidence(Evidence::kv("finding", kv));
369 if let Some(desc) = &f.description {
370 check = check.with_evidence(Evidence::snippet("description", desc.clone()));
371 }
372 report.push(check);
373 }
374 }
375 report.finish();
376 report
377 }
378}
379
380fn scope_label(s: AuditScope) -> &'static str {
381 match s {
382 AuditScope::Vulnerabilities => "vulnerabilities",
383 AuditScope::Policy => "policy",
384 AuditScope::All => "all",
385 }
386}
387
388pub(crate) fn severity_ord(s: Severity) -> u8 {
389 match s {
390 Severity::Info => 0,
391 Severity::Warning => 1,
392 Severity::Error => 2,
393 Severity::Critical => 3,
394 }
395}
396
397#[derive(Debug)]
403pub enum AuditError {
404 AuditToolNotInstalled,
406 DenyToolNotInstalled,
408 SubprocessFailed(String),
410 ParseError(String),
412}
413
414impl std::fmt::Display for AuditError {
415 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416 match self {
417 Self::AuditToolNotInstalled => write!(
418 f,
419 "cargo-audit is not installed; run `cargo install cargo-audit`"
420 ),
421 Self::DenyToolNotInstalled => write!(
422 f,
423 "cargo-deny is not installed; run `cargo install cargo-deny`"
424 ),
425 Self::SubprocessFailed(s) => write!(f, "audit subprocess failed: {s}"),
426 Self::ParseError(s) => write!(f, "could not parse audit output: {s}"),
427 }
428 }
429}
430
431impl std::error::Error for AuditError {}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn audit_finding(id: &str, sev: Severity) -> Finding {
438 Finding {
439 id: id.into(),
440 title: format!("issue {id}"),
441 severity: sev,
442 affected_crate: "foo".into(),
443 affected_version: Some("1.2.3".into()),
444 url: Some(format!("https://rustsec.org/advisories/{id}")),
445 description: None,
446 source: FindingSource::Audit,
447 }
448 }
449
450 #[test]
451 fn run_builds_with_full_chain() {
452 let r = AuditRun::new("x", "0.1.0")
453 .scope(AuditScope::Vulnerabilities)
454 .allow("RUSTSEC-0000-0000")
455 .severity_threshold(Severity::Warning);
456 assert_eq!(r.audit_scope(), AuditScope::Vulnerabilities);
457 assert_eq!(r.subject(), "x");
458 assert_eq!(r.subject_version(), "0.1.0");
459 }
460
461 #[test]
462 fn empty_findings_produces_passing_report() {
463 let res = AuditResult {
464 name: "x".into(),
465 version: "0.1.0".into(),
466 scope: AuditScope::All,
467 findings: Vec::new(),
468 };
469 let report = res.into_report();
470 assert!(report.passed());
471 }
472
473 #[test]
474 fn findings_produce_failing_report() {
475 let res = AuditResult {
476 name: "x".into(),
477 version: "0.1.0".into(),
478 scope: AuditScope::All,
479 findings: vec![audit_finding("RUSTSEC-2024-0001", Severity::Critical)],
480 };
481 let report = res.into_report();
482 assert!(report.failed());
483 assert_eq!(report.checks.len(), 1);
485 let c = &report.checks[0];
486 assert!(c.has_tag("security"));
487 assert!(c.has_tag("cve"));
488 assert_eq!(c.name, "security::audit::RUSTSEC-2024-0001");
489 }
490
491 #[test]
492 fn report_includes_evidence_keyvalue_for_finding_metadata() {
493 let res = AuditResult {
494 name: "x".into(),
495 version: "0.1.0".into(),
496 scope: AuditScope::Vulnerabilities,
497 findings: vec![audit_finding("RUSTSEC-2024-1111", Severity::Error)],
498 };
499 let report = res.into_report();
500 let c = &report.checks[0];
501 let ev_labels: Vec<&str> = c.evidence.iter().map(|e| e.label.as_str()).collect();
502 assert!(ev_labels.contains(&"finding"));
503 }
504
505 #[test]
506 fn count_at_or_above_filters_severity() {
507 let res = AuditResult {
508 name: "x".into(),
509 version: "0.1.0".into(),
510 scope: AuditScope::All,
511 findings: vec![
512 audit_finding("A", Severity::Info),
513 audit_finding("B", Severity::Error),
514 audit_finding("C", Severity::Critical),
515 ],
516 };
517 assert_eq!(res.count_at_or_above(Severity::Critical), 1);
518 assert_eq!(res.count_at_or_above(Severity::Error), 2);
519 assert_eq!(res.count_at_or_above(Severity::Info), 3);
520 }
521
522 #[test]
523 fn count_from_filters_source() {
524 let f1 = audit_finding("A", Severity::Error);
525 let mut f2 = audit_finding("B", Severity::Warning);
526 f2.source = FindingSource::Deny;
527 let res = AuditResult {
528 name: "x".into(),
529 version: "0.1.0".into(),
530 scope: AuditScope::All,
531 findings: vec![f1, f2],
532 };
533 assert_eq!(res.count_from(FindingSource::Audit), 1);
534 assert_eq!(res.count_from(FindingSource::Deny), 1);
535 }
536
537 #[test]
538 fn worst_severity_picks_max() {
539 let res = AuditResult {
540 name: "x".into(),
541 version: "0.1.0".into(),
542 scope: AuditScope::All,
543 findings: vec![
544 audit_finding("A", Severity::Warning),
545 audit_finding("B", Severity::Critical),
546 audit_finding("C", Severity::Info),
547 ],
548 };
549 assert_eq!(res.worst_severity(), Some(Severity::Critical));
550 let empty = AuditResult {
551 name: "x".into(),
552 version: "0.1.0".into(),
553 scope: AuditScope::All,
554 findings: Vec::new(),
555 };
556 assert_eq!(empty.worst_severity(), None);
557 }
558
559 #[test]
560 fn result_round_trips_through_json() {
561 let res = AuditResult {
562 name: "x".into(),
563 version: "0.1.0".into(),
564 scope: AuditScope::Vulnerabilities,
565 findings: vec![audit_finding("RUSTSEC-2024-0001", Severity::Error)],
566 };
567 let s = serde_json::to_string(&res).unwrap();
568 let back: AuditResult = serde_json::from_str(&s).unwrap();
569 assert_eq!(back.findings.len(), 1);
570 assert_eq!(back.findings[0].id, "RUSTSEC-2024-0001");
571 }
572
573 #[test]
574 fn auditscope_runs_helpers() {
575 assert!(AuditScope::All.runs_audit());
576 assert!(AuditScope::All.runs_deny());
577 assert!(AuditScope::Vulnerabilities.runs_audit());
578 assert!(!AuditScope::Vulnerabilities.runs_deny());
579 assert!(!AuditScope::Policy.runs_audit());
580 assert!(AuditScope::Policy.runs_deny());
581 }
582}