use std::path::PathBuf;
use serde::Serialize;
use crate::detect::extract::{Pattern, extract_patterns};
use crate::detect::redos::Severity;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Diagnostic {
pub(crate) severity: String,
pub(crate) code: String,
pub(crate) message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub(crate) struct Summary {
pub(crate) patterns: usize,
pub(crate) findings: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct FileReport {
pub(crate) file: String,
pub(crate) patterns: Vec<Pattern>,
pub(crate) diagnostics: Vec<Diagnostic>,
pub(crate) summary: Summary,
}
impl FileReport {
pub(crate) fn is_unexamined(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == "error")
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ScanOptions {
pub(crate) threshold: Severity,
pub(crate) all: bool,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
threshold: Severity::Medium,
all: false,
}
}
}
fn at_or_above(severity: Severity, threshold: Severity) -> bool {
match threshold {
Severity::High => severity == Severity::High,
Severity::Medium => severity != Severity::Low,
Severity::Low => true,
}
}
pub(crate) fn scan_file(path: &PathBuf, options: ScanOptions) -> Option<FileReport> {
let file = path.to_string_lossy().into_owned();
match std::fs::read(path) {
Ok(bytes) => String::from_utf8(bytes)
.ok()
.map(|content| scan_content(&content, file, options)),
Err(error) => Some(FileReport {
file,
patterns: Vec::new(),
diagnostics: vec![Diagnostic {
severity: "error".to_string(),
code: "unreadable".to_string(),
message: format!("could not be read: {error}"),
}],
summary: Summary {
patterns: 0,
findings: 0,
},
}),
}
}
pub(crate) fn scan_content(content: &str, file: String, options: ScanOptions) -> FileReport {
let (patterns, diagnostics) = match extract_patterns(content) {
Ok(patterns) => (patterns, Vec::new()),
Err(message) => (
Vec::new(),
vec![Diagnostic {
severity: "error".to_string(),
code: "incomplete".to_string(),
message,
}],
),
};
let findings = patterns
.iter()
.filter(|pattern| at_or_above(pattern.redos.severity, options.threshold))
.count();
let reported: Vec<Pattern> = if options.all {
patterns
} else {
patterns
.into_iter()
.filter(|pattern| at_or_above(pattern.redos.severity, options.threshold))
.collect()
};
FileReport {
file,
summary: Summary {
patterns: reported.len(),
findings,
},
patterns: reported,
diagnostics,
}
}
pub(crate) fn exit_code(reports: &[FileReport]) -> u8 {
if reports.iter().any(FileReport::is_unexamined) {
return 2;
}
u8::from(reports.iter().any(|report| report.summary.findings > 0))
}
pub(crate) fn describe(report: &FileReport, pattern: &Pattern) -> String {
format!(
"{}:{}:{} /{}/{} [{}] {}",
report.file,
pattern.line,
pattern.column,
pattern.pattern,
pattern.flags,
severity_name(pattern.redos.severity),
pattern.redos.reason
)
}
pub(crate) fn severity_name(severity: Severity) -> &'static str {
match severity {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
#[test]
fn a_clean_file_exits_zero() {
let report = scan_content(
"const re = /[a-z]+/;",
"a.js".into(),
ScanOptions::default(),
);
assert_eq!(report.summary.findings, 0);
assert_eq!(exit_code(&[report]), 0);
}
#[test]
fn a_vulnerable_pattern_is_a_finding() {
let report = scan_content("const re = /(a+)+/;", "a.js".into(), ScanOptions::default());
assert_eq!(report.summary.findings, 1);
assert_eq!(exit_code(&[report]), 1);
}
#[test]
fn only_findings_are_reported_unless_all_is_asked_for() {
let content = "const a = /[a-z]+/;\nconst b = /(a+)+/;\n";
let lint = scan_content(content, "a.js".into(), ScanOptions::default());
assert_eq!(lint.patterns.len(), 1);
let everything = scan_content(
content,
"a.js".into(),
ScanOptions {
all: true,
..ScanOptions::default()
},
);
assert_eq!(everything.patterns.len(), 2);
assert_eq!(
everything.summary.findings, 1,
"reporting more does not find more"
);
}
#[test]
fn the_threshold_narrows_what_counts() {
let content = "const re = /(a|a)*/;";
let medium = scan_content(content, "a.js".into(), ScanOptions::default());
assert_eq!(medium.summary.findings, 1);
let high = scan_content(
content,
"a.js".into(),
ScanOptions {
threshold: Severity::High,
..ScanOptions::default()
},
);
assert_eq!(high.summary.findings, 0);
}
#[test]
fn a_binary_file_is_skipped_rather_than_failed() {
let tree = TempTree::new("scan-binary");
let file = tree.path().join("logo.png");
std::fs::write(&file, [0x89, 0x50, 0xff, 0xfe]).expect("a file");
assert!(scan_file(&file, ScanOptions::default()).is_none());
}
#[test]
fn an_unreadable_file_ends_the_run_at_two() {
let tree = TempTree::new("scan-unreadable");
let report =
scan_file(&tree.path().join("gone.js"), ScanOptions::default()).expect("a report");
assert!(report.is_unexamined());
assert_eq!(exit_code(&[report]), 2);
}
#[test]
fn nothing_to_scan_exits_clear() {
assert_eq!(exit_code(&[]), 0);
}
#[test]
fn the_human_line_carries_the_pattern_and_its_verdict() {
let report = scan_content(
"const re = /(a+)+/g;",
"a.js".into(),
ScanOptions::default(),
);
let line = describe(&report, &report.patterns[0]);
assert!(line.contains("a.js:1:12"), "{line}");
assert!(line.contains("/(a+)+/g"), "{line}");
assert!(line.contains("[high]"), "{line}");
}
}