mod commands;
#[cfg(feature = "daemon")]
mod daemon;
pub(crate) mod exit_code;
mod fix;
use std::path::PathBuf;
use std::process;
use clap::{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>,
#[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,
},
#[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 cli = Cli::parse();
#[cfg(feature = "daemon")]
let is_daemon = matches!(
cli.command,
Commands::Engine {
cmd: EngineCommands::Daemon(_),
..
} | Commands::Daemon(_)
);
#[cfg(not(feature = "daemon"))]
let is_daemon = false;
if !is_daemon && let Some(format) = cli.log_format {
init_cli_log_subscriber(format);
}
dispatch(cli.command);
}
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) {
match command {
Commands::Engine { cmd } => dispatch_engine(cmd),
Commands::Rule { cmd } => dispatch_rule(cmd),
Commands::Backend { cmd } => dispatch_backend(cmd),
Commands::Pipeline { cmd } => dispatch_pipeline(cmd),
Commands::Eval(args) => {
deprecation_warn("eval", "engine eval");
run_eval(args);
}
#[cfg(feature = "daemon")]
Commands::Daemon(args) => {
deprecation_warn("daemon", "engine daemon");
cmd_daemon(args);
}
Commands::Parse(args) => {
deprecation_warn("parse", "rule parse");
commands::cmd_parse(args);
}
Commands::Validate(args) => {
deprecation_warn("validate", "rule validate");
commands::cmd_validate(args);
}
Commands::Lint(args) => {
deprecation_warn("lint", "rule lint");
run_lint(args);
}
Commands::Fields(args) => {
deprecation_warn("fields", "rule fields");
commands::cmd_fields(args);
}
Commands::Condition(args) => {
deprecation_warn("condition", "rule condition");
commands::cmd_condition(args);
}
Commands::Stdin(args) => {
deprecation_warn("stdin", "rule stdin");
commands::cmd_stdin(args);
}
Commands::Convert(args) => {
deprecation_warn("convert", "backend convert");
commands::cmd_convert(args);
}
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) {
match cmd {
EngineCommands::Eval(args) => run_eval(args),
#[cfg(feature = "daemon")]
EngineCommands::Daemon(args) => cmd_daemon(args),
}
}
fn dispatch_rule(cmd: RuleCommands) {
match cmd {
RuleCommands::Parse(args) => commands::cmd_parse(args),
RuleCommands::Validate(args) => commands::cmd_validate(args),
RuleCommands::Lint(args) => run_lint(args),
RuleCommands::Fields(args) => commands::cmd_fields(args),
RuleCommands::Condition(args) => commands::cmd_condition(args),
RuleCommands::Stdin(args) => commands::cmd_stdin(args),
RuleCommands::MigrateSources(args) => commands::cmd_migrate_sources(args),
}
}
fn dispatch_backend(cmd: BackendCommands) {
match cmd {
BackendCommands::Convert(args) => commands::cmd_convert(args),
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(args: EvalArgs) {
let fail_on_detection = args.fail_on_detection;
let had_matches = commands::cmd_eval(args);
if fail_on_detection && had_matches {
process::exit(exit_code::FINDINGS);
}
}
fn run_lint(args: LintArgs) {
let fail_level = args.fail_level.clone();
let LintCounts {
errors,
warnings,
infos,
} = commands::cmd_lint(args);
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(", "));
}
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}");
}
}
}
pub(crate) fn print_json(value: &impl serde::Serialize, pretty: bool) {
let json = if pretty {
serde_json::to_string_pretty(value)
} else {
serde_json::to_string(value)
};
match json {
Ok(j) => println!("{j}"),
Err(e) => {
eprintln!("JSON serialization error: {e}");
process::exit(exit_code::CONFIG_ERROR);
}
}
}
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(),
),
}
}
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)
}
})
}