1#![cfg_attr(docsrs, feature(doc_cfg))]
32#![warn(missing_docs)]
33#![warn(rust_2018_idioms)]
34
35use dev_report::{CheckResult, Report, Severity};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AuditScope {
40 Vulnerabilities,
42 Policy,
44 All,
46}
47
48#[derive(Debug, Clone)]
50pub struct AuditRun {
51 name: String,
52 version: String,
53 scope: AuditScope,
54}
55
56impl AuditRun {
57 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
59 Self {
60 name: name.into(),
61 version: version.into(),
62 scope: AuditScope::All,
63 }
64 }
65
66 pub fn scope(mut self, scope: AuditScope) -> Self {
68 self.scope = scope;
69 self
70 }
71
72 pub fn audit_scope(&self) -> AuditScope {
74 self.scope
75 }
76
77 pub fn execute(&self) -> Result<AuditResult, AuditError> {
82 Ok(AuditResult {
83 name: self.name.clone(),
84 version: self.version.clone(),
85 scope: self.scope,
86 findings: Vec::new(),
87 })
88 }
89}
90
91#[derive(Debug, Clone)]
93pub struct Finding {
94 pub id: String,
96 pub title: String,
98 pub severity: Severity,
100 pub affected_crate: String,
102}
103
104#[derive(Debug, Clone)]
106pub struct AuditResult {
107 pub name: String,
109 pub version: String,
111 pub scope: AuditScope,
113 pub findings: Vec<Finding>,
115}
116
117impl AuditResult {
118 pub fn count_at_or_above(&self, threshold: Severity) -> usize {
120 self.findings
121 .iter()
122 .filter(|f| severity_ord(f.severity) >= severity_ord(threshold))
123 .count()
124 }
125
126 pub fn into_report(self) -> Report {
128 let mut report = Report::new(&self.name, &self.version).with_producer("dev-security");
129 if self.findings.is_empty() {
130 report.push(CheckResult::pass("security::audit"));
131 } else {
132 for f in &self.findings {
133 report.push(
134 CheckResult::fail(format!("security::{}", f.id), f.severity)
135 .with_detail(format!("{} (in {})", f.title, f.affected_crate)),
136 );
137 }
138 }
139 report.finish();
140 report
141 }
142}
143
144fn severity_ord(s: Severity) -> u8 {
145 match s {
146 Severity::Info => 0,
147 Severity::Warning => 1,
148 Severity::Error => 2,
149 Severity::Critical => 3,
150 }
151}
152
153#[derive(Debug)]
155pub enum AuditError {
156 AuditToolNotInstalled,
158 DenyToolNotInstalled,
160 SubprocessFailed(String),
162 ParseError(String),
164}
165
166impl std::fmt::Display for AuditError {
167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 match self {
169 Self::AuditToolNotInstalled => write!(f, "cargo-audit is not installed"),
170 Self::DenyToolNotInstalled => write!(f, "cargo-deny is not installed"),
171 Self::SubprocessFailed(s) => write!(f, "subprocess failed: {s}"),
172 Self::ParseError(s) => write!(f, "parse error: {s}"),
173 }
174 }
175}
176
177impl std::error::Error for AuditError {}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn run_builds() {
185 let r = AuditRun::new("x", "0.1.0").scope(AuditScope::All);
186 assert_eq!(r.audit_scope(), AuditScope::All);
187 }
188
189 #[test]
190 fn empty_findings_produces_passing_report() {
191 let res = AuditResult {
192 name: "x".into(),
193 version: "0.1.0".into(),
194 scope: AuditScope::All,
195 findings: Vec::new(),
196 };
197 let report = res.into_report();
198 assert!(report.passed());
199 }
200
201 #[test]
202 fn findings_produce_failing_report() {
203 let res = AuditResult {
204 name: "x".into(),
205 version: "0.1.0".into(),
206 scope: AuditScope::All,
207 findings: vec![Finding {
208 id: "RUSTSEC-2024-0001".into(),
209 title: "Use after free in foo".into(),
210 severity: Severity::Critical,
211 affected_crate: "foo".into(),
212 }],
213 };
214 let report = res.into_report();
215 assert!(report.failed());
216 }
217
218 #[test]
219 fn severity_filter_works() {
220 let res = AuditResult {
221 name: "x".into(),
222 version: "0.1.0".into(),
223 scope: AuditScope::All,
224 findings: vec![
225 Finding {
226 id: "A".into(),
227 title: "low".into(),
228 severity: Severity::Info,
229 affected_crate: "a".into(),
230 },
231 Finding {
232 id: "B".into(),
233 title: "high".into(),
234 severity: Severity::Critical,
235 affected_crate: "b".into(),
236 },
237 ],
238 };
239 assert_eq!(res.count_at_or_above(Severity::Critical), 1);
240 assert_eq!(res.count_at_or_above(Severity::Info), 2);
241 }
242}