#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
use dev_report::{CheckResult, Report, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditScope {
Vulnerabilities,
Policy,
All,
}
#[derive(Debug, Clone)]
pub struct AuditRun {
name: String,
version: String,
scope: AuditScope,
}
impl AuditRun {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
scope: AuditScope::All,
}
}
pub fn scope(mut self, scope: AuditScope) -> Self {
self.scope = scope;
self
}
pub fn audit_scope(&self) -> AuditScope {
self.scope
}
pub fn execute(&self) -> Result<AuditResult, AuditError> {
Ok(AuditResult {
name: self.name.clone(),
version: self.version.clone(),
scope: self.scope,
findings: Vec::new(),
})
}
}
#[derive(Debug, Clone)]
pub struct Finding {
pub id: String,
pub title: String,
pub severity: Severity,
pub affected_crate: String,
}
#[derive(Debug, Clone)]
pub struct AuditResult {
pub name: String,
pub version: String,
pub scope: AuditScope,
pub findings: Vec<Finding>,
}
impl AuditResult {
pub fn count_at_or_above(&self, threshold: Severity) -> usize {
self.findings
.iter()
.filter(|f| severity_ord(f.severity) >= severity_ord(threshold))
.count()
}
pub fn into_report(self) -> Report {
let mut report = Report::new(&self.name, &self.version).with_producer("dev-security");
if self.findings.is_empty() {
report.push(CheckResult::pass("security::audit"));
} else {
for f in &self.findings {
report.push(
CheckResult::fail(format!("security::{}", f.id), f.severity)
.with_detail(format!("{} (in {})", f.title, f.affected_crate)),
);
}
}
report.finish();
report
}
}
fn severity_ord(s: Severity) -> u8 {
match s {
Severity::Info => 0,
Severity::Warning => 1,
Severity::Error => 2,
Severity::Critical => 3,
}
}
#[derive(Debug)]
pub enum AuditError {
AuditToolNotInstalled,
DenyToolNotInstalled,
SubprocessFailed(String),
ParseError(String),
}
impl std::fmt::Display for AuditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AuditToolNotInstalled => write!(f, "cargo-audit is not installed"),
Self::DenyToolNotInstalled => write!(f, "cargo-deny is not installed"),
Self::SubprocessFailed(s) => write!(f, "subprocess failed: {s}"),
Self::ParseError(s) => write!(f, "parse error: {s}"),
}
}
}
impl std::error::Error for AuditError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_builds() {
let r = AuditRun::new("x", "0.1.0").scope(AuditScope::All);
assert_eq!(r.audit_scope(), AuditScope::All);
}
#[test]
fn empty_findings_produces_passing_report() {
let res = AuditResult {
name: "x".into(),
version: "0.1.0".into(),
scope: AuditScope::All,
findings: Vec::new(),
};
let report = res.into_report();
assert!(report.passed());
}
#[test]
fn findings_produce_failing_report() {
let res = AuditResult {
name: "x".into(),
version: "0.1.0".into(),
scope: AuditScope::All,
findings: vec![Finding {
id: "RUSTSEC-2024-0001".into(),
title: "Use after free in foo".into(),
severity: Severity::Critical,
affected_crate: "foo".into(),
}],
};
let report = res.into_report();
assert!(report.failed());
}
#[test]
fn severity_filter_works() {
let res = AuditResult {
name: "x".into(),
version: "0.1.0".into(),
scope: AuditScope::All,
findings: vec![
Finding {
id: "A".into(),
title: "low".into(),
severity: Severity::Info,
affected_crate: "a".into(),
},
Finding {
id: "B".into(),
title: "high".into(),
severity: Severity::Critical,
affected_crate: "b".into(),
},
],
};
assert_eq!(res.count_at_or_above(Severity::Critical), 1);
assert_eq!(res.count_at_or_above(Severity::Info), 2);
}
}