Skip to main content

dev_security/
lib.rs

1//! # dev-security
2//!
3//! Security auditing for Rust. Wraps [`cargo-audit`][cargo-audit]
4//! (RustSec advisory database) and [`cargo-deny`][cargo-deny] (license
5//! + policy enforcement). Part of 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//! ## Requirements
28//!
29//! ```text
30//! cargo install cargo-audit cargo-deny
31//! ```
32//!
33//! The crate detects absence of each tool and emits
34//! [`AuditError::AuditToolNotInstalled`] or
35//! [`AuditError::DenyToolNotInstalled`] without panicking.
36//!
37//! [cargo-audit]: https://crates.io/crates/cargo-audit
38//! [cargo-deny]: https://crates.io/crates/cargo-deny
39
40#![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// ---------------------------------------------------------------------------
56// AuditScope
57// ---------------------------------------------------------------------------
58
59/// Scope of an audit run.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum AuditScope {
63    /// Run only the vulnerability scanner (`cargo audit`).
64    Vulnerabilities,
65    /// Run only the policy enforcer (`cargo deny`).
66    Policy,
67    /// Run both vulnerability and policy checks.
68    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// ---------------------------------------------------------------------------
82// AuditRun
83// ---------------------------------------------------------------------------
84
85/// Configuration for an audit run.
86///
87/// # Example
88///
89/// ```no_run
90/// use dev_security::{AuditRun, AuditScope};
91/// use dev_report::Severity;
92///
93/// let run = AuditRun::new("my-crate", "0.1.0")
94///     .scope(AuditScope::All)
95///     .allow("RUSTSEC-2024-9999")
96///     .severity_threshold(Severity::Warning);
97///
98/// let _result = run.execute().unwrap();
99/// ```
100#[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    /// Begin a new audit run for the given subject name and version.
113    ///
114    /// `name` and `version` are descriptive — they identify the subject
115    /// in the produced `Report`.
116    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    /// Pick which checks to run. Defaults to [`AuditScope::All`].
129    pub fn scope(mut self, scope: AuditScope) -> Self {
130        self.scope = scope;
131        self
132    }
133
134    /// Selected scope.
135    pub fn audit_scope(&self) -> AuditScope {
136        self.scope
137    }
138
139    /// Run the subprocesses from `dir` instead of the current directory.
140    pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
141        self.workdir = Some(dir.into());
142        self
143    }
144
145    /// Pass `--config <path>` to `cargo deny` so callers can point at a
146    /// non-default `deny.toml` location.
147    pub fn deny_config(mut self, path: impl Into<PathBuf>) -> Self {
148        self.deny_config = Some(path.into());
149        self
150    }
151
152    /// Suppress a single advisory ID. Matches advisories from
153    /// `cargo-audit` and rule names / advisory IDs from `cargo-deny`.
154    ///
155    /// May be called repeatedly to add more entries.
156    pub fn allow(mut self, id: impl Into<String>) -> Self {
157        self.allow_list.push(id.into());
158        self
159    }
160
161    /// Add multiple allow-list entries at once.
162    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    /// Discard findings whose severity is *below* `threshold`. Findings
172    /// at or above the threshold are kept.
173    ///
174    /// Order: `Info` < `Warning` < `Error` < `Critical`.
175    pub fn severity_threshold(mut self, threshold: Severity) -> Self {
176        self.severity_threshold = Some(threshold);
177        self
178    }
179
180    /// Subject name passed in via [`new`](Self::new).
181    pub fn subject(&self) -> &str {
182        &self.name
183    }
184
185    /// Subject version passed in via [`new`](Self::new).
186    pub fn subject_version(&self) -> &str {
187        &self.version
188    }
189
190    /// Execute the audit.
191    ///
192    /// Each enabled tool is invoked as a subprocess. Findings are
193    /// merged, deduplicated by `(id, affected_crate)`, filtered through
194    /// the allow-list and severity threshold, then sorted by `id` for
195    /// determinism.
196    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// ---------------------------------------------------------------------------
230// Finding + FindingSource
231// ---------------------------------------------------------------------------
232
233/// Which tool emitted a [`Finding`].
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum FindingSource {
237    /// `cargo-audit` (RustSec advisory database).
238    Audit,
239    /// `cargo-deny` (license / banned crates / sources policy).
240    Deny,
241}
242
243/// A single security finding.
244///
245/// # Example
246///
247/// ```
248/// use dev_security::{Finding, FindingSource};
249/// use dev_report::Severity;
250///
251/// let f = Finding {
252///     id: "RUSTSEC-2024-0001".into(),
253///     title: "Use after free in foo".into(),
254///     severity: Severity::Critical,
255///     affected_crate: "foo".into(),
256///     affected_version: Some("1.2.3".into()),
257///     url: Some("https://rustsec.org/advisories/RUSTSEC-2024-0001".into()),
258///     description: None,
259///     source: FindingSource::Audit,
260/// };
261/// assert_eq!(f.severity, Severity::Critical);
262/// ```
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct Finding {
265    /// Advisory ID (e.g. `RUSTSEC-2024-0001`) or `cargo-deny` rule code.
266    pub id: String,
267    /// Short human-readable title.
268    pub title: String,
269    /// Severity classification mapped from the underlying tool.
270    pub severity: Severity,
271    /// Affected crate name.
272    pub affected_crate: String,
273    /// Affected crate version, when the underlying tool exposed it.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub affected_version: Option<String>,
276    /// URL with more detail (advisory page, license SPDX page, etc.).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub url: Option<String>,
279    /// Long-form description, when the underlying tool exposed it.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub description: Option<String>,
282    /// Which tool emitted this finding.
283    pub source: FindingSource,
284}
285
286// ---------------------------------------------------------------------------
287// AuditResult
288// ---------------------------------------------------------------------------
289
290/// Result of an audit run.
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct AuditResult {
293    /// Subject name.
294    pub name: String,
295    /// Subject version.
296    pub version: String,
297    /// Scope that produced this result.
298    pub scope: AuditScope,
299    /// Findings discovered (deduped, allow-list filtered, sorted by id).
300    pub findings: Vec<Finding>,
301}
302
303impl AuditResult {
304    /// Number of findings at the given severity *or higher*.
305    ///
306    /// `Info` < `Warning` < `Error` < `Critical`.
307    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    /// Number of findings from the given source.
315    pub fn count_from(&self, source: FindingSource) -> usize {
316        self.findings.iter().filter(|f| f.source == source).count()
317    }
318
319    /// Highest severity present in the findings, if any.
320    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    /// Convert this result into a [`dev_report::Report`].
328    ///
329    /// Pass when there are no findings; otherwise push one
330    /// [`CheckResult::fail`] per finding, named
331    /// `security::<source>::<id>` and tagged `security` plus a
332    /// source-specific tag (`cve` for audit, `policy` for deny).
333    /// Each check carries `Evidence::KeyValue` with `crate`,
334    /// `affected_version`, and `url` when known.
335    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// ---------------------------------------------------------------------------
398// AuditError
399// ---------------------------------------------------------------------------
400
401/// Errors that can arise during an audit run.
402#[derive(Debug)]
403pub enum AuditError {
404    /// `cargo-audit` is not installed.
405    AuditToolNotInstalled,
406    /// `cargo-deny` is not installed.
407    DenyToolNotInstalled,
408    /// Subprocess failure with the captured stderr.
409    SubprocessFailed(String),
410    /// Output parsing failure.
411    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        // One CheckResult per finding.
484        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}