Skip to main content

oxidized_agentic_audit/
finding.rs

1//! Core data types for scan findings and reports.
2//!
3//! This module contains the primary output types of the scan pipeline:
4//!
5//! - [`Finding`] — a single security issue detected by a scanner.
6//! - [`ScanResult`] — aggregated output from one scanner run.
7//! - [`ScanReport`] — the final report combining all scanners.
8//! - [`Severity`], [`ScanStatus`], [`RiskLevel`], [`SecurityGrade`] — classification enums.
9
10use std::fmt;
11use std::path::PathBuf;
12
13/// Severity level for a security finding.
14///
15/// Variants are ordered from most to least critical and implement [`Ord`],
16/// so collections of findings can be sorted by severity.
17///
18/// Serializes to lowercase strings (`"error"`, `"warning"`, `"info"`).
19#[derive(
20    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
21)]
22#[serde(rename_all = "lowercase")]
23pub enum Severity {
24    /// Critical issue that must be resolved before the skill can be trusted.
25    Error,
26    /// Potential issue that should be reviewed but may be acceptable.
27    Warning,
28    /// Informational observation that does not affect the audit outcome.
29    Info,
30}
31
32impl fmt::Display for Severity {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Severity::Error => write!(f, "error"),
36            Severity::Warning => write!(f, "warning"),
37            Severity::Info => write!(f, "info"),
38        }
39    }
40}
41
42/// A single security finding detected by a scanner.
43///
44/// Each finding carries the rule it violates, a human-readable message,
45/// optional source location, and remediation guidance.
46///
47/// # Suppression
48///
49/// Findings can be suppressed either by inline comments (`# scan:ignore`) or
50/// by entries in a [`.oxidized-agentic-audit-ignore`](crate::config::Suppression) file.
51/// When suppressed, [`suppressed`](Finding::suppressed) is `true` and the
52/// finding is moved to [`ScanReport::suppressed`] instead of
53/// [`ScanReport::findings`].
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct Finding {
56    /// Unique rule identifier (e.g., `"bash/CAT-A-001"`, `"prompt/P01"`).
57    pub rule_id: String,
58    /// Human-readable description of the issue.
59    pub message: String,
60    /// Severity level.
61    pub severity: Severity,
62    /// Path to the source file, relative to the skill root.
63    pub file: Option<PathBuf>,
64    /// 1-based line number inside the source file.
65    pub line: Option<usize>,
66    /// 1-based column number inside the source file.
67    pub column: Option<usize>,
68    /// Name of the scanner that produced this finding.
69    pub scanner: String,
70    /// Code snippet showing the offending line.
71    pub snippet: Option<String>,
72    /// Whether this finding has been suppressed.
73    pub suppressed: bool,
74    /// Reason for suppression (from a suppression rule or inline marker).
75    pub suppression_reason: Option<String>,
76    /// Guidance on how to resolve the issue.
77    pub remediation: Option<String>,
78}
79
80/// Results from running a single [`Scanner`](crate::scanners::Scanner).
81///
82/// Scanners that are not installed on the host are represented as skipped
83/// results (see [`ScanResult::skipped`]).
84#[derive(Debug, serde::Serialize)]
85pub struct ScanResult {
86    /// Scanner identifier (matches [`Scanner::name`](crate::scanners::Scanner::name)).
87    pub scanner_name: String,
88    /// Findings produced by this scan.
89    pub findings: Vec<Finding>,
90    /// Number of files examined.
91    pub files_scanned: usize,
92    /// `true` when the scanner did not run (e.g., external tool missing).
93    pub skipped: bool,
94    /// Human-readable reason when `skipped` is `true`.
95    pub skip_reason: Option<String>,
96    /// Error message if the scanner encountered a fatal error.
97    pub error: Option<String>,
98    /// Wall-clock time for this scanner, in milliseconds.
99    pub duration_ms: u64,
100    /// Security score (0–100) for this scanner's raw findings.
101    ///
102    /// Computed from the scanner's own findings before suppressions are applied.
103    /// `None` when the scanner was skipped, disabled, or encountered an error.
104    pub scanner_score: Option<u8>,
105    /// Letter grade derived from [`scanner_score`](Self::scanner_score).
106    /// `None` when `scanner_score` is `None`.
107    pub scanner_grade: Option<SecurityGrade>,
108}
109
110impl ScanResult {
111    /// Creates a [`ScanResult`] representing a skipped scanner.
112    ///
113    /// Use this when a scanner cannot run — for example because its external
114    /// tool is not installed.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use oxidized_agentic_audit::finding::ScanResult;
120    ///
121    /// let result = ScanResult::skipped("semgrep", "semgrep not found on PATH");
122    /// assert!(result.skipped);
123    /// assert_eq!(result.findings.len(), 0);
124    /// ```
125    pub fn skipped(name: &str, reason: &str) -> Self {
126        ScanResult {
127            scanner_name: name.to_string(),
128            findings: vec![],
129            files_scanned: 0,
130            skipped: true,
131            skip_reason: Some(reason.to_string()),
132            error: None,
133            duration_ms: 0,
134            scanner_score: None,
135            scanner_grade: None,
136        }
137    }
138
139    /// Creates a [`ScanResult`] representing a scanner that encountered an error.
140    ///
141    /// Use this when a scanner fails to run — for example because the external
142    /// tool exited with an unexpected error code.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use oxidized_agentic_audit::finding::ScanResult;
148    ///
149    /// let result = ScanResult::error("shellcheck", "Failed to run shellcheck".to_string(), 42);
150    /// assert!(result.error.is_some());
151    /// assert!(!result.skipped);
152    /// ```
153    pub fn error(name: &str, error: String, duration_ms: u64) -> Self {
154        ScanResult {
155            scanner_name: name.to_string(),
156            findings: vec![],
157            files_scanned: 0,
158            skipped: false,
159            skip_reason: None,
160            error: Some(error),
161            duration_ms,
162            scanner_score: None,
163            scanner_grade: None,
164        }
165    }
166}
167
168/// Complete scan report for a single skill.
169///
170/// Created by [`ScanReport::from_results`] after all scanners have run.
171/// This is the main output of [`scan::run_scan`](crate::scan::run_scan)
172/// and is consumed by the [`output`](crate::output) formatters.
173///
174/// # Examples
175///
176/// ```rust,no_run
177/// use std::path::Path;
178/// use oxidized_agentic_audit::{scan::{self, ScanMode}, config::Config};
179///
180/// let config = Config::load(None).unwrap();
181/// let report = scan::run_scan(Path::new("./my-skill"), &config, ScanMode::Skill);
182///
183/// println!("status: {:?}, errors: {}", report.status, report.error_count());
184/// ```
185#[derive(Debug, serde::Serialize)]
186pub struct ScanReport {
187    /// Name of the scanned skill (derived from the directory name).
188    pub skill: String,
189    /// Optional skill version (reserved for future use).
190    pub version: Option<String>,
191    /// RFC 3339 timestamp of when the scan ran.
192    pub scan_timestamp: String,
193    /// Overall scan outcome.
194    pub status: ScanStatus,
195    /// Overall risk assessment.
196    pub risk_level: RiskLevel,
197    /// Numeric security score from 0 (worst) to 100 (best).
198    ///
199    /// Computed by deducting points per active finding:
200    /// - Critical error (RCE/backdoor/prompt): −30
201    /// - Regular error: −15
202    /// - Warning: −5
203    /// - Info: −1
204    ///
205    /// The score is clamped to [0, 100].
206    pub security_score: u8,
207    /// Letter grade derived from [`security_score`](Self::security_score).
208    pub security_grade: SecurityGrade,
209    /// Total number of files examined across all scanners.
210    pub files_scanned: usize,
211    /// Per-scanner results (including skipped scanners).
212    pub scanner_results: Vec<ScanResult>,
213    /// Active (non-suppressed) findings.
214    pub findings: Vec<Finding>,
215    /// Suppressed findings (kept for transparency in reports).
216    pub suppressed: Vec<Finding>,
217    /// Convenience flag: `true` when `status` is [`ScanStatus::Passed`].
218    pub passed: bool,
219}
220
221impl ScanReport {
222    /// Builds a [`ScanReport`] from raw scanner results.
223    ///
224    /// This constructor:
225    /// 1. Separates suppressed findings from active ones.
226    /// 2. Applies file-level suppression rules.
227    /// 3. Computes [`ScanStatus`] and [`RiskLevel`].
228    ///
229    /// # Arguments
230    ///
231    /// * `skill`        — skill name (usually the directory basename).
232    /// * `results`      — scanner results to aggregate.
233    /// * `suppressions` — rules loaded from `.oxidized-agentic-audit-ignore`.
234    /// * `strict`       — when `true`, warnings are treated as failures.
235    pub fn from_results(
236        skill: &str,
237        results: Vec<ScanResult>,
238        suppressions: &[crate::config::Suppression],
239        strict: bool,
240    ) -> Self {
241        let files_scanned: usize = results.iter().map(|r| r.files_scanned).sum();
242
243        // Pre-pass: annotate each scanner result with its own score, computed
244        // on the raw (pre-suppression) findings.  Skipped / errored scanners
245        // receive `None` because there are no meaningful findings to score.
246        let results: Vec<ScanResult> = results
247            .into_iter()
248            .map(|mut r| {
249                if !r.skipped && r.error.is_none() {
250                    let (score, grade) = compute_security_score(&r.findings);
251                    r.scanner_score = Some(score);
252                    r.scanner_grade = Some(grade);
253                }
254                r
255            })
256            .collect();
257
258        let mut active = Vec::new();
259        let mut suppressed = Vec::new();
260
261        for result in &results {
262            for finding in &result.findings {
263                if finding.suppressed {
264                    suppressed.push(finding.clone());
265                } else if let Some(s) = find_suppression(finding, suppressions) {
266                    // Single call — avoids traversing the suppression list twice
267                    // (once for the boolean check, once to retrieve the reason).
268                    let mut f = finding.clone();
269                    f.suppressed = true;
270                    f.suppression_reason = Some(s.reason.clone());
271                    suppressed.push(f);
272                } else {
273                    active.push(finding.clone());
274                }
275            }
276        }
277
278        let (status, risk_level, security_score, security_grade) =
279            compute_scan_metrics(&active, strict);
280        let passed = matches!(status, ScanStatus::Passed);
281
282        ScanReport {
283            skill: skill.to_string(),
284            version: None,
285            scan_timestamp: chrono::Utc::now().to_rfc3339(),
286            status,
287            risk_level,
288            security_score,
289            security_grade,
290            files_scanned,
291            scanner_results: results,
292            findings: active,
293            suppressed,
294            passed,
295        }
296    }
297
298    /// Returns the number of active findings with [`Severity::Error`].
299    pub fn error_count(&self) -> usize {
300        self.findings
301            .iter()
302            .filter(|f| f.severity == Severity::Error)
303            .count()
304    }
305
306    /// Returns the number of active findings with [`Severity::Warning`].
307    pub fn warning_count(&self) -> usize {
308        self.findings
309            .iter()
310            .filter(|f| f.severity == Severity::Warning)
311            .count()
312    }
313
314    /// Returns the number of active findings with [`Severity::Info`].
315    pub fn info_count(&self) -> usize {
316        self.findings
317            .iter()
318            .filter(|f| f.severity == Severity::Info)
319            .count()
320    }
321
322    /// Counts errors, warnings, and info findings in a single pass.
323    ///
324    /// Returns `(errors, warnings, info)`. Prefer this over calling
325    /// [`error_count`](Self::error_count), [`warning_count`](Self::warning_count),
326    /// and [`info_count`](Self::info_count) separately when all three values are
327    /// needed (avoids three iterations).
328    pub fn count_by_severity(&self) -> (usize, usize, usize) {
329        self.findings
330            .iter()
331            .fold((0, 0, 0), |(e, w, i), f| match f.severity {
332                Severity::Error => (e + 1, w, i),
333                Severity::Warning => (e, w + 1, i),
334                Severity::Info => (e, w, i + 1),
335            })
336    }
337}
338
339/// Overall outcome of a scan.
340///
341/// The status is derived from the active (non-suppressed) findings and the
342/// [`StrictConfig`](crate::config::StrictConfig) setting.
343#[derive(Debug, serde::Serialize)]
344#[serde(rename_all = "lowercase")]
345pub enum ScanStatus {
346    /// No errors or warnings (or all were suppressed).
347    Passed,
348    /// Warnings present, but no errors (and strict mode is off).
349    Warning,
350    /// Errors present, or warnings in strict mode.
351    Failed,
352}
353
354/// Risk level derived from the nature of the findings.
355///
356/// The classification considers whether critical patterns (RCE, backdoors,
357/// prompt injection) are present, not just the count of errors.
358#[derive(Debug, serde::Serialize)]
359#[serde(rename_all = "lowercase")]
360pub enum RiskLevel {
361    /// No active findings.
362    Low,
363    /// Only warnings, no errors.
364    Medium,
365    /// Errors present but none in critical categories.
366    High,
367    /// Findings in critical categories (RCE, backdoor, prompt injection).
368    Critical,
369}
370
371/// Letter-grade summary of a skill's security posture.
372///
373/// Derived from [`ScanReport::security_score`]:
374///
375/// | Score   | Grade |
376/// |---------|-------|
377/// | 90–100  | `A`   |
378/// | 75–89   | `B`   |
379/// | 60–74   | `C`   |
380/// | 40–59   | `D`   |
381/// | 0–39    | `F`   |
382#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
383pub enum SecurityGrade {
384    A,
385    B,
386    C,
387    D,
388    F,
389}
390
391impl fmt::Display for SecurityGrade {
392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393        match self {
394            SecurityGrade::A => write!(f, "A"),
395            SecurityGrade::B => write!(f, "B"),
396            SecurityGrade::C => write!(f, "C"),
397            SecurityGrade::D => write!(f, "D"),
398            SecurityGrade::F => write!(f, "F"),
399        }
400    }
401}
402
403/// Computes status, risk level, security score, and grade in a single pass.
404///
405/// Used by [`ScanReport::from_results`] to derive all aggregate metrics
406/// without iterating the findings list three times.
407fn compute_scan_metrics(
408    findings: &[Finding],
409    strict: bool,
410) -> (ScanStatus, RiskLevel, u8, SecurityGrade) {
411    let mut has_errors = false;
412    let mut has_warnings = false;
413    let mut has_rce_or_backdoor = false;
414    let mut deduction: u32 = 0;
415
416    for f in findings {
417        let is_critical = f.rule_id.starts_with("bash/CAT-A")
418            || f.rule_id.starts_with("bash/CAT-D")
419            || f.rule_id.starts_with("typescript/CAT-A")
420            || f.rule_id.starts_with("typescript/CAT-D")
421            || f.rule_id.starts_with("prompt/");
422
423        match f.severity {
424            Severity::Error => {
425                has_errors = true;
426                if is_critical {
427                    has_rce_or_backdoor = true;
428                    deduction += 30;
429                } else {
430                    deduction += 15;
431                }
432            }
433            Severity::Warning => {
434                has_warnings = true;
435                deduction += 5;
436            }
437            Severity::Info => {
438                deduction += 1;
439            }
440        }
441    }
442
443    let status = if has_errors {
444        ScanStatus::Failed
445    } else if has_warnings {
446        if strict {
447            ScanStatus::Failed
448        } else {
449            ScanStatus::Warning
450        }
451    } else {
452        ScanStatus::Passed
453    };
454
455    let risk_level = if has_rce_or_backdoor {
456        RiskLevel::Critical
457    } else if has_errors {
458        RiskLevel::High
459    } else if has_warnings {
460        RiskLevel::Medium
461    } else {
462        RiskLevel::Low
463    };
464
465    let score = (100u32.saturating_sub(deduction)).min(100) as u8;
466    let grade = match score {
467        90..=100 => SecurityGrade::A,
468        75..=89 => SecurityGrade::B,
469        60..=74 => SecurityGrade::C,
470        40..=59 => SecurityGrade::D,
471        _ => SecurityGrade::F,
472    };
473
474    (status, risk_level, score, grade)
475}
476
477/// Computes the security score and grade for a set of findings.
478///
479/// Kept as a standalone function for per-scanner scoring in
480/// [`ScanReport::from_results`].
481fn compute_security_score(findings: &[Finding]) -> (u8, SecurityGrade) {
482    let deduction: u32 = findings.iter().fold(0u32, |acc, f| {
483        let pts: u32 = match f.severity {
484            Severity::Error => {
485                let is_critical = f.rule_id.starts_with("bash/CAT-A")
486                    || f.rule_id.starts_with("bash/CAT-D")
487                    || f.rule_id.starts_with("typescript/CAT-A")
488                    || f.rule_id.starts_with("typescript/CAT-D")
489                    || f.rule_id.starts_with("prompt/");
490                if is_critical {
491                    30
492                } else {
493                    15
494                }
495            }
496            Severity::Warning => 5,
497            Severity::Info => 1,
498        };
499        acc + pts
500    });
501
502    let score = (100u32.saturating_sub(deduction)).min(100) as u8;
503    let grade = match score {
504        90..=100 => SecurityGrade::A,
505        75..=89 => SecurityGrade::B,
506        60..=74 => SecurityGrade::C,
507        40..=59 => SecurityGrade::D,
508        _ => SecurityGrade::F,
509    };
510    (score, grade)
511}
512
513fn find_suppression<'a>(
514    finding: &Finding,
515    suppressions: &'a [crate::config::Suppression],
516) -> Option<&'a crate::config::Suppression> {
517    suppressions.iter().find(|s| {
518        if s.rule != finding.rule_id {
519            return false;
520        }
521        // Use Path::ends_with so that a suppression for "test.sh" matches
522        // "/path/to/test.sh" but NOT "/path/to/maltest.sh".  A raw string
523        // ends_with check fails this: "maltest.sh".ends_with("test.sh") is true.
524        //
525        // When the finding has no file path, the file check cannot be satisfied
526        // unless the suppression also has an empty file field (wildcard).
527        // Falling through unconditionally when file is None would let any
528        // rule-only suppression suppress across all file-less findings.
529        match &finding.file {
530            Some(file) => {
531                if !file.ends_with(std::path::Path::new(&s.file)) {
532                    return false;
533                }
534            }
535            None => {
536                // Only allow suppression when the suppression entry does not
537                // target a specific file (empty string acts as a wildcard).
538                if !s.file.is_empty() {
539                    return false;
540                }
541            }
542        }
543        if let (Some(ref lines), Some(line)) = (&s.lines, finding.line) {
544            match parse_line_range(lines) {
545                Some((start, end)) if line >= start && line <= end => {}
546                // Range is either invalid (None) or the line is outside the range —
547                // either way the suppression does not apply.
548                _ => return false,
549            }
550        }
551        true
552    })
553}
554
555fn parse_line_range(lines: &str) -> Option<(usize, usize)> {
556    let parts: Vec<&str> = lines.split('-').collect();
557    if parts.len() == 2 {
558        let start = parts[0].parse().ok()?;
559        let end = parts[1].parse().ok()?;
560        if start > end {
561            return None;
562        }
563        Some((start, end))
564    } else if parts.len() == 1 {
565        let line = parts[0].parse().ok()?;
566        Some((line, line))
567    } else {
568        None
569    }
570}