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
504fn lint_yaml_value_ext(value: &Value, extra_ns: &[String]) -> Vec<LintWarning> {
505    let Some(m) = value.as_mapping() else {
506        return vec![err(
507            LintRule::NotAMapping,
508            "document is not a YAML mapping",
509            "/",
510        )];
511    };
512
513    if is_action_fragment(m) {
514        return Vec::new();
515    }
516
517    let mut warnings = Vec::new();
518
519    rules::metadata::lint_shared(m, &mut warnings);
520
521    let doc_type = detect_doc_type(m);
522    match doc_type {
523        DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings, extra_ns),
524        DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
525        DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
526    }
527
528    rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
529
530    warnings
531}
532
533/// Lint a single YAML document value.
534pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
535    lint_yaml_value_ext(value, &[])
536}
537
538fn lint_yaml_str_ext(text: &str, extra_ns: &[String]) -> Vec<LintWarning> {
539    let mut all_warnings = Vec::new();
540
541    for doc in yaml_serde::Deserializer::from_str(text) {
542        let value: Value = match Value::deserialize(doc) {
543            Ok(v) => v,
544            Err(e) => {
545                let mut w = err(
546                    LintRule::YamlParseError,
547                    format!("YAML parse error: {e}"),
548                    "/",
549                );
550                if let Some(loc) = e.location() {
551                    w.span = Some(Span {
552                        start_line: loc.line().saturating_sub(1) as u32,
553                        start_col: loc.column() as u32,
554                        end_line: loc.line().saturating_sub(1) as u32,
555                        end_col: loc.column() as u32 + 1,
556                    });
557                }
558                all_warnings.push(w);
559                break;
560            }
561        };
562
563        for mut w in lint_yaml_value_ext(&value, extra_ns) {
564            w.span = resolve_path_to_span(text, &w.path);
565            all_warnings.push(w);
566        }
567    }
568
569    all_warnings
570}
571
572/// Lint a raw YAML string, returning warnings with resolved source spans.
573pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
574    lint_yaml_str_ext(text, &[])
575}
576
577fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
578    if path == "/" || path.is_empty() {
579        for (i, line) in text.lines().enumerate() {
580            let trimmed = line.trim();
581            if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
582                return Some(Span {
583                    start_line: i as u32,
584                    start_col: 0,
585                    end_line: i as u32,
586                    end_col: line.len() as u32,
587                });
588            }
589        }
590        return None;
591    }
592
593    let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
594
595    if segments.is_empty() {
596        return None;
597    }
598
599    let lines: Vec<&str> = text.lines().collect();
600    let mut current_indent: i32 = -1;
601    let mut search_start = 0usize;
602    let mut last_matched_line: Option<usize> = None;
603
604    for segment in &segments {
605        let array_index: Option<usize> = segment.parse().ok();
606        let mut found = false;
607
608        let mut line_num = search_start;
609        while line_num < lines.len() {
610            let line = lines[line_num];
611            let trimmed = line.trim();
612            if trimmed.is_empty() || trimmed.starts_with('#') {
613                line_num += 1;
614                continue;
615            }
616
617            let indent = (line.len() - trimmed.len()) as i32;
618
619            if indent <= current_indent && found {
620                break;
621            }
622            if indent <= current_indent {
623                line_num += 1;
624                continue;
625            }
626
627            if let Some(idx) = array_index {
628                if trimmed.starts_with("- ") && indent > current_indent {
629                    let mut count = 0usize;
630                    for (offset, sl) in lines[search_start..].iter().enumerate() {
631                        let scan = search_start + offset;
632                        let st = sl.trim();
633                        if st.is_empty() || st.starts_with('#') {
634                            continue;
635                        }
636                        let si = (sl.len() - st.len()) as i32;
637                        if si == indent && st.starts_with("- ") {
638                            if count == idx {
639                                last_matched_line = Some(scan);
640                                search_start = scan + 1;
641                                current_indent = indent;
642                                found = true;
643                                break;
644                            }
645                            count += 1;
646                        }
647                        if si < indent && count > 0 {
648                            break;
649                        }
650                    }
651                    break;
652                }
653            } else {
654                let key_pattern = format!("{segment}:");
655                if trimmed.starts_with(&key_pattern) || trimmed == *segment {
656                    last_matched_line = Some(line_num);
657                    search_start = line_num + 1;
658                    current_indent = indent;
659                    found = true;
660                    break;
661                }
662            }
663
664            line_num += 1;
665        }
666
667        if !found && last_matched_line.is_none() {
668            break;
669        }
670    }
671
672    last_matched_line.map(|line_num| {
673        let line = lines[line_num];
674        Span {
675            start_line: line_num as u32,
676            start_col: 0,
677            end_line: line_num as u32,
678            end_col: line.len() as u32,
679        }
680    })
681}
682
683/// Lint all YAML documents in a file.
684pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
685    let content = std::fs::read_to_string(path)?;
686    let warnings = lint_yaml_str(&content);
687    Ok(FileLintResult {
688        path: path.to_path_buf(),
689        warnings,
690    })
691}
692
693/// Lint all `.yml`/`.yaml` files in a directory recursively.
694pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
695    let mut results = Vec::new();
696    let mut visited = HashSet::new();
697
698    fn walk(
699        dir: &Path,
700        results: &mut Vec<FileLintResult>,
701        visited: &mut HashSet<std::path::PathBuf>,
702    ) -> crate::error::Result<()> {
703        let canonical = match dir.canonicalize() {
704            Ok(p) => p,
705            Err(_) => return Ok(()),
706        };
707        if !visited.insert(canonical) {
708            return Ok(());
709        }
710
711        let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
712        entries.sort_by_key(|e| e.path());
713
714        for entry in entries {
715            let path = entry.path();
716
717            if path.is_dir() {
718                if path
719                    .file_name()
720                    .and_then(|n| n.to_str())
721                    .is_some_and(|n| n.starts_with('.'))
722                {
723                    continue;
724                }
725                walk(&path, results, visited)?;
726            } else if matches!(
727                path.extension().and_then(|e| e.to_str()),
728                Some("yml" | "yaml")
729            ) {
730                match crate::lint::lint_yaml_file(&path) {
731                    Ok(file_result) => results.push(file_result),
732                    Err(e) => {
733                        results.push(FileLintResult {
734                            path: path.clone(),
735                            warnings: vec![err(
736                                LintRule::FileReadError,
737                                format!("error reading file: {e}"),
738                                "/",
739                            )],
740                        });
741                    }
742                }
743            }
744        }
745        Ok(())
746    }
747
748    walk(dir, &mut results, &mut visited)?;
749    Ok(results)
750}
751
752// =============================================================================
753// Lint configuration & suppression
754// =============================================================================
755
756/// Configuration for lint rule suppression and severity overrides.
757#[derive(Debug, Clone, Default, Serialize)]
758pub struct LintConfig {
759    pub disabled_rules: HashSet<String>,
760    pub severity_overrides: HashMap<String, Severity>,
761    pub exclude_patterns: Vec<String>,
762    /// Extra tag namespaces recognised in addition to the built-in set.
763    pub tag_namespaces: Vec<String>,
764}
765
766#[derive(Debug, Deserialize)]
767struct RawLintConfig {
768    #[serde(default)]
769    disabled_rules: Vec<String>,
770    #[serde(default)]
771    severity_overrides: HashMap<String, String>,
772    #[serde(default)]
773    exclude: Vec<String>,
774    #[serde(default)]
775    tag_namespaces: Vec<String>,
776}
777
778/// Remove duplicate entries from a list while keeping the first occurrence of
779/// each, so merged `exclude_patterns` / `tag_namespaces` stay stable and don't
780/// repeat a value that appears in both the config file and a CLI flag.
781fn dedup_preserving_order(items: &mut Vec<String>) {
782    let mut seen = HashSet::new();
783    items.retain(|item| seen.insert(item.clone()));
784}
785
786impl LintConfig {
787    pub fn load(path: &Path) -> crate::error::Result<Self> {
788        let content = std::fs::read_to_string(path)?;
789        let raw: RawLintConfig = yaml_serde::from_str(&content)?;
790
791        let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
792        let mut severity_overrides = HashMap::new();
793        for (rule, sev_str) in &raw.severity_overrides {
794            let sev = match sev_str.as_str() {
795                "error" => Severity::Error,
796                "warning" => Severity::Warning,
797                "info" => Severity::Info,
798                "hint" => Severity::Hint,
799                other => {
800                    return Err(crate::error::SigmaParserError::InvalidRule(format!(
801                        "invalid severity '{other}' for rule '{rule}' in lint config"
802                    )));
803                }
804            };
805            severity_overrides.insert(rule.clone(), sev);
806        }
807
808        let mut exclude_patterns = raw.exclude;
809        dedup_preserving_order(&mut exclude_patterns);
810
811        let mut tag_namespaces: Vec<String> = raw
812            .tag_namespaces
813            .into_iter()
814            .map(|s| s.to_lowercase())
815            .collect();
816        dedup_preserving_order(&mut tag_namespaces);
817
818        Ok(LintConfig {
819            disabled_rules,
820            severity_overrides,
821            exclude_patterns,
822            tag_namespaces,
823        })
824    }
825
826    pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
827        let dir = if start_path.is_file() {
828            start_path.parent()?
829        } else {
830            start_path
831        };
832
833        let mut current = dir;
834        loop {
835            let candidate = current.join(".rsigma-lint.yml");
836            if candidate.is_file() {
837                return Some(candidate);
838            }
839            let candidate_yaml = current.join(".rsigma-lint.yaml");
840            if candidate_yaml.is_file() {
841                return Some(candidate_yaml);
842            }
843            current = current.parent()?;
844        }
845    }
846
847    pub fn merge(&mut self, other: &LintConfig) {
848        self.disabled_rules
849            .extend(other.disabled_rules.iter().cloned());
850        for (rule, sev) in &other.severity_overrides {
851            self.severity_overrides.insert(rule.clone(), *sev);
852        }
853        self.exclude_patterns
854            .extend(other.exclude_patterns.iter().cloned());
855        dedup_preserving_order(&mut self.exclude_patterns);
856        self.tag_namespaces
857            .extend(other.tag_namespaces.iter().cloned());
858        dedup_preserving_order(&mut self.tag_namespaces);
859    }
860
861    pub fn is_disabled(&self, rule: &LintRule) -> bool {
862        self.disabled_rules.contains(&rule.to_string())
863    }
864
865    pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
866        if self.exclude_patterns.is_empty() {
867            return None;
868        }
869        let mut builder = globset::GlobSetBuilder::new();
870        for pat in &self.exclude_patterns {
871            if let Ok(glob) = globset::GlobBuilder::new(pat)
872                .literal_separator(false)
873                .build()
874            {
875                builder.add(glob);
876            }
877        }
878        builder.build().ok()
879    }
880}
881
882// =============================================================================
883// Inline suppression comments
884// =============================================================================
885
886#[derive(Debug, Clone, Default)]
887pub struct InlineSuppressions {
888    pub disable_all: bool,
889    pub file_disabled: HashSet<String>,
890    pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
891}
892
893pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
894    let mut result = InlineSuppressions::default();
895
896    for (i, line) in text.lines().enumerate() {
897        let trimmed = line.trim();
898
899        let comment = if let Some(pos) = find_yaml_comment(trimmed) {
900            trimmed[pos + 1..].trim()
901        } else {
902            continue;
903        };
904
905        if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
906            let rest = rest.trim();
907            let next_line = (i + 1) as u32;
908            if rest.is_empty() {
909                result.line_disabled.insert(next_line, None);
910            } else {
911                let rules: HashSet<String> = rest
912                    .split(',')
913                    .map(|s| s.trim().to_string())
914                    .filter(|s| !s.is_empty())
915                    .collect();
916                if !rules.is_empty() {
917                    result
918                        .line_disabled
919                        .entry(next_line)
920                        .and_modify(|existing| {
921                            if let Some(existing_set) = existing {
922                                existing_set.extend(rules.iter().cloned());
923                            }
924                        })
925                        .or_insert(Some(rules));
926                }
927            }
928        } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
929            let rest = rest.trim();
930            if rest.is_empty() {
931                result.disable_all = true;
932            } else {
933                for rule in rest.split(',') {
934                    let rule = rule.trim();
935                    if !rule.is_empty() {
936                        result.file_disabled.insert(rule.to_string());
937                    }
938                }
939            }
940        }
941    }
942
943    result
944}
945
946fn find_yaml_comment(line: &str) -> Option<usize> {
947    let mut in_single = false;
948    let mut in_double = false;
949    for (i, c) in line.char_indices() {
950        match c {
951            '\'' if !in_double => in_single = !in_single,
952            '"' if !in_single => in_double = !in_double,
953            '#' if !in_single && !in_double => return Some(i),
954            _ => {}
955        }
956    }
957    None
958}
959
960impl InlineSuppressions {
961    pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
962        if self.disable_all {
963            return true;
964        }
965
966        let rule_name = warning.rule.to_string();
967        if self.file_disabled.contains(&rule_name) {
968            return true;
969        }
970
971        if let Some(span) = &warning.span
972            && let Some(line_rules) = self.line_disabled.get(&span.start_line)
973        {
974            return match line_rules {
975                None => true,
976                Some(rules) => rules.contains(&rule_name),
977            };
978        }
979
980        false
981    }
982}
983
984// =============================================================================
985// Suppression filtering
986// =============================================================================
987
988pub fn apply_suppressions(
989    warnings: Vec<LintWarning>,
990    config: &LintConfig,
991    inline: &InlineSuppressions,
992) -> Vec<LintWarning> {
993    warnings
994        .into_iter()
995        .filter(|w| !config.is_disabled(&w.rule))
996        .filter(|w| !inline.is_suppressed(w))
997        .map(|mut w| {
998            let rule_name = w.rule.to_string();
999            if let Some(sev) = config.severity_overrides.get(&rule_name) {
1000                w.severity = *sev;
1001            }
1002            w
1003        })
1004        .collect()
1005}
1006
1007pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
1008    let warnings = lint_yaml_str_ext(text, &config.tag_namespaces);
1009    let inline = parse_inline_suppressions(text);
1010    apply_suppressions(warnings, config, &inline)
1011}
1012
1013pub fn lint_yaml_file_with_config(
1014    path: &Path,
1015    config: &LintConfig,
1016) -> crate::error::Result<FileLintResult> {
1017    let content = std::fs::read_to_string(path)?;
1018    let warnings = lint_yaml_str_with_config(&content, config);
1019    Ok(FileLintResult {
1020        path: path.to_path_buf(),
1021        warnings,
1022    })
1023}
1024
1025pub fn lint_yaml_directory_with_config(
1026    dir: &Path,
1027    config: &LintConfig,
1028) -> crate::error::Result<Vec<FileLintResult>> {
1029    let mut results = Vec::new();
1030    let mut visited = HashSet::new();
1031    let exclude_set = config.build_exclude_set();
1032
1033    fn walk(
1034        dir: &Path,
1035        base: &Path,
1036        config: &LintConfig,
1037        exclude_set: &Option<globset::GlobSet>,
1038        results: &mut Vec<FileLintResult>,
1039        visited: &mut HashSet<std::path::PathBuf>,
1040    ) -> crate::error::Result<()> {
1041        let canonical = match dir.canonicalize() {
1042            Ok(p) => p,
1043            Err(_) => return Ok(()),
1044        };
1045        if !visited.insert(canonical) {
1046            return Ok(());
1047        }
1048
1049        let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
1050        entries.sort_by_key(|e| e.path());
1051
1052        for entry in entries {
1053            let path = entry.path();
1054
1055            if let Some(gs) = exclude_set
1056                && let Ok(rel) = path.strip_prefix(base)
1057                && gs.is_match(rel)
1058            {
1059                continue;
1060            }
1061
1062            if path.is_dir() {
1063                if path
1064                    .file_name()
1065                    .and_then(|n| n.to_str())
1066                    .is_some_and(|n| n.starts_with('.'))
1067                {
1068                    continue;
1069                }
1070                walk(&path, base, config, exclude_set, results, visited)?;
1071            } else if matches!(
1072                path.extension().and_then(|e| e.to_str()),
1073                Some("yml" | "yaml")
1074            ) {
1075                match lint_yaml_file_with_config(&path, config) {
1076                    Ok(file_result) => results.push(file_result),
1077                    Err(e) => {
1078                        results.push(FileLintResult {
1079                            path: path.clone(),
1080                            warnings: vec![err(
1081                                LintRule::FileReadError,
1082                                format!("error reading file: {e}"),
1083                                "/",
1084                            )],
1085                        });
1086                    }
1087                }
1088            }
1089        }
1090        Ok(())
1091    }
1092
1093    walk(dir, dir, config, &exclude_set, &mut results, &mut visited)?;
1094    Ok(results)
1095}
1096
1097// =============================================================================
1098// Tests
1099// =============================================================================
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    fn yaml_value(yaml: &str) -> Value {
1106        yaml_serde::from_str(yaml).unwrap()
1107    }
1108
1109    fn lint(yaml: &str) -> Vec<LintWarning> {
1110        lint_yaml_value(&yaml_value(yaml))
1111    }
1112
1113    fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1114        warnings.iter().any(|w| w.rule == rule)
1115    }
1116
1117    fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1118        !has_rule(warnings, rule)
1119    }
1120
1121    #[test]
1122    fn valid_detection_rule_no_errors() {
1123        let w = lint(
1124            r#"
1125title: Test Rule
1126id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1127status: test
1128logsource:
1129    category: process_creation
1130    product: windows
1131detection:
1132    selection:
1133        CommandLine|contains: 'whoami'
1134    condition: selection
1135level: medium
1136tags:
1137    - attack.execution
1138    - attack.t1059
1139"#,
1140        );
1141        let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1142        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1143    }
1144
1145    #[test]
1146    fn not_a_mapping() {
1147        let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1148        let w = lint_yaml_value(&v);
1149        assert!(has_rule(&w, LintRule::NotAMapping));
1150    }
1151
1152    #[test]
1153    fn lint_yaml_str_produces_spans() {
1154        let text = r#"title: Test
1155status: invalid_status
1156logsource:
1157    category: test
1158detection:
1159    selection:
1160        field: value
1161    condition: selection
1162level: medium
1163"#;
1164        let warnings = lint_yaml_str(text);
1165        let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1166        assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1167        let span = invalid_status.unwrap().span;
1168        assert!(span.is_some(), "expected span to be resolved");
1169        assert_eq!(span.unwrap().start_line, 1);
1170    }
1171
1172    #[test]
1173    fn yaml_parse_error_uses_correct_rule() {
1174        let text = "title: [unclosed";
1175        let warnings = lint_yaml_str(text);
1176        assert!(has_rule(&warnings, LintRule::YamlParseError));
1177        assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1178    }
1179
1180    #[test]
1181    fn action_global_skipped() {
1182        let w = lint(
1183            r#"
1184action: global
1185title: Global Template
1186logsource:
1187    product: windows
1188"#,
1189        );
1190        assert!(w.is_empty());
1191    }
1192
1193    #[test]
1194    fn action_reset_skipped() {
1195        let w = lint(
1196            r#"
1197action: reset
1198"#,
1199        );
1200        assert!(w.is_empty());
1201    }
1202
1203    #[test]
1204    fn resolve_path_to_span_root() {
1205        let text = "title: Test\nstatus: test\n";
1206        let span = resolve_path_to_span(text, "/");
1207        assert!(span.is_some());
1208        assert_eq!(span.unwrap().start_line, 0);
1209    }
1210
1211    #[test]
1212    fn resolve_path_to_span_top_level_key() {
1213        let text = "title: Test\nstatus: test\nlevel: high\n";
1214        let span = resolve_path_to_span(text, "/status");
1215        assert!(span.is_some());
1216        assert_eq!(span.unwrap().start_line, 1);
1217    }
1218
1219    #[test]
1220    fn resolve_path_to_span_nested_key() {
1221        let text = "title: Test\nlogsource:\n    category: test\n    product: windows\n";
1222        let span = resolve_path_to_span(text, "/logsource/product");
1223        assert!(span.is_some());
1224        assert_eq!(span.unwrap().start_line, 3);
1225    }
1226
1227    #[test]
1228    fn resolve_path_to_span_missing_key() {
1229        let text = "title: Test\nstatus: test\n";
1230        let span = resolve_path_to_span(text, "/nonexistent");
1231        assert!(span.is_none());
1232    }
1233
1234    #[test]
1235    fn multi_doc_yaml_lints_all_documents() {
1236        let text = r#"title: Rule 1
1237logsource:
1238    category: test
1239detection:
1240    selection:
1241        field: value
1242    condition: selection
1243level: medium
1244---
1245title: Rule 2
1246status: bad_status
1247logsource:
1248    category: test
1249detection:
1250    selection:
1251        field: value
1252    condition: selection
1253level: medium
1254"#;
1255        let warnings = lint_yaml_str(text);
1256        assert!(has_rule(&warnings, LintRule::InvalidStatus));
1257    }
1258
1259    #[test]
1260    fn severity_display() {
1261        assert_eq!(format!("{}", Severity::Error), "error");
1262        assert_eq!(format!("{}", Severity::Warning), "warning");
1263        assert_eq!(format!("{}", Severity::Info), "info");
1264        assert_eq!(format!("{}", Severity::Hint), "hint");
1265    }
1266
1267    #[test]
1268    fn file_lint_result_has_errors() {
1269        let result = FileLintResult {
1270            path: std::path::PathBuf::from("test.yml"),
1271            warnings: vec![
1272                warning(LintRule::TitleTooLong, "too long", "/title"),
1273                err(
1274                    LintRule::MissingCondition,
1275                    "missing",
1276                    "/detection/condition",
1277                ),
1278            ],
1279        };
1280        assert!(result.has_errors());
1281        assert_eq!(result.error_count(), 1);
1282        assert_eq!(result.warning_count(), 1);
1283    }
1284
1285    #[test]
1286    fn file_lint_result_no_errors() {
1287        let result = FileLintResult {
1288            path: std::path::PathBuf::from("test.yml"),
1289            warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1290        };
1291        assert!(!result.has_errors());
1292        assert_eq!(result.error_count(), 0);
1293        assert_eq!(result.warning_count(), 1);
1294    }
1295
1296    #[test]
1297    fn file_lint_result_empty() {
1298        let result = FileLintResult {
1299            path: std::path::PathBuf::from("test.yml"),
1300            warnings: vec![],
1301        };
1302        assert!(!result.has_errors());
1303        assert_eq!(result.error_count(), 0);
1304        assert_eq!(result.warning_count(), 0);
1305    }
1306
1307    #[test]
1308    fn lint_warning_display() {
1309        let w = err(
1310            LintRule::MissingTitle,
1311            "missing required field 'title'",
1312            "/title",
1313        );
1314        let display = format!("{w}");
1315        assert!(display.contains("error"));
1316        assert!(display.contains("missing_title"));
1317        assert!(display.contains("/title"));
1318    }
1319
1320    #[test]
1321    fn file_lint_result_info_count() {
1322        let result = FileLintResult {
1323            path: std::path::PathBuf::from("test.yml"),
1324            warnings: vec![
1325                info(LintRule::MissingDescription, "missing desc", "/description"),
1326                info(LintRule::MissingAuthor, "missing author", "/author"),
1327                warning(LintRule::TitleTooLong, "too long", "/title"),
1328            ],
1329        };
1330        assert_eq!(result.info_count(), 2);
1331        assert_eq!(result.warning_count(), 1);
1332        assert_eq!(result.error_count(), 0);
1333        assert!(!result.has_errors());
1334    }
1335
1336    #[test]
1337    fn parse_inline_disable_all() {
1338        let text = "# rsigma-disable\ntitle: Test\n";
1339        let sup = parse_inline_suppressions(text);
1340        assert!(sup.disable_all);
1341    }
1342
1343    #[test]
1344    fn parse_inline_disable_specific_rules() {
1345        let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1346        let sup = parse_inline_suppressions(text);
1347        assert!(!sup.disable_all);
1348        assert!(sup.file_disabled.contains("missing_description"));
1349        assert!(sup.file_disabled.contains("missing_author"));
1350    }
1351
1352    #[test]
1353    fn parse_inline_disable_next_line_all() {
1354        let text = "# rsigma-disable-next-line\ntitle: Test\n";
1355        let sup = parse_inline_suppressions(text);
1356        assert!(!sup.disable_all);
1357        assert!(sup.line_disabled.contains_key(&1));
1358        assert!(sup.line_disabled[&1].is_none());
1359    }
1360
1361    #[test]
1362    fn parse_inline_disable_next_line_specific() {
1363        let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1364        let sup = parse_inline_suppressions(text);
1365        assert!(sup.line_disabled.contains_key(&2));
1366        let rules = sup.line_disabled[&2].as_ref().unwrap();
1367        assert!(rules.contains("missing_level"));
1368    }
1369
1370    #[test]
1371    fn parse_inline_no_comments() {
1372        let text = "title: Test\nstatus: test\n";
1373        let sup = parse_inline_suppressions(text);
1374        assert!(!sup.disable_all);
1375        assert!(sup.file_disabled.is_empty());
1376        assert!(sup.line_disabled.is_empty());
1377    }
1378
1379    #[test]
1380    fn parse_inline_comment_in_quoted_string() {
1381        let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1382        let sup = parse_inline_suppressions(text);
1383        assert!(!sup.disable_all);
1384        assert!(sup.file_disabled.is_empty());
1385    }
1386
1387    #[test]
1388    fn apply_suppressions_disables_rule() {
1389        let warnings = vec![
1390            info(LintRule::MissingDescription, "desc", "/description"),
1391            info(LintRule::MissingAuthor, "author", "/author"),
1392            warning(LintRule::TitleTooLong, "title", "/title"),
1393        ];
1394        let mut config = LintConfig::default();
1395        config
1396            .disabled_rules
1397            .insert("missing_description".to_string());
1398        let inline = InlineSuppressions::default();
1399
1400        let result = apply_suppressions(warnings, &config, &inline);
1401        assert_eq!(result.len(), 2);
1402        assert!(
1403            result
1404                .iter()
1405                .all(|w| w.rule != LintRule::MissingDescription)
1406        );
1407    }
1408
1409    #[test]
1410    fn apply_suppressions_severity_override() {
1411        let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1412        let mut config = LintConfig::default();
1413        config
1414            .severity_overrides
1415            .insert("title_too_long".to_string(), Severity::Info);
1416        let inline = InlineSuppressions::default();
1417
1418        let result = apply_suppressions(warnings, &config, &inline);
1419        assert_eq!(result.len(), 1);
1420        assert_eq!(result[0].severity, Severity::Info);
1421    }
1422
1423    #[test]
1424    fn apply_suppressions_inline_file_disable() {
1425        let warnings = vec![
1426            info(LintRule::MissingDescription, "desc", "/description"),
1427            info(LintRule::MissingAuthor, "author", "/author"),
1428        ];
1429        let config = LintConfig::default();
1430        let mut inline = InlineSuppressions::default();
1431        inline.file_disabled.insert("missing_author".to_string());
1432
1433        let result = apply_suppressions(warnings, &config, &inline);
1434        assert_eq!(result.len(), 1);
1435        assert_eq!(result[0].rule, LintRule::MissingDescription);
1436    }
1437
1438    #[test]
1439    fn apply_suppressions_inline_disable_all() {
1440        let warnings = vec![
1441            err(LintRule::MissingTitle, "title", "/title"),
1442            warning(LintRule::TitleTooLong, "long", "/title"),
1443        ];
1444        let config = LintConfig::default();
1445        let inline = InlineSuppressions {
1446            disable_all: true,
1447            ..Default::default()
1448        };
1449
1450        let result = apply_suppressions(warnings, &config, &inline);
1451        assert!(result.is_empty());
1452    }
1453
1454    #[test]
1455    fn apply_suppressions_inline_next_line() {
1456        let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1457        w1.span = Some(Span {
1458            start_line: 5,
1459            start_col: 0,
1460            end_line: 5,
1461            end_col: 10,
1462        });
1463        let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1464        w2.span = Some(Span {
1465            start_line: 6,
1466            start_col: 0,
1467            end_line: 6,
1468            end_col: 10,
1469        });
1470
1471        let config = LintConfig::default();
1472        let mut inline = InlineSuppressions::default();
1473        inline.line_disabled.insert(5, None);
1474
1475        let result = apply_suppressions(vec![w1, w2], &config, &inline);
1476        assert_eq!(result.len(), 1);
1477        assert_eq!(result[0].rule, LintRule::InvalidStatus);
1478    }
1479
1480    #[test]
1481    fn lint_with_config_disables_rules() {
1482        let text = r#"title: Test
1483logsource:
1484    category: test
1485detection:
1486    selection:
1487        field: value
1488    condition: selection
1489level: medium
1490"#;
1491        let mut config = LintConfig::default();
1492        config
1493            .disabled_rules
1494            .insert("missing_description".to_string());
1495        config.disabled_rules.insert("missing_author".to_string());
1496
1497        let warnings = lint_yaml_str_with_config(text, &config);
1498        assert!(
1499            !warnings
1500                .iter()
1501                .any(|w| w.rule == LintRule::MissingDescription)
1502        );
1503        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1504    }
1505
1506    #[test]
1507    fn lint_with_inline_disable_next_line() {
1508        let text = r#"title: Test
1509# rsigma-disable-next-line missing_level
1510logsource:
1511    category: test
1512detection:
1513    selection:
1514        field: value
1515    condition: selection
1516"#;
1517        let config = LintConfig::default();
1518        let warnings = lint_yaml_str_with_config(text, &config);
1519        assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1520    }
1521
1522    #[test]
1523    fn lint_with_inline_file_disable() {
1524        let text = r#"# rsigma-disable missing_description, missing_author
1525title: Test
1526logsource:
1527    category: test
1528detection:
1529    selection:
1530        field: value
1531    condition: selection
1532level: medium
1533"#;
1534        let config = LintConfig::default();
1535        let warnings = lint_yaml_str_with_config(text, &config);
1536        assert!(
1537            !warnings
1538                .iter()
1539                .any(|w| w.rule == LintRule::MissingDescription)
1540        );
1541        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1542    }
1543
1544    #[test]
1545    fn lint_with_inline_disable_all() {
1546        let text = r#"# rsigma-disable
1547title: Test
1548status: invalid_status
1549logsource:
1550    category: test
1551detection:
1552    selection:
1553        field: value
1554    condition: selection
1555"#;
1556        let config = LintConfig::default();
1557        let warnings = lint_yaml_str_with_config(text, &config);
1558        assert!(warnings.is_empty());
1559    }
1560
1561    #[test]
1562    fn lint_config_merge() {
1563        let mut base = LintConfig::default();
1564        base.disabled_rules.insert("rule_a".to_string());
1565        base.severity_overrides
1566            .insert("rule_b".to_string(), Severity::Info);
1567
1568        let other = LintConfig {
1569            disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1570            severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1571                .into_iter()
1572                .collect(),
1573            exclude_patterns: vec!["test/**".to_string()],
1574            tag_namespaces: vec!["myns".to_string()],
1575        };
1576
1577        base.merge(&other);
1578        assert!(base.disabled_rules.contains("rule_a"));
1579        assert!(base.disabled_rules.contains("rule_c"));
1580        assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
1581        assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
1582        assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
1583        assert!(base.tag_namespaces.contains(&"myns".to_string()));
1584    }
1585
1586    #[test]
1587    fn lint_config_merge_dedups_lists() {
1588        let mut base = LintConfig {
1589            exclude_patterns: vec!["config/**".to_string(), "shared/**".to_string()],
1590            tag_namespaces: vec!["myorg".to_string(), "shared".to_string()],
1591            ..Default::default()
1592        };
1593        let other = LintConfig {
1594            // "shared/**" and "shared" overlap with base on purpose.
1595            exclude_patterns: vec!["shared/**".to_string(), "extra/**".to_string()],
1596            tag_namespaces: vec!["shared".to_string(), "internal".to_string()],
1597            ..Default::default()
1598        };
1599
1600        base.merge(&other);
1601
1602        assert_eq!(
1603            base.exclude_patterns,
1604            vec![
1605                "config/**".to_string(),
1606                "shared/**".to_string(),
1607                "extra/**".to_string()
1608            ]
1609        );
1610        assert_eq!(
1611            base.tag_namespaces,
1612            vec![
1613                "myorg".to_string(),
1614                "shared".to_string(),
1615                "internal".to_string()
1616            ]
1617        );
1618    }
1619
1620    #[test]
1621    fn lint_config_load_dedups_and_normalises() {
1622        let yaml = r#"
1623exclude:
1624  - "config/**"
1625  - "config/**"
1626tag_namespaces:
1627  - MyOrg
1628  - myorg
1629  - internal
1630"#;
1631        let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
1632        std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
1633        let config = LintConfig::load(tmp.path()).unwrap();
1634
1635        assert_eq!(config.exclude_patterns, vec!["config/**".to_string()]);
1636        // "MyOrg" lowercases to "myorg" and then collapses with the duplicate.
1637        assert_eq!(
1638            config.tag_namespaces,
1639            vec!["myorg".to_string(), "internal".to_string()]
1640        );
1641    }
1642
1643    #[test]
1644    fn lint_config_is_disabled() {
1645        let mut config = LintConfig::default();
1646        config.disabled_rules.insert("missing_title".to_string());
1647        assert!(config.is_disabled(&LintRule::MissingTitle));
1648        assert!(!config.is_disabled(&LintRule::EmptyTitle));
1649    }
1650
1651    #[test]
1652    fn find_yaml_comment_basic() {
1653        assert_eq!(find_yaml_comment("# comment"), Some(0));
1654        assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
1655        assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
1656        assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
1657        assert_eq!(find_yaml_comment("key: value"), None);
1658    }
1659
1660    #[test]
1661    fn no_fix_for_unfixable_rule() {
1662        let w = lint(
1663            r#"
1664title: Test
1665logsource:
1666    category: test
1667"#,
1668        );
1669        assert!(has_rule(&w, LintRule::MissingDetection));
1670        let fix = w
1671            .iter()
1672            .find(|w| w.rule == LintRule::MissingDetection)
1673            .and_then(|w| w.fix.as_ref());
1674        assert!(fix.is_none());
1675    }
1676
1677    #[test]
1678    fn lint_config_exclude_from_yaml() {
1679        let yaml = r#"
1680disabled_rules:
1681  - missing_description
1682exclude:
1683  - "config/**"
1684  - "**/unsupported/**"
1685"#;
1686        let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
1687        std::fs::write(&tmp, yaml).unwrap();
1688        let config = LintConfig::load(&tmp).unwrap();
1689        std::fs::remove_file(&tmp).ok();
1690
1691        assert!(config.disabled_rules.contains("missing_description"));
1692        assert_eq!(config.exclude_patterns.len(), 2);
1693        assert_eq!(config.exclude_patterns[0], "config/**");
1694        assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
1695    }
1696
1697    #[test]
1698    fn lint_config_build_exclude_set_empty() {
1699        let config = LintConfig::default();
1700        assert!(config.build_exclude_set().is_none());
1701    }
1702
1703    #[test]
1704    fn lint_config_build_exclude_set_matches() {
1705        let config = LintConfig {
1706            exclude_patterns: vec!["config/**".to_string()],
1707            ..Default::default()
1708        };
1709        let gs = config.build_exclude_set().expect("should build");
1710        assert!(gs.is_match("config/data_mapping/foo.yaml"));
1711        assert!(gs.is_match("config/nested/deep/bar.yml"));
1712        assert!(!gs.is_match("rules/windows/test.yml"));
1713    }
1714
1715    #[test]
1716    fn lint_directory_with_excludes() {
1717        let tmp = tempfile::tempdir().unwrap();
1718        let rules_dir = tmp.path().join("rules");
1719        let config_dir = tmp.path().join("config");
1720        std::fs::create_dir_all(&rules_dir).unwrap();
1721        std::fs::create_dir_all(&config_dir).unwrap();
1722
1723        std::fs::write(
1724            rules_dir.join("good.yml"),
1725            r#"
1726title: Good Rule
1727logsource:
1728    category: test
1729detection:
1730    sel:
1731        field: value
1732    condition: sel
1733level: medium
1734"#,
1735        )
1736        .unwrap();
1737
1738        std::fs::write(
1739            config_dir.join("mapping.yaml"),
1740            r#"
1741Title: Logon
1742Channel: Security
1743EventID: 4624
1744"#,
1745        )
1746        .unwrap();
1747
1748        let no_exclude = LintConfig::default();
1749        let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
1750        let config_warnings: Vec<_> = results
1751            .iter()
1752            .filter(|r| r.path.to_string_lossy().contains("config"))
1753            .flat_map(|r| &r.warnings)
1754            .collect();
1755        assert!(
1756            !config_warnings.is_empty(),
1757            "config file should produce warnings without excludes"
1758        );
1759
1760        let with_exclude = LintConfig {
1761            exclude_patterns: vec!["config/**".to_string()],
1762            ..Default::default()
1763        };
1764        let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
1765        let config_results: Vec<_> = results
1766            .iter()
1767            .filter(|r| r.path.to_string_lossy().contains("config"))
1768            .collect();
1769        assert!(config_results.is_empty(), "config file should be excluded");
1770
1771        let rule_results: Vec<_> = results
1772            .iter()
1773            .filter(|r| r.path.to_string_lossy().contains("good.yml"))
1774            .collect();
1775        assert_eq!(rule_results.len(), 1);
1776    }
1777
1778    #[test]
1779    fn all_lint_keys_are_cached() {
1780        const ALL_LINT_KEYS: &[&str] = &[
1781            "action",
1782            "author",
1783            "condition",
1784            "correlation",
1785            "date",
1786            "description",
1787            "detection",
1788            "field",
1789            "filter",
1790            "generate",
1791            "group-by",
1792            "id",
1793            "level",
1794            "logsource",
1795            "modified",
1796            "name",
1797            "rules",
1798            "selection",
1799            "status",
1800            "tags",
1801            "taxonomy",
1802            "timeframe",
1803            "timespan",
1804            "title",
1805            "type",
1806        ];
1807        for key_str in ALL_LINT_KEYS {
1808            assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
1809        }
1810    }
1811
1812    #[test]
1813    fn extra_tag_namespace_suppresses_warning() {
1814        let text = r#"title: Test
1815logsource:
1816    category: test
1817detection:
1818    selection:
1819        field: value
1820    condition: selection
1821level: medium
1822tags:
1823    - myorg.custom_tag
1824"#;
1825        // Without extra namespaces, unknown_tag_namespace fires.
1826        let warnings = lint_yaml_str(text);
1827        assert!(has_rule(&warnings, LintRule::UnknownTagNamespace));
1828
1829        // With "myorg" added, the warning is gone.
1830        let config = LintConfig {
1831            tag_namespaces: vec!["myorg".to_string()],
1832            ..Default::default()
1833        };
1834        let warnings = lint_yaml_str_with_config(text, &config);
1835        assert!(has_no_rule(&warnings, LintRule::UnknownTagNamespace));
1836    }
1837
1838    #[test]
1839    fn extra_tag_namespace_from_config_file() {
1840        let yaml = r#"
1841tag_namespaces:
1842  - myorg
1843  - internal
1844"#;
1845        let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
1846        std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
1847        let config = LintConfig::load(tmp.path()).unwrap();
1848
1849        assert!(config.tag_namespaces.contains(&"myorg".to_string()));
1850        assert!(config.tag_namespaces.contains(&"internal".to_string()));
1851    }
1852}