Skip to main content

rsigma_parser/lint/
mod.rs

1//! Built-in linter for Sigma rules, correlations, and filters.
2//!
3//! Validates raw `yaml_serde::Value` documents against the Sigma specification
4//! v2.1.0 constraints — catching metadata issues that the parser silently
5//! ignores (invalid enums, date formats, tag patterns, etc.).
6//!
7//! # Usage
8//!
9//! ```rust
10//! use rsigma_parser::lint::{lint_yaml_value, Severity};
11//!
12//! let yaml = "title: Test\nlogsource:\n  category: test\ndetection:\n  sel:\n    field: value\n  condition: sel\n";
13//! let value: yaml_serde::Value = yaml_serde::from_str(yaml).unwrap();
14//! let warnings = lint_yaml_value(&value);
15//! for w in &warnings {
16//!     if w.severity == Severity::Error {
17//!         eprintln!("{}", w.message);
18//!     }
19//! }
20//! ```
21
22mod rules;
23
24use std::collections::{HashMap, HashSet};
25use std::fmt;
26use std::path::Path;
27use std::sync::LazyLock;
28
29use serde::{Deserialize, Serialize};
30use yaml_serde::Value;
31
32// =============================================================================
33// Public types
34// =============================================================================
35
36/// Severity of a lint finding.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
38pub enum Severity {
39    /// Spec violation — the rule is invalid.
40    Error,
41    /// Best-practice issue — the rule works but is not spec-ideal.
42    Warning,
43    /// Informational suggestion — soft best-practice hint (e.g. missing author).
44    Info,
45    /// Subtle hint — lowest severity, for stylistic suggestions.
46    Hint,
47}
48
49impl fmt::Display for Severity {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Severity::Error => write!(f, "error"),
53            Severity::Warning => write!(f, "warning"),
54            Severity::Info => write!(f, "info"),
55            Severity::Hint => write!(f, "hint"),
56        }
57    }
58}
59
60/// Identifies which lint rule fired.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
62pub enum LintRule {
63    // ── Infrastructure / parse errors ────────────────────────────────────
64    YamlParseError,
65    NotAMapping,
66    FileReadError,
67    SchemaViolation,
68
69    // ── Shared (all document types) ──────────────────────────────────────
70    MissingTitle,
71    EmptyTitle,
72    TitleTooLong,
73    MissingDescription,
74    MissingAuthor,
75    InvalidId,
76    InvalidStatus,
77    MissingLevel,
78    InvalidLevel,
79    InvalidDate,
80    InvalidModified,
81    ModifiedBeforeDate,
82    DescriptionTooLong,
83    NameTooLong,
84    TaxonomyTooLong,
85    NonLowercaseKey,
86
87    // ── Detection rules ──────────────────────────────────────────────────
88    MissingLogsource,
89    MissingDetection,
90    MissingCondition,
91    EmptyDetection,
92    InvalidRelatedType,
93    InvalidRelatedId,
94    RelatedMissingRequired,
95    DeprecatedWithoutRelated,
96    InvalidTag,
97    UnknownTagNamespace,
98    DuplicateTags,
99    DuplicateReferences,
100    DuplicateFields,
101    FalsepositiveTooShort,
102    ScopeTooShort,
103    LogsourceValueNotLowercase,
104    ConditionReferencesUnknown,
105    DeprecatedAggregationSyntax,
106
107    // ── Correlation rules ────────────────────────────────────────────────
108    MissingCorrelation,
109    MissingCorrelationType,
110    InvalidCorrelationType,
111    MissingCorrelationRules,
112    EmptyCorrelationRules,
113    MissingCorrelationTimespan,
114    InvalidTimespanFormat,
115    MissingGroupBy,
116    MissingCorrelationCondition,
117    MissingConditionField,
118    InvalidConditionOperator,
119    ConditionValueNotNumeric,
120    GenerateNotBoolean,
121
122    // ── Filter rules ─────────────────────────────────────────────────────
123    MissingFilter,
124    MissingFilterRules,
125    EmptyFilterRules,
126    MissingFilterSelection,
127    MissingFilterCondition,
128    FilterHasLevel,
129    FilterHasStatus,
130    MissingFilterLogsource,
131
132    // ── Detection logic (cross-cutting) ──────────────────────────────────
133    NullInValueList,
134    SingleValueAllModifier,
135    AllWithRe,
136    IncompatibleModifiers,
137    EmptyValueList,
138    WildcardOnlyValue,
139    UnknownKey,
140}
141
142impl fmt::Display for LintRule {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        let s = match self {
145            LintRule::YamlParseError => "yaml_parse_error",
146            LintRule::NotAMapping => "not_a_mapping",
147            LintRule::FileReadError => "file_read_error",
148            LintRule::SchemaViolation => "schema_violation",
149            LintRule::MissingTitle => "missing_title",
150            LintRule::EmptyTitle => "empty_title",
151            LintRule::TitleTooLong => "title_too_long",
152            LintRule::MissingDescription => "missing_description",
153            LintRule::MissingAuthor => "missing_author",
154            LintRule::InvalidId => "invalid_id",
155            LintRule::InvalidStatus => "invalid_status",
156            LintRule::MissingLevel => "missing_level",
157            LintRule::InvalidLevel => "invalid_level",
158            LintRule::InvalidDate => "invalid_date",
159            LintRule::InvalidModified => "invalid_modified",
160            LintRule::ModifiedBeforeDate => "modified_before_date",
161            LintRule::DescriptionTooLong => "description_too_long",
162            LintRule::NameTooLong => "name_too_long",
163            LintRule::TaxonomyTooLong => "taxonomy_too_long",
164            LintRule::NonLowercaseKey => "non_lowercase_key",
165            LintRule::MissingLogsource => "missing_logsource",
166            LintRule::MissingDetection => "missing_detection",
167            LintRule::MissingCondition => "missing_condition",
168            LintRule::EmptyDetection => "empty_detection",
169            LintRule::InvalidRelatedType => "invalid_related_type",
170            LintRule::InvalidRelatedId => "invalid_related_id",
171            LintRule::RelatedMissingRequired => "related_missing_required",
172            LintRule::DeprecatedWithoutRelated => "deprecated_without_related",
173            LintRule::InvalidTag => "invalid_tag",
174            LintRule::UnknownTagNamespace => "unknown_tag_namespace",
175            LintRule::DuplicateTags => "duplicate_tags",
176            LintRule::DuplicateReferences => "duplicate_references",
177            LintRule::DuplicateFields => "duplicate_fields",
178            LintRule::FalsepositiveTooShort => "falsepositive_too_short",
179            LintRule::ScopeTooShort => "scope_too_short",
180            LintRule::LogsourceValueNotLowercase => "logsource_value_not_lowercase",
181            LintRule::ConditionReferencesUnknown => "condition_references_unknown",
182            LintRule::DeprecatedAggregationSyntax => "deprecated_aggregation_syntax",
183            LintRule::MissingCorrelation => "missing_correlation",
184            LintRule::MissingCorrelationType => "missing_correlation_type",
185            LintRule::InvalidCorrelationType => "invalid_correlation_type",
186            LintRule::MissingCorrelationRules => "missing_correlation_rules",
187            LintRule::EmptyCorrelationRules => "empty_correlation_rules",
188            LintRule::MissingCorrelationTimespan => "missing_correlation_timespan",
189            LintRule::InvalidTimespanFormat => "invalid_timespan_format",
190            LintRule::MissingGroupBy => "missing_group_by",
191            LintRule::MissingCorrelationCondition => "missing_correlation_condition",
192            LintRule::MissingConditionField => "missing_condition_field",
193            LintRule::InvalidConditionOperator => "invalid_condition_operator",
194            LintRule::ConditionValueNotNumeric => "condition_value_not_numeric",
195            LintRule::GenerateNotBoolean => "generate_not_boolean",
196            LintRule::MissingFilter => "missing_filter",
197            LintRule::MissingFilterRules => "missing_filter_rules",
198            LintRule::EmptyFilterRules => "empty_filter_rules",
199            LintRule::MissingFilterSelection => "missing_filter_selection",
200            LintRule::MissingFilterCondition => "missing_filter_condition",
201            LintRule::FilterHasLevel => "filter_has_level",
202            LintRule::FilterHasStatus => "filter_has_status",
203            LintRule::MissingFilterLogsource => "missing_filter_logsource",
204            LintRule::NullInValueList => "null_in_value_list",
205            LintRule::SingleValueAllModifier => "single_value_all_modifier",
206            LintRule::AllWithRe => "all_with_re",
207            LintRule::IncompatibleModifiers => "incompatible_modifiers",
208            LintRule::EmptyValueList => "empty_value_list",
209            LintRule::WildcardOnlyValue => "wildcard_only_value",
210            LintRule::UnknownKey => "unknown_key",
211        };
212        write!(f, "{s}")
213    }
214}
215
216/// A source span (line/column, both 0-indexed).
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
218pub struct Span {
219    pub start_line: u32,
220    pub start_col: u32,
221    pub end_line: u32,
222    pub end_col: u32,
223}
224
225// =============================================================================
226// Auto-fix types
227// =============================================================================
228
229/// Whether a fix is safe to apply automatically or needs manual review.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
231pub enum FixDisposition {
232    Safe,
233    Unsafe,
234}
235
236/// A single patch operation within a [`Fix`].
237#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
238pub enum FixPatch {
239    ReplaceValue { path: String, new_value: String },
240    ReplaceKey { path: String, new_key: String },
241    Remove { path: String },
242}
243
244/// A suggested fix for a lint finding.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
246pub struct Fix {
247    pub title: String,
248    pub disposition: FixDisposition,
249    pub patches: Vec<FixPatch>,
250}
251
252/// A single lint finding.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
254pub struct LintWarning {
255    pub rule: LintRule,
256    pub severity: Severity,
257    pub message: String,
258    pub path: String,
259    pub span: Option<Span>,
260    pub fix: Option<Fix>,
261}
262
263impl fmt::Display for LintWarning {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        write!(
266            f,
267            "{}[{}]: {}\n    --> {}",
268            self.severity, self.rule, self.message, self.path
269        )
270    }
271}
272
273/// Result of linting a single file (may contain multiple YAML documents).
274#[derive(Debug, Clone, Serialize)]
275pub struct FileLintResult {
276    pub path: std::path::PathBuf,
277    pub warnings: Vec<LintWarning>,
278}
279
280impl FileLintResult {
281    pub fn has_errors(&self) -> bool {
282        self.warnings.iter().any(|w| w.severity == Severity::Error)
283    }
284
285    pub fn error_count(&self) -> usize {
286        self.warnings
287            .iter()
288            .filter(|w| w.severity == Severity::Error)
289            .count()
290    }
291
292    pub fn warning_count(&self) -> usize {
293        self.warnings
294            .iter()
295            .filter(|w| w.severity == Severity::Warning)
296            .count()
297    }
298
299    pub fn info_count(&self) -> usize {
300        self.warnings
301            .iter()
302            .filter(|w| w.severity == Severity::Info)
303            .count()
304    }
305
306    pub fn hint_count(&self) -> usize {
307        self.warnings
308            .iter()
309            .filter(|w| w.severity == Severity::Hint)
310            .count()
311    }
312}
313
314// =============================================================================
315// Helpers (shared with rule submodules)
316// =============================================================================
317
318static KEY_CACHE: LazyLock<HashMap<&'static str, Value>> = LazyLock::new(|| {
319    [
320        "action",
321        "author",
322        "category",
323        "condition",
324        "correlation",
325        "date",
326        "description",
327        "detection",
328        "falsepositives",
329        "field",
330        "fields",
331        "filter",
332        "generate",
333        "group-by",
334        "id",
335        "level",
336        "logsource",
337        "modified",
338        "name",
339        "product",
340        "references",
341        "related",
342        "rules",
343        "scope",
344        "selection",
345        "service",
346        "status",
347        "tags",
348        "taxonomy",
349        "timeframe",
350        "timespan",
351        "title",
352        "type",
353    ]
354    .into_iter()
355    .map(|n| (n, Value::String(n.into())))
356    .collect()
357});
358
359pub(crate) fn key(s: &str) -> &'static Value {
360    KEY_CACHE
361        .get(s)
362        .unwrap_or_else(|| panic!("lint key not pre-cached: \"{s}\" — add it to KEY_CACHE"))
363}
364
365pub(crate) fn get_str<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a str> {
366    m.get(key(k)).and_then(|v| v.as_str())
367}
368
369pub(crate) fn get_mapping<'a>(
370    m: &'a yaml_serde::Mapping,
371    k: &str,
372) -> Option<&'a yaml_serde::Mapping> {
373    m.get(key(k)).and_then(|v| v.as_mapping())
374}
375
376pub(crate) fn get_seq<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a yaml_serde::Sequence> {
377    m.get(key(k)).and_then(|v| v.as_sequence())
378}
379
380pub(crate) fn warn(
381    rule: LintRule,
382    severity: Severity,
383    message: impl Into<String>,
384    path: impl Into<String>,
385) -> LintWarning {
386    LintWarning {
387        rule,
388        severity,
389        message: message.into(),
390        path: path.into(),
391        span: None,
392        fix: None,
393    }
394}
395
396pub(crate) fn err(
397    rule: LintRule,
398    message: impl Into<String>,
399    path: impl Into<String>,
400) -> LintWarning {
401    warn(rule, Severity::Error, message, path)
402}
403
404pub(crate) fn warning(
405    rule: LintRule,
406    message: impl Into<String>,
407    path: impl Into<String>,
408) -> LintWarning {
409    warn(rule, Severity::Warning, message, path)
410}
411
412pub(crate) fn info(
413    rule: LintRule,
414    message: impl Into<String>,
415    path: impl Into<String>,
416) -> LintWarning {
417    warn(rule, Severity::Info, message, path)
418}
419
420pub(crate) fn safe_fix(title: impl Into<String>, patches: Vec<FixPatch>) -> Option<Fix> {
421    Some(Fix {
422        title: title.into(),
423        disposition: FixDisposition::Safe,
424        patches,
425    })
426}
427
428/// Find the closest match for `input` among `candidates` using edit distance.
429pub(crate) fn closest_match<'a>(
430    input: &str,
431    candidates: &[&'a str],
432    max_distance: usize,
433) -> Option<&'a str> {
434    candidates
435        .iter()
436        .filter(|c| edit_distance(input, c) <= max_distance)
437        .min_by_key(|c| edit_distance(input, c))
438        .copied()
439}
440
441/// Levenshtein edit distance between two strings.
442pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
443    let (a_len, b_len) = (a.len(), b.len());
444    if a_len == 0 {
445        return b_len;
446    }
447    if b_len == 0 {
448        return a_len;
449    }
450    let mut prev: Vec<usize> = (0..=b_len).collect();
451    let mut curr = vec![0; b_len + 1];
452    for (i, ca) in a.bytes().enumerate() {
453        curr[0] = i + 1;
454        for (j, cb) in b.bytes().enumerate() {
455            let cost = if ca == cb { 0 } else { 1 };
456            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
457        }
458        std::mem::swap(&mut prev, &mut curr);
459    }
460    prev[b_len]
461}
462
463pub(crate) const TYPO_MAX_EDIT_DISTANCE: usize = 2;
464
465// =============================================================================
466// Document type detection
467// =============================================================================
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub(crate) enum DocType {
471    Detection,
472    Correlation,
473    Filter,
474}
475
476impl DocType {
477    pub(crate) fn known_keys(&self) -> &'static [&'static str] {
478        match self {
479            DocType::Detection => rules::shared::KNOWN_KEYS_DETECTION,
480            DocType::Correlation => rules::shared::KNOWN_KEYS_CORRELATION,
481            DocType::Filter => rules::shared::KNOWN_KEYS_FILTER,
482        }
483    }
484}
485
486fn detect_doc_type(m: &yaml_serde::Mapping) -> DocType {
487    if m.contains_key(key("correlation")) {
488        DocType::Correlation
489    } else if m.contains_key(key("filter")) {
490        DocType::Filter
491    } else {
492        DocType::Detection
493    }
494}
495
496fn is_action_fragment(m: &yaml_serde::Mapping) -> bool {
497    matches!(get_str(m, "action"), Some("global" | "reset" | "repeat"))
498}
499
500// =============================================================================
501// Public API
502// =============================================================================
503
504/// Lint a single YAML document value.
505pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
506    let Some(m) = value.as_mapping() else {
507        return vec![err(
508            LintRule::NotAMapping,
509            "document is not a YAML mapping",
510            "/",
511        )];
512    };
513
514    if is_action_fragment(m) {
515        return Vec::new();
516    }
517
518    let mut warnings = Vec::new();
519
520    rules::metadata::lint_shared(m, &mut warnings);
521
522    let doc_type = detect_doc_type(m);
523    match doc_type {
524        DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings),
525        DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
526        DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
527    }
528
529    rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
530
531    warnings
532}
533
534/// Lint a raw YAML string, returning warnings with resolved source spans.
535pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
536    let mut all_warnings = Vec::new();
537
538    for doc in yaml_serde::Deserializer::from_str(text) {
539        let value: Value = match Value::deserialize(doc) {
540            Ok(v) => v,
541            Err(e) => {
542                let mut w = err(
543                    LintRule::YamlParseError,
544                    format!("YAML parse error: {e}"),
545                    "/",
546                );
547                if let Some(loc) = e.location() {
548                    w.span = Some(Span {
549                        start_line: loc.line().saturating_sub(1) as u32,
550                        start_col: loc.column() as u32,
551                        end_line: loc.line().saturating_sub(1) as u32,
552                        end_col: loc.column() as u32 + 1,
553                    });
554                }
555                all_warnings.push(w);
556                break;
557            }
558        };
559
560        let warnings = lint_yaml_value(&value);
561        for mut w in warnings {
562            w.span = resolve_path_to_span(text, &w.path);
563            all_warnings.push(w);
564        }
565    }
566
567    all_warnings
568}
569
570fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
571    if path == "/" || path.is_empty() {
572        for (i, line) in text.lines().enumerate() {
573            let trimmed = line.trim();
574            if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
575                return Some(Span {
576                    start_line: i as u32,
577                    start_col: 0,
578                    end_line: i as u32,
579                    end_col: line.len() as u32,
580                });
581            }
582        }
583        return None;
584    }
585
586    let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
587
588    if segments.is_empty() {
589        return None;
590    }
591
592    let lines: Vec<&str> = text.lines().collect();
593    let mut current_indent: i32 = -1;
594    let mut search_start = 0usize;
595    let mut last_matched_line: Option<usize> = None;
596
597    for segment in &segments {
598        let array_index: Option<usize> = segment.parse().ok();
599        let mut found = false;
600
601        let mut line_num = search_start;
602        while line_num < lines.len() {
603            let line = lines[line_num];
604            let trimmed = line.trim();
605            if trimmed.is_empty() || trimmed.starts_with('#') {
606                line_num += 1;
607                continue;
608            }
609
610            let indent = (line.len() - trimmed.len()) as i32;
611
612            if indent <= current_indent && found {
613                break;
614            }
615            if indent <= current_indent {
616                line_num += 1;
617                continue;
618            }
619
620            if let Some(idx) = array_index {
621                if trimmed.starts_with("- ") && indent > current_indent {
622                    let mut count = 0usize;
623                    for (offset, sl) in lines[search_start..].iter().enumerate() {
624                        let scan = search_start + offset;
625                        let st = sl.trim();
626                        if st.is_empty() || st.starts_with('#') {
627                            continue;
628                        }
629                        let si = (sl.len() - st.len()) as i32;
630                        if si == indent && st.starts_with("- ") {
631                            if count == idx {
632                                last_matched_line = Some(scan);
633                                search_start = scan + 1;
634                                current_indent = indent;
635                                found = true;
636                                break;
637                            }
638                            count += 1;
639                        }
640                        if si < indent && count > 0 {
641                            break;
642                        }
643                    }
644                    break;
645                }
646            } else {
647                let key_pattern = format!("{segment}:");
648                if trimmed.starts_with(&key_pattern) || trimmed == *segment {
649                    last_matched_line = Some(line_num);
650                    search_start = line_num + 1;
651                    current_indent = indent;
652                    found = true;
653                    break;
654                }
655            }
656
657            line_num += 1;
658        }
659
660        if !found && last_matched_line.is_none() {
661            break;
662        }
663    }
664
665    last_matched_line.map(|line_num| {
666        let line = lines[line_num];
667        Span {
668            start_line: line_num as u32,
669            start_col: 0,
670            end_line: line_num as u32,
671            end_col: line.len() as u32,
672        }
673    })
674}
675
676/// Lint all YAML documents in a file.
677pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
678    let content = std::fs::read_to_string(path)?;
679    let warnings = lint_yaml_str(&content);
680    Ok(FileLintResult {
681        path: path.to_path_buf(),
682        warnings,
683    })
684}
685
686/// Lint all `.yml`/`.yaml` files in a directory recursively.
687pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
688    let mut results = Vec::new();
689    let mut visited = HashSet::new();
690
691    fn walk(
692        dir: &Path,
693        results: &mut Vec<FileLintResult>,
694        visited: &mut HashSet<std::path::PathBuf>,
695    ) -> crate::error::Result<()> {
696        let canonical = match dir.canonicalize() {
697            Ok(p) => p,
698            Err(_) => return Ok(()),
699        };
700        if !visited.insert(canonical) {
701            return Ok(());
702        }
703
704        let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
705        entries.sort_by_key(|e| e.path());
706
707        for entry in entries {
708            let path = entry.path();
709
710            if path.is_dir() {
711                if path
712                    .file_name()
713                    .and_then(|n| n.to_str())
714                    .is_some_and(|n| n.starts_with('.'))
715                {
716                    continue;
717                }
718                walk(&path, results, visited)?;
719            } else if matches!(
720                path.extension().and_then(|e| e.to_str()),
721                Some("yml" | "yaml")
722            ) {
723                match crate::lint::lint_yaml_file(&path) {
724                    Ok(file_result) => results.push(file_result),
725                    Err(e) => {
726                        results.push(FileLintResult {
727                            path: path.clone(),
728                            warnings: vec![err(
729                                LintRule::FileReadError,
730                                format!("error reading file: {e}"),
731                                "/",
732                            )],
733                        });
734                    }
735                }
736            }
737        }
738        Ok(())
739    }
740
741    walk(dir, &mut results, &mut visited)?;
742    Ok(results)
743}
744
745// =============================================================================
746// Lint configuration & suppression
747// =============================================================================
748
749/// Configuration for lint rule suppression and severity overrides.
750#[derive(Debug, Clone, Default, Serialize)]
751pub struct LintConfig {
752    pub disabled_rules: HashSet<String>,
753    pub severity_overrides: HashMap<String, Severity>,
754    pub exclude_patterns: Vec<String>,
755}
756
757#[derive(Debug, Deserialize)]
758struct RawLintConfig {
759    #[serde(default)]
760    disabled_rules: Vec<String>,
761    #[serde(default)]
762    severity_overrides: HashMap<String, String>,
763    #[serde(default)]
764    exclude: Vec<String>,
765}
766
767impl LintConfig {
768    pub fn load(path: &Path) -> crate::error::Result<Self> {
769        let content = std::fs::read_to_string(path)?;
770        let raw: RawLintConfig = yaml_serde::from_str(&content)?;
771
772        let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
773        let mut severity_overrides = HashMap::new();
774        for (rule, sev_str) in &raw.severity_overrides {
775            let sev = match sev_str.as_str() {
776                "error" => Severity::Error,
777                "warning" => Severity::Warning,
778                "info" => Severity::Info,
779                "hint" => Severity::Hint,
780                other => {
781                    return Err(crate::error::SigmaParserError::InvalidRule(format!(
782                        "invalid severity '{other}' for rule '{rule}' in lint config"
783                    )));
784                }
785            };
786            severity_overrides.insert(rule.clone(), sev);
787        }
788
789        Ok(LintConfig {
790            disabled_rules,
791            severity_overrides,
792            exclude_patterns: raw.exclude,
793        })
794    }
795
796    pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
797        let dir = if start_path.is_file() {
798            start_path.parent()?
799        } else {
800            start_path
801        };
802
803        let mut current = dir;
804        loop {
805            let candidate = current.join(".rsigma-lint.yml");
806            if candidate.is_file() {
807                return Some(candidate);
808            }
809            let candidate_yaml = current.join(".rsigma-lint.yaml");
810            if candidate_yaml.is_file() {
811                return Some(candidate_yaml);
812            }
813            current = current.parent()?;
814        }
815    }
816
817    pub fn merge(&mut self, other: &LintConfig) {
818        self.disabled_rules
819            .extend(other.disabled_rules.iter().cloned());
820        for (rule, sev) in &other.severity_overrides {
821            self.severity_overrides.insert(rule.clone(), *sev);
822        }
823        self.exclude_patterns
824            .extend(other.exclude_patterns.iter().cloned());
825    }
826
827    pub fn is_disabled(&self, rule: &LintRule) -> bool {
828        self.disabled_rules.contains(&rule.to_string())
829    }
830
831    pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
832        if self.exclude_patterns.is_empty() {
833            return None;
834        }
835        let mut builder = globset::GlobSetBuilder::new();
836        for pat in &self.exclude_patterns {
837            if let Ok(glob) = globset::GlobBuilder::new(pat)
838                .literal_separator(false)
839                .build()
840            {
841                builder.add(glob);
842            }
843        }
844        builder.build().ok()
845    }
846}
847
848// =============================================================================
849// Inline suppression comments
850// =============================================================================
851
852#[derive(Debug, Clone, Default)]
853pub struct InlineSuppressions {
854    pub disable_all: bool,
855    pub file_disabled: HashSet<String>,
856    pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
857}
858
859pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
860    let mut result = InlineSuppressions::default();
861
862    for (i, line) in text.lines().enumerate() {
863        let trimmed = line.trim();
864
865        let comment = if let Some(pos) = find_yaml_comment(trimmed) {
866            trimmed[pos + 1..].trim()
867        } else {
868            continue;
869        };
870
871        if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
872            let rest = rest.trim();
873            let next_line = (i + 1) as u32;
874            if rest.is_empty() {
875                result.line_disabled.insert(next_line, None);
876            } else {
877                let rules: HashSet<String> = rest
878                    .split(',')
879                    .map(|s| s.trim().to_string())
880                    .filter(|s| !s.is_empty())
881                    .collect();
882                if !rules.is_empty() {
883                    result
884                        .line_disabled
885                        .entry(next_line)
886                        .and_modify(|existing| {
887                            if let Some(existing_set) = existing {
888                                existing_set.extend(rules.iter().cloned());
889                            }
890                        })
891                        .or_insert(Some(rules));
892                }
893            }
894        } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
895            let rest = rest.trim();
896            if rest.is_empty() {
897                result.disable_all = true;
898            } else {
899                for rule in rest.split(',') {
900                    let rule = rule.trim();
901                    if !rule.is_empty() {
902                        result.file_disabled.insert(rule.to_string());
903                    }
904                }
905            }
906        }
907    }
908
909    result
910}
911
912fn find_yaml_comment(line: &str) -> Option<usize> {
913    let mut in_single = false;
914    let mut in_double = false;
915    for (i, c) in line.char_indices() {
916        match c {
917            '\'' if !in_double => in_single = !in_single,
918            '"' if !in_single => in_double = !in_double,
919            '#' if !in_single && !in_double => return Some(i),
920            _ => {}
921        }
922    }
923    None
924}
925
926impl InlineSuppressions {
927    pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
928        if self.disable_all {
929            return true;
930        }
931
932        let rule_name = warning.rule.to_string();
933        if self.file_disabled.contains(&rule_name) {
934            return true;
935        }
936
937        if let Some(span) = &warning.span
938            && let Some(line_rules) = self.line_disabled.get(&span.start_line)
939        {
940            return match line_rules {
941                None => true,
942                Some(rules) => rules.contains(&rule_name),
943            };
944        }
945
946        false
947    }
948}
949
950// =============================================================================
951// Suppression filtering
952// =============================================================================
953
954pub fn apply_suppressions(
955    warnings: Vec<LintWarning>,
956    config: &LintConfig,
957    inline: &InlineSuppressions,
958) -> Vec<LintWarning> {
959    warnings
960        .into_iter()
961        .filter(|w| !config.is_disabled(&w.rule))
962        .filter(|w| !inline.is_suppressed(w))
963        .map(|mut w| {
964            let rule_name = w.rule.to_string();
965            if let Some(sev) = config.severity_overrides.get(&rule_name) {
966                w.severity = *sev;
967            }
968            w
969        })
970        .collect()
971}
972
973pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
974    let warnings = lint_yaml_str(text);
975    let inline = parse_inline_suppressions(text);
976    apply_suppressions(warnings, config, &inline)
977}
978
979pub fn lint_yaml_file_with_config(
980    path: &Path,
981    config: &LintConfig,
982) -> crate::error::Result<FileLintResult> {
983    let content = std::fs::read_to_string(path)?;
984    let warnings = lint_yaml_str_with_config(&content, config);
985    Ok(FileLintResult {
986        path: path.to_path_buf(),
987        warnings,
988    })
989}
990
991pub fn lint_yaml_directory_with_config(
992    dir: &Path,
993    config: &LintConfig,
994) -> crate::error::Result<Vec<FileLintResult>> {
995    let mut results = Vec::new();
996    let mut visited = HashSet::new();
997    let exclude_set = config.build_exclude_set();
998
999    fn walk(
1000        dir: &Path,
1001        base: &Path,
1002        config: &LintConfig,
1003        exclude_set: &Option<globset::GlobSet>,
1004        results: &mut Vec<FileLintResult>,
1005        visited: &mut HashSet<std::path::PathBuf>,
1006    ) -> crate::error::Result<()> {
1007        let canonical = match dir.canonicalize() {
1008            Ok(p) => p,
1009            Err(_) => return Ok(()),
1010        };
1011        if !visited.insert(canonical) {
1012            return Ok(());
1013        }
1014
1015        let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
1016        entries.sort_by_key(|e| e.path());
1017
1018        for entry in entries {
1019            let path = entry.path();
1020
1021            if let Some(gs) = exclude_set
1022                && let Ok(rel) = path.strip_prefix(base)
1023                && gs.is_match(rel)
1024            {
1025                continue;
1026            }
1027
1028            if path.is_dir() {
1029                if path
1030                    .file_name()
1031                    .and_then(|n| n.to_str())
1032                    .is_some_and(|n| n.starts_with('.'))
1033                {
1034                    continue;
1035                }
1036                walk(&path, base, config, exclude_set, results, visited)?;
1037            } else if matches!(
1038                path.extension().and_then(|e| e.to_str()),
1039                Some("yml" | "yaml")
1040            ) {
1041                match lint_yaml_file_with_config(&path, config) {
1042                    Ok(file_result) => results.push(file_result),
1043                    Err(e) => {
1044                        results.push(FileLintResult {
1045                            path: path.clone(),
1046                            warnings: vec![err(
1047                                LintRule::FileReadError,
1048                                format!("error reading file: {e}"),
1049                                "/",
1050                            )],
1051                        });
1052                    }
1053                }
1054            }
1055        }
1056        Ok(())
1057    }
1058
1059    walk(dir, dir, config, &exclude_set, &mut results, &mut visited)?;
1060    Ok(results)
1061}
1062
1063// =============================================================================
1064// Tests
1065// =============================================================================
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070
1071    fn yaml_value(yaml: &str) -> Value {
1072        yaml_serde::from_str(yaml).unwrap()
1073    }
1074
1075    fn lint(yaml: &str) -> Vec<LintWarning> {
1076        lint_yaml_value(&yaml_value(yaml))
1077    }
1078
1079    fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1080        warnings.iter().any(|w| w.rule == rule)
1081    }
1082
1083    fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1084        !has_rule(warnings, rule)
1085    }
1086
1087    #[test]
1088    fn valid_detection_rule_no_errors() {
1089        let w = lint(
1090            r#"
1091title: Test Rule
1092id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1093status: test
1094logsource:
1095    category: process_creation
1096    product: windows
1097detection:
1098    selection:
1099        CommandLine|contains: 'whoami'
1100    condition: selection
1101level: medium
1102tags:
1103    - attack.execution
1104    - attack.t1059
1105"#,
1106        );
1107        let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1108        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1109    }
1110
1111    #[test]
1112    fn not_a_mapping() {
1113        let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1114        let w = lint_yaml_value(&v);
1115        assert!(has_rule(&w, LintRule::NotAMapping));
1116    }
1117
1118    #[test]
1119    fn lint_yaml_str_produces_spans() {
1120        let text = r#"title: Test
1121status: invalid_status
1122logsource:
1123    category: test
1124detection:
1125    selection:
1126        field: value
1127    condition: selection
1128level: medium
1129"#;
1130        let warnings = lint_yaml_str(text);
1131        let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1132        assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1133        let span = invalid_status.unwrap().span;
1134        assert!(span.is_some(), "expected span to be resolved");
1135        assert_eq!(span.unwrap().start_line, 1);
1136    }
1137
1138    #[test]
1139    fn yaml_parse_error_uses_correct_rule() {
1140        let text = "title: [unclosed";
1141        let warnings = lint_yaml_str(text);
1142        assert!(has_rule(&warnings, LintRule::YamlParseError));
1143        assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1144    }
1145
1146    #[test]
1147    fn action_global_skipped() {
1148        let w = lint(
1149            r#"
1150action: global
1151title: Global Template
1152logsource:
1153    product: windows
1154"#,
1155        );
1156        assert!(w.is_empty());
1157    }
1158
1159    #[test]
1160    fn action_reset_skipped() {
1161        let w = lint(
1162            r#"
1163action: reset
1164"#,
1165        );
1166        assert!(w.is_empty());
1167    }
1168
1169    #[test]
1170    fn resolve_path_to_span_root() {
1171        let text = "title: Test\nstatus: test\n";
1172        let span = resolve_path_to_span(text, "/");
1173        assert!(span.is_some());
1174        assert_eq!(span.unwrap().start_line, 0);
1175    }
1176
1177    #[test]
1178    fn resolve_path_to_span_top_level_key() {
1179        let text = "title: Test\nstatus: test\nlevel: high\n";
1180        let span = resolve_path_to_span(text, "/status");
1181        assert!(span.is_some());
1182        assert_eq!(span.unwrap().start_line, 1);
1183    }
1184
1185    #[test]
1186    fn resolve_path_to_span_nested_key() {
1187        let text = "title: Test\nlogsource:\n    category: test\n    product: windows\n";
1188        let span = resolve_path_to_span(text, "/logsource/product");
1189        assert!(span.is_some());
1190        assert_eq!(span.unwrap().start_line, 3);
1191    }
1192
1193    #[test]
1194    fn resolve_path_to_span_missing_key() {
1195        let text = "title: Test\nstatus: test\n";
1196        let span = resolve_path_to_span(text, "/nonexistent");
1197        assert!(span.is_none());
1198    }
1199
1200    #[test]
1201    fn multi_doc_yaml_lints_all_documents() {
1202        let text = r#"title: Rule 1
1203logsource:
1204    category: test
1205detection:
1206    selection:
1207        field: value
1208    condition: selection
1209level: medium
1210---
1211title: Rule 2
1212status: bad_status
1213logsource:
1214    category: test
1215detection:
1216    selection:
1217        field: value
1218    condition: selection
1219level: medium
1220"#;
1221        let warnings = lint_yaml_str(text);
1222        assert!(has_rule(&warnings, LintRule::InvalidStatus));
1223    }
1224
1225    #[test]
1226    fn severity_display() {
1227        assert_eq!(format!("{}", Severity::Error), "error");
1228        assert_eq!(format!("{}", Severity::Warning), "warning");
1229        assert_eq!(format!("{}", Severity::Info), "info");
1230        assert_eq!(format!("{}", Severity::Hint), "hint");
1231    }
1232
1233    #[test]
1234    fn file_lint_result_has_errors() {
1235        let result = FileLintResult {
1236            path: std::path::PathBuf::from("test.yml"),
1237            warnings: vec![
1238                warning(LintRule::TitleTooLong, "too long", "/title"),
1239                err(
1240                    LintRule::MissingCondition,
1241                    "missing",
1242                    "/detection/condition",
1243                ),
1244            ],
1245        };
1246        assert!(result.has_errors());
1247        assert_eq!(result.error_count(), 1);
1248        assert_eq!(result.warning_count(), 1);
1249    }
1250
1251    #[test]
1252    fn file_lint_result_no_errors() {
1253        let result = FileLintResult {
1254            path: std::path::PathBuf::from("test.yml"),
1255            warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1256        };
1257        assert!(!result.has_errors());
1258        assert_eq!(result.error_count(), 0);
1259        assert_eq!(result.warning_count(), 1);
1260    }
1261
1262    #[test]
1263    fn file_lint_result_empty() {
1264        let result = FileLintResult {
1265            path: std::path::PathBuf::from("test.yml"),
1266            warnings: vec![],
1267        };
1268        assert!(!result.has_errors());
1269        assert_eq!(result.error_count(), 0);
1270        assert_eq!(result.warning_count(), 0);
1271    }
1272
1273    #[test]
1274    fn lint_warning_display() {
1275        let w = err(
1276            LintRule::MissingTitle,
1277            "missing required field 'title'",
1278            "/title",
1279        );
1280        let display = format!("{w}");
1281        assert!(display.contains("error"));
1282        assert!(display.contains("missing_title"));
1283        assert!(display.contains("/title"));
1284    }
1285
1286    #[test]
1287    fn file_lint_result_info_count() {
1288        let result = FileLintResult {
1289            path: std::path::PathBuf::from("test.yml"),
1290            warnings: vec![
1291                info(LintRule::MissingDescription, "missing desc", "/description"),
1292                info(LintRule::MissingAuthor, "missing author", "/author"),
1293                warning(LintRule::TitleTooLong, "too long", "/title"),
1294            ],
1295        };
1296        assert_eq!(result.info_count(), 2);
1297        assert_eq!(result.warning_count(), 1);
1298        assert_eq!(result.error_count(), 0);
1299        assert!(!result.has_errors());
1300    }
1301
1302    #[test]
1303    fn parse_inline_disable_all() {
1304        let text = "# rsigma-disable\ntitle: Test\n";
1305        let sup = parse_inline_suppressions(text);
1306        assert!(sup.disable_all);
1307    }
1308
1309    #[test]
1310    fn parse_inline_disable_specific_rules() {
1311        let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1312        let sup = parse_inline_suppressions(text);
1313        assert!(!sup.disable_all);
1314        assert!(sup.file_disabled.contains("missing_description"));
1315        assert!(sup.file_disabled.contains("missing_author"));
1316    }
1317
1318    #[test]
1319    fn parse_inline_disable_next_line_all() {
1320        let text = "# rsigma-disable-next-line\ntitle: Test\n";
1321        let sup = parse_inline_suppressions(text);
1322        assert!(!sup.disable_all);
1323        assert!(sup.line_disabled.contains_key(&1));
1324        assert!(sup.line_disabled[&1].is_none());
1325    }
1326
1327    #[test]
1328    fn parse_inline_disable_next_line_specific() {
1329        let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1330        let sup = parse_inline_suppressions(text);
1331        assert!(sup.line_disabled.contains_key(&2));
1332        let rules = sup.line_disabled[&2].as_ref().unwrap();
1333        assert!(rules.contains("missing_level"));
1334    }
1335
1336    #[test]
1337    fn parse_inline_no_comments() {
1338        let text = "title: Test\nstatus: test\n";
1339        let sup = parse_inline_suppressions(text);
1340        assert!(!sup.disable_all);
1341        assert!(sup.file_disabled.is_empty());
1342        assert!(sup.line_disabled.is_empty());
1343    }
1344
1345    #[test]
1346    fn parse_inline_comment_in_quoted_string() {
1347        let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1348        let sup = parse_inline_suppressions(text);
1349        assert!(!sup.disable_all);
1350        assert!(sup.file_disabled.is_empty());
1351    }
1352
1353    #[test]
1354    fn apply_suppressions_disables_rule() {
1355        let warnings = vec![
1356            info(LintRule::MissingDescription, "desc", "/description"),
1357            info(LintRule::MissingAuthor, "author", "/author"),
1358            warning(LintRule::TitleTooLong, "title", "/title"),
1359        ];
1360        let mut config = LintConfig::default();
1361        config
1362            .disabled_rules
1363            .insert("missing_description".to_string());
1364        let inline = InlineSuppressions::default();
1365
1366        let result = apply_suppressions(warnings, &config, &inline);
1367        assert_eq!(result.len(), 2);
1368        assert!(
1369            result
1370                .iter()
1371                .all(|w| w.rule != LintRule::MissingDescription)
1372        );
1373    }
1374
1375    #[test]
1376    fn apply_suppressions_severity_override() {
1377        let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1378        let mut config = LintConfig::default();
1379        config
1380            .severity_overrides
1381            .insert("title_too_long".to_string(), Severity::Info);
1382        let inline = InlineSuppressions::default();
1383
1384        let result = apply_suppressions(warnings, &config, &inline);
1385        assert_eq!(result.len(), 1);
1386        assert_eq!(result[0].severity, Severity::Info);
1387    }
1388
1389    #[test]
1390    fn apply_suppressions_inline_file_disable() {
1391        let warnings = vec![
1392            info(LintRule::MissingDescription, "desc", "/description"),
1393            info(LintRule::MissingAuthor, "author", "/author"),
1394        ];
1395        let config = LintConfig::default();
1396        let mut inline = InlineSuppressions::default();
1397        inline.file_disabled.insert("missing_author".to_string());
1398
1399        let result = apply_suppressions(warnings, &config, &inline);
1400        assert_eq!(result.len(), 1);
1401        assert_eq!(result[0].rule, LintRule::MissingDescription);
1402    }
1403
1404    #[test]
1405    fn apply_suppressions_inline_disable_all() {
1406        let warnings = vec![
1407            err(LintRule::MissingTitle, "title", "/title"),
1408            warning(LintRule::TitleTooLong, "long", "/title"),
1409        ];
1410        let config = LintConfig::default();
1411        let inline = InlineSuppressions {
1412            disable_all: true,
1413            ..Default::default()
1414        };
1415
1416        let result = apply_suppressions(warnings, &config, &inline);
1417        assert!(result.is_empty());
1418    }
1419
1420    #[test]
1421    fn apply_suppressions_inline_next_line() {
1422        let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1423        w1.span = Some(Span {
1424            start_line: 5,
1425            start_col: 0,
1426            end_line: 5,
1427            end_col: 10,
1428        });
1429        let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1430        w2.span = Some(Span {
1431            start_line: 6,
1432            start_col: 0,
1433            end_line: 6,
1434            end_col: 10,
1435        });
1436
1437        let config = LintConfig::default();
1438        let mut inline = InlineSuppressions::default();
1439        inline.line_disabled.insert(5, None);
1440
1441        let result = apply_suppressions(vec![w1, w2], &config, &inline);
1442        assert_eq!(result.len(), 1);
1443        assert_eq!(result[0].rule, LintRule::InvalidStatus);
1444    }
1445
1446    #[test]
1447    fn lint_with_config_disables_rules() {
1448        let text = r#"title: Test
1449logsource:
1450    category: test
1451detection:
1452    selection:
1453        field: value
1454    condition: selection
1455level: medium
1456"#;
1457        let mut config = LintConfig::default();
1458        config
1459            .disabled_rules
1460            .insert("missing_description".to_string());
1461        config.disabled_rules.insert("missing_author".to_string());
1462
1463        let warnings = lint_yaml_str_with_config(text, &config);
1464        assert!(
1465            !warnings
1466                .iter()
1467                .any(|w| w.rule == LintRule::MissingDescription)
1468        );
1469        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1470    }
1471
1472    #[test]
1473    fn lint_with_inline_disable_next_line() {
1474        let text = r#"title: Test
1475# rsigma-disable-next-line missing_level
1476logsource:
1477    category: test
1478detection:
1479    selection:
1480        field: value
1481    condition: selection
1482"#;
1483        let config = LintConfig::default();
1484        let warnings = lint_yaml_str_with_config(text, &config);
1485        assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1486    }
1487
1488    #[test]
1489    fn lint_with_inline_file_disable() {
1490        let text = r#"# rsigma-disable missing_description, missing_author
1491title: Test
1492logsource:
1493    category: test
1494detection:
1495    selection:
1496        field: value
1497    condition: selection
1498level: medium
1499"#;
1500        let config = LintConfig::default();
1501        let warnings = lint_yaml_str_with_config(text, &config);
1502        assert!(
1503            !warnings
1504                .iter()
1505                .any(|w| w.rule == LintRule::MissingDescription)
1506        );
1507        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1508    }
1509
1510    #[test]
1511    fn lint_with_inline_disable_all() {
1512        let text = r#"# rsigma-disable
1513title: Test
1514status: invalid_status
1515logsource:
1516    category: test
1517detection:
1518    selection:
1519        field: value
1520    condition: selection
1521"#;
1522        let config = LintConfig::default();
1523        let warnings = lint_yaml_str_with_config(text, &config);
1524        assert!(warnings.is_empty());
1525    }
1526
1527    #[test]
1528    fn lint_config_merge() {
1529        let mut base = LintConfig::default();
1530        base.disabled_rules.insert("rule_a".to_string());
1531        base.severity_overrides
1532            .insert("rule_b".to_string(), Severity::Info);
1533
1534        let other = LintConfig {
1535            disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1536            severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1537                .into_iter()
1538                .collect(),
1539            exclude_patterns: vec!["test/**".to_string()],
1540        };
1541
1542        base.merge(&other);
1543        assert!(base.disabled_rules.contains("rule_a"));
1544        assert!(base.disabled_rules.contains("rule_c"));
1545        assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
1546        assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
1547        assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
1548    }
1549
1550    #[test]
1551    fn lint_config_is_disabled() {
1552        let mut config = LintConfig::default();
1553        config.disabled_rules.insert("missing_title".to_string());
1554        assert!(config.is_disabled(&LintRule::MissingTitle));
1555        assert!(!config.is_disabled(&LintRule::EmptyTitle));
1556    }
1557
1558    #[test]
1559    fn find_yaml_comment_basic() {
1560        assert_eq!(find_yaml_comment("# comment"), Some(0));
1561        assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
1562        assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
1563        assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
1564        assert_eq!(find_yaml_comment("key: value"), None);
1565    }
1566
1567    #[test]
1568    fn no_fix_for_unfixable_rule() {
1569        let w = lint(
1570            r#"
1571title: Test
1572logsource:
1573    category: test
1574"#,
1575        );
1576        assert!(has_rule(&w, LintRule::MissingDetection));
1577        let fix = w
1578            .iter()
1579            .find(|w| w.rule == LintRule::MissingDetection)
1580            .and_then(|w| w.fix.as_ref());
1581        assert!(fix.is_none());
1582    }
1583
1584    #[test]
1585    fn lint_config_exclude_from_yaml() {
1586        let yaml = r#"
1587disabled_rules:
1588  - missing_description
1589exclude:
1590  - "config/**"
1591  - "**/unsupported/**"
1592"#;
1593        let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
1594        std::fs::write(&tmp, yaml).unwrap();
1595        let config = LintConfig::load(&tmp).unwrap();
1596        std::fs::remove_file(&tmp).ok();
1597
1598        assert!(config.disabled_rules.contains("missing_description"));
1599        assert_eq!(config.exclude_patterns.len(), 2);
1600        assert_eq!(config.exclude_patterns[0], "config/**");
1601        assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
1602    }
1603
1604    #[test]
1605    fn lint_config_build_exclude_set_empty() {
1606        let config = LintConfig::default();
1607        assert!(config.build_exclude_set().is_none());
1608    }
1609
1610    #[test]
1611    fn lint_config_build_exclude_set_matches() {
1612        let config = LintConfig {
1613            exclude_patterns: vec!["config/**".to_string()],
1614            ..Default::default()
1615        };
1616        let gs = config.build_exclude_set().expect("should build");
1617        assert!(gs.is_match("config/data_mapping/foo.yaml"));
1618        assert!(gs.is_match("config/nested/deep/bar.yml"));
1619        assert!(!gs.is_match("rules/windows/test.yml"));
1620    }
1621
1622    #[test]
1623    fn lint_directory_with_excludes() {
1624        let tmp = tempfile::tempdir().unwrap();
1625        let rules_dir = tmp.path().join("rules");
1626        let config_dir = tmp.path().join("config");
1627        std::fs::create_dir_all(&rules_dir).unwrap();
1628        std::fs::create_dir_all(&config_dir).unwrap();
1629
1630        std::fs::write(
1631            rules_dir.join("good.yml"),
1632            r#"
1633title: Good Rule
1634logsource:
1635    category: test
1636detection:
1637    sel:
1638        field: value
1639    condition: sel
1640level: medium
1641"#,
1642        )
1643        .unwrap();
1644
1645        std::fs::write(
1646            config_dir.join("mapping.yaml"),
1647            r#"
1648Title: Logon
1649Channel: Security
1650EventID: 4624
1651"#,
1652        )
1653        .unwrap();
1654
1655        let no_exclude = LintConfig::default();
1656        let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
1657        let config_warnings: Vec<_> = results
1658            .iter()
1659            .filter(|r| r.path.to_string_lossy().contains("config"))
1660            .flat_map(|r| &r.warnings)
1661            .collect();
1662        assert!(
1663            !config_warnings.is_empty(),
1664            "config file should produce warnings without excludes"
1665        );
1666
1667        let with_exclude = LintConfig {
1668            exclude_patterns: vec!["config/**".to_string()],
1669            ..Default::default()
1670        };
1671        let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
1672        let config_results: Vec<_> = results
1673            .iter()
1674            .filter(|r| r.path.to_string_lossy().contains("config"))
1675            .collect();
1676        assert!(config_results.is_empty(), "config file should be excluded");
1677
1678        let rule_results: Vec<_> = results
1679            .iter()
1680            .filter(|r| r.path.to_string_lossy().contains("good.yml"))
1681            .collect();
1682        assert_eq!(rule_results.len(), 1);
1683    }
1684
1685    #[test]
1686    fn all_lint_keys_are_cached() {
1687        const ALL_LINT_KEYS: &[&str] = &[
1688            "action",
1689            "author",
1690            "condition",
1691            "correlation",
1692            "date",
1693            "description",
1694            "detection",
1695            "field",
1696            "filter",
1697            "generate",
1698            "group-by",
1699            "id",
1700            "level",
1701            "logsource",
1702            "modified",
1703            "name",
1704            "rules",
1705            "selection",
1706            "status",
1707            "tags",
1708            "taxonomy",
1709            "timeframe",
1710            "timespan",
1711            "title",
1712            "type",
1713        ];
1714        for key_str in ALL_LINT_KEYS {
1715            assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
1716        }
1717    }
1718}