mod coverage;
mod diff;
mod navigator;
mod report;
mod sarif;
mod scaffold;
mod theme;
mod verify;
use opseclint_core::{analyzer, edr, kb, model, parser, sigma, sigma_eval, telemetry};
use std::io::{IsTerminal, Read, Write};
use std::process::ExitCode;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "opseclint", version, about, long_about = None)]
struct Cli {
path: Option<String>,
#[arg(short, long)]
command: Option<String>,
#[arg(
long,
value_name = "FILE",
conflicts_with_all = ["command", "path", "check_rule", "verify_detections"],
help_heading = "Ingest"
)]
telemetry: Option<String>,
#[arg(long, value_enum, default_value = "sysmon", help_heading = "Ingest")]
format: telemetry::Format,
#[arg(long, value_name = "FILE", help_heading = "Ingest")]
users: Option<String>,
#[arg(long, value_enum, default_value = "linux-auditd")]
platform: kb::Platform,
#[arg(long, default_value_t = 0, help_heading = "Filtering")]
min: u8,
#[arg(long, help_heading = "Output")]
json: bool,
#[arg(long, conflicts_with = "json", help_heading = "Output")]
sarif: bool,
#[arg(long, conflicts_with_all = ["json", "sarif", "diff", "coverage_gaps", "check_rule"], help_heading = "Output")]
navigator: bool,
#[arg(long, help_heading = "Output")]
no_color: bool,
#[arg(long, help_heading = "CI gate")]
ci: bool,
#[arg(long, default_value_t = 50, help_heading = "CI gate")]
threshold: u8,
#[arg(
long,
value_enum,
value_name = "VENDOR",
num_args = 0..=1,
default_missing_value = "all",
help_heading = "EDR"
)]
edr: Option<edr::Vendor>,
#[arg(long, value_name = "DIR", help_heading = "Sigma")]
sigma: Option<String>,
#[arg(long, help_heading = "Sigma")]
no_sigma_cache: bool,
#[arg(
long,
value_name = "RULE.yml",
conflicts_with_all = ["json", "sarif", "coverage_gaps"],
help_heading = "Modes"
)]
check_rule: Option<String>,
#[arg(
long,
requires = "sigma",
conflicts_with = "sarif",
help_heading = "Modes"
)]
coverage_gaps: bool,
#[arg(
long,
conflicts_with_all = ["json", "sarif", "navigator", "check_rule", "diff"],
help_heading = "Modes"
)]
scaffold: bool,
#[arg(
long,
requires = "sigma",
conflicts_with_all = ["sarif", "coverage_gaps", "check_rule", "navigator", "scaffold"],
help_heading = "Modes"
)]
verify_detections: bool,
#[arg(
long,
value_name = "BASELINE.json",
conflicts_with_all = ["sarif", "check_rule"],
help_heading = "Modes"
)]
diff: Option<String>,
}
fn read_input(cli: &Cli) -> std::io::Result<String> {
if let Some(cmd) = &cli.command {
return Ok(cmd.clone());
}
if let Some(path) = &cli.path {
return std::fs::read_to_string(path);
}
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
Ok(buf)
}
fn run_check_rule(cli: &Cli, rule_path: &str, input: &str) -> ExitCode {
let yaml = match std::fs::read_to_string(rule_path) {
Ok(y) => y,
Err(e) => {
eprintln!("opseclint: could not read rule '{rule_path}': {e}");
return ExitCode::from(2);
}
};
let Some(rule) = sigma_eval::parse_rule(&yaml) else {
eprintln!("opseclint: could not parse a Sigma detection from '{rule_path}'");
return ExitCode::from(2);
};
let color = !cli.no_color && std::io::stdout().is_terminal();
let p = theme::Painter::new(color);
println!(
"{}{}",
p.bold(theme::BLUE, "opseclint"),
p.paint(
theme::COMMENT,
&format!(" · rule check · {} ({})", rule.title, rule.id)
)
);
println!("{}", p.rule(60));
for (idx, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
for cmd in parser::parse_line(line) {
let v = sigma_eval::evaluate(&rule, &cmd, cli.platform);
let (glyph, col, label) = match v.outcome {
sigma_eval::Outcome::Fires => ("✓", theme::GREEN, "FIRES "),
sigma_eval::Outcome::NoFire => ("·", theme::COMMENT, "NO-FIRE "),
sigma_eval::Outcome::Indeterminate => ("?", theme::YELLOW, "INDETERMINATE"),
};
println!(
" {} {} {} {}",
p.paint(col, glyph),
p.paint(col, label),
p.paint(theme::COMMENT, &format!("L{}", idx + 1)),
p.paint(theme::FG, &cmd.program),
);
if v.outcome == sigma_eval::Outcome::Indeterminate && !v.missing_fields.is_empty() {
println!(
" {}",
p.paint(
theme::COMMENT,
&format!("needs {}", v.missing_fields.join(", "))
)
);
}
}
}
ExitCode::SUCCESS
}
fn emit_scaffold(entries: &[&model::KbEntry], platform: kb::Platform) {
if entries.is_empty() {
eprintln!("opseclint: no actions to scaffold");
return;
}
print!(
"{}",
scaffold::rules_for(entries, platform, &scaffold::today())
);
let _ = std::io::stdout().flush();
eprintln!("opseclint: scaffolded {} starter rule(s)", entries.len());
}
fn run_verify(cli: &Cli) -> ExitCode {
let kb = match kb::load(cli.platform) {
Ok(k) => k,
Err(e) => {
eprintln!("opseclint: failed to load knowledge base: {e}");
return ExitCode::from(2);
}
};
let dir = cli.sigma.as_deref().expect("clap requires --sigma");
let index = match sigma::load_cached(
std::path::Path::new(dir),
cli.platform.sigma_product(),
!cli.no_sigma_cache,
) {
Ok((i, _from_cache)) => i,
Err(e) => {
eprintln!("opseclint: could not read sigma dir '{dir}': {e}");
return ExitCode::from(2);
}
};
let current = verify::verify(&kb, &index, cli.platform);
let color = !cli.no_color && std::io::stdout().is_terminal();
if let Some(baseline_path) = &cli.diff {
let baseline: verify::VerifyReport = match std::fs::read_to_string(baseline_path)
.map_err(|e| e.to_string())
.and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
{
Ok(r) => r,
Err(e) => {
eprintln!(
"opseclint: could not read baseline '{baseline_path}': {e} \
(expected a file saved with --verify-detections --json)"
);
return ExitCode::from(2);
}
};
if baseline.platform != current.platform {
eprintln!(
"opseclint: baseline platform '{}' does not match --platform '{}' \
(pass the matching .ci/verified-<platform>.json)",
baseline.platform, current.platform
);
return ExitCode::from(2);
}
let delta = verify::compute_delta(&baseline, ¤t);
if cli.json {
println!("{}", verify::render_delta_json(&delta));
} else {
print!("{}", verify::render_delta(&delta, color));
}
if cli.ci && delta.has_regressed() {
if !cli.json {
eprintln!(
"\nopseclint: CI gate failed — a verified detection regressed from the baseline"
);
}
return ExitCode::from(1);
}
return ExitCode::SUCCESS;
}
if cli.json {
println!("{}", verify::render_json(¤t));
} else {
print!("{}", verify::render(¤t, color));
}
let unverified = current.count(verify::Status::Unverified);
if cli.ci && unverified > 0 {
if !cli.json {
eprintln!(
"\nopseclint: CI gate failed — {unverified} claimed detection(s) do not fire"
);
}
return ExitCode::from(1);
}
ExitCode::SUCCESS
}
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.verify_detections {
return run_verify(&cli);
}
if cli.command.is_none()
&& cli.path.is_none()
&& cli.telemetry.is_none()
&& std::io::stdin().is_terminal()
&& std::io::stdout().is_terminal()
{
print!("{}", theme::banner(!cli.no_color));
return ExitCode::SUCCESS;
}
let kb = match kb::load(cli.platform) {
Ok(kb) => kb,
Err(e) => {
eprintln!("opseclint: failed to load knowledge base: {e}");
return ExitCode::from(2);
}
};
let mut report = if let Some(tel_path) = &cli.telemetry {
let text = match std::fs::read_to_string(tel_path) {
Ok(t) => t,
Err(e) => {
eprintln!("opseclint: failed to read telemetry '{tel_path}': {e}");
return ExitCode::from(2);
}
};
let users = match &cli.users {
Some(path) => match std::fs::read_to_string(path) {
Ok(t) => telemetry::parse_passwd(&t),
Err(e) => {
eprintln!("opseclint: failed to read --users '{path}': {e}");
return ExitCode::from(2);
}
},
None => Default::default(),
};
let ingest = match telemetry::parse_with_users(&text, cli.format, &users) {
Ok(i) => i,
Err(e) => {
eprintln!("opseclint: could not parse telemetry '{tel_path}': {e}");
return ExitCode::from(2);
}
};
if !cli.json && !cli.sarif && !cli.navigator {
let skipped = if ingest.skipped > 0 {
format!(", {} non-execution record(s) skipped", ingest.skipped)
} else {
String::new()
};
let standalone = if !ingest.event_observations.is_empty() {
format!(
" ({} evaluated as standalone event(s))",
ingest.event_observations.len()
)
} else {
String::new()
};
eprintln!(
"opseclint: telemetry — {} process-execution event(s) ingested{skipped}{standalone}",
ingest.observations.len()
);
}
analyzer::analyze_telemetry(&ingest, &kb)
} else {
let input = match read_input(&cli) {
Ok(s) => s,
Err(e) => {
eprintln!("opseclint: failed to read input: {e}");
return ExitCode::from(2);
}
};
if let Some(rule_path) = &cli.check_rule {
return run_check_rule(&cli, rule_path, &input);
}
analyzer::analyze(&input, &kb)
};
if cli.min > 0 {
report.findings.retain(|f| f.noise >= cli.min);
}
if cli.coverage_gaps {
let dir = cli.sigma.as_deref().expect("clap requires --sigma");
let index = match sigma::load_cached(
std::path::Path::new(dir),
cli.platform.sigma_product(),
!cli.no_sigma_cache,
) {
Ok((i, _from_cache)) => i,
Err(e) => {
eprintln!("opseclint: could not read sigma dir '{dir}': {e}");
return ExitCode::from(2);
}
};
let results = coverage::analyze(&report, &index, cli.platform);
if cli.scaffold {
let gap_ids: Vec<&str> = results
.iter()
.filter(|r| r.coverage == coverage::Coverage::Gap)
.map(|r| r.rule_id.as_str())
.collect();
emit_scaffold(&scaffold::entries_by_ids(&kb, &gap_ids), cli.platform);
return ExitCode::SUCCESS;
}
let color = !cli.no_color && std::io::stdout().is_terminal();
let current = coverage::CoverageReport {
platform: report.platform.clone(),
rules_indexed: index.rules_indexed,
results,
};
if let Some(baseline_path) = &cli.diff {
let baseline: coverage::CoverageReport = match std::fs::read_to_string(baseline_path)
.map_err(|e| e.to_string())
.and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
{
Ok(r) => r,
Err(e) => {
eprintln!(
"opseclint: could not read baseline '{baseline_path}': {e} \
(expected a file saved with --coverage-gaps --json)"
);
return ExitCode::from(2);
}
};
let delta = coverage::compute_delta(&baseline, ¤t);
if cli.json {
println!("{}", coverage::render_delta_json(&delta));
} else {
print!("{}", coverage::render_delta(&delta, color));
}
if cli.ci && delta.has_regressed() {
if !cli.json {
eprintln!("\nopseclint: CI gate failed — coverage regressed from the baseline");
}
return ExitCode::from(1);
}
return ExitCode::SUCCESS;
}
if cli.json {
println!("{}", coverage::render_json(¤t));
} else {
print!(
"{}",
coverage::render(
¤t.results,
¤t.platform,
current.rules_indexed,
color
)
);
}
if cli.ci && coverage::gap_count(¤t.results) > 0 {
return ExitCode::from(1);
}
return ExitCode::SUCCESS;
}
if cli.scaffold {
let ids: Vec<&str> = report.findings.iter().map(|f| f.rule_id.as_str()).collect();
emit_scaffold(&scaffold::entries_by_ids(&kb, &ids), cli.platform);
return ExitCode::SUCCESS;
}
if let Some(dir) = &cli.sigma {
let product = cli.platform.sigma_product();
match sigma::load_cached(std::path::Path::new(dir), product, !cli.no_sigma_cache) {
Ok((index, from_cache)) => {
let enriched = sigma::enrich(&mut report, &index, cli.platform);
if !cli.json && !cli.sarif && !cli.navigator {
eprintln!(
"opseclint: sigma — {} rule(s) from {} file(s){}; enriched {} finding(s)",
index.rules_indexed,
index.files_scanned,
if from_cache { " [cached]" } else { "" },
enriched
);
}
}
Err(e) => {
eprintln!(
"opseclint: could not read sigma dir '{dir}': {e} (using seed references)"
);
}
}
}
if let Some(vendor) = cli.edr {
let note = edr::annotate(&mut report, &[vendor]);
if !cli.json && !cli.sarif && !cli.navigator {
eprintln!("opseclint: edr — {note}");
}
}
if let Some(baseline_path) = &cli.diff {
let baseline: model::Report = match std::fs::read_to_string(baseline_path)
.map_err(|e| e.to_string())
.and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string()))
{
Ok(r) => r,
Err(e) => {
eprintln!(
"opseclint: could not read baseline report '{baseline_path}': {e} \
(expected a file saved with --json)"
);
return ExitCode::from(2);
}
};
let delta = diff::compute(&baseline, &report);
if cli.json {
println!("{}", diff::render_json(&delta));
} else {
let color = !cli.no_color && std::io::stdout().is_terminal();
print!("{}", diff::render_human(&delta, color));
}
if cli.ci && delta.is_louder() {
if !cli.json {
eprintln!("\nopseclint: CI gate failed — coverage is louder than the baseline");
}
return ExitCode::from(1);
}
return ExitCode::SUCCESS;
}
if cli.navigator {
println!("{}", navigator::render(&report));
} else if cli.sarif {
let source_uri = cli
.path
.clone()
.or_else(|| cli.telemetry.clone())
.unwrap_or_else(|| {
if cli.command.is_some() {
"<command>"
} else {
"stdin"
}
.to_string()
});
println!("{}", sarif::render(&report, &source_uri));
} else if cli.json {
println!("{}", report::render_json(&report));
} else {
let color = !cli.no_color && std::io::stdout().is_terminal();
print!("{}", report::render_human(&report, color));
}
if cli.ci && report.max_noise >= cli.threshold {
if !cli.json {
eprintln!(
"\nopseclint: CI gate failed — loudest action {} (>= threshold {})",
report::severity_word(report.max_severity()),
cli.threshold
);
}
return ExitCode::from(1);
}
ExitCode::SUCCESS
}