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
22pub mod catalogue;
23#[cfg(feature = "fix")]
24pub mod fix;
25mod rules;
26
27use std::collections::{HashMap, HashSet};
28use std::fmt;
29use std::path::Path;
30use std::sync::LazyLock;
31
32use serde::{Deserialize, Serialize};
33use yaml_serde::Value;
34
35use crate::ads::AdsSection;
36
37// =============================================================================
38// Public types
39// =============================================================================
40
41/// Severity of a lint finding.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
43pub enum Severity {
44    /// Spec violation — the rule is invalid.
45    Error,
46    /// Best-practice issue — the rule works but is not spec-ideal.
47    Warning,
48    /// Informational suggestion — soft best-practice hint (e.g. missing author).
49    Info,
50    /// Subtle hint — lowest severity, for stylistic suggestions.
51    Hint,
52}
53
54impl fmt::Display for Severity {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Severity::Error => write!(f, "error"),
58            Severity::Warning => write!(f, "warning"),
59            Severity::Info => write!(f, "info"),
60            Severity::Hint => write!(f, "hint"),
61        }
62    }
63}
64
65/// Identifies which lint rule fired.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
67pub enum LintRule {
68    // ── Infrastructure / parse errors ────────────────────────────────────
69    YamlParseError,
70    NotAMapping,
71    FileReadError,
72    SchemaViolation,
73
74    // ── Shared (all document types) ──────────────────────────────────────
75    MissingTitle,
76    EmptyTitle,
77    TitleTooLong,
78    MissingDescription,
79    MissingAuthor,
80    InvalidId,
81    InvalidStatus,
82    MissingLevel,
83    InvalidLevel,
84    InvalidDate,
85    InvalidModified,
86    ModifiedBeforeDate,
87    DescriptionTooLong,
88    NameTooLong,
89    TaxonomyTooLong,
90    NonLowercaseKey,
91
92    // ── Detection rules ──────────────────────────────────────────────────
93    MissingLogsource,
94    MissingDetection,
95    MissingCondition,
96    EmptyDetection,
97    InvalidRelatedType,
98    InvalidRelatedId,
99    RelatedMissingRequired,
100    DeprecatedWithoutRelated,
101    InvalidTag,
102    UnknownTagNamespace,
103    DuplicateTags,
104    DuplicateReferences,
105    DuplicateFields,
106    FalsepositiveTooShort,
107    ScopeTooShort,
108    LogsourceValueNotLowercase,
109    ConditionReferencesUnknown,
110    DeprecatedAggregationSyntax,
111
112    // ── Correlation rules ────────────────────────────────────────────────
113    MissingCorrelation,
114    MissingCorrelationType,
115    InvalidCorrelationType,
116    MissingCorrelationRules,
117    EmptyCorrelationRules,
118    MissingCorrelationTimespan,
119    InvalidTimespanFormat,
120    InvalidWindowMode,
121    MissingSessionGap,
122    GapWithoutSession,
123    InvalidGapFormat,
124    MissingGroupBy,
125    MissingCorrelationCondition,
126    MissingConditionField,
127    InvalidConditionOperator,
128    ConditionValueNotNumeric,
129    GenerateNotBoolean,
130
131    // ── Filter rules ─────────────────────────────────────────────────────
132    MissingFilter,
133    MissingFilterRules,
134    EmptyFilterRules,
135    MissingFilterSelection,
136    MissingFilterCondition,
137    FilterHasLevel,
138    FilterHasStatus,
139    MissingFilterLogsource,
140
141    // ── Detection logic (cross-cutting) ──────────────────────────────────
142    NullInValueList,
143    SingleValueAllModifier,
144    AllWithRe,
145    IncompatibleModifiers,
146    EmptyValueList,
147    WildcardOnlyValue,
148    FlattenedArrayCorrelation,
149    UnsupportedSigmaVersion,
150    ArrayMatchingWithoutVersion,
151    SigmaVersionMismatch,
152    UnknownRuleReference,
153    UnknownKey,
154
155    // ── ADS detection-strategy metadata ──────────────────────────────────
156    AdsMissingGoal,
157    AdsMissingCategorization,
158    AdsMissingStrategy,
159    AdsMissingTechnicalContext,
160    AdsMissingBlindSpots,
161    AdsMissingFalsePositives,
162    AdsMissingValidation,
163    AdsMissingPriority,
164    AdsMissingResponse,
165    AdsEmptySection,
166    AdsUnknownSection,
167
168    // ── Embedded exemplars ───────────────────────────────────────────────
169    ExemplarShape,
170    ExemplarWrongRuleKind,
171}
172
173impl fmt::Display for LintRule {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        let s = match self {
176            LintRule::YamlParseError => "yaml_parse_error",
177            LintRule::NotAMapping => "not_a_mapping",
178            LintRule::FileReadError => "file_read_error",
179            LintRule::SchemaViolation => "schema_violation",
180            LintRule::MissingTitle => "missing_title",
181            LintRule::EmptyTitle => "empty_title",
182            LintRule::TitleTooLong => "title_too_long",
183            LintRule::MissingDescription => "missing_description",
184            LintRule::MissingAuthor => "missing_author",
185            LintRule::InvalidId => "invalid_id",
186            LintRule::InvalidStatus => "invalid_status",
187            LintRule::MissingLevel => "missing_level",
188            LintRule::InvalidLevel => "invalid_level",
189            LintRule::InvalidDate => "invalid_date",
190            LintRule::InvalidModified => "invalid_modified",
191            LintRule::ModifiedBeforeDate => "modified_before_date",
192            LintRule::DescriptionTooLong => "description_too_long",
193            LintRule::NameTooLong => "name_too_long",
194            LintRule::TaxonomyTooLong => "taxonomy_too_long",
195            LintRule::NonLowercaseKey => "non_lowercase_key",
196            LintRule::MissingLogsource => "missing_logsource",
197            LintRule::MissingDetection => "missing_detection",
198            LintRule::MissingCondition => "missing_condition",
199            LintRule::EmptyDetection => "empty_detection",
200            LintRule::InvalidRelatedType => "invalid_related_type",
201            LintRule::InvalidRelatedId => "invalid_related_id",
202            LintRule::RelatedMissingRequired => "related_missing_required",
203            LintRule::DeprecatedWithoutRelated => "deprecated_without_related",
204            LintRule::InvalidTag => "invalid_tag",
205            LintRule::UnknownTagNamespace => "unknown_tag_namespace",
206            LintRule::DuplicateTags => "duplicate_tags",
207            LintRule::DuplicateReferences => "duplicate_references",
208            LintRule::DuplicateFields => "duplicate_fields",
209            LintRule::FalsepositiveTooShort => "falsepositive_too_short",
210            LintRule::ScopeTooShort => "scope_too_short",
211            LintRule::LogsourceValueNotLowercase => "logsource_value_not_lowercase",
212            LintRule::ConditionReferencesUnknown => "condition_references_unknown",
213            LintRule::DeprecatedAggregationSyntax => "deprecated_aggregation_syntax",
214            LintRule::MissingCorrelation => "missing_correlation",
215            LintRule::MissingCorrelationType => "missing_correlation_type",
216            LintRule::InvalidCorrelationType => "invalid_correlation_type",
217            LintRule::MissingCorrelationRules => "missing_correlation_rules",
218            LintRule::EmptyCorrelationRules => "empty_correlation_rules",
219            LintRule::MissingCorrelationTimespan => "missing_correlation_timespan",
220            LintRule::InvalidTimespanFormat => "invalid_timespan_format",
221            LintRule::InvalidWindowMode => "invalid_window_mode",
222            LintRule::MissingSessionGap => "missing_session_gap",
223            LintRule::GapWithoutSession => "gap_without_session",
224            LintRule::InvalidGapFormat => "invalid_gap_format",
225            LintRule::MissingGroupBy => "missing_group_by",
226            LintRule::MissingCorrelationCondition => "missing_correlation_condition",
227            LintRule::MissingConditionField => "missing_condition_field",
228            LintRule::InvalidConditionOperator => "invalid_condition_operator",
229            LintRule::ConditionValueNotNumeric => "condition_value_not_numeric",
230            LintRule::GenerateNotBoolean => "generate_not_boolean",
231            LintRule::MissingFilter => "missing_filter",
232            LintRule::MissingFilterRules => "missing_filter_rules",
233            LintRule::EmptyFilterRules => "empty_filter_rules",
234            LintRule::MissingFilterSelection => "missing_filter_selection",
235            LintRule::MissingFilterCondition => "missing_filter_condition",
236            LintRule::FilterHasLevel => "filter_has_level",
237            LintRule::FilterHasStatus => "filter_has_status",
238            LintRule::MissingFilterLogsource => "missing_filter_logsource",
239            LintRule::NullInValueList => "null_in_value_list",
240            LintRule::SingleValueAllModifier => "single_value_all_modifier",
241            LintRule::AllWithRe => "all_with_re",
242            LintRule::IncompatibleModifiers => "incompatible_modifiers",
243            LintRule::EmptyValueList => "empty_value_list",
244            LintRule::WildcardOnlyValue => "wildcard_only_value",
245            LintRule::FlattenedArrayCorrelation => "flattened_array_correlation",
246            LintRule::UnsupportedSigmaVersion => "unsupported_sigma_version",
247            LintRule::ArrayMatchingWithoutVersion => "array_matching_without_version",
248            LintRule::SigmaVersionMismatch => "sigma_version_mismatch",
249            LintRule::UnknownRuleReference => "unknown_rule_reference",
250            LintRule::UnknownKey => "unknown_key",
251            LintRule::AdsMissingGoal => "ads_missing_goal",
252            LintRule::AdsMissingCategorization => "ads_missing_categorization",
253            LintRule::AdsMissingStrategy => "ads_missing_strategy",
254            LintRule::AdsMissingTechnicalContext => "ads_missing_technical_context",
255            LintRule::AdsMissingBlindSpots => "ads_missing_blind_spots",
256            LintRule::AdsMissingFalsePositives => "ads_missing_false_positives",
257            LintRule::AdsMissingValidation => "ads_missing_validation",
258            LintRule::AdsMissingPriority => "ads_missing_priority",
259            LintRule::AdsMissingResponse => "ads_missing_response",
260            LintRule::AdsEmptySection => "ads_empty_section",
261            LintRule::AdsUnknownSection => "ads_unknown_section",
262            LintRule::ExemplarShape => "exemplar_shape",
263            LintRule::ExemplarWrongRuleKind => "exemplar_wrong_rule_kind",
264        };
265        write!(f, "{s}")
266    }
267}
268
269/// A source span (line/column, both 0-indexed).
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
271pub struct Span {
272    pub start_line: u32,
273    pub start_col: u32,
274    pub end_line: u32,
275    pub end_col: u32,
276}
277
278// =============================================================================
279// Auto-fix types
280// =============================================================================
281
282/// Whether a fix is safe to apply automatically or needs manual review.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
284pub enum FixDisposition {
285    Safe,
286    Unsafe,
287}
288
289/// A single patch operation within a [`Fix`].
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub enum FixPatch {
292    ReplaceValue { path: String, new_value: String },
293    ReplaceKey { path: String, new_key: String },
294    Remove { path: String },
295}
296
297/// A suggested fix for a lint finding.
298#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
299pub struct Fix {
300    pub title: String,
301    pub disposition: FixDisposition,
302    pub patches: Vec<FixPatch>,
303}
304
305/// A single lint finding.
306#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
307pub struct LintWarning {
308    pub rule: LintRule,
309    pub severity: Severity,
310    pub message: String,
311    pub path: String,
312    pub span: Option<Span>,
313    pub fix: Option<Fix>,
314}
315
316impl fmt::Display for LintWarning {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        write!(
319            f,
320            "{}[{}]: {}\n    --> {}",
321            self.severity, self.rule, self.message, self.path
322        )
323    }
324}
325
326/// Result of linting a single file (may contain multiple YAML documents).
327#[derive(Debug, Clone, Serialize)]
328pub struct FileLintResult {
329    pub path: std::path::PathBuf,
330    pub warnings: Vec<LintWarning>,
331}
332
333impl FileLintResult {
334    pub fn has_errors(&self) -> bool {
335        self.warnings.iter().any(|w| w.severity == Severity::Error)
336    }
337
338    pub fn error_count(&self) -> usize {
339        self.warnings
340            .iter()
341            .filter(|w| w.severity == Severity::Error)
342            .count()
343    }
344
345    pub fn warning_count(&self) -> usize {
346        self.warnings
347            .iter()
348            .filter(|w| w.severity == Severity::Warning)
349            .count()
350    }
351
352    pub fn info_count(&self) -> usize {
353        self.warnings
354            .iter()
355            .filter(|w| w.severity == Severity::Info)
356            .count()
357    }
358
359    pub fn hint_count(&self) -> usize {
360        self.warnings
361            .iter()
362            .filter(|w| w.severity == Severity::Hint)
363            .count()
364    }
365}
366
367// =============================================================================
368// Helpers (shared with rule submodules)
369// =============================================================================
370
371static KEY_CACHE: LazyLock<HashMap<&'static str, Value>> = LazyLock::new(|| {
372    [
373        "action",
374        "author",
375        "category",
376        "condition",
377        "correlation",
378        "custom_attributes",
379        "date",
380        "description",
381        "detection",
382        "falsepositives",
383        "field",
384        "fields",
385        "filter",
386        "gap",
387        "generate",
388        "group-by",
389        "id",
390        "level",
391        "logsource",
392        "modified",
393        "name",
394        "product",
395        "references",
396        "related",
397        "rsigma.gap",
398        "rsigma.window",
399        "rules",
400        "scope",
401        "selection",
402        "service",
403        "sigma-version",
404        "status",
405        "tags",
406        "taxonomy",
407        "timeframe",
408        "timespan",
409        "title",
410        "type",
411        "window",
412    ]
413    .into_iter()
414    .map(|n| (n, Value::String(n.into())))
415    .collect()
416});
417
418pub(crate) fn key(s: &str) -> &'static Value {
419    KEY_CACHE
420        .get(s)
421        .unwrap_or_else(|| panic!("lint key not pre-cached: \"{s}\" — add it to KEY_CACHE"))
422}
423
424pub(crate) fn get_str<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a str> {
425    m.get(key(k)).and_then(|v| v.as_str())
426}
427
428pub(crate) fn get_mapping<'a>(
429    m: &'a yaml_serde::Mapping,
430    k: &str,
431) -> Option<&'a yaml_serde::Mapping> {
432    m.get(key(k)).and_then(|v| v.as_mapping())
433}
434
435pub(crate) fn get_seq<'a>(m: &'a yaml_serde::Mapping, k: &str) -> Option<&'a yaml_serde::Sequence> {
436    m.get(key(k)).and_then(|v| v.as_sequence())
437}
438
439pub(crate) fn warn(
440    rule: LintRule,
441    severity: Severity,
442    message: impl Into<String>,
443    path: impl Into<String>,
444) -> LintWarning {
445    LintWarning {
446        rule,
447        severity,
448        message: message.into(),
449        path: path.into(),
450        span: None,
451        fix: None,
452    }
453}
454
455pub(crate) fn err(
456    rule: LintRule,
457    message: impl Into<String>,
458    path: impl Into<String>,
459) -> LintWarning {
460    warn(rule, Severity::Error, message, path)
461}
462
463pub(crate) fn warning(
464    rule: LintRule,
465    message: impl Into<String>,
466    path: impl Into<String>,
467) -> LintWarning {
468    warn(rule, Severity::Warning, message, path)
469}
470
471pub(crate) fn info(
472    rule: LintRule,
473    message: impl Into<String>,
474    path: impl Into<String>,
475) -> LintWarning {
476    warn(rule, Severity::Info, message, path)
477}
478
479pub(crate) fn safe_fix(title: impl Into<String>, patches: Vec<FixPatch>) -> Option<Fix> {
480    Some(Fix {
481        title: title.into(),
482        disposition: FixDisposition::Safe,
483        patches,
484    })
485}
486
487/// Find the closest match for `input` among `candidates` using edit distance.
488pub(crate) fn closest_match<'a>(
489    input: &str,
490    candidates: &[&'a str],
491    max_distance: usize,
492) -> Option<&'a str> {
493    candidates
494        .iter()
495        .filter(|c| edit_distance(input, c) <= max_distance)
496        .min_by_key(|c| edit_distance(input, c))
497        .copied()
498}
499
500/// Levenshtein edit distance between two strings.
501pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
502    let (a_len, b_len) = (a.len(), b.len());
503    if a_len == 0 {
504        return b_len;
505    }
506    if b_len == 0 {
507        return a_len;
508    }
509    let mut prev: Vec<usize> = (0..=b_len).collect();
510    let mut curr = vec![0; b_len + 1];
511    for (i, ca) in a.bytes().enumerate() {
512        curr[0] = i + 1;
513        for (j, cb) in b.bytes().enumerate() {
514            let cost = if ca == cb { 0 } else { 1 };
515            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
516        }
517        std::mem::swap(&mut prev, &mut curr);
518    }
519    prev[b_len]
520}
521
522pub(crate) const TYPO_MAX_EDIT_DISTANCE: usize = 2;
523
524// =============================================================================
525// Document type detection
526// =============================================================================
527
528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub(crate) enum DocType {
530    Detection,
531    Correlation,
532    Filter,
533}
534
535impl DocType {
536    pub(crate) fn known_keys(&self) -> &'static [&'static str] {
537        match self {
538            DocType::Detection => rules::shared::KNOWN_KEYS_DETECTION,
539            DocType::Correlation => rules::shared::KNOWN_KEYS_CORRELATION,
540            DocType::Filter => rules::shared::KNOWN_KEYS_FILTER,
541        }
542    }
543}
544
545fn detect_doc_type(m: &yaml_serde::Mapping) -> DocType {
546    if m.contains_key(key("correlation")) {
547        DocType::Correlation
548    } else if m.contains_key(key("filter")) {
549        DocType::Filter
550    } else {
551        DocType::Detection
552    }
553}
554
555fn is_action_fragment(m: &yaml_serde::Mapping) -> bool {
556    matches!(get_str(m, "action"), Some("global" | "reset" | "repeat"))
557}
558
559// =============================================================================
560// Cross-document reference resolution
561// =============================================================================
562
563/// An index of referenceable rules (detection rules and correlation rules) by
564/// their identifiers (`id` and `name`), each mapped to its resolved
565/// specification major. Built file-local for single-text linting and
566/// directory-global for directory linting.
567struct RuleIndex {
568    majors: HashMap<String, u32>,
569    /// Whether the index covers the whole set being linted. Only then is an
570    /// unresolved reference genuinely missing rather than living in a file
571    /// outside the linted scope.
572    complete: bool,
573}
574
575impl RuleIndex {
576    fn new(complete: bool) -> Self {
577        Self {
578            majors: HashMap::new(),
579            complete,
580        }
581    }
582
583    /// Index every referenceable document in one multi-document YAML text.
584    fn add_text(&mut self, text: &str) {
585        for doc in yaml_serde::Deserializer::from_str(text) {
586            let Ok(value) = Value::deserialize(doc) else {
587                break;
588            };
589            self.add_value(&value);
590        }
591    }
592
593    fn add_value(&mut self, value: &Value) {
594        let Some(m) = value.as_mapping() else {
595            return;
596        };
597        if is_action_fragment(m) {
598            return;
599        }
600        // Only detection rules and correlation rules can be referenced.
601        if matches!(
602            detect_doc_type(m),
603            DocType::Detection | DocType::Correlation
604        ) {
605            let major = crate::version::resolve_major(
606                m.get(key("sigma-version"))
607                    .and_then(crate::version::major_from_value),
608            );
609            for id_key in ["id", "name"] {
610                if let Some(v) = get_str(m, id_key) {
611                    self.majors.insert(v.to_string(), major);
612                }
613            }
614        }
615    }
616}
617
618/// Extract a `rules:` reference list (a single string or a sequence of strings).
619fn reference_list(v: Option<&Value>) -> Vec<String> {
620    match v {
621        Some(Value::String(s)) => vec![s.clone()],
622        Some(Value::Sequence(seq)) => seq
623            .iter()
624            .filter_map(|x| x.as_str().map(str::to_string))
625            .collect(),
626        _ => Vec::new(),
627    }
628}
629
630/// References declared by a correlation rule (`correlation.rules`).
631fn correlation_rule_refs(m: &yaml_serde::Mapping) -> Vec<String> {
632    m.get(key("correlation"))
633        .and_then(|c| c.as_mapping())
634        .map(|c| reference_list(c.get(key("rules"))))
635        .unwrap_or_default()
636}
637
638/// References declared by a filter rule (`filter.rules`). Returns `None` when the
639/// filter targets every rule (`rules: any`), which is not resolvable.
640fn filter_rule_refs(m: &yaml_serde::Mapping) -> Option<Vec<String>> {
641    let f = m.get(key("filter"))?.as_mapping()?;
642    let rules = f.get(key("rules"))?;
643    if let Some(s) = rules.as_str()
644        && s.eq_ignore_ascii_case("any")
645    {
646        return None;
647    }
648    Some(reference_list(Some(rules)))
649}
650
651/// Cross-document lints over the documents in one YAML text, resolving each
652/// correlation/filter reference against `index`:
653///
654/// - `sigma_version_mismatch` (warning): a referencing document and a resolved
655///   referenced rule declare different specification majors.
656/// - `unknown_rule_reference` (warning): a reference resolves to no rule and the
657///   index is complete (so it is genuinely missing, not out of the linted scope).
658fn lint_cross_references(docs: &[Value], index: &RuleIndex, warnings: &mut Vec<LintWarning>) {
659    for value in docs {
660        let Some(m) = value.as_mapping() else {
661            continue;
662        };
663        if is_action_fragment(m) {
664            continue;
665        }
666        let (refs, path) = match detect_doc_type(m) {
667            DocType::Correlation => (correlation_rule_refs(m), "/correlation/rules"),
668            DocType::Filter => match filter_rule_refs(m) {
669                Some(refs) => (refs, "/filter/rules"),
670                None => continue,
671            },
672            DocType::Detection => continue,
673        };
674        if refs.is_empty() {
675            continue;
676        }
677        let self_major = crate::version::resolve_major(
678            m.get(key("sigma-version"))
679                .and_then(crate::version::major_from_value),
680        );
681        let label = get_str(m, "title")
682            .or_else(|| get_str(m, "name"))
683            .unwrap_or("<rule>");
684        for r in refs {
685            match index.majors.get(&r).copied() {
686                Some(target) if target != self_major => warnings.push(warning(
687                    LintRule::SigmaVersionMismatch,
688                    format!(
689                        "'{label}' targets sigma-version major {self_major} but references rule \
690                         '{r}' which targets major {target}; cross-referencing rules must share a \
691                         specification major"
692                    ),
693                    path,
694                )),
695                Some(_) => {}
696                None if index.complete => warnings.push(warning(
697                    LintRule::UnknownRuleReference,
698                    format!(
699                        "'{label}' references rule '{r}', which was not found among the linted \
700                         rules (matched by id or name)"
701                    ),
702                    path,
703                )),
704                None => {}
705            }
706        }
707    }
708}
709
710// =============================================================================
711// Public API
712// =============================================================================
713
714fn lint_yaml_value_ext(
715    value: &Value,
716    extra_ns: &[String],
717    ads: Option<&AdsConfig>,
718) -> Vec<LintWarning> {
719    let Some(m) = value.as_mapping() else {
720        return vec![err(
721            LintRule::NotAMapping,
722            "document is not a YAML mapping",
723            "/",
724        )];
725    };
726
727    if is_action_fragment(m) {
728        let mut warnings = Vec::new();
729        rules::exemplar::lint_exemplars(m, None, &mut warnings);
730        return warnings;
731    }
732
733    let mut warnings = Vec::new();
734
735    rules::metadata::lint_shared(m, &mut warnings);
736
737    let doc_type = detect_doc_type(m);
738    match doc_type {
739        DocType::Detection => rules::detection::lint_detection_rule(m, &mut warnings, extra_ns),
740        DocType::Correlation => rules::correlation::lint_correlation_rule(m, &mut warnings),
741        DocType::Filter => rules::filter::lint_filter_rule(m, &mut warnings),
742    }
743
744    rules::version::lint_sigma_version(m, doc_type, &mut warnings);
745    rules::shared::lint_unknown_keys(m, doc_type, &mut warnings);
746    rules::exemplar::lint_exemplars(m, Some(doc_type), &mut warnings);
747
748    // ADS enforcement applies to detection rules only and only when an `ads:`
749    // block is configured.
750    if let Some(ads_cfg) = ads
751        && doc_type == DocType::Detection
752    {
753        rules::ads::lint_ads(m, ads_cfg, extra_ns, &mut warnings);
754    }
755
756    warnings
757}
758
759/// Lint a single YAML document value.
760pub fn lint_yaml_value(value: &Value) -> Vec<LintWarning> {
761    lint_yaml_value_ext(value, &[], None)
762}
763
764fn lint_yaml_str_ext(text: &str, extra_ns: &[String], ads: Option<&AdsConfig>) -> Vec<LintWarning> {
765    lint_yaml_str_indexed(text, extra_ns, ads, None)
766}
767
768/// Lint one YAML text. When `external_index` is `Some` (directory linting) it is
769/// the directory-global rule index used for cross-reference checks; when `None`,
770/// a file-local index is built from this text, so cross-file references are out
771/// of scope and `unknown_rule_reference` does not fire.
772fn lint_yaml_str_indexed(
773    text: &str,
774    extra_ns: &[String],
775    ads: Option<&AdsConfig>,
776    external_index: Option<&RuleIndex>,
777) -> Vec<LintWarning> {
778    let mut all_warnings = Vec::new();
779    let mut docs: Vec<Value> = Vec::new();
780
781    for doc in yaml_serde::Deserializer::from_str(text) {
782        let value: Value = match Value::deserialize(doc) {
783            Ok(v) => v,
784            Err(e) => {
785                let mut w = err(
786                    LintRule::YamlParseError,
787                    format!("YAML parse error: {e}"),
788                    "/",
789                );
790                if let Some(loc) = e.location() {
791                    w.span = Some(Span {
792                        start_line: loc.line().saturating_sub(1) as u32,
793                        start_col: loc.column() as u32,
794                        end_line: loc.line().saturating_sub(1) as u32,
795                        end_col: loc.column() as u32 + 1,
796                    });
797                }
798                all_warnings.push(w);
799                break;
800            }
801        };
802
803        for mut w in lint_yaml_value_ext(&value, extra_ns, ads) {
804            w.span = resolve_path_to_span(text, &w.path);
805            all_warnings.push(w);
806        }
807        docs.push(value);
808    }
809
810    // Cross-document checks resolve references against the directory-global index
811    // when given, otherwise a file-local index built from this text's documents.
812    let local_index;
813    let index = match external_index {
814        Some(idx) => idx,
815        None => {
816            let mut idx = RuleIndex::new(false);
817            for v in &docs {
818                idx.add_value(v);
819            }
820            local_index = idx;
821            &local_index
822        }
823    };
824    let mut xref = Vec::new();
825    lint_cross_references(&docs, index, &mut xref);
826    for mut w in xref {
827        w.span = resolve_path_to_span(text, &w.path);
828        all_warnings.push(w);
829    }
830
831    all_warnings
832}
833
834/// Lint a raw YAML string, returning warnings with resolved source spans.
835pub fn lint_yaml_str(text: &str) -> Vec<LintWarning> {
836    lint_yaml_str_ext(text, &[], None)
837}
838
839fn resolve_path_to_span(text: &str, path: &str) -> Option<Span> {
840    if path == "/" || path.is_empty() {
841        for (i, line) in text.lines().enumerate() {
842            let trimmed = line.trim();
843            if !trimmed.is_empty() && !trimmed.starts_with('#') && trimmed != "---" {
844                return Some(Span {
845                    start_line: i as u32,
846                    start_col: 0,
847                    end_line: i as u32,
848                    end_col: line.len() as u32,
849                });
850            }
851        }
852        return None;
853    }
854
855    let segments: Vec<&str> = path.strip_prefix('/').unwrap_or(path).split('/').collect();
856
857    if segments.is_empty() {
858        return None;
859    }
860
861    let lines: Vec<&str> = text.lines().collect();
862    let mut current_indent: i32 = -1;
863    let mut search_start = 0usize;
864    let mut last_matched_line: Option<usize> = None;
865
866    for segment in &segments {
867        let array_index: Option<usize> = segment.parse().ok();
868        let mut found = false;
869
870        let mut line_num = search_start;
871        while line_num < lines.len() {
872            let line = lines[line_num];
873            let trimmed = line.trim();
874            if trimmed.is_empty() || trimmed.starts_with('#') {
875                line_num += 1;
876                continue;
877            }
878
879            let indent = (line.len() - trimmed.len()) as i32;
880
881            if indent <= current_indent && found {
882                break;
883            }
884            if indent <= current_indent {
885                line_num += 1;
886                continue;
887            }
888
889            if let Some(idx) = array_index {
890                if trimmed.starts_with("- ") && indent > current_indent {
891                    let mut count = 0usize;
892                    for (offset, sl) in lines[search_start..].iter().enumerate() {
893                        let scan = search_start + offset;
894                        let st = sl.trim();
895                        if st.is_empty() || st.starts_with('#') {
896                            continue;
897                        }
898                        let si = (sl.len() - st.len()) as i32;
899                        if si == indent && st.starts_with("- ") {
900                            if count == idx {
901                                last_matched_line = Some(scan);
902                                search_start = scan + 1;
903                                current_indent = indent;
904                                found = true;
905                                break;
906                            }
907                            count += 1;
908                        }
909                        if si < indent && count > 0 {
910                            break;
911                        }
912                    }
913                    break;
914                }
915            } else {
916                let key_pattern = format!("{segment}:");
917                if trimmed.starts_with(&key_pattern) || trimmed == *segment {
918                    last_matched_line = Some(line_num);
919                    search_start = line_num + 1;
920                    current_indent = indent;
921                    found = true;
922                    break;
923                }
924            }
925
926            line_num += 1;
927        }
928
929        if !found && last_matched_line.is_none() {
930            break;
931        }
932    }
933
934    last_matched_line.map(|line_num| {
935        let line = lines[line_num];
936        Span {
937            start_line: line_num as u32,
938            start_col: 0,
939            end_line: line_num as u32,
940            end_col: line.len() as u32,
941        }
942    })
943}
944
945/// Lint all YAML documents in a file.
946pub fn lint_yaml_file(path: &Path) -> crate::error::Result<FileLintResult> {
947    let content = std::fs::read_to_string(path)?;
948    let warnings = lint_yaml_str(&content);
949    Ok(FileLintResult {
950        path: path.to_path_buf(),
951        warnings,
952    })
953}
954
955/// Recursively collect `.yml`/`.yaml` file paths under `dir`, in sorted
956/// depth-first order, skipping hidden directories and any path matching the
957/// exclude set (relative to `base`). Symlink loops are guarded by `visited`.
958fn collect_yaml_files(
959    dir: &Path,
960    base: &Path,
961    exclude_set: Option<&globset::GlobSet>,
962    files: &mut Vec<std::path::PathBuf>,
963    visited: &mut HashSet<std::path::PathBuf>,
964) -> crate::error::Result<()> {
965    let canonical = match dir.canonicalize() {
966        Ok(p) => p,
967        Err(_) => return Ok(()),
968    };
969    if !visited.insert(canonical) {
970        return Ok(());
971    }
972
973    let mut entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
974    entries.sort_by_key(|e| e.path());
975
976    for entry in entries {
977        let path = entry.path();
978
979        if let Some(gs) = exclude_set
980            && let Ok(rel) = path.strip_prefix(base)
981            && gs.is_match(rel)
982        {
983            continue;
984        }
985
986        if path.is_dir() {
987            if path
988                .file_name()
989                .and_then(|n| n.to_str())
990                .is_some_and(|n| n.starts_with('.'))
991            {
992                continue;
993            }
994            collect_yaml_files(&path, base, exclude_set, files, visited)?;
995        } else if matches!(
996            path.extension().and_then(|e| e.to_str()),
997            Some("yml" | "yaml")
998        ) {
999            files.push(path);
1000        }
1001    }
1002    Ok(())
1003}
1004
1005/// Two-pass directory lint: collect and read every file once to build a
1006/// directory-global rule index, then lint each file against it so
1007/// cross-reference checks see rules defined in sibling files.
1008fn lint_directory_impl(
1009    dir: &Path,
1010    config: Option<&LintConfig>,
1011) -> crate::error::Result<Vec<FileLintResult>> {
1012    let exclude_set = config.and_then(LintConfig::build_exclude_set);
1013    let mut files = Vec::new();
1014    let mut visited = HashSet::new();
1015    collect_yaml_files(dir, dir, exclude_set.as_ref(), &mut files, &mut visited)?;
1016
1017    // Read each file once and index every referenceable rule across the tree.
1018    let mut index = RuleIndex::new(true);
1019    let mut contents: Vec<(std::path::PathBuf, std::result::Result<String, String>)> =
1020        Vec::with_capacity(files.len());
1021    for path in files {
1022        match std::fs::read_to_string(&path) {
1023            Ok(text) => {
1024                index.add_text(&text);
1025                contents.push((path, Ok(text)));
1026            }
1027            Err(e) => contents.push((path, Err(format!("error reading file: {e}")))),
1028        }
1029    }
1030
1031    let mut results = Vec::with_capacity(contents.len());
1032    for (path, content) in contents {
1033        match content {
1034            Ok(text) => {
1035                let warnings = match config {
1036                    Some(cfg) => {
1037                        let w = lint_yaml_str_indexed(
1038                            &text,
1039                            &cfg.tag_namespaces,
1040                            cfg.ads.as_ref(),
1041                            Some(&index),
1042                        );
1043                        apply_suppressions(w, cfg, &parse_inline_suppressions(&text))
1044                    }
1045                    None => lint_yaml_str_indexed(&text, &[], None, Some(&index)),
1046                };
1047                results.push(FileLintResult { path, warnings });
1048            }
1049            Err(msg) => results.push(FileLintResult {
1050                path,
1051                warnings: vec![err(LintRule::FileReadError, msg, "/")],
1052            }),
1053        }
1054    }
1055    Ok(results)
1056}
1057
1058/// Lint all `.yml`/`.yaml` files in a directory recursively.
1059pub fn lint_yaml_directory(dir: &Path) -> crate::error::Result<Vec<FileLintResult>> {
1060    lint_directory_impl(dir, None)
1061}
1062
1063// =============================================================================
1064// Lint configuration & suppression
1065// =============================================================================
1066
1067/// Configuration for lint rule suppression and severity overrides.
1068#[derive(Debug, Clone, Default, Serialize)]
1069pub struct LintConfig {
1070    pub disabled_rules: HashSet<String>,
1071    pub severity_overrides: HashMap<String, Severity>,
1072    pub exclude_patterns: Vec<String>,
1073    /// Extra tag namespaces recognised in addition to the built-in set.
1074    pub tag_namespaces: Vec<String>,
1075    /// ADS enforcement configuration. `None` (the default) leaves the ADS
1076    /// presence checks off; an `ads:` block in the config enables them.
1077    #[serde(skip_serializing_if = "Option::is_none")]
1078    pub ads: Option<AdsConfig>,
1079}
1080
1081/// ADS (Alerting and Detection Strategy) enforcement configuration.
1082///
1083/// Present (`Some`) only when an `ads:` block appears in the layered lint
1084/// config; the ADS presence checks are off otherwise. When enabled, the checks
1085/// fire on detection rules whose `status` is in [`enforce_status`](Self::enforce_status)
1086/// and flag each missing [`required`](Self::required) section.
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1088pub struct AdsConfig {
1089    /// Rule statuses that require ADS sections (lowercased).
1090    pub enforce_status: Vec<String>,
1091    /// The ADS section ids that are mandatory.
1092    pub required: Vec<String>,
1093    /// A single severity applied to every ADS finding, overriding the
1094    /// per-section default. `None` keeps the catalogue defaults.
1095    #[serde(skip_serializing_if = "Option::is_none")]
1096    pub severity: Option<Severity>,
1097}
1098
1099impl Default for AdsConfig {
1100    fn default() -> Self {
1101        AdsConfig {
1102            enforce_status: vec!["stable".to_string()],
1103            required: AdsSection::all()
1104                .iter()
1105                .map(|s| s.id().to_string())
1106                .collect(),
1107            severity: None,
1108        }
1109    }
1110}
1111
1112impl AdsConfig {
1113    /// Whether a rule with the given `status` string is in scope for ADS
1114    /// enforcement.
1115    pub fn enforces_status(&self, status: Option<&str>) -> bool {
1116        match status {
1117            Some(s) => self.enforce_status.iter().any(|e| e == s),
1118            None => false,
1119        }
1120    }
1121
1122    /// Whether the section id is required.
1123    pub fn requires(&self, section_id: &str) -> bool {
1124        self.required.iter().any(|r| r == section_id)
1125    }
1126}
1127
1128#[derive(Debug, Deserialize)]
1129struct RawLintConfig {
1130    #[serde(default)]
1131    disabled_rules: Vec<String>,
1132    #[serde(default)]
1133    severity_overrides: HashMap<String, String>,
1134    #[serde(default)]
1135    exclude: Vec<String>,
1136    #[serde(default)]
1137    tag_namespaces: Vec<String>,
1138    #[serde(default)]
1139    ads: Option<RawAdsConfig>,
1140}
1141
1142#[derive(Debug, Deserialize)]
1143struct RawAdsConfig {
1144    #[serde(default)]
1145    enforce_status: Option<Vec<String>>,
1146    #[serde(default)]
1147    required: Option<Vec<String>>,
1148    #[serde(default)]
1149    severity: Option<String>,
1150}
1151
1152/// Parse a lint severity wire string.
1153fn parse_severity(s: &str) -> Option<Severity> {
1154    match s {
1155        "error" => Some(Severity::Error),
1156        "warning" => Some(Severity::Warning),
1157        "info" => Some(Severity::Info),
1158        "hint" => Some(Severity::Hint),
1159        _ => None,
1160    }
1161}
1162
1163/// Build a validated [`AdsConfig`] from its raw, deserialized form, layering
1164/// any provided fields over the defaults.
1165fn ads_config_from_raw(raw: RawAdsConfig) -> crate::error::Result<AdsConfig> {
1166    let mut config = AdsConfig::default();
1167
1168    if let Some(statuses) = raw.enforce_status {
1169        const VALID_STATUSES: &[&str] = &[
1170            "stable",
1171            "test",
1172            "experimental",
1173            "deprecated",
1174            "unsupported",
1175        ];
1176        let mut normalised = Vec::with_capacity(statuses.len());
1177        for s in statuses {
1178            let lower = s.to_lowercase();
1179            if !VALID_STATUSES.contains(&lower.as_str()) {
1180                return Err(crate::error::SigmaParserError::InvalidRule(format!(
1181                    "invalid ads.enforce_status '{s}'; expected one of: {}",
1182                    VALID_STATUSES.join(", ")
1183                )));
1184            }
1185            normalised.push(lower);
1186        }
1187        dedup_preserving_order(&mut normalised);
1188        config.enforce_status = normalised;
1189    }
1190
1191    if let Some(required) = raw.required {
1192        let mut ids = Vec::with_capacity(required.len());
1193        for id in required {
1194            let lower = id.to_lowercase();
1195            if AdsSection::from_id(&lower).is_none() {
1196                return Err(crate::error::SigmaParserError::InvalidRule(format!(
1197                    "invalid ads.required section '{id}'; expected one of: {}",
1198                    AdsSection::all()
1199                        .iter()
1200                        .map(|s| s.id())
1201                        .collect::<Vec<_>>()
1202                        .join(", ")
1203                )));
1204            }
1205            ids.push(lower);
1206        }
1207        dedup_preserving_order(&mut ids);
1208        config.required = ids;
1209    }
1210
1211    if let Some(sev) = raw.severity {
1212        config.severity = Some(parse_severity(&sev).ok_or_else(|| {
1213            crate::error::SigmaParserError::InvalidRule(format!(
1214                "invalid ads.severity '{sev}'; expected error, warning, info, or hint"
1215            ))
1216        })?);
1217    }
1218
1219    Ok(config)
1220}
1221
1222/// Remove duplicate entries from a list while keeping the first occurrence of
1223/// each, so merged `exclude_patterns` / `tag_namespaces` stay stable and don't
1224/// repeat a value that appears in both the config file and a CLI flag.
1225fn dedup_preserving_order(items: &mut Vec<String>) {
1226    let mut seen = HashSet::new();
1227    items.retain(|item| seen.insert(item.clone()));
1228}
1229
1230impl LintConfig {
1231    pub fn load(path: &Path) -> crate::error::Result<Self> {
1232        let content = std::fs::read_to_string(path)?;
1233        let raw: RawLintConfig = yaml_serde::from_str(&content)?;
1234
1235        let disabled_rules: HashSet<String> = raw.disabled_rules.into_iter().collect();
1236        let mut severity_overrides = HashMap::new();
1237        for (rule, sev_str) in &raw.severity_overrides {
1238            let sev = parse_severity(sev_str).ok_or_else(|| {
1239                crate::error::SigmaParserError::InvalidRule(format!(
1240                    "invalid severity '{sev_str}' for rule '{rule}' in lint config"
1241                ))
1242            })?;
1243            severity_overrides.insert(rule.clone(), sev);
1244        }
1245
1246        let mut exclude_patterns = raw.exclude;
1247        dedup_preserving_order(&mut exclude_patterns);
1248
1249        let mut tag_namespaces: Vec<String> = raw
1250            .tag_namespaces
1251            .into_iter()
1252            .map(|s| s.to_lowercase())
1253            .collect();
1254        dedup_preserving_order(&mut tag_namespaces);
1255
1256        let ads = raw.ads.map(ads_config_from_raw).transpose()?;
1257
1258        Ok(LintConfig {
1259            disabled_rules,
1260            severity_overrides,
1261            exclude_patterns,
1262            tag_namespaces,
1263            ads,
1264        })
1265    }
1266
1267    pub fn find_in_ancestors(start_path: &Path) -> Option<std::path::PathBuf> {
1268        let dir = if start_path.is_file() {
1269            start_path.parent()?
1270        } else {
1271            start_path
1272        };
1273
1274        let mut current = dir;
1275        loop {
1276            let candidate = current.join(".rsigma-lint.yml");
1277            if candidate.is_file() {
1278                return Some(candidate);
1279            }
1280            let candidate_yaml = current.join(".rsigma-lint.yaml");
1281            if candidate_yaml.is_file() {
1282                return Some(candidate_yaml);
1283            }
1284            current = current.parent()?;
1285        }
1286    }
1287
1288    pub fn merge(&mut self, other: &LintConfig) {
1289        self.disabled_rules
1290            .extend(other.disabled_rules.iter().cloned());
1291        for (rule, sev) in &other.severity_overrides {
1292            self.severity_overrides.insert(rule.clone(), *sev);
1293        }
1294        self.exclude_patterns
1295            .extend(other.exclude_patterns.iter().cloned());
1296        dedup_preserving_order(&mut self.exclude_patterns);
1297        self.tag_namespaces
1298            .extend(other.tag_namespaces.iter().cloned());
1299        dedup_preserving_order(&mut self.tag_namespaces);
1300        // A nearer-layer `ads:` block replaces the inherited one wholesale, so
1301        // a project can set its own ADS bar without merging stale section lists.
1302        if other.ads.is_some() {
1303            self.ads = other.ads.clone();
1304        }
1305    }
1306
1307    pub fn is_disabled(&self, rule: &LintRule) -> bool {
1308        self.disabled_rules.contains(&rule.to_string())
1309    }
1310
1311    pub fn build_exclude_set(&self) -> Option<globset::GlobSet> {
1312        if self.exclude_patterns.is_empty() {
1313            return None;
1314        }
1315        let mut builder = globset::GlobSetBuilder::new();
1316        for pat in &self.exclude_patterns {
1317            if let Ok(glob) = globset::GlobBuilder::new(pat)
1318                .literal_separator(false)
1319                .build()
1320            {
1321                builder.add(glob);
1322            }
1323        }
1324        builder.build().ok()
1325    }
1326}
1327
1328// =============================================================================
1329// Inline suppression comments
1330// =============================================================================
1331
1332#[derive(Debug, Clone, Default)]
1333pub struct InlineSuppressions {
1334    pub disable_all: bool,
1335    pub file_disabled: HashSet<String>,
1336    pub line_disabled: HashMap<u32, Option<HashSet<String>>>,
1337}
1338
1339pub fn parse_inline_suppressions(text: &str) -> InlineSuppressions {
1340    let mut result = InlineSuppressions::default();
1341
1342    for (i, line) in text.lines().enumerate() {
1343        let trimmed = line.trim();
1344
1345        let comment = if let Some(pos) = find_yaml_comment(trimmed) {
1346            trimmed[pos + 1..].trim()
1347        } else {
1348            continue;
1349        };
1350
1351        if let Some(rest) = comment.strip_prefix("rsigma-disable-next-line") {
1352            let rest = rest.trim();
1353            let next_line = (i + 1) as u32;
1354            if rest.is_empty() {
1355                result.line_disabled.insert(next_line, None);
1356            } else {
1357                let rules: HashSet<String> = rest
1358                    .split(',')
1359                    .map(|s| s.trim().to_string())
1360                    .filter(|s| !s.is_empty())
1361                    .collect();
1362                if !rules.is_empty() {
1363                    result
1364                        .line_disabled
1365                        .entry(next_line)
1366                        .and_modify(|existing| {
1367                            if let Some(existing_set) = existing {
1368                                existing_set.extend(rules.iter().cloned());
1369                            }
1370                        })
1371                        .or_insert(Some(rules));
1372                }
1373            }
1374        } else if let Some(rest) = comment.strip_prefix("rsigma-disable") {
1375            let rest = rest.trim();
1376            if rest.is_empty() {
1377                result.disable_all = true;
1378            } else {
1379                for rule in rest.split(',') {
1380                    let rule = rule.trim();
1381                    if !rule.is_empty() {
1382                        result.file_disabled.insert(rule.to_string());
1383                    }
1384                }
1385            }
1386        }
1387    }
1388
1389    result
1390}
1391
1392fn find_yaml_comment(line: &str) -> Option<usize> {
1393    let mut in_single = false;
1394    let mut in_double = false;
1395    for (i, c) in line.char_indices() {
1396        match c {
1397            '\'' if !in_double => in_single = !in_single,
1398            '"' if !in_single => in_double = !in_double,
1399            '#' if !in_single && !in_double => return Some(i),
1400            _ => {}
1401        }
1402    }
1403    None
1404}
1405
1406impl InlineSuppressions {
1407    pub fn is_suppressed(&self, warning: &LintWarning) -> bool {
1408        if self.disable_all {
1409            return true;
1410        }
1411
1412        let rule_name = warning.rule.to_string();
1413        if self.file_disabled.contains(&rule_name) {
1414            return true;
1415        }
1416
1417        if let Some(span) = &warning.span
1418            && let Some(line_rules) = self.line_disabled.get(&span.start_line)
1419        {
1420            return match line_rules {
1421                None => true,
1422                Some(rules) => rules.contains(&rule_name),
1423            };
1424        }
1425
1426        false
1427    }
1428}
1429
1430// =============================================================================
1431// Suppression filtering
1432// =============================================================================
1433
1434pub fn apply_suppressions(
1435    warnings: Vec<LintWarning>,
1436    config: &LintConfig,
1437    inline: &InlineSuppressions,
1438) -> Vec<LintWarning> {
1439    warnings
1440        .into_iter()
1441        .filter(|w| !config.is_disabled(&w.rule))
1442        .filter(|w| !inline.is_suppressed(w))
1443        .map(|mut w| {
1444            let rule_name = w.rule.to_string();
1445            if let Some(sev) = config.severity_overrides.get(&rule_name) {
1446                w.severity = *sev;
1447            }
1448            w
1449        })
1450        .collect()
1451}
1452
1453pub fn lint_yaml_str_with_config(text: &str, config: &LintConfig) -> Vec<LintWarning> {
1454    let warnings = lint_yaml_str_ext(text, &config.tag_namespaces, config.ads.as_ref());
1455    let inline = parse_inline_suppressions(text);
1456    apply_suppressions(warnings, config, &inline)
1457}
1458
1459pub fn lint_yaml_file_with_config(
1460    path: &Path,
1461    config: &LintConfig,
1462) -> crate::error::Result<FileLintResult> {
1463    let content = std::fs::read_to_string(path)?;
1464    let warnings = lint_yaml_str_with_config(&content, config);
1465    Ok(FileLintResult {
1466        path: path.to_path_buf(),
1467        warnings,
1468    })
1469}
1470
1471pub fn lint_yaml_directory_with_config(
1472    dir: &Path,
1473    config: &LintConfig,
1474) -> crate::error::Result<Vec<FileLintResult>> {
1475    lint_directory_impl(dir, Some(config))
1476}
1477
1478// =============================================================================
1479// Tests
1480// =============================================================================
1481
1482#[cfg(test)]
1483mod tests {
1484    use super::*;
1485
1486    fn yaml_value(yaml: &str) -> Value {
1487        yaml_serde::from_str(yaml).unwrap()
1488    }
1489
1490    fn lint(yaml: &str) -> Vec<LintWarning> {
1491        lint_yaml_value(&yaml_value(yaml))
1492    }
1493
1494    fn has_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1495        warnings.iter().any(|w| w.rule == rule)
1496    }
1497
1498    fn has_no_rule(warnings: &[LintWarning], rule: LintRule) -> bool {
1499        !has_rule(warnings, rule)
1500    }
1501
1502    #[test]
1503    fn valid_detection_rule_no_errors() {
1504        let w = lint(
1505            r#"
1506title: Test Rule
1507id: 929a690e-bef0-4204-a928-ef5e620d6fcc
1508status: test
1509logsource:
1510    category: process_creation
1511    product: windows
1512detection:
1513    selection:
1514        CommandLine|contains: 'whoami'
1515    condition: selection
1516level: medium
1517tags:
1518    - attack.execution
1519    - attack.t1059
1520"#,
1521        );
1522        let errors: Vec<_> = w.iter().filter(|w| w.severity == Severity::Error).collect();
1523        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1524    }
1525
1526    #[test]
1527    fn not_a_mapping() {
1528        let v: yaml_serde::Value = yaml_serde::from_str("- item1\n- item2").unwrap();
1529        let w = lint_yaml_value(&v);
1530        assert!(has_rule(&w, LintRule::NotAMapping));
1531    }
1532
1533    #[test]
1534    fn lint_yaml_str_produces_spans() {
1535        let text = r#"title: Test
1536status: invalid_status
1537logsource:
1538    category: test
1539detection:
1540    selection:
1541        field: value
1542    condition: selection
1543level: medium
1544"#;
1545        let warnings = lint_yaml_str(text);
1546        let invalid_status = warnings.iter().find(|w| w.rule == LintRule::InvalidStatus);
1547        assert!(invalid_status.is_some(), "expected InvalidStatus warning");
1548        let span = invalid_status.unwrap().span;
1549        assert!(span.is_some(), "expected span to be resolved");
1550        assert_eq!(span.unwrap().start_line, 1);
1551    }
1552
1553    #[test]
1554    fn yaml_parse_error_uses_correct_rule() {
1555        let text = "title: [unclosed";
1556        let warnings = lint_yaml_str(text);
1557        assert!(has_rule(&warnings, LintRule::YamlParseError));
1558        assert!(has_no_rule(&warnings, LintRule::MissingTitle));
1559    }
1560
1561    #[test]
1562    fn action_global_skipped() {
1563        let w = lint(
1564            r#"
1565action: global
1566title: Global Template
1567logsource:
1568    product: windows
1569"#,
1570        );
1571        assert!(w.is_empty());
1572    }
1573
1574    #[test]
1575    fn action_reset_skipped() {
1576        let w = lint(
1577            r#"
1578action: reset
1579"#,
1580        );
1581        assert!(w.is_empty());
1582    }
1583
1584    #[test]
1585    fn resolve_path_to_span_root() {
1586        let text = "title: Test\nstatus: test\n";
1587        let span = resolve_path_to_span(text, "/");
1588        assert!(span.is_some());
1589        assert_eq!(span.unwrap().start_line, 0);
1590    }
1591
1592    #[test]
1593    fn resolve_path_to_span_top_level_key() {
1594        let text = "title: Test\nstatus: test\nlevel: high\n";
1595        let span = resolve_path_to_span(text, "/status");
1596        assert!(span.is_some());
1597        assert_eq!(span.unwrap().start_line, 1);
1598    }
1599
1600    #[test]
1601    fn resolve_path_to_span_nested_key() {
1602        let text = "title: Test\nlogsource:\n    category: test\n    product: windows\n";
1603        let span = resolve_path_to_span(text, "/logsource/product");
1604        assert!(span.is_some());
1605        assert_eq!(span.unwrap().start_line, 3);
1606    }
1607
1608    #[test]
1609    fn resolve_path_to_span_missing_key() {
1610        let text = "title: Test\nstatus: test\n";
1611        let span = resolve_path_to_span(text, "/nonexistent");
1612        assert!(span.is_none());
1613    }
1614
1615    #[test]
1616    fn multi_doc_yaml_lints_all_documents() {
1617        let text = r#"title: Rule 1
1618logsource:
1619    category: test
1620detection:
1621    selection:
1622        field: value
1623    condition: selection
1624level: medium
1625---
1626title: Rule 2
1627status: bad_status
1628logsource:
1629    category: test
1630detection:
1631    selection:
1632        field: value
1633    condition: selection
1634level: medium
1635"#;
1636        let warnings = lint_yaml_str(text);
1637        assert!(has_rule(&warnings, LintRule::InvalidStatus));
1638    }
1639
1640    #[test]
1641    fn severity_display() {
1642        assert_eq!(format!("{}", Severity::Error), "error");
1643        assert_eq!(format!("{}", Severity::Warning), "warning");
1644        assert_eq!(format!("{}", Severity::Info), "info");
1645        assert_eq!(format!("{}", Severity::Hint), "hint");
1646    }
1647
1648    #[test]
1649    fn file_lint_result_has_errors() {
1650        let result = FileLintResult {
1651            path: std::path::PathBuf::from("test.yml"),
1652            warnings: vec![
1653                warning(LintRule::TitleTooLong, "too long", "/title"),
1654                err(
1655                    LintRule::MissingCondition,
1656                    "missing",
1657                    "/detection/condition",
1658                ),
1659            ],
1660        };
1661        assert!(result.has_errors());
1662        assert_eq!(result.error_count(), 1);
1663        assert_eq!(result.warning_count(), 1);
1664    }
1665
1666    #[test]
1667    fn file_lint_result_no_errors() {
1668        let result = FileLintResult {
1669            path: std::path::PathBuf::from("test.yml"),
1670            warnings: vec![warning(LintRule::TitleTooLong, "too long", "/title")],
1671        };
1672        assert!(!result.has_errors());
1673        assert_eq!(result.error_count(), 0);
1674        assert_eq!(result.warning_count(), 1);
1675    }
1676
1677    #[test]
1678    fn file_lint_result_empty() {
1679        let result = FileLintResult {
1680            path: std::path::PathBuf::from("test.yml"),
1681            warnings: vec![],
1682        };
1683        assert!(!result.has_errors());
1684        assert_eq!(result.error_count(), 0);
1685        assert_eq!(result.warning_count(), 0);
1686    }
1687
1688    #[test]
1689    fn lint_warning_display() {
1690        let w = err(
1691            LintRule::MissingTitle,
1692            "missing required field 'title'",
1693            "/title",
1694        );
1695        let display = format!("{w}");
1696        assert!(display.contains("error"));
1697        assert!(display.contains("missing_title"));
1698        assert!(display.contains("/title"));
1699    }
1700
1701    #[test]
1702    fn file_lint_result_info_count() {
1703        let result = FileLintResult {
1704            path: std::path::PathBuf::from("test.yml"),
1705            warnings: vec![
1706                info(LintRule::MissingDescription, "missing desc", "/description"),
1707                info(LintRule::MissingAuthor, "missing author", "/author"),
1708                warning(LintRule::TitleTooLong, "too long", "/title"),
1709            ],
1710        };
1711        assert_eq!(result.info_count(), 2);
1712        assert_eq!(result.warning_count(), 1);
1713        assert_eq!(result.error_count(), 0);
1714        assert!(!result.has_errors());
1715    }
1716
1717    #[test]
1718    fn parse_inline_disable_all() {
1719        let text = "# rsigma-disable\ntitle: Test\n";
1720        let sup = parse_inline_suppressions(text);
1721        assert!(sup.disable_all);
1722    }
1723
1724    #[test]
1725    fn parse_inline_disable_specific_rules() {
1726        let text = "# rsigma-disable missing_description, missing_author\ntitle: Test\n";
1727        let sup = parse_inline_suppressions(text);
1728        assert!(!sup.disable_all);
1729        assert!(sup.file_disabled.contains("missing_description"));
1730        assert!(sup.file_disabled.contains("missing_author"));
1731    }
1732
1733    #[test]
1734    fn parse_inline_disable_next_line_all() {
1735        let text = "# rsigma-disable-next-line\ntitle: Test\n";
1736        let sup = parse_inline_suppressions(text);
1737        assert!(!sup.disable_all);
1738        assert!(sup.line_disabled.contains_key(&1));
1739        assert!(sup.line_disabled[&1].is_none());
1740    }
1741
1742    #[test]
1743    fn parse_inline_disable_next_line_specific() {
1744        let text = "title: Test\n# rsigma-disable-next-line missing_level\nlevel: medium\n";
1745        let sup = parse_inline_suppressions(text);
1746        assert!(sup.line_disabled.contains_key(&2));
1747        let rules = sup.line_disabled[&2].as_ref().unwrap();
1748        assert!(rules.contains("missing_level"));
1749    }
1750
1751    #[test]
1752    fn parse_inline_no_comments() {
1753        let text = "title: Test\nstatus: test\n";
1754        let sup = parse_inline_suppressions(text);
1755        assert!(!sup.disable_all);
1756        assert!(sup.file_disabled.is_empty());
1757        assert!(sup.line_disabled.is_empty());
1758    }
1759
1760    #[test]
1761    fn parse_inline_comment_in_quoted_string() {
1762        let text = "description: 'no # rsigma-disable here'\ntitle: Test\n";
1763        let sup = parse_inline_suppressions(text);
1764        assert!(!sup.disable_all);
1765        assert!(sup.file_disabled.is_empty());
1766    }
1767
1768    #[test]
1769    fn apply_suppressions_disables_rule() {
1770        let warnings = vec![
1771            info(LintRule::MissingDescription, "desc", "/description"),
1772            info(LintRule::MissingAuthor, "author", "/author"),
1773            warning(LintRule::TitleTooLong, "title", "/title"),
1774        ];
1775        let mut config = LintConfig::default();
1776        config
1777            .disabled_rules
1778            .insert("missing_description".to_string());
1779        let inline = InlineSuppressions::default();
1780
1781        let result = apply_suppressions(warnings, &config, &inline);
1782        assert_eq!(result.len(), 2);
1783        assert!(
1784            result
1785                .iter()
1786                .all(|w| w.rule != LintRule::MissingDescription)
1787        );
1788    }
1789
1790    #[test]
1791    fn apply_suppressions_severity_override() {
1792        let warnings = vec![warning(LintRule::TitleTooLong, "title too long", "/title")];
1793        let mut config = LintConfig::default();
1794        config
1795            .severity_overrides
1796            .insert("title_too_long".to_string(), Severity::Info);
1797        let inline = InlineSuppressions::default();
1798
1799        let result = apply_suppressions(warnings, &config, &inline);
1800        assert_eq!(result.len(), 1);
1801        assert_eq!(result[0].severity, Severity::Info);
1802    }
1803
1804    #[test]
1805    fn apply_suppressions_inline_file_disable() {
1806        let warnings = vec![
1807            info(LintRule::MissingDescription, "desc", "/description"),
1808            info(LintRule::MissingAuthor, "author", "/author"),
1809        ];
1810        let config = LintConfig::default();
1811        let mut inline = InlineSuppressions::default();
1812        inline.file_disabled.insert("missing_author".to_string());
1813
1814        let result = apply_suppressions(warnings, &config, &inline);
1815        assert_eq!(result.len(), 1);
1816        assert_eq!(result[0].rule, LintRule::MissingDescription);
1817    }
1818
1819    #[test]
1820    fn apply_suppressions_inline_disable_all() {
1821        let warnings = vec![
1822            err(LintRule::MissingTitle, "title", "/title"),
1823            warning(LintRule::TitleTooLong, "long", "/title"),
1824        ];
1825        let config = LintConfig::default();
1826        let inline = InlineSuppressions {
1827            disable_all: true,
1828            ..Default::default()
1829        };
1830
1831        let result = apply_suppressions(warnings, &config, &inline);
1832        assert!(result.is_empty());
1833    }
1834
1835    #[test]
1836    fn apply_suppressions_inline_next_line() {
1837        let mut w1 = warning(LintRule::TitleTooLong, "long", "/title");
1838        w1.span = Some(Span {
1839            start_line: 5,
1840            start_col: 0,
1841            end_line: 5,
1842            end_col: 10,
1843        });
1844        let mut w2 = err(LintRule::InvalidStatus, "bad", "/status");
1845        w2.span = Some(Span {
1846            start_line: 6,
1847            start_col: 0,
1848            end_line: 6,
1849            end_col: 10,
1850        });
1851
1852        let config = LintConfig::default();
1853        let mut inline = InlineSuppressions::default();
1854        inline.line_disabled.insert(5, None);
1855
1856        let result = apply_suppressions(vec![w1, w2], &config, &inline);
1857        assert_eq!(result.len(), 1);
1858        assert_eq!(result[0].rule, LintRule::InvalidStatus);
1859    }
1860
1861    #[test]
1862    fn lint_with_config_disables_rules() {
1863        let text = r#"title: Test
1864logsource:
1865    category: test
1866detection:
1867    selection:
1868        field: value
1869    condition: selection
1870level: medium
1871"#;
1872        let mut config = LintConfig::default();
1873        config
1874            .disabled_rules
1875            .insert("missing_description".to_string());
1876        config.disabled_rules.insert("missing_author".to_string());
1877
1878        let warnings = lint_yaml_str_with_config(text, &config);
1879        assert!(
1880            !warnings
1881                .iter()
1882                .any(|w| w.rule == LintRule::MissingDescription)
1883        );
1884        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1885    }
1886
1887    #[test]
1888    fn lint_with_inline_disable_next_line() {
1889        let text = r#"title: Test
1890# rsigma-disable-next-line missing_level
1891logsource:
1892    category: test
1893detection:
1894    selection:
1895        field: value
1896    condition: selection
1897"#;
1898        let config = LintConfig::default();
1899        let warnings = lint_yaml_str_with_config(text, &config);
1900        assert!(warnings.iter().any(|w| w.rule == LintRule::MissingLevel));
1901    }
1902
1903    #[test]
1904    fn lint_with_inline_file_disable() {
1905        let text = r#"# rsigma-disable missing_description, missing_author
1906title: Test
1907logsource:
1908    category: test
1909detection:
1910    selection:
1911        field: value
1912    condition: selection
1913level: medium
1914"#;
1915        let config = LintConfig::default();
1916        let warnings = lint_yaml_str_with_config(text, &config);
1917        assert!(
1918            !warnings
1919                .iter()
1920                .any(|w| w.rule == LintRule::MissingDescription)
1921        );
1922        assert!(!warnings.iter().any(|w| w.rule == LintRule::MissingAuthor));
1923    }
1924
1925    #[test]
1926    fn lint_with_inline_disable_all() {
1927        let text = r#"# rsigma-disable
1928title: Test
1929status: invalid_status
1930logsource:
1931    category: test
1932detection:
1933    selection:
1934        field: value
1935    condition: selection
1936"#;
1937        let config = LintConfig::default();
1938        let warnings = lint_yaml_str_with_config(text, &config);
1939        assert!(warnings.is_empty());
1940    }
1941
1942    #[test]
1943    fn lint_config_merge() {
1944        let mut base = LintConfig::default();
1945        base.disabled_rules.insert("rule_a".to_string());
1946        base.severity_overrides
1947            .insert("rule_b".to_string(), Severity::Info);
1948
1949        let other = LintConfig {
1950            disabled_rules: ["rule_c".to_string()].into_iter().collect(),
1951            severity_overrides: [("rule_d".to_string(), Severity::Hint)]
1952                .into_iter()
1953                .collect(),
1954            exclude_patterns: vec!["test/**".to_string()],
1955            tag_namespaces: vec!["myns".to_string()],
1956            ads: None,
1957        };
1958
1959        base.merge(&other);
1960        assert!(base.disabled_rules.contains("rule_a"));
1961        assert!(base.disabled_rules.contains("rule_c"));
1962        assert_eq!(base.severity_overrides.get("rule_b"), Some(&Severity::Info));
1963        assert_eq!(base.severity_overrides.get("rule_d"), Some(&Severity::Hint));
1964        assert_eq!(base.exclude_patterns, vec!["test/**".to_string()]);
1965        assert!(base.tag_namespaces.contains(&"myns".to_string()));
1966    }
1967
1968    #[test]
1969    fn lint_config_merge_dedups_lists() {
1970        let mut base = LintConfig {
1971            exclude_patterns: vec!["config/**".to_string(), "shared/**".to_string()],
1972            tag_namespaces: vec!["myorg".to_string(), "shared".to_string()],
1973            ..Default::default()
1974        };
1975        let other = LintConfig {
1976            // "shared/**" and "shared" overlap with base on purpose.
1977            exclude_patterns: vec!["shared/**".to_string(), "extra/**".to_string()],
1978            tag_namespaces: vec!["shared".to_string(), "internal".to_string()],
1979            ..Default::default()
1980        };
1981
1982        base.merge(&other);
1983
1984        assert_eq!(
1985            base.exclude_patterns,
1986            vec![
1987                "config/**".to_string(),
1988                "shared/**".to_string(),
1989                "extra/**".to_string()
1990            ]
1991        );
1992        assert_eq!(
1993            base.tag_namespaces,
1994            vec![
1995                "myorg".to_string(),
1996                "shared".to_string(),
1997                "internal".to_string()
1998            ]
1999        );
2000    }
2001
2002    #[test]
2003    fn lint_config_load_dedups_and_normalises() {
2004        let yaml = r#"
2005exclude:
2006  - "config/**"
2007  - "config/**"
2008tag_namespaces:
2009  - MyOrg
2010  - myorg
2011  - internal
2012"#;
2013        let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
2014        std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
2015        let config = LintConfig::load(tmp.path()).unwrap();
2016
2017        assert_eq!(config.exclude_patterns, vec!["config/**".to_string()]);
2018        // "MyOrg" lowercases to "myorg" and then collapses with the duplicate.
2019        assert_eq!(
2020            config.tag_namespaces,
2021            vec!["myorg".to_string(), "internal".to_string()]
2022        );
2023    }
2024
2025    #[test]
2026    fn lint_config_is_disabled() {
2027        let mut config = LintConfig::default();
2028        config.disabled_rules.insert("missing_title".to_string());
2029        assert!(config.is_disabled(&LintRule::MissingTitle));
2030        assert!(!config.is_disabled(&LintRule::EmptyTitle));
2031    }
2032
2033    #[test]
2034    fn find_yaml_comment_basic() {
2035        assert_eq!(find_yaml_comment("# comment"), Some(0));
2036        assert_eq!(find_yaml_comment("key: value # comment"), Some(11));
2037        assert_eq!(find_yaml_comment("key: 'value # not comment'"), None);
2038        assert_eq!(find_yaml_comment("key: \"value # not comment\""), None);
2039        assert_eq!(find_yaml_comment("key: value"), None);
2040    }
2041
2042    #[test]
2043    fn no_fix_for_unfixable_rule() {
2044        let w = lint(
2045            r#"
2046title: Test
2047logsource:
2048    category: test
2049"#,
2050        );
2051        assert!(has_rule(&w, LintRule::MissingDetection));
2052        let fix = w
2053            .iter()
2054            .find(|w| w.rule == LintRule::MissingDetection)
2055            .and_then(|w| w.fix.as_ref());
2056        assert!(fix.is_none());
2057    }
2058
2059    #[test]
2060    fn lint_config_exclude_from_yaml() {
2061        let yaml = r#"
2062disabled_rules:
2063  - missing_description
2064exclude:
2065  - "config/**"
2066  - "**/unsupported/**"
2067"#;
2068        let tmp = std::env::temp_dir().join("rsigma_test_exclude.yml");
2069        std::fs::write(&tmp, yaml).unwrap();
2070        let config = LintConfig::load(&tmp).unwrap();
2071        std::fs::remove_file(&tmp).ok();
2072
2073        assert!(config.disabled_rules.contains("missing_description"));
2074        assert_eq!(config.exclude_patterns.len(), 2);
2075        assert_eq!(config.exclude_patterns[0], "config/**");
2076        assert_eq!(config.exclude_patterns[1], "**/unsupported/**");
2077    }
2078
2079    #[test]
2080    fn lint_config_build_exclude_set_empty() {
2081        let config = LintConfig::default();
2082        assert!(config.build_exclude_set().is_none());
2083    }
2084
2085    #[test]
2086    fn lint_config_build_exclude_set_matches() {
2087        let config = LintConfig {
2088            exclude_patterns: vec!["config/**".to_string()],
2089            ..Default::default()
2090        };
2091        let gs = config.build_exclude_set().expect("should build");
2092        assert!(gs.is_match("config/data_mapping/foo.yaml"));
2093        assert!(gs.is_match("config/nested/deep/bar.yml"));
2094        assert!(!gs.is_match("rules/windows/test.yml"));
2095    }
2096
2097    #[test]
2098    fn cross_ref_version_mismatch_within_file() {
2099        // A correlation (major 3) referencing a base rule (major 2) by name, in
2100        // the same file, flags the mismatch. unknown_rule_reference does NOT
2101        // fire for a single file (the index is not complete).
2102        let yaml = r#"
2103title: Base Rule
2104name: base_rule
2105sigma-version: 2
2106logsource:
2107    category: test
2108detection:
2109    selection:
2110        EventID: 1
2111    condition: selection
2112---
2113title: Brute Force
2114sigma-version: 3
2115correlation:
2116    type: event_count
2117    rules:
2118        - base_rule
2119    group-by:
2120        - SourceIP
2121    timespan: 5m
2122    condition:
2123        gte: 10
2124"#;
2125        let w = lint_yaml_str(yaml);
2126        assert!(has_rule(&w, LintRule::SigmaVersionMismatch));
2127        assert!(has_no_rule(&w, LintRule::UnknownRuleReference));
2128    }
2129
2130    #[test]
2131    fn cross_ref_matching_version_no_mismatch() {
2132        let yaml = r#"
2133title: Base Rule
2134name: base_rule
2135sigma-version: 3
2136logsource:
2137    category: test
2138detection:
2139    selection:
2140        EventID: 1
2141    condition: selection
2142---
2143title: Brute Force
2144sigma-version: 3
2145correlation:
2146    type: event_count
2147    rules:
2148        - base_rule
2149    group-by:
2150        - SourceIP
2151    timespan: 5m
2152    condition:
2153        gte: 10
2154"#;
2155        assert!(has_no_rule(
2156            &lint_yaml_str(yaml),
2157            LintRule::SigmaVersionMismatch
2158        ));
2159    }
2160
2161    #[test]
2162    fn cross_ref_unknown_only_with_complete_index() {
2163        let yaml = r#"
2164title: Brute Force
2165correlation:
2166    type: event_count
2167    rules:
2168        - nonexistent_rule
2169    group-by:
2170        - SourceIP
2171    timespan: 5m
2172    condition:
2173        gte: 10
2174"#;
2175        // Single file: the referenced rule may live elsewhere, so it is out of
2176        // scope and unknown_rule_reference must not fire.
2177        assert!(has_no_rule(
2178            &lint_yaml_str(yaml),
2179            LintRule::UnknownRuleReference
2180        ));
2181
2182        // Directory: the index is complete, so the missing reference is flagged.
2183        let tmp = tempfile::tempdir().unwrap();
2184        std::fs::write(tmp.path().join("corr.yml"), yaml).unwrap();
2185        let results = lint_yaml_directory(tmp.path()).unwrap();
2186        assert!(
2187            results
2188                .iter()
2189                .flat_map(|r| &r.warnings)
2190                .any(|w| w.rule == LintRule::UnknownRuleReference)
2191        );
2192    }
2193
2194    #[test]
2195    fn cross_ref_resolves_across_files() {
2196        // Base rule in one file, correlation in another: the directory index
2197        // resolves the reference and flags the major mismatch across files.
2198        let tmp = tempfile::tempdir().unwrap();
2199        std::fs::write(
2200            tmp.path().join("base.yml"),
2201            r#"
2202title: Base Rule
2203name: base_rule
2204sigma-version: 2
2205logsource:
2206    category: test
2207detection:
2208    selection:
2209        EventID: 1
2210    condition: selection
2211"#,
2212        )
2213        .unwrap();
2214        std::fs::write(
2215            tmp.path().join("corr.yml"),
2216            r#"
2217title: Brute Force
2218sigma-version: 3
2219correlation:
2220    type: event_count
2221    rules:
2222        - base_rule
2223    group-by:
2224        - SourceIP
2225    timespan: 5m
2226    condition:
2227        gte: 10
2228"#,
2229        )
2230        .unwrap();
2231        let results = lint_yaml_directory(tmp.path()).unwrap();
2232        let all: Vec<_> = results.iter().flat_map(|r| &r.warnings).collect();
2233        assert!(all.iter().any(|w| w.rule == LintRule::SigmaVersionMismatch));
2234        assert!(!all.iter().any(|w| w.rule == LintRule::UnknownRuleReference));
2235    }
2236
2237    #[test]
2238    fn lint_directory_with_excludes() {
2239        let tmp = tempfile::tempdir().unwrap();
2240        let rules_dir = tmp.path().join("rules");
2241        let config_dir = tmp.path().join("config");
2242        std::fs::create_dir_all(&rules_dir).unwrap();
2243        std::fs::create_dir_all(&config_dir).unwrap();
2244
2245        std::fs::write(
2246            rules_dir.join("good.yml"),
2247            r#"
2248title: Good Rule
2249logsource:
2250    category: test
2251detection:
2252    sel:
2253        field: value
2254    condition: sel
2255level: medium
2256"#,
2257        )
2258        .unwrap();
2259
2260        std::fs::write(
2261            config_dir.join("mapping.yaml"),
2262            r#"
2263Title: Logon
2264Channel: Security
2265EventID: 4624
2266"#,
2267        )
2268        .unwrap();
2269
2270        let no_exclude = LintConfig::default();
2271        let results = lint_yaml_directory_with_config(tmp.path(), &no_exclude).unwrap();
2272        let config_warnings: Vec<_> = results
2273            .iter()
2274            .filter(|r| r.path.to_string_lossy().contains("config"))
2275            .flat_map(|r| &r.warnings)
2276            .collect();
2277        assert!(
2278            !config_warnings.is_empty(),
2279            "config file should produce warnings without excludes"
2280        );
2281
2282        let with_exclude = LintConfig {
2283            exclude_patterns: vec!["config/**".to_string()],
2284            ..Default::default()
2285        };
2286        let results = lint_yaml_directory_with_config(tmp.path(), &with_exclude).unwrap();
2287        let config_results: Vec<_> = results
2288            .iter()
2289            .filter(|r| r.path.to_string_lossy().contains("config"))
2290            .collect();
2291        assert!(config_results.is_empty(), "config file should be excluded");
2292
2293        let rule_results: Vec<_> = results
2294            .iter()
2295            .filter(|r| r.path.to_string_lossy().contains("good.yml"))
2296            .collect();
2297        assert_eq!(rule_results.len(), 1);
2298    }
2299
2300    #[test]
2301    fn all_lint_keys_are_cached() {
2302        const ALL_LINT_KEYS: &[&str] = &[
2303            "action",
2304            "author",
2305            "condition",
2306            "correlation",
2307            "date",
2308            "description",
2309            "detection",
2310            "field",
2311            "filter",
2312            "generate",
2313            "group-by",
2314            "id",
2315            "level",
2316            "logsource",
2317            "modified",
2318            "name",
2319            "rules",
2320            "selection",
2321            "status",
2322            "tags",
2323            "taxonomy",
2324            "timeframe",
2325            "timespan",
2326            "title",
2327            "type",
2328        ];
2329        for key_str in ALL_LINT_KEYS {
2330            assert!(KEY_CACHE.contains_key(key_str), "key not cached: {key_str}");
2331        }
2332    }
2333
2334    #[test]
2335    fn extra_tag_namespace_suppresses_warning() {
2336        let text = r#"title: Test
2337logsource:
2338    category: test
2339detection:
2340    selection:
2341        field: value
2342    condition: selection
2343level: medium
2344tags:
2345    - myorg.custom_tag
2346"#;
2347        // Without extra namespaces, unknown_tag_namespace fires.
2348        let warnings = lint_yaml_str(text);
2349        assert!(has_rule(&warnings, LintRule::UnknownTagNamespace));
2350
2351        // With "myorg" added, the warning is gone.
2352        let config = LintConfig {
2353            tag_namespaces: vec!["myorg".to_string()],
2354            ..Default::default()
2355        };
2356        let warnings = lint_yaml_str_with_config(text, &config);
2357        assert!(has_no_rule(&warnings, LintRule::UnknownTagNamespace));
2358    }
2359
2360    #[test]
2361    fn extra_tag_namespace_from_config_file() {
2362        let yaml = r#"
2363tag_namespaces:
2364  - myorg
2365  - internal
2366"#;
2367        let mut tmp = tempfile::NamedTempFile::with_suffix(".yml").unwrap();
2368        std::io::Write::write_all(&mut tmp, yaml.as_bytes()).unwrap();
2369        let config = LintConfig::load(tmp.path()).unwrap();
2370
2371        assert!(config.tag_namespaces.contains(&"myorg".to_string()));
2372        assert!(config.tag_namespaces.contains(&"internal".to_string()));
2373    }
2374}