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}