use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use crate::detect::redos::Severity;
use crate::scan::{self, FileReport, ScanOptions};
use crate::walk::{self, WalkOptions};
const USAGE: &str = "usage: regex-le [options] <file|dir>...
regex-le [options] --stdin
regex-le mcp
regex-le --version | --help
Finds every regular expression in a tree and reports which of them can be
driven into catastrophic backtracking. Nothing is executed: the verdict
comes from the shape of the pattern text, never from running it.
It flags shapes; it cannot prove a pattern safe. A pattern it does not
recognise may still backtrack badly on adversarial input.
Options:
--severity <level> fail at this verdict or worse: high or medium
(default medium)
--all report every pattern, not only the vulnerable ones
--stdin read one document from stdin
--hidden walk hidden files and directories too
--no-ignore walk files that .gitignore excludes
Exit codes: 0 nothing vulnerable · 1 at least one finding · 2 the
question was malformed.";
const FLAGS: [&str; 5] = ["--severity", "--all", "--stdin", "--hidden", "--no-ignore"];
const THRESHOLDS: [&str; 2] = ["high", "medium"];
#[derive(Debug)]
struct Options {
inputs: Vec<PathBuf>,
stdin: bool,
scan: ScanOptions,
walk: WalkOptions,
}
pub(crate) fn run() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
if let Some(first) = args.first() {
match first.as_str() {
"mcp" => return crate::mcp::serve(),
"--help" | "-h" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
"--version" | "-V" => {
println!("regex-le {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
_ => {}
}
}
match execute(&args) {
Ok(code) => ExitCode::from(code),
Err(message) => {
eprintln!("regex-le: {message}");
ExitCode::from(2)
}
}
}
fn execute(args: &[String]) -> Result<u8, String> {
let options = parse(args)?;
let reports = if options.stdin {
vec![scan_stdin(options.scan)?]
} else {
walk::collect(&options.inputs, &options.walk)?
.files
.iter()
.filter_map(|target| scan::scan_file(target, options.scan))
.collect()
};
let mut stdout = std::io::stdout().lock();
for report in &reports {
let line = serde_json::to_string(report).expect("a report serializes");
writeln!(stdout, "{line}")
.map_err(|error| format!("could not write the report: {error}"))?;
}
drop(stdout);
summarise(&reports);
Ok(scan::exit_code(&reports))
}
fn scan_stdin(options: ScanOptions) -> Result<FileReport, String> {
let mut content = String::new();
std::io::stdin()
.read_to_string(&mut content)
.map_err(|error| format!("could not read stdin: {error}"))?;
Ok(scan::scan_content(&content, "<stdin>".to_string(), options))
}
fn parse(args: &[String]) -> Result<Options, String> {
let mut options = Options {
inputs: Vec::new(),
stdin: false,
scan: ScanOptions::default(),
walk: WalkOptions::default(),
};
let mut rest = args.iter();
while let Some(arg) = rest.next() {
if arg.starts_with('-') && !FLAGS.contains(&arg.as_str()) {
return Err(format!("{arg} is not an option. Try --help."));
}
match arg.as_str() {
"--all" => options.scan.all = true,
"--stdin" => options.stdin = true,
"--hidden" => options.walk.hidden = true,
"--no-ignore" => options.walk.respect_ignore = false,
"--severity" => {
let value = rest
.next()
.ok_or_else(|| "--severity needs a level".to_string())?;
options.scan.threshold = threshold(value)?;
}
path => options.inputs.push(PathBuf::from(path)),
}
}
if options.stdin && !options.inputs.is_empty() {
return Err("reading from stdin takes no file arguments".to_string());
}
if !options.stdin && options.inputs.is_empty() {
return Err("name a file or a directory to scan. Try --help.".to_string());
}
Ok(options)
}
fn threshold(value: &str) -> Result<Severity, String> {
match value {
"high" => Ok(Severity::High),
"medium" => Ok(Severity::Medium),
"low" => Err(
"low is not a threshold: every pattern has a verdict, so it would fail on any file \
holding a regex at all. Use --all to see them."
.to_string(),
),
other => Err(format!(
"{other} is not a severity; one of: {}",
THRESHOLDS.join(", ")
)),
}
}
fn summarise(reports: &[FileReport]) {
let mut stderr = std::io::stderr().lock();
let mut findings = 0;
for report in reports {
for diagnostic in &report.diagnostics {
let _ = writeln!(stderr, "{}: {}", report.file, diagnostic.message);
}
for pattern in &report.patterns {
let _ = writeln!(stderr, "{}", scan::describe(report, pattern));
}
findings += report.summary.findings;
}
let _ = writeln!(
stderr,
"{} in {}",
plural(findings, "finding", "findings"),
plural(reports.len(), "file", "files")
);
}
fn plural(count: usize, one: &str, many: &str) -> String {
format!("{count} {}", if count == 1 { one } else { many })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_documented_flag_is_parsed_and_the_reverse() {
let mut documented: Vec<&str> = USAGE
.split_whitespace()
.filter(|word| word.starts_with("--"))
.map(|word| word.trim_end_matches([',', '.', ':', ';']))
.filter(|word| !matches!(*word, "--version" | "--help"))
.collect();
documented.sort_unstable();
documented.dedup();
let mut implemented = FLAGS.to_vec();
implemented.sort_unstable();
assert_eq!(documented, implemented);
}
#[test]
fn the_parser_accepts_every_flag_it_lists() {
for flag in FLAGS {
let args: Vec<String> = match flag {
"--severity" => vec![flag.into(), "high".into(), "x".into()],
"--stdin" => vec![flag.into()],
_ => vec![flag.into(), "x".into()],
};
assert!(parse(&args).is_ok(), "{flag}");
}
}
#[test]
fn every_documented_threshold_is_accepted() {
for level in THRESHOLDS {
assert!(threshold(level).is_ok(), "{level}");
assert!(USAGE.contains(level), "{level} is undocumented");
}
}
#[test]
fn an_unknown_flag_is_refused_rather_than_ignored() {
let error = parse(&["--sever".into(), "x".into()]).expect_err("a refusal");
assert!(error.contains("--sever"), "{error}");
}
#[test]
fn low_is_refused_with_its_reason() {
let error = threshold("low").expect_err("a refusal");
assert!(error.contains("--all"), "{error}");
assert!(!USAGE.contains("low"), "the usage text offers low");
}
#[test]
fn an_unknown_severity_is_refused_by_name() {
let error = threshold("critical").expect_err("a refusal");
assert!(error.contains("critical"), "{error}");
}
#[test]
fn no_flag_offers_to_run_a_pattern() {
for attempt in ["--test", "--match", "--input", "--timeout", "--fix"] {
assert!(
parse(&[attempt.into(), "x".into()]).is_err(),
"{attempt} was accepted"
);
}
}
#[test]
fn naming_nothing_is_refused() {
assert!(parse(&[]).is_err());
}
#[test]
fn stdin_and_file_arguments_together_are_refused() {
assert!(parse(&["--stdin".into(), "x".into()]).is_err());
}
#[test]
fn the_default_threshold_is_medium() {
let options = parse(&["x".into()]).expect("options");
assert_eq!(options.scan.threshold, Severity::Medium);
}
#[test]
fn the_usage_text_states_it_proves_nothing_safe() {
assert!(USAGE.contains("cannot prove"), "the scope is unstated");
for code in ["0", "1", "2"] {
assert!(USAGE.contains(code), "exit code {code} is undocumented");
}
}
}