#[cfg(feature = "hunt-postgres")]
pub(crate) mod execute;
pub(crate) mod query;
#[cfg(feature = "hunt-postgres")]
pub(crate) mod reshape;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process;
use clap::{Args, Subcommand, ValueEnum};
use crate::exit_code;
use crate::output::OutputCtx;
use query::{DEFAULT_LIMIT, HuntQueryError, HuntWindow};
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
pub(crate) enum EmitMode {
#[default]
Events,
Sql,
}
#[derive(Args, Debug)]
pub(crate) struct HuntRunArgs {
#[arg(short = 'r', long = "rules", value_name = "PATH", num_args = 1.., required = true)]
pub rules: Vec<PathBuf>,
#[arg(short, long)]
pub target: String,
#[arg(long, env = "RSIGMA_HUNT_DSN", hide_env_values = true)]
pub dsn: Option<String>,
#[arg(short = 'p', long = "pipeline")]
pub pipeline: Vec<PathBuf>,
#[arg(short = 'O', long = "option")]
pub backend_options: Vec<String>,
#[arg(long)]
pub since: Option<String>,
#[arg(long)]
pub until: Option<String>,
#[arg(long, default_value_t = DEFAULT_LIMIT)]
pub limit: usize,
#[arg(long, default_value = "60s")]
pub timeout: String,
#[arg(long, value_enum, default_value = "events")]
pub emit: EmitMode,
#[arg(short, long)]
pub output: Option<PathBuf>,
}
#[derive(Subcommand)]
pub(crate) enum HuntCommands {
Run(HuntRunArgs),
}
pub(crate) fn dispatch_hunt(cmd: HuntCommands, ctx: OutputCtx) {
match cmd {
HuntCommands::Run(args) => cmd_hunt_run(args, &ctx),
}
}
fn query_error_exit_code(err: &HuntQueryError) -> i32 {
match err {
HuntQueryError::InvalidIdentifier { .. }
| HuntQueryError::EmptyWindow { .. }
| HuntQueryError::InvalidTimeBound { .. } => exit_code::CONFIG_ERROR,
HuntQueryError::CorrelationRejected { .. }
| HuntQueryError::Conversion(_)
| HuntQueryError::RuleFailures(_)
| HuntQueryError::NonSelectOutput { .. } => exit_code::RULE_ERROR,
}
}
fn parse_timeout(value: &str) -> std::time::Duration {
let timeout = humantime::parse_duration(value).unwrap_or_else(|_| {
eprintln!("invalid --timeout '{value}': expected a duration like 30s, 5m");
process::exit(exit_code::CONFIG_ERROR);
});
let ms = timeout.as_millis();
if ms == 0 || ms > i32::MAX as u128 {
eprintln!(
"invalid --timeout '{value}': must be between 1ms and {}ms",
i32::MAX
);
process::exit(exit_code::CONFIG_ERROR);
}
timeout
}
fn cmd_hunt_run(args: HuntRunArgs, ctx: &OutputCtx) {
if !matches!(args.target.as_str(), "postgres" | "postgresql" | "pg") {
eprintln!(
"hunt run supports --target postgres only; '{}' is convert-only. \
Convert with `rsigma backend convert -t {}` and run the query in the \
target's own tooling.",
args.target, args.target
);
process::exit(exit_code::CONFIG_ERROR);
}
let timeout = parse_timeout(&args.timeout);
let now = chrono::Utc::now();
let parse_bound = |value: &str| {
query::parse_time_bound(value, now).unwrap_or_else(|e| {
eprintln!("{}", e.message());
process::exit(exit_code::CONFIG_ERROR);
})
};
let window = HuntWindow {
since: args.since.as_deref().map(parse_bound),
until: args.until.as_deref().map(parse_bound),
};
let options: HashMap<String, String> = args
.backend_options
.iter()
.filter_map(|opt| {
opt.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect();
let plan = query::build_hunt_plan(&args.rules, &args.pipeline, &options, &window, args.limit)
.unwrap_or_else(|e| {
let code = query_error_exit_code(&e);
eprintln!("{}", e.message());
process::exit(code);
});
match args.emit {
EmitMode::Sql => emit_sql(&plan, args.output.as_deref()),
EmitMode::Events => run_events(args, plan, timeout, ctx),
}
}
fn emit_sql(plan: &query::HuntPlan, output: Option<&std::path::Path>) {
let mut rendered = format!("-- timestamp_field: {}\n", plan.timestamp_field);
if let Some(json_field) = &plan.json_field {
rendered.push_str(&format!("-- json_field: {json_field}\n"));
}
for (i, q) in plan.queries.iter().enumerate() {
if i > 0 {
rendered.push('\n');
}
let id = q
.rule_id
.as_deref()
.map(|id| format!(" (id: {id})"))
.unwrap_or_default();
rendered.push_str(&format!("-- rule: {}{}\n{};\n", q.rule_title, id, q.sql));
}
match output {
Some(path) => {
if let Err(e) = std::fs::write(path, &rendered) {
eprintln!("Error writing to {}: {e}", path.display());
process::exit(exit_code::CONFIG_ERROR);
}
}
None => print!("{rendered}"),
}
}
#[cfg(feature = "hunt-postgres")]
fn run_events(
args: HuntRunArgs,
plan: query::HuntPlan,
timeout: std::time::Duration,
ctx: &OutputCtx,
) {
let dsn = args.dsn.clone().unwrap_or_else(|| {
eprintln!(
"hunt execution needs a connection string: pass --dsn or set RSIGMA_HUNT_DSN \
(or use --emit sql to review the queries without connecting)"
);
process::exit(exit_code::CONFIG_ERROR);
});
execute::run(&dsn, &plan, timeout, args.output.as_deref(), ctx);
}
#[cfg(not(feature = "hunt-postgres"))]
fn run_events(
args: HuntRunArgs,
plan: query::HuntPlan,
_timeout: std::time::Duration,
_ctx: &OutputCtx,
) {
let _ = (&args, &plan);
eprintln!(
"this binary was built without the 'hunt-postgres' feature; rebuild with \
--features hunt-postgres or use a released binary. \
(--emit sql works in every build.)"
);
process::exit(exit_code::CONFIG_ERROR);
}