mod commands;
mod config;
#[cfg(feature = "daemon")]
mod daemon;
pub(crate) mod exit_code;
mod fix;
pub(crate) mod output;
use std::path::PathBuf;
use std::process;
use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand};
use commands::{
ConditionArgs, ConvertArgs, EvalArgs, FieldsArgs, LintArgs, LintCounts, ListFormatsArgs,
MigrateSourcesArgs, ParseArgs, ResolveArgs, StdinArgs, ValidateArgs,
};
#[cfg(feature = "daemon")]
use commands::{DaemonArgs, cmd_daemon};
use jaq_core::load::{Arena, File, Loader};
use jaq_core::{Compiler, Ctx, Vars, data, unwrap_valr};
use jaq_json::Val;
use rsigma_eval::{
CorrelationAction, CorrelationConfig, CorrelationEventMode, Pipeline, parse_pipeline_file,
resolve_builtin_pipeline,
};
use rsigma_parser::{SigmaCollection, parse_sigma_directory, parse_sigma_file};
use serde_json_path::JsonPath;
#[derive(Parser)]
#[command(name = "rsigma")]
#[command(about = "Parse, validate, and evaluate Sigma detection rules")]
#[command(version)]
struct Cli {
#[arg(long = "log-format", value_enum, global = true)]
log_format: Option<LogFormat>,
#[arg(long = "output-format", value_enum, global = true)]
output_format: Option<output::OutputFormat>,
#[arg(long = "color", value_enum, global = true)]
color: Option<output::ColorChoice>,
#[arg(long = "quiet", short = 'q', global = true)]
quiet: bool,
#[arg(long = "no-stats", global = true)]
no_stats: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Clone, Copy, Debug, clap::ValueEnum)]
enum LogFormat {
Json,
Text,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Commands {
Engine {
#[command(subcommand)]
cmd: EngineCommands,
},
Rule {
#[command(subcommand)]
cmd: RuleCommands,
},
Backend {
#[command(subcommand)]
cmd: BackendCommands,
},
Pipeline {
#[command(subcommand)]
cmd: PipelineCommands,
},
Config {
#[command(subcommand)]
cmd: config::commands::ConfigCommands,
},
#[command(hide = true)]
Eval(EvalArgs),
#[cfg(feature = "daemon")]
#[command(hide = true)]
Daemon(DaemonArgs),
#[command(hide = true)]
Parse(ParseArgs),
#[command(hide = true)]
Validate(ValidateArgs),
#[command(hide = true)]
Lint(LintArgs),
#[command(hide = true)]
Fields(FieldsArgs),
#[command(hide = true)]
Condition(ConditionArgs),
#[command(hide = true)]
Stdin(StdinArgs),
#[command(hide = true)]
Convert(ConvertArgs),
#[command(name = "list-targets", hide = true)]
ListTargets,
#[command(name = "list-formats", hide = true)]
ListFormats(ListFormatsArgs),
#[command(hide = true)]
Resolve(ResolveArgs),
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum EngineCommands {
Eval(EvalArgs),
#[cfg(feature = "daemon")]
Daemon(DaemonArgs),
}
#[derive(Subcommand)]
enum RuleCommands {
Parse(ParseArgs),
Validate(ValidateArgs),
Lint(LintArgs),
Fields(FieldsArgs),
Condition(ConditionArgs),
Stdin(StdinArgs),
MigrateSources(MigrateSourcesArgs),
}
#[derive(Subcommand)]
enum BackendCommands {
Convert(ConvertArgs),
Targets,
Formats(ListFormatsArgs),
}
#[derive(Subcommand)]
enum PipelineCommands {
Resolve(ResolveArgs),
}
fn main() {
let matches = Cli::command().get_matches();
let cli = match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
#[cfg(feature = "daemon")]
let is_daemon = matches!(
cli.command,
Commands::Engine {
cmd: EngineCommands::Daemon(_),
..
} | Commands::Daemon(_)
);
#[cfg(not(feature = "daemon"))]
let is_daemon = false;
let cfg_override = scan_config_flag();
let log_format = cli.log_format.or_else(|| {
config::discovered_log_format(cfg_override.as_deref()).and_then(|s| parse_log_format(&s))
});
if !is_daemon && let Some(format) = log_format {
init_cli_log_subscriber(format);
}
let (cfg_format, cfg_color) = config::discovered_global_output(cfg_override.as_deref());
let (cfg_format, cfg_color) = output::warn_invalid_global_output(cfg_format, cfg_color);
let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
let ctx = output::OutputCtx::resolve(
cli.output_format,
cfg_format.as_deref(),
cli.color,
cfg_color.as_deref(),
cli.quiet,
cli.no_stats,
stdout_is_tty,
);
dispatch(cli.command, &matches, ctx);
}
fn scan_config_flag() -> Option<PathBuf> {
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
if arg == "--config" {
return args.next().map(PathBuf::from);
}
if let Some(value) = arg.strip_prefix("--config=") {
return Some(PathBuf::from(value));
}
}
None
}
fn parse_log_format(s: &str) -> Option<LogFormat> {
match s {
"json" => Some(LogFormat::Json),
"text" => Some(LogFormat::Text),
_ => None,
}
}
fn deprecation_warn(old: &str, new: &str) {
eprintln!(
"warning: `rsigma {old}` is deprecated; use `rsigma {new}` instead. \
This alias is hidden from `--help` and will be removed in v1.0."
);
}
fn dispatch(command: Commands, matches: &ArgMatches, ctx: output::OutputCtx) {
match command {
Commands::Engine { cmd } => dispatch_engine(cmd, matches, ctx),
Commands::Rule { cmd } => dispatch_rule(cmd, ctx),
Commands::Backend { cmd } => dispatch_backend(cmd, ctx),
Commands::Pipeline { cmd } => dispatch_pipeline(cmd),
Commands::Config { cmd } => config::commands::dispatch(cmd),
Commands::Eval(args) => {
deprecation_warn("eval", "engine eval");
let em = matches
.subcommand_matches("eval")
.expect("eval submatches present");
run_eval(args, em, ctx);
}
#[cfg(feature = "daemon")]
Commands::Daemon(args) => {
deprecation_warn("daemon", "engine daemon");
let dm = matches
.subcommand_matches("daemon")
.expect("daemon submatches present");
cmd_daemon(args, dm);
}
Commands::Parse(args) => {
deprecation_warn("parse", "rule parse");
commands::cmd_parse(args, ctx);
}
Commands::Validate(args) => {
deprecation_warn("validate", "rule validate");
commands::cmd_validate(args);
}
Commands::Lint(args) => {
deprecation_warn("lint", "rule lint");
run_lint(args, ctx);
}
Commands::Fields(args) => {
deprecation_warn("fields", "rule fields");
commands::cmd_fields(args, ctx);
}
Commands::Condition(args) => {
deprecation_warn("condition", "rule condition");
commands::cmd_condition(args, ctx);
}
Commands::Stdin(args) => {
deprecation_warn("stdin", "rule stdin");
commands::cmd_stdin(args, ctx);
}
Commands::Convert(args) => {
deprecation_warn("convert", "backend convert");
commands::cmd_convert(args, ctx);
}
Commands::ListTargets => {
deprecation_warn("list-targets", "backend targets");
commands::cmd_list_targets();
}
Commands::ListFormats(ListFormatsArgs { target }) => {
deprecation_warn("list-formats", "backend formats");
commands::cmd_list_formats(target);
}
Commands::Resolve(args) => {
deprecation_warn("resolve", "pipeline resolve");
commands::cmd_resolve(args);
}
}
}
fn dispatch_engine(cmd: EngineCommands, matches: &ArgMatches, ctx: output::OutputCtx) {
match cmd {
EngineCommands::Eval(args) => {
let em = matches
.subcommand_matches("engine")
.and_then(|m| m.subcommand_matches("eval"))
.expect("engine eval submatches present");
run_eval(args, em, ctx);
}
#[cfg(feature = "daemon")]
EngineCommands::Daemon(args) => {
let dm = matches
.subcommand_matches("engine")
.and_then(|m| m.subcommand_matches("daemon"))
.expect("engine daemon submatches present");
cmd_daemon(args, dm);
}
}
}
fn dispatch_rule(cmd: RuleCommands, ctx: output::OutputCtx) {
match cmd {
RuleCommands::Parse(args) => commands::cmd_parse(args, ctx),
RuleCommands::Validate(args) => commands::cmd_validate(args),
RuleCommands::Lint(args) => run_lint(args, ctx),
RuleCommands::Fields(args) => commands::cmd_fields(args, ctx),
RuleCommands::Condition(args) => commands::cmd_condition(args, ctx),
RuleCommands::Stdin(args) => commands::cmd_stdin(args, ctx),
RuleCommands::MigrateSources(args) => commands::cmd_migrate_sources(args),
}
}
fn dispatch_backend(cmd: BackendCommands, ctx: output::OutputCtx) {
match cmd {
BackendCommands::Convert(args) => commands::cmd_convert(args, ctx),
BackendCommands::Targets => commands::cmd_list_targets(),
BackendCommands::Formats(ListFormatsArgs { target }) => commands::cmd_list_formats(target),
}
}
fn dispatch_pipeline(cmd: PipelineCommands) {
match cmd {
PipelineCommands::Resolve(args) => commands::cmd_resolve(args),
}
}
fn run_eval(mut args: EvalArgs, matches: &ArgMatches, ctx: output::OutputCtx) {
commands::apply_eval_config(&mut args, matches);
let fail_on_detection = args.fail_on_detection;
let had_matches = commands::cmd_eval(args, ctx);
if fail_on_detection && had_matches {
process::exit(exit_code::FINDINGS);
}
}
fn run_lint(args: LintArgs, ctx: output::OutputCtx) {
let fail_level = args.fail_level.clone();
let LintCounts {
errors,
warnings,
infos,
} = commands::cmd_lint(args, ctx);
let should_fail = match fail_level.as_str() {
"info" => errors > 0 || warnings > 0 || infos > 0,
"warning" => errors > 0 || warnings > 0,
_ => errors > 0,
};
if should_fail {
process::exit(exit_code::FINDINGS);
}
}
fn init_cli_log_subscriber(format: LogFormat) {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let builder = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr);
match format {
LogFormat::Json => {
let _ = builder.json().try_init();
}
LogFormat::Text => {
let _ = builder.try_init();
}
}
}
pub(crate) fn load_pipelines(paths: &[PathBuf]) -> Vec<Pipeline> {
let mut pipelines = Vec::new();
for path in paths {
let name = path.to_str().unwrap_or("");
if let Some(result) = resolve_builtin_pipeline(name) {
match result {
Ok(p) => {
eprintln!(
"Loaded builtin pipeline: {} (priority {})",
p.name, p.priority
);
pipelines.push(p);
}
Err(e) => {
eprintln!("Error parsing builtin pipeline '{name}': {e}");
process::exit(exit_code::CONFIG_ERROR);
}
}
} else {
match parse_pipeline_file(path) {
Ok(p) => {
eprintln!("Loaded pipeline: {} (priority {})", p.name, p.priority);
if p.is_dynamic() {
let source_ids: Vec<&str> =
p.sources.iter().map(|s| s.id.as_str()).collect();
eprintln!(" dynamic source(s): {}", source_ids.join(", "));
}
if !p.sources.is_empty() {
rsigma_runtime::warn_pipeline_inline_sources(path, &p.name);
}
pipelines.push(p);
}
Err(e) => {
eprintln!("Error loading pipeline {}: {e}", path.display());
process::exit(exit_code::CONFIG_ERROR);
}
}
}
}
pipelines.sort_by_key(|p| p.priority);
pipelines
}
pub(crate) fn load_collection(path: &std::path::Path) -> SigmaCollection {
let collection = if path.is_dir() {
match parse_sigma_directory(path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error loading rules from {}: {e}", path.display());
process::exit(exit_code::RULE_ERROR);
}
}
} else {
match parse_sigma_file(path) {
Ok(c) => c,
Err(e) => {
eprintln!("Error loading rule {}: {e}", path.display());
process::exit(exit_code::RULE_ERROR);
}
}
};
if !collection.errors.is_empty() {
eprintln!(
"Warning: {} parse errors while loading rules",
collection.errors.len()
);
}
collection
}
pub(crate) fn print_warnings(errors: &[String]) {
if !errors.is_empty() {
eprintln!("Warnings:");
for err in errors {
eprintln!(" - {err}");
}
}
}
type CompiledJqFilter = jaq_core::Filter<data::JustLut<Val>>;
pub(crate) enum EventFilter {
None,
Jq(CompiledJqFilter),
JsonPath(JsonPath),
}
pub(crate) fn build_event_filter(jq: Option<String>, jsonpath: Option<String>) -> EventFilter {
if let Some(jq_expr) = jq {
eprintln!("Event filter: jq '{jq_expr}'");
let program = File {
code: jq_expr.as_str(),
path: (),
};
let defs = jaq_core::defs()
.chain(jaq_std::defs())
.chain(jaq_json::defs());
let funs = jaq_core::funs()
.chain(jaq_std::funs())
.chain(jaq_json::funs());
let arena = Arena::default();
let modules = Loader::new(defs).load(&arena, program).unwrap_or_else(|e| {
eprintln!("Invalid jq filter '{jq_expr}': {} module error(s)", e.len());
process::exit(exit_code::CONFIG_ERROR);
});
let filter = Compiler::default()
.with_funs(funs)
.compile(modules)
.unwrap_or_else(|e| {
eprintln!("jq compilation errors ({} error(s))", e.len());
process::exit(exit_code::CONFIG_ERROR);
});
EventFilter::Jq(filter)
} else if let Some(jp_expr) = jsonpath {
eprintln!("Event filter: jsonpath '{jp_expr}'");
match JsonPath::parse(&jp_expr) {
Ok(path) => EventFilter::JsonPath(path),
Err(e) => {
eprintln!("Invalid JSONPath: {e}");
process::exit(exit_code::CONFIG_ERROR);
}
}
} else {
EventFilter::None
}
}
pub(crate) fn build_correlation_config(
suppress: Option<String>,
action: Option<String>,
no_detections: bool,
correlation_event_mode: String,
max_correlation_events: usize,
extra_timestamp_fields: Vec<String>,
timestamp_fallback: &str,
) -> CorrelationConfig {
let suppress_secs = suppress.map(|s| match rsigma_parser::Timespan::parse(&s) {
Ok(ts) => ts.seconds,
Err(e) => {
eprintln!("Invalid suppress duration '{s}': {e}");
process::exit(exit_code::CONFIG_ERROR);
}
});
let action_on_match = action
.map(|s| {
s.parse::<CorrelationAction>().unwrap_or_else(|e| {
eprintln!("{e}");
process::exit(exit_code::CONFIG_ERROR);
})
})
.unwrap_or_default();
let event_mode = correlation_event_mode
.parse::<CorrelationEventMode>()
.unwrap_or_else(|e| {
eprintln!("{e}");
process::exit(exit_code::CONFIG_ERROR);
});
let ts_fallback = match timestamp_fallback {
"skip" => rsigma_eval::TimestampFallback::Skip,
_ => rsigma_eval::TimestampFallback::WallClock,
};
let mut config = CorrelationConfig {
suppress: suppress_secs,
action_on_match,
emit_detections: !no_detections,
correlation_event_mode: event_mode,
max_correlation_events,
timestamp_fallback: ts_fallback,
..Default::default()
};
if !extra_timestamp_fields.is_empty() {
let mut fields = extra_timestamp_fields;
fields.extend(config.timestamp_fields);
config.timestamp_fields = fields;
}
config
}
pub(crate) fn apply_event_filter(
value: &serde_json::Value,
filter: &EventFilter,
) -> Vec<serde_json::Value> {
match filter {
EventFilter::None => vec![value.clone()],
EventFilter::Jq(f) => {
let input = json_to_val(value.clone());
let ctx = Ctx::<data::JustLut<Val>>::new(&f.lut, Vars::new([]));
f.id.run((ctx, input))
.map(unwrap_valr)
.filter_map(|r| match r {
Ok(val) => val_to_json(&val),
Err(e) => {
eprintln!("jq runtime error: {e}");
None
}
})
.collect()
}
EventFilter::JsonPath(path) => {
let nodes = path.query(value);
nodes.all().into_iter().cloned().collect()
}
}
}
fn json_to_val(v: serde_json::Value) -> Val {
use jaq_core::ValT;
match v {
serde_json::Value::Null => Val::Null,
serde_json::Value::Bool(b) => Val::Bool(b),
serde_json::Value::Number(n) => Val::from_num(&n.to_string()).unwrap_or(Val::Null),
serde_json::Value::String(s) => Val::from(s),
serde_json::Value::Array(arr) => arr.into_iter().map(json_to_val).collect(),
serde_json::Value::Object(obj) => Val::obj(
obj.into_iter()
.map(|(k, v)| (Val::from(k), json_to_val(v)))
.collect(),
),
}
}
#[cfg(all(test, feature = "daemon"))]
mod config_default_drift {
use super::*;
use crate::config::defaults;
fn daemon_default(id: &str) -> Option<String> {
let cmd = Cli::command();
let engine = cmd.find_subcommand("engine")?;
let daemon = engine.find_subcommand("daemon")?;
daemon
.get_arguments()
.find(|a| a.get_id() == id)?
.get_default_values()
.first()
.map(|s| s.to_string_lossy().into_owned())
}
#[test]
fn clap_daemon_defaults_match_config_defaults() {
assert_eq!(
daemon_default("api_addr").as_deref(),
Some(defaults::API_ADDR)
);
assert_eq!(
daemon_default("input_format").as_deref(),
Some(defaults::INPUT_FORMAT)
);
assert_eq!(
daemon_default("syslog_tz").as_deref(),
Some(defaults::SYSLOG_TZ)
);
assert_eq!(
daemon_default("syslog_strip_bom"),
Some(defaults::SYSLOG_STRIP_BOM.to_string())
);
assert_eq!(
daemon_default("correlation_event_mode").as_deref(),
Some(defaults::CORRELATION_EVENT_MODE)
);
assert_eq!(
daemon_default("timestamp_fallback").as_deref(),
Some(defaults::TIMESTAMP_FALLBACK)
);
assert_eq!(
daemon_default("buffer_size"),
Some(defaults::BUFFER_SIZE.to_string())
);
assert_eq!(
daemon_default("batch_size"),
Some(defaults::BATCH_SIZE.to_string())
);
assert_eq!(
daemon_default("drain_timeout"),
Some(defaults::DRAIN_TIMEOUT.to_string())
);
assert_eq!(
daemon_default("max_correlation_events"),
Some(defaults::MAX_CORRELATION_EVENTS.to_string())
);
assert_eq!(
daemon_default("state_save_interval"),
Some(defaults::STATE_SAVE_INTERVAL.to_string())
);
assert_eq!(
daemon_default("observe_fields_max_keys"),
Some(defaults::OBSERVE_FIELDS_MAX_KEYS.to_string())
);
}
}
fn val_to_json(val: &Val) -> Option<serde_json::Value> {
use jaq_std::ValT;
Some(match val {
Val::Null => serde_json::Value::Null,
Val::Bool(b) => serde_json::Value::Bool(*b),
Val::Num(_) => {
if let Some(i) = val.as_isize() {
serde_json::Value::Number((i as i64).into())
} else if let Some(f) = val.as_f64() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.unwrap_or_else(|| serde_json::Value::String(val.to_string()))
} else {
serde_json::Value::String(val.to_string())
}
}
Val::BStr(b) | Val::TStr(b) => {
serde_json::Value::String(String::from_utf8_lossy(b).into_owned())
}
Val::Arr(arr) => {
let items: Vec<serde_json::Value> = arr.iter().filter_map(val_to_json).collect();
serde_json::Value::Array(items)
}
Val::Obj(obj) => {
let mut map = serde_json::Map::new();
for (k, v) in obj.iter() {
let key = match k {
Val::BStr(b) | Val::TStr(b) => String::from_utf8_lossy(b).into_owned(),
_ => k.to_string(),
};
if let Some(jv) = val_to_json(v) {
map.insert(key, jv);
}
}
serde_json::Value::Object(map)
}
})
}