Skip to main content

keyhog_core/suppression/
rule.rs

1//! Declarative rule-based finding suppression.
2//!
3//! Loads a `.keyhogignore.toml` file alongside the legacy line-based
4//! `.keyhogignore`. Each `[[suppress]]` table compiles into a vyre
5//! `RuleFormula` evaluated per-finding via VYRE CPU evaluator
6//! (`vyre_libs::rule::evaluate_formula`). Findings whose rules
7//! evaluate to `true` are dropped from the report.
8//!
9//! Schema (one or more `[[suppress]]` tables):
10//!
11//! ```toml
12//! # Drop every aws-access-key finding inside test directories.
13//! [[suppress]]
14//! detector = "aws-access-key"
15//! path_contains = "/tests/"
16//!
17//! # Drop low-severity stripe findings on a specific file.
18//! [[suppress]]
19//! service = "stripe"
20//! severity_lte = "low"
21//! path_eq = "fixtures/stripe.yml"
22//!
23//! # Drop a single credential by hash, regardless of where it
24//! # appears (mirrors the legacy `hash:` entry in .keyhogignore).
25//! [[suppress]]
26//! credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
27//! ```
28//!
29//! Within one `[[suppress]]` the named fields combine with AND.
30//! Across multiple `[[suppress]]` tables they combine with OR (any
31//! suppress matching the finding drops it). All conditions are
32//! optional; a `[[suppress]]` table with no condition is rejected.
33//! Use `literal_true = true` to request an explicit match-everything rule.
34
35use std::path::Path;
36use std::sync::Arc;
37
38use serde::Deserialize;
39use vyre_libs::rule::{evaluate_formula, RuleCondition, RuleEvaluationContext, RuleFormula};
40
41use crate::{RawMatch, Severity, VerifiedFinding};
42
43/// Parsed `.keyhogignore.toml` containing a list of `[[suppress]]` rules,
44/// each compiled into a `RuleFormula`.
45#[derive(Debug, Default)]
46pub struct RuleSuppressor {
47    rules: Vec<RuleFormula>,
48}
49
50/// One `[[suppress]]` table from the TOML.
51#[derive(Debug, Default, Deserialize)]
52#[serde(deny_unknown_fields)]
53struct SuppressEntry {
54    /// Explicit match-everything predicate. Kept noisy on purpose: an empty
55    /// table is rejected so a missing or typoed condition cannot suppress every
56    /// finding accidentally.
57    #[serde(default)]
58    literal_true: bool,
59    /// Detector ID exact match (e.g. `"aws-access-key"`).
60    detector: Option<String>,
61    /// Service exact match (e.g. `"stripe"`).
62    service: Option<String>,
63    /// Severity equals (case-insensitive: info, client-safe, low, medium, high, critical).
64    severity: Option<String>,
65    /// Severity <= (finding severity must be at most this rank).
66    severity_lte: Option<String>,
67    /// File path exact match.
68    path_eq: Option<String>,
69    /// File path contains substring.
70    path_contains: Option<String>,
71    /// File path starts with prefix.
72    path_starts_with: Option<String>,
73    /// File path ends with suffix.
74    path_ends_with: Option<String>,
75    /// File path matches regex.
76    path_regex: Option<String>,
77    /// Credential SHA-256 hash exact match.
78    credential_hash: Option<String>,
79}
80
81/// File context around which a `RuleFormula` is evaluated. One per finding.
82struct FindingContext<'a> {
83    detector_id: &'a str,
84    service: &'a str,
85    severity: Severity,
86    path: &'a str,
87    credential_hash: &'a str,
88}
89
90impl<'a> RuleEvaluationContext for FindingContext<'a> {
91    fn field_value(&self, name: &str) -> Option<&str> {
92        match name {
93            "detector_id" => Some(self.detector_id),
94            "service" => Some(self.service),
95            "path" => Some(self.path),
96            "credential_hash" => Some(self.credential_hash),
97            // `Severity::as_str` is the single source of truth for the
98            // kebab-case wire form; rehand-rolling the match here drifted
99            // from it once already (the `client-safe` tier).
100            "severity" => Some(self.severity.as_str()),
101            _ => None,
102        }
103    }
104}
105
106/// Return severity rank using canonical Severity table.
107///
108/// Rank ordering MUST match the `Severity` enum's derived `Ord`
109/// (Info < ClientSafe < Low < Medium < High < Critical). `severity_lte`
110/// expands to the set of every label at or below the threshold rank, so a
111/// drift between this table and the enum would suppress the wrong tiers - in
112/// particular, omitting `client-safe` made `severity_lte = "low"` silently
113/// skip client-safe findings that rank *below* low.
114pub(crate) fn severity_rank_from_str(s: &str) -> Result<usize, String> {
115    Severity::from_filter_label(s)
116        .map(|sev| sev.rank())
117        .ok_or_else(|| {
118            format!(
119                "unknown severity {:?}; expected {}",
120                s.trim().to_ascii_lowercase(),
121                Severity::FILTER_EXPECTED_LABELS
122            )
123        })
124}
125
126/// Check if character is a regular expression metacharacter.
127#[inline]
128fn is_regex_meta(c: char) -> bool {
129    matches!(
130        c,
131        '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
132    )
133}
134
135impl RuleSuppressor {
136    /// Build an empty suppressor that matches no findings.
137    pub fn empty() -> Self {
138        Self::default()
139    }
140
141    /// Load from a TOML path. Returns `Ok(empty())` when the file
142    /// is missing so callers do not need to gate on existence.
143    pub fn load(path: &Path) -> Result<Self, RuleSuppressorError> {
144        if !path.exists() {
145            return Ok(Self::empty());
146        }
147        let bytes = crate::state_file::read_capped(
148            path,
149            crate::state_file::RULE_CONFIG_FILE_BYTES,
150            "suppression rules",
151        )
152        .map_err(RuleSuppressorError::Io)?;
153        let raw = String::from_utf8(bytes).map_err(|e| {
154            RuleSuppressorError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
155        })?;
156        Self::parse(&raw)
157    }
158
159    /// Parse a TOML string.
160    pub fn parse(toml_text: &str) -> Result<Self, RuleSuppressorError> {
161        #[derive(Deserialize)]
162        struct Doc {
163            #[serde(default)]
164            suppress: Vec<SuppressEntry>,
165        }
166        let doc: Doc = toml::from_str(toml_text).map_err(RuleSuppressorError::Toml)?;
167        let mut rules = Vec::with_capacity(doc.suppress.len());
168        for (idx, entry) in doc.suppress.into_iter().enumerate() {
169            rules.push(
170                entry_to_formula(&entry).map_err(|e| RuleSuppressorError::Schema {
171                    rule_index: idx,
172                    message: e,
173                })?,
174            );
175        }
176        Ok(Self { rules })
177    }
178
179    /// True when at least one rule matches and the finding should be dropped.
180    #[must_use]
181    pub fn matches(&self, finding: &VerifiedFinding) -> bool {
182        self.matches_identity(
183            finding.detector_id.as_ref(),
184            finding.service.as_ref(),
185            finding.severity,
186            finding.location.file_path.as_deref(),
187            &finding.credential_hash,
188        )
189    }
190
191    /// Same predicate as [`Self::matches`] for a pre-verify [`RawMatch`].
192    #[must_use]
193    pub fn matches_raw_match(&self, matched: &RawMatch) -> bool {
194        self.matches_identity(
195            matched.detector_id.as_ref(),
196            matched.service.as_ref(),
197            matched.severity,
198            matched.location.file_path.as_deref(),
199            &matched.credential_hash,
200        )
201    }
202
203    /// Shared rule evaluation over identity fields.
204    #[must_use]
205    pub fn matches_identity(
206        &self,
207        detector_id: &str,
208        service: &str,
209        severity: crate::Severity,
210        file_path: Option<&str>,
211        credential_hash: &crate::CredentialHash,
212    ) -> bool {
213        if self.rules.is_empty() {
214            return false;
215        }
216        // Law 10: recall-safe (fail-OPEN for suppression), a finding with no
217        // file_path yields `""`, which a path-scoped suppression rule will not
218        // match, so the finding is LESS likely to be suppressed and MORE likely
219        // to be reported. A missing path can never silently drop a real finding.
220        let path = file_path.unwrap_or(""); // LAW10: missing/non-string field => empty/placeholder; recall-safe
221        let credential_hash_hex = crate::finding::hex_encode(credential_hash);
222        let ctx = FindingContext {
223            detector_id,
224            service,
225            severity,
226            path,
227            credential_hash: &credential_hash_hex,
228        };
229        self.rules.iter().any(|rule| evaluate_formula(rule, &ctx))
230    }
231}
232
233impl std::str::FromStr for RuleSuppressor {
234    type Err = RuleSuppressorError;
235
236    fn from_str(toml_text: &str) -> Result<Self, Self::Err> {
237        Self::parse(toml_text)
238    }
239}
240
241/// Single owner for the empty table rejection message.
242const NO_CONDITIONS_ERR: &str = "no conditions specified in [[suppress]] entry; \
243     use `[[suppress]]\\nliteral_true = true` if you really want \
244     to drop every finding";
245
246fn entry_to_formula(entry: &SuppressEntry) -> Result<RuleFormula, String> {
247    let mut conditions: Vec<RuleCondition> = Vec::new();
248
249    if entry.literal_true {
250        conditions.push(RuleCondition::LiteralTrue);
251    }
252
253    if let Some(d) = entry.detector.as_deref() {
254        conditions.push(eq_field("detector_id", d));
255    }
256    if let Some(s) = entry.service.as_deref() {
257        conditions.push(eq_field("service", s));
258    }
259    if let Some(s) = entry.severity.as_deref() {
260        let normalized = Severity::from_filter_label(s)
261            .map(|sev| sev.as_str())
262            .ok_or_else(|| {
263                format!(
264                    "unknown severity {:?}; expected {}",
265                    s.trim().to_ascii_lowercase(),
266                    Severity::FILTER_EXPECTED_LABELS
267                )
268            })?;
269        conditions.push(eq_field("severity", normalized));
270    }
271    if let Some(s) = entry.severity_lte.as_deref() {
272        let max = severity_rank_from_str(s)?;
273        let allowed: smallvec::SmallVec<[Arc<str>; 4]> = (0..=max)
274            .map(|r| Arc::from(Severity::label_for_rank(r)))
275            .collect();
276        conditions.push(RuleCondition::FieldInSet {
277            field: "severity".into(),
278            set: allowed,
279        });
280    }
281    if let Some(p) = entry.path_eq.as_deref() {
282        conditions.push(RuleCondition::FieldInSet {
283            field: "path".into(),
284            set: smallvec::smallvec![Arc::from(p)],
285        });
286    }
287    if let Some(p) = entry.path_contains.as_deref() {
288        conditions.push(RuleCondition::SubstringMatch {
289            haystack: "path".into(),
290            needle: Arc::from(p),
291        });
292    }
293    if let Some(p) = entry.path_starts_with.as_deref() {
294        conditions.push(RuleCondition::PrefixMatch {
295            value: "path".into(),
296            prefix: Arc::from(p),
297        });
298    }
299    if let Some(p) = entry.path_ends_with.as_deref() {
300        conditions.push(RuleCondition::SuffixMatch {
301            value: "path".into(),
302            suffix: Arc::from(p),
303        });
304    }
305    if let Some(p) = entry.path_regex.as_deref() {
306        // Optimize exact literal path rules to avoid regex allocation and evaluation.
307        if p.starts_with('^') && p.ends_with('$') && p.len() >= 2 {
308            let inner = &p[1..p.len() - 1];
309            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
310                conditions.push(RuleCondition::FieldInSet {
311                    field: "path".into(),
312                    set: smallvec::smallvec![Arc::from(inner)],
313                });
314            } else {
315                conditions.push(RuleCondition::RegexMatch {
316                    field: "path".into(),
317                    pattern: Arc::from(p),
318                });
319            }
320        } else if p.starts_with('^') && p.ends_with(".*") && p.len() >= 3 {
321            let inner = &p[1..p.len() - 2];
322            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
323                conditions.push(RuleCondition::PrefixMatch {
324                    value: "path".into(),
325                    prefix: Arc::from(inner),
326                });
327            } else {
328                conditions.push(RuleCondition::RegexMatch {
329                    field: "path".into(),
330                    pattern: Arc::from(p),
331                });
332            }
333        } else if p.starts_with('^') && p.len() > 1 {
334            let inner = &p[1..];
335            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
336                conditions.push(RuleCondition::PrefixMatch {
337                    value: "path".into(),
338                    prefix: Arc::from(inner),
339                });
340            } else {
341                conditions.push(RuleCondition::RegexMatch {
342                    field: "path".into(),
343                    pattern: Arc::from(p),
344                });
345            }
346        } else if p.ends_with('$') && p.len() > 1 {
347            let inner = &p[..p.len() - 1];
348            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
349                conditions.push(RuleCondition::SuffixMatch {
350                    value: "path".into(),
351                    suffix: Arc::from(inner),
352                });
353            } else {
354                conditions.push(RuleCondition::RegexMatch {
355                    field: "path".into(),
356                    pattern: Arc::from(p),
357                });
358            }
359        } else if p.starts_with(".*") && p.ends_with(".*") && p.len() >= 4 {
360            let inner = &p[2..p.len() - 2];
361            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
362                conditions.push(RuleCondition::SubstringMatch {
363                    haystack: "path".into(),
364                    needle: Arc::from(inner),
365                });
366            } else {
367                conditions.push(RuleCondition::RegexMatch {
368                    field: "path".into(),
369                    pattern: Arc::from(p),
370                });
371            }
372        } else if !p.is_empty() && !p.chars().any(is_regex_meta) {
373            conditions.push(RuleCondition::SubstringMatch {
374                haystack: "path".into(),
375                needle: Arc::from(p),
376            });
377        } else {
378            conditions.push(RuleCondition::RegexMatch {
379                field: "path".into(),
380                pattern: Arc::from(p),
381            });
382        }
383    }
384    if let Some(h) = entry.credential_hash.as_deref() {
385        conditions.push(eq_field("credential_hash", h));
386    }
387
388    if conditions.is_empty() {
389        return Err(NO_CONDITIONS_ERR.into());
390    }
391
392    let mut iter = conditions.into_iter();
393    let Some(first) = iter.next() else {
394        return Err(NO_CONDITIONS_ERR.into());
395    };
396    let mut formula = RuleFormula::condition(first);
397    for cond in iter {
398        formula = RuleFormula::and(formula, RuleFormula::condition(cond));
399    }
400    Ok(formula)
401}
402
403fn eq_field(field: &'static str, value: &str) -> RuleCondition {
404    RuleCondition::FieldInSet {
405        field: field.into(),
406        set: smallvec::smallvec![Arc::from(value)],
407    }
408}
409
410/// Errors from loading or parsing `.keyhogignore.toml`.
411#[derive(Debug)]
412pub enum RuleSuppressorError {
413    /// Filesystem read failed.
414    Io(std::io::Error),
415    /// TOML deserialization failed.
416    Toml(toml::de::Error),
417    /// One `[[suppress]]` entry failed schema validation.
418    Schema {
419        /// Zero-based index of the offending `[[suppress]]` entry.
420        rule_index: usize,
421        /// Human-readable message.
422        message: String,
423    },
424}
425
426impl std::fmt::Display for RuleSuppressorError {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        match self {
429            Self::Io(e) => write!(f, "reading .keyhogignore.toml: {e}"),
430            Self::Toml(e) => write!(f, "parsing .keyhogignore.toml: {e}"),
431            Self::Schema {
432                rule_index,
433                message,
434            } => write!(
435                f,
436                "schema error in [[suppress]] entry {rule_index}: {message}"
437            ),
438        }
439    }
440}
441
442impl std::error::Error for RuleSuppressorError {}