use std::io::{self, IsTerminal};
use std::path::PathBuf;
use std::process;
use std::time::SystemTime;
use clap::Args;
use rsigma_parser::lint::{self, FileLintResult, LintConfig};
use serde::Deserialize;
pub(crate) struct LintCounts {
pub errors: usize,
pub warnings: usize,
pub infos: usize,
}
#[derive(Args, Debug)]
pub(crate) struct LintArgs {
pub path: PathBuf,
#[arg(short, long)]
pub schema: Option<String>,
#[arg(short, long)]
pub verbose: bool,
#[arg(long, default_value = "auto", value_parser = ["auto", "always", "never"])]
pub color: String,
#[arg(long, value_delimiter = ',')]
pub disable: Vec<String>,
#[arg(long = "config")]
pub lint_config: Option<PathBuf>,
#[arg(long)]
pub exclude: Vec<String>,
#[arg(long)]
pub fix: bool,
#[arg(long = "fail-level", default_value = "error",
value_parser = ["error", "warning", "info"])]
pub fail_level: String,
}
pub(crate) fn cmd_lint(args: LintArgs) -> LintCounts {
let LintArgs {
path,
schema,
verbose,
color,
disable,
lint_config: lint_config_path,
exclude,
fix: apply_fix,
fail_level: _,
} = args;
let p = Painter::new(&color);
let config = build_lint_config(&path, disable, lint_config_path, exclude);
let results: Vec<FileLintResult> = if path.is_dir() {
match lint::lint_yaml_directory_with_config(&path, &config) {
Ok(r) => r,
Err(e) => {
eprintln!("Error: {e}");
process::exit(crate::exit_code::RULE_ERROR);
}
}
} else {
match lint::lint_yaml_file_with_config(&path, &config) {
Ok(r) => vec![r],
Err(e) => {
eprintln!("Error: {e}");
process::exit(crate::exit_code::RULE_ERROR);
}
}
};
let schema_results = schema.map(|schema_arg| run_schema_validation(&path, &schema_arg));
let mut all_results = results;
if let Some(sr) = schema_results {
merge_schema_results(&mut all_results, sr);
}
let mut total_files = 0usize;
let mut failed_files = 0usize;
let mut total_errors = 0usize;
let mut total_warnings = 0usize;
let mut total_infos = 0usize;
for result in &all_results {
total_files += 1;
let errors = result.error_count();
let warnings = result.warning_count();
let infos = result.info_count();
total_errors += errors;
total_warnings += warnings;
total_infos += infos;
let has_failures = result
.warnings
.iter()
.any(|w| matches!(w.severity, lint::Severity::Error | lint::Severity::Warning));
if result.warnings.is_empty() {
if verbose {
println!(
"{} {}",
p.bold(&result.path.display().to_string()),
p.green("OK"),
);
}
} else if has_failures {
failed_files += 1;
println!("{}", p.bold(&result.path.display().to_string()));
for w in &result.warnings {
render_lint_warning(w, &p);
}
println!(); } else {
if verbose {
println!("{}", p.bold(&result.path.display().to_string()));
for w in &result.warnings {
render_lint_warning(w, &p);
}
println!();
}
}
}
let passed = total_files - failed_files;
let separator = "─".repeat(60);
println!("{}", p.dim(&separator));
let passed_str = format!("{passed} passed");
let failed_str = format!("{failed_files} failed");
let errors_str = format!("{total_errors} error(s)");
let warnings_str = format!("{total_warnings} warning(s)");
let infos_str = format!("{total_infos} info(s)");
let passed_colored = if passed > 0 {
p.green_bold(&passed_str)
} else {
p.dim(&passed_str)
};
let failed_colored = if failed_files > 0 {
p.red_bold(&failed_str)
} else {
p.dim(&failed_str)
};
let errors_colored = if total_errors > 0 {
p.red(&errors_str)
} else {
p.dim(&errors_str)
};
let warnings_colored = if total_warnings > 0 {
p.yellow(&warnings_str)
} else {
p.dim(&warnings_str)
};
let infos_colored = if total_infos > 0 {
p.blue(&infos_str)
} else {
p.dim(&infos_str)
};
println!(
"Checked {} file(s): {}, {} ({}, {}, {})",
total_files,
passed_colored,
failed_colored,
errors_colored,
warnings_colored,
infos_colored,
);
tracing::info!(
files = total_files,
passed,
failed = failed_files,
errors = total_errors,
warnings = total_warnings,
infos = total_infos,
"Lint summary",
);
if apply_fix {
let fixable: usize = all_results
.iter()
.flat_map(|r| &r.warnings)
.filter(|w| {
w.fix
.as_ref()
.is_some_and(|f| f.disposition == lint::FixDisposition::Safe)
})
.count();
if fixable == 0 {
println!("{}", p.dim("No auto-fixable issues found."));
} else {
let result = crate::fix::apply_fixes(&all_results);
println!(
"\n{}",
p.green_bold(&format!(
"Applied {} fix(es) across {} file(s).",
result.applied, result.files_modified,
))
);
if result.failed > 0 {
println!(
"{}",
p.yellow(&format!(
"{} fix(es) could not be applied (conflicts).",
result.failed,
))
);
}
}
}
LintCounts {
errors: total_errors,
warnings: total_warnings,
infos: total_infos,
}
}
fn render_lint_warning(w: &lint::LintWarning, p: &Painter) {
let (severity_label, rule_bracket) = match w.severity {
lint::Severity::Error => (p.red_bold("error"), p.red(&format!("[{}]", w.rule))),
lint::Severity::Warning => (p.yellow_bold("warning"), p.yellow(&format!("[{}]", w.rule))),
lint::Severity::Info => (p.blue("info"), p.blue(&format!("[{}]", w.rule))),
lint::Severity::Hint => (p.dim("hint"), p.dim(&format!("[{}]", w.rule))),
};
println!(" {}{}: {}", severity_label, rule_bracket, w.message);
let location = if let Some(span) = &w.span {
format!("{} (line {})", w.path, span.start_line + 1)
} else {
w.path.clone()
};
println!(" {} {}", p.cyan("-->"), p.cyan(&location));
}
const SCHEMA_URL: &str = "https://raw.githubusercontent.com/SigmaHQ/sigma-specification/main/json-schema/sigma-detection-rule-schema.json";
const CACHE_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60;
fn resolve_schema(schema_arg: &str) -> String {
if schema_arg == "default" {
resolve_default_schema()
} else {
match std::fs::read_to_string(schema_arg) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading schema file '{schema_arg}': {e}");
process::exit(crate::exit_code::CONFIG_ERROR);
}
}
}
}
fn resolve_default_schema() -> String {
let cache_dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("rsigma");
let cache_path = cache_dir.join("sigma-schema.json");
if let Ok(meta) = std::fs::metadata(&cache_path)
&& let Ok(modified) = meta.modified()
{
let age = SystemTime::now()
.duration_since(modified)
.unwrap_or_default();
if age.as_secs() < CACHE_MAX_AGE_SECS
&& let Ok(content) = std::fs::read_to_string(&cache_path)
{
eprintln!("Using cached schema: {}", cache_path.display());
return content;
}
}
eprintln!("Downloading schema from {SCHEMA_URL}...");
match ureq::get(SCHEMA_URL).call() {
Ok(response) => {
let body = response.into_body().read_to_string().unwrap_or_else(|e| {
eprintln!("Error reading schema response: {e}");
process::exit(crate::exit_code::CONFIG_ERROR);
});
if let Err(e) = std::fs::create_dir_all(&cache_dir) {
eprintln!("Warning: could not create cache dir: {e}");
} else if let Err(e) = std::fs::write(&cache_path, &body) {
eprintln!("Warning: could not cache schema: {e}");
} else {
eprintln!("Cached schema at {}", cache_path.display());
}
body
}
Err(e) => {
if let Ok(content) = std::fs::read_to_string(&cache_path) {
eprintln!("Warning: schema download failed ({e}), using stale cache");
content
} else {
eprintln!("Error downloading schema: {e}");
process::exit(crate::exit_code::CONFIG_ERROR);
}
}
}
}
fn run_schema_validation(path: &std::path::Path, schema_arg: &str) -> Vec<FileLintResult> {
let schema_json_str = resolve_schema(schema_arg);
let schema_value: serde_json::Value = match serde_json::from_str(&schema_json_str) {
Ok(v) => v,
Err(e) => {
eprintln!("Error parsing schema JSON: {e}");
process::exit(crate::exit_code::CONFIG_ERROR);
}
};
let validator = jsonschema::validator_for(&schema_value).unwrap_or_else(|e| {
eprintln!("Error compiling JSON schema: {e}");
process::exit(crate::exit_code::CONFIG_ERROR);
});
let mut results = Vec::new();
if path.is_dir() {
fn walk_schema(
dir: &std::path::Path,
validator: &jsonschema::Validator,
results: &mut Vec<FileLintResult>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
walk_schema(&p, validator, results);
} else if matches!(p.extension().and_then(|e| e.to_str()), Some("yml" | "yaml")) {
results.push(validate_file_against_schema(&p, validator));
}
}
}
walk_schema(path, &validator, &mut results);
} else {
results.push(validate_file_against_schema(path, &validator));
}
results
}
fn validate_file_against_schema(
path: &std::path::Path,
validator: &jsonschema::Validator,
) -> FileLintResult {
let mut warnings = Vec::new();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
warnings.push(lint::LintWarning {
rule: lint::LintRule::FileReadError,
severity: lint::Severity::Error,
message: format!("error reading file: {e}"),
path: "/".to_string(),
span: None,
fix: None,
});
return FileLintResult {
path: path.to_path_buf(),
warnings,
};
}
};
for doc in yaml_serde::Deserializer::from_str(&content) {
let yaml_value: yaml_serde::Value = match yaml_serde::Value::deserialize(doc) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(m) = yaml_value.as_mapping()
&& let Some(action) = m
.get(yaml_serde::Value::String("action".into()))
.and_then(|v| v.as_str())
&& matches!(action, "global" | "reset" | "repeat")
{
continue;
}
let json_str = match serde_json::to_string(&yaml_value) {
Ok(s) => s,
Err(_) => continue,
};
let json_value: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(_) => continue,
};
for error in validator.iter_errors(&json_value) {
warnings.push(lint::LintWarning {
rule: lint::LintRule::SchemaViolation,
severity: lint::Severity::Error,
message: format!("schema: {error}"),
path: error.instance_path().to_string(),
span: None,
fix: None,
});
}
}
FileLintResult {
path: path.to_path_buf(),
warnings,
}
}
fn merge_schema_results(
main_results: &mut Vec<FileLintResult>,
schema_results: Vec<FileLintResult>,
) {
use std::collections::HashMap;
let mut index: HashMap<PathBuf, usize> = main_results
.iter()
.enumerate()
.map(|(i, r)| (r.path.clone(), i))
.collect();
for sr in schema_results {
if let Some(&idx) = index.get(&sr.path) {
main_results[idx].warnings.extend(sr.warnings);
} else {
let idx = main_results.len();
index.insert(sr.path.clone(), idx);
main_results.push(sr);
}
}
}
fn build_lint_config(
path: &std::path::Path,
disable: Vec<String>,
lint_config_path: Option<PathBuf>,
exclude: Vec<String>,
) -> LintConfig {
let mut config = if let Some(explicit) = lint_config_path {
match LintConfig::load(&explicit) {
Ok(c) => {
eprintln!("Loaded lint config: {}", explicit.display());
c
}
Err(e) => {
eprintln!("Error loading lint config '{}': {e}", explicit.display());
process::exit(crate::exit_code::CONFIG_ERROR);
}
}
} else if let Some(found) = LintConfig::find_in_ancestors(path) {
match LintConfig::load(&found) {
Ok(c) => {
eprintln!("Loaded lint config: {}", found.display());
c
}
Err(e) => {
eprintln!(
"Warning: found .rsigma-lint.yml at {} but failed to load: {e}",
found.display()
);
LintConfig::default()
}
}
} else {
LintConfig::default()
};
if !disable.is_empty() || !exclude.is_empty() {
let cli_config = LintConfig {
disabled_rules: disable.into_iter().collect(),
exclude_patterns: exclude,
..Default::default()
};
config.merge(&cli_config);
}
config
}
struct Painter {
enabled: bool,
}
impl Painter {
fn new(color_arg: &str) -> Self {
let enabled = match color_arg {
"always" => true,
"never" => false,
_ => io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
};
Painter { enabled }
}
fn paint(&self, code: &str, text: &str) -> String {
if self.enabled {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_string()
}
}
fn bold(&self, s: &str) -> String {
self.paint("1", s)
}
fn dim(&self, s: &str) -> String {
self.paint("2", s)
}
fn red(&self, s: &str) -> String {
self.paint("31", s)
}
fn red_bold(&self, s: &str) -> String {
self.paint("1;31", s)
}
fn green(&self, s: &str) -> String {
self.paint("32", s)
}
fn green_bold(&self, s: &str) -> String {
self.paint("1;32", s)
}
fn yellow(&self, s: &str) -> String {
self.paint("33", s)
}
fn yellow_bold(&self, s: &str) -> String {
self.paint("1;33", s)
}
fn blue(&self, s: &str) -> String {
self.paint("34", s)
}
fn cyan(&self, s: &str) -> String {
self.paint("36", s)
}
}