use std::fmt;
use std::path::PathBuf;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Error,
Warning,
Info,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Severity::Error => write!(f, "error"),
Severity::Warning => write!(f, "warning"),
Severity::Info => write!(f, "info"),
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Finding {
pub rule_id: String,
pub message: String,
pub severity: Severity,
pub file: Option<PathBuf>,
pub line: Option<usize>,
pub column: Option<usize>,
pub scanner: String,
pub snippet: Option<String>,
pub suppressed: bool,
pub suppression_reason: Option<String>,
pub remediation: Option<String>,
}
#[derive(Debug, serde::Serialize)]
pub struct ScanResult {
pub scanner_name: String,
pub findings: Vec<Finding>,
pub files_scanned: usize,
pub skipped: bool,
pub skip_reason: Option<String>,
pub error: Option<String>,
pub duration_ms: u64,
pub scanner_score: Option<u8>,
pub scanner_grade: Option<SecurityGrade>,
}
impl ScanResult {
pub fn skipped(name: &str, reason: &str) -> Self {
ScanResult {
scanner_name: name.to_string(),
findings: vec![],
files_scanned: 0,
skipped: true,
skip_reason: Some(reason.to_string()),
error: None,
duration_ms: 0,
scanner_score: None,
scanner_grade: None,
}
}
pub fn error(name: &str, error: String, duration_ms: u64) -> Self {
ScanResult {
scanner_name: name.to_string(),
findings: vec![],
files_scanned: 0,
skipped: false,
skip_reason: None,
error: Some(error),
duration_ms,
scanner_score: None,
scanner_grade: None,
}
}
}
#[derive(Debug, serde::Serialize)]
pub struct ScanReport {
pub skill: String,
pub version: Option<String>,
pub scan_timestamp: String,
pub status: ScanStatus,
pub risk_level: RiskLevel,
pub security_score: u8,
pub security_grade: SecurityGrade,
pub files_scanned: usize,
pub scanner_results: Vec<ScanResult>,
pub findings: Vec<Finding>,
pub suppressed: Vec<Finding>,
pub passed: bool,
}
impl ScanReport {
pub fn from_results(
skill: &str,
results: Vec<ScanResult>,
suppressions: &[crate::config::Suppression],
strict: bool,
) -> Self {
let files_scanned: usize = results.iter().map(|r| r.files_scanned).sum();
let results: Vec<ScanResult> = results
.into_iter()
.map(|mut r| {
if !r.skipped && r.error.is_none() {
let (score, grade) = compute_security_score(&r.findings);
r.scanner_score = Some(score);
r.scanner_grade = Some(grade);
}
r
})
.collect();
let mut active = Vec::new();
let mut suppressed = Vec::new();
for result in &results {
for finding in &result.findings {
if finding.suppressed {
suppressed.push(finding.clone());
} else if let Some(s) = find_suppression(finding, suppressions) {
let mut f = finding.clone();
f.suppressed = true;
f.suppression_reason = Some(s.reason.clone());
suppressed.push(f);
} else {
active.push(finding.clone());
}
}
}
let (status, risk_level, security_score, security_grade) =
compute_scan_metrics(&active, strict);
let passed = matches!(status, ScanStatus::Passed);
ScanReport {
skill: skill.to_string(),
version: None,
scan_timestamp: chrono::Utc::now().to_rfc3339(),
status,
risk_level,
security_score,
security_grade,
files_scanned,
scanner_results: results,
findings: active,
suppressed,
passed,
}
}
pub fn error_count(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == Severity::Error)
.count()
}
pub fn warning_count(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == Severity::Warning)
.count()
}
pub fn info_count(&self) -> usize {
self.findings
.iter()
.filter(|f| f.severity == Severity::Info)
.count()
}
pub fn count_by_severity(&self) -> (usize, usize, usize) {
self.findings
.iter()
.fold((0, 0, 0), |(e, w, i), f| match f.severity {
Severity::Error => (e + 1, w, i),
Severity::Warning => (e, w + 1, i),
Severity::Info => (e, w, i + 1),
})
}
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ScanStatus {
Passed,
Warning,
Failed,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskLevel {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SecurityGrade {
A,
B,
C,
D,
F,
}
impl fmt::Display for SecurityGrade {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SecurityGrade::A => write!(f, "A"),
SecurityGrade::B => write!(f, "B"),
SecurityGrade::C => write!(f, "C"),
SecurityGrade::D => write!(f, "D"),
SecurityGrade::F => write!(f, "F"),
}
}
}
fn compute_scan_metrics(
findings: &[Finding],
strict: bool,
) -> (ScanStatus, RiskLevel, u8, SecurityGrade) {
let mut has_errors = false;
let mut has_warnings = false;
let mut has_rce_or_backdoor = false;
let mut deduction: u32 = 0;
for f in findings {
let is_critical = f.rule_id.starts_with("bash/CAT-A")
|| f.rule_id.starts_with("bash/CAT-D")
|| f.rule_id.starts_with("typescript/CAT-A")
|| f.rule_id.starts_with("typescript/CAT-D")
|| f.rule_id.starts_with("prompt/");
match f.severity {
Severity::Error => {
has_errors = true;
if is_critical {
has_rce_or_backdoor = true;
deduction += 30;
} else {
deduction += 15;
}
}
Severity::Warning => {
has_warnings = true;
deduction += 5;
}
Severity::Info => {
deduction += 1;
}
}
}
let status = if has_errors {
ScanStatus::Failed
} else if has_warnings {
if strict {
ScanStatus::Failed
} else {
ScanStatus::Warning
}
} else {
ScanStatus::Passed
};
let risk_level = if has_rce_or_backdoor {
RiskLevel::Critical
} else if has_errors {
RiskLevel::High
} else if has_warnings {
RiskLevel::Medium
} else {
RiskLevel::Low
};
let score = (100u32.saturating_sub(deduction)).min(100) as u8;
let grade = match score {
90..=100 => SecurityGrade::A,
75..=89 => SecurityGrade::B,
60..=74 => SecurityGrade::C,
40..=59 => SecurityGrade::D,
_ => SecurityGrade::F,
};
(status, risk_level, score, grade)
}
fn compute_security_score(findings: &[Finding]) -> (u8, SecurityGrade) {
let deduction: u32 = findings.iter().fold(0u32, |acc, f| {
let pts: u32 = match f.severity {
Severity::Error => {
let is_critical = f.rule_id.starts_with("bash/CAT-A")
|| f.rule_id.starts_with("bash/CAT-D")
|| f.rule_id.starts_with("typescript/CAT-A")
|| f.rule_id.starts_with("typescript/CAT-D")
|| f.rule_id.starts_with("prompt/");
if is_critical {
30
} else {
15
}
}
Severity::Warning => 5,
Severity::Info => 1,
};
acc + pts
});
let score = (100u32.saturating_sub(deduction)).min(100) as u8;
let grade = match score {
90..=100 => SecurityGrade::A,
75..=89 => SecurityGrade::B,
60..=74 => SecurityGrade::C,
40..=59 => SecurityGrade::D,
_ => SecurityGrade::F,
};
(score, grade)
}
fn find_suppression<'a>(
finding: &Finding,
suppressions: &'a [crate::config::Suppression],
) -> Option<&'a crate::config::Suppression> {
suppressions.iter().find(|s| {
if s.rule != finding.rule_id {
return false;
}
match &finding.file {
Some(file) => {
if !file.ends_with(std::path::Path::new(&s.file)) {
return false;
}
}
None => {
if !s.file.is_empty() {
return false;
}
}
}
if let (Some(ref lines), Some(line)) = (&s.lines, finding.line) {
match parse_line_range(lines) {
Some((start, end)) if line >= start && line <= end => {}
_ => return false,
}
}
true
})
}
fn parse_line_range(lines: &str) -> Option<(usize, usize)> {
let parts: Vec<&str> = lines.split('-').collect();
if parts.len() == 2 {
let start = parts[0].parse().ok()?;
let end = parts[1].parse().ok()?;
if start > end {
return None;
}
Some((start, end))
} else if parts.len() == 1 {
let line = parts[0].parse().ok()?;
Some((line, line))
} else {
None
}
}