use clap::{Parser, Subcommand};
use zentinel_modsec::{ModSecurity, Result, Transaction};
use std::path::PathBuf;
use tracing::{error, info};
#[derive(Parser)]
#[command(name = "zentinel-modsec")]
#[command(author, version, about, long_about = None)]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Check {
#[arg(short, long)]
rules: PathBuf,
},
Test {
#[arg(short, long)]
rules: PathBuf,
#[arg(short, long)]
uri: String,
#[arg(short, long, default_value = "GET")]
method: String,
#[arg(short = 'H', long)]
header: Vec<String>,
#[arg(short, long)]
body: Option<String>,
},
Dump {
#[arg(short, long)]
rules: PathBuf,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
let filter = match cli.verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.init();
match cli.command {
Commands::Check { rules } => check_rules(&rules),
Commands::Test {
rules,
uri,
method,
header,
body,
} => test_request(&rules, &uri, &method, &header, body.as_deref()),
Commands::Dump { rules } => dump_rules(&rules),
}
}
fn check_rules(path: &PathBuf) -> Result<()> {
info!("Checking rules from {:?}", path);
let path_str = path.to_string_lossy();
let modsec = ModSecurity::from_file(&path_str)?;
println!("Successfully parsed {} rules", modsec.rule_count());
Ok(())
}
fn test_request(
rules_path: &PathBuf,
uri: &str,
method: &str,
headers: &[String],
body: Option<&str>,
) -> Result<()> {
info!("Testing request against rules from {:?}", rules_path);
let path_str = rules_path.to_string_lossy();
let modsec = ModSecurity::from_file(&path_str)?;
let mut tx = modsec.new_transaction();
tx.process_uri(uri, method, "HTTP/1.1")?;
for header in headers {
if let Some((name, value)) = header.split_once(':') {
tx.add_request_header(name.trim(), value.trim())?;
}
}
tx.process_request_headers()?;
if let Some(intervention) = tx.intervention() {
println!("BLOCKED (Phase 1)");
println!(" Status: {}", intervention.status);
println!(" Rules: {:?}", intervention.rule_ids);
if let Some(ref log) = intervention.log {
println!(" Message: {}", log);
}
return Ok(());
}
if let Some(body_data) = body {
tx.append_request_body(body_data.as_bytes())?;
tx.process_request_body()?;
if let Some(intervention) = tx.intervention() {
println!("BLOCKED (Phase 2)");
println!(" Status: {}", intervention.status);
println!(" Rules: {:?}", intervention.rule_ids);
if let Some(ref log) = intervention.log {
println!(" Message: {}", log);
}
return Ok(());
}
}
println!("ALLOWED");
println!(" Matched rules: {:?}", tx.matched_rules());
println!(" Anomaly score: {}", tx.anomaly_score());
Ok(())
}
fn dump_rules(path: &PathBuf) -> Result<()> {
info!("Dumping rules from {:?}", path);
let path_str = path.to_string_lossy();
let modsec = ModSecurity::from_file(&path_str)?;
println!("Total rules: {}", modsec.rule_count());
println!("\nRules by phase:");
for phase_num in 1..=5 {
if let Some(phase) = zentinel_modsec::engine::phase::Phase::from_number(phase_num) {
let rules = modsec.ruleset().rules_for_phase(phase);
if !rules.is_empty() {
println!("\n Phase {} ({}):", phase_num, phase.name());
for rule in rules {
let id = rule.id.as_deref().unwrap_or("no-id");
println!(" - Rule {}", id);
}
}
}
}
Ok(())
}