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