Skip to main content

dev_security/
lib.rs

1//! # dev-security
2//!
3//! Security auditing for Rust. Wraps `cargo-audit` (RustSec advisory
4//! database) and `cargo-deny` (license + policy enforcement). Part of
5//! the `dev-*` verification suite.
6//!
7//! Output is a `dev-report::Report` so AI agents and CI gates can act
8//! on findings programmatically.
9//!
10//! ## What it checks
11//!
12//! - **Vulnerabilities**: known CVEs in your dependency tree (via `cargo-audit`).
13//! - **Licenses**: license policy compliance (via `cargo-deny`).
14//! - **Banned crates**: explicit allow/deny lists (via `cargo-deny`).
15//! - **Source policies**: registry/git source restrictions (via `cargo-deny`).
16//!
17//! ## Quick example
18//!
19//! ```no_run
20//! use dev_security::{AuditRun, AuditScope};
21//!
22//! let run = AuditRun::new("my-crate", "0.1.0").scope(AuditScope::All);
23//! let result = run.execute().unwrap();
24//! let report = result.into_report();
25//! ```
26//!
27//! ## Status
28//!
29//! Pre-1.0. API shape defined; subprocess integration lands in `0.9.1`.
30
31#![cfg_attr(docsrs, feature(doc_cfg))]
32#![warn(missing_docs)]
33#![warn(rust_2018_idioms)]
34
35use dev_report::{CheckResult, Report, Severity};
36
37/// Scope of an audit run.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum AuditScope {
40    /// Run only the vulnerability scanner (cargo-audit).
41    Vulnerabilities,
42    /// Run only the policy enforcer (cargo-deny).
43    Policy,
44    /// Run both vulnerability and policy checks.
45    All,
46}
47
48/// Configuration for an audit run.
49#[derive(Debug, Clone)]
50pub struct AuditRun {
51    name: String,
52    version: String,
53    scope: AuditScope,
54}
55
56impl AuditRun {
57    /// Begin a new audit run for the given crate name and version.
58    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    /// Set the audit scope.
67    pub fn scope(mut self, scope: AuditScope) -> Self {
68        self.scope = scope;
69        self
70    }
71
72    /// Selected scope.
73    pub fn audit_scope(&self) -> AuditScope {
74        self.scope
75    }
76
77    /// Execute the audit run.
78    ///
79    /// In `0.9.0` this is a stub; full `cargo-audit` and `cargo-deny`
80    /// integration lands in `0.9.1`.
81    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/// A single security finding.
92#[derive(Debug, Clone)]
93pub struct Finding {
94    /// Advisory ID (e.g. `RUSTSEC-2024-0001`) or policy rule name.
95    pub id: String,
96    /// Short human-readable title.
97    pub title: String,
98    /// Severity classification.
99    pub severity: Severity,
100    /// Affected crate.
101    pub affected_crate: String,
102}
103
104/// Result of an audit run.
105#[derive(Debug, Clone)]
106pub struct AuditResult {
107    /// Crate name.
108    pub name: String,
109    /// Crate version.
110    pub version: String,
111    /// Scope that produced this result.
112    pub scope: AuditScope,
113    /// All findings discovered.
114    pub findings: Vec<Finding>,
115}
116
117impl AuditResult {
118    /// Number of findings at the given severity or higher.
119    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    /// Convert this result into a `dev-report::Report`.
127    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/// Errors that can arise during an audit.
154#[derive(Debug)]
155pub enum AuditError {
156    /// `cargo-audit` is not installed.
157    AuditToolNotInstalled,
158    /// `cargo-deny` is not installed.
159    DenyToolNotInstalled,
160    /// Subprocess failure.
161    SubprocessFailed(String),
162    /// Output parsing failure.
163    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}