keyhog-core 0.5.84

keyhog-core: shared data model and detector specifications for the KeyHog secret scanner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! Declarative rule-based finding suppression.
//!
//! Loads a `.keyhogignore.toml` file alongside the legacy line-based
//! `.keyhogignore`. Each `[[suppress]]` table compiles into a vyre
//! `RuleFormula` evaluated per-finding via VYRE CPU evaluator
//! (`vyre_libs::rule::evaluate_formula`). Findings whose rules
//! evaluate to `true` are dropped from the report.
//!
//! Schema (one or more `[[suppress]]` tables):
//!
//! ```toml
//! # Drop every aws-access-key finding inside test directories.
//! [[suppress]]
//! detector = "aws-access-key"
//! path_contains = "/tests/"
//!
//! # Drop low-severity stripe findings on a specific file.
//! [[suppress]]
//! service = "stripe"
//! severity_lte = "low"
//! path_eq = "fixtures/stripe.yml"
//!
//! # Drop a single credential by hash, regardless of where it
//! # appears (mirrors the legacy `hash:` entry in .keyhogignore).
//! [[suppress]]
//! credential_hash = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
//! ```
//!
//! Within one `[[suppress]]` the named fields combine with AND.
//! Across multiple `[[suppress]]` tables they combine with OR (any
//! suppress matching the finding drops it). All conditions are
//! optional; a `[[suppress]]` table with no condition is rejected.
//! Use `literal_true = true` to request an explicit match-everything rule.

use std::path::Path;
use std::sync::Arc;

use serde::Deserialize;
use vyre_libs::rule::{evaluate_formula, RuleCondition, RuleEvaluationContext, RuleFormula};

use crate::{RawMatch, Severity, VerifiedFinding};

/// Parsed `.keyhogignore.toml` containing a list of `[[suppress]]` rules,
/// each compiled into a `RuleFormula`.
#[derive(Debug, Default)]
pub struct RuleSuppressor {
    rules: Vec<RuleFormula>,
}

/// One `[[suppress]]` table from the TOML.
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct SuppressEntry {
    /// Explicit match-everything predicate. Kept noisy on purpose: an empty
    /// table is rejected so a missing or typoed condition cannot suppress every
    /// finding accidentally.
    #[serde(default)]
    literal_true: bool,
    /// Detector ID exact match (e.g. `"aws-access-key"`).
    detector: Option<String>,
    /// Service exact match (e.g. `"stripe"`).
    service: Option<String>,
    /// Severity equals (case-insensitive: info, client-safe, low, medium, high, critical).
    severity: Option<String>,
    /// Severity <= (finding severity must be at most this rank).
    severity_lte: Option<String>,
    /// File path exact match.
    path_eq: Option<String>,
    /// File path contains substring.
    path_contains: Option<String>,
    /// File path starts with prefix.
    path_starts_with: Option<String>,
    /// File path ends with suffix.
    path_ends_with: Option<String>,
    /// File path matches regex.
    path_regex: Option<String>,
    /// Credential SHA-256 hash exact match.
    credential_hash: Option<String>,
}

/// File context around which a `RuleFormula` is evaluated. One per finding.
struct FindingContext<'a> {
    detector_id: &'a str,
    service: &'a str,
    severity: Severity,
    path: &'a str,
    credential_hash: &'a str,
}

impl<'a> RuleEvaluationContext for FindingContext<'a> {
    fn field_value(&self, name: &str) -> Option<&str> {
        match name {
            "detector_id" => Some(self.detector_id),
            "service" => Some(self.service),
            "path" => Some(self.path),
            "credential_hash" => Some(self.credential_hash),
            // `Severity::as_str` is the single source of truth for the
            // kebab-case wire form; rehand-rolling the match here drifted
            // from it once already (the `client-safe` tier).
            "severity" => Some(self.severity.as_str()),
            _ => None,
        }
    }
}

/// Return severity rank using canonical Severity table.
///
/// Rank ordering MUST match the `Severity` enum's derived `Ord`
/// (Info < ClientSafe < Low < Medium < High < Critical). `severity_lte`
/// expands to the set of every label at or below the threshold rank, so a
/// drift between this table and the enum would suppress the wrong tiers - in
/// particular, omitting `client-safe` made `severity_lte = "low"` silently
/// skip client-safe findings that rank *below* low.
pub(crate) fn severity_rank_from_str(s: &str) -> Result<usize, String> {
    Severity::from_filter_label(s)
        .map(|sev| sev.rank())
        .ok_or_else(|| {
            format!(
                "unknown severity {:?}; expected {}",
                s.trim().to_ascii_lowercase(),
                Severity::FILTER_EXPECTED_LABELS
            )
        })
}

/// Check if character is a regular expression metacharacter.
#[inline]
fn is_regex_meta(c: char) -> bool {
    matches!(
        c,
        '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
    )
}

impl RuleSuppressor {
    /// Build an empty suppressor that matches no findings.
    pub fn empty() -> Self {
        Self::default()
    }

    /// Load from a TOML path. Returns `Ok(empty())` when the file
    /// is missing so callers do not need to gate on existence.
    pub fn load(path: &Path) -> Result<Self, RuleSuppressorError> {
        if !path.exists() {
            return Ok(Self::empty());
        }
        let bytes = crate::state_file::read_capped(
            path,
            crate::state_file::RULE_CONFIG_FILE_BYTES,
            "suppression rules",
        )
        .map_err(RuleSuppressorError::Io)?;
        let raw = String::from_utf8(bytes).map_err(|e| {
            RuleSuppressorError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
        })?;
        Self::parse(&raw)
    }

    /// Parse a TOML string.
    pub fn parse(toml_text: &str) -> Result<Self, RuleSuppressorError> {
        #[derive(Deserialize)]
        struct Doc {
            #[serde(default)]
            suppress: Vec<SuppressEntry>,
        }
        let doc: Doc = toml::from_str(toml_text).map_err(RuleSuppressorError::Toml)?;
        let mut rules = Vec::with_capacity(doc.suppress.len());
        for (idx, entry) in doc.suppress.into_iter().enumerate() {
            rules.push(
                entry_to_formula(&entry).map_err(|e| RuleSuppressorError::Schema {
                    rule_index: idx,
                    message: e,
                })?,
            );
        }
        Ok(Self { rules })
    }

    /// True when at least one rule matches and the finding should be dropped.
    #[must_use]
    pub fn matches(&self, finding: &VerifiedFinding) -> bool {
        self.matches_identity(
            finding.detector_id.as_ref(),
            finding.service.as_ref(),
            finding.severity,
            finding.location.file_path.as_deref(),
            &finding.credential_hash,
        )
    }

    /// Same predicate as [`Self::matches`] for a pre-verify [`RawMatch`].
    #[must_use]
    pub fn matches_raw_match(&self, matched: &RawMatch) -> bool {
        self.matches_identity(
            matched.detector_id.as_ref(),
            matched.service.as_ref(),
            matched.severity,
            matched.location.file_path.as_deref(),
            &matched.credential_hash,
        )
    }

    /// Shared rule evaluation over identity fields.
    #[must_use]
    pub fn matches_identity(
        &self,
        detector_id: &str,
        service: &str,
        severity: crate::Severity,
        file_path: Option<&str>,
        credential_hash: &crate::CredentialHash,
    ) -> bool {
        if self.rules.is_empty() {
            return false;
        }
        // Law 10: recall-safe (fail-OPEN for suppression), a finding with no
        // file_path yields `""`, which a path-scoped suppression rule will not
        // match, so the finding is LESS likely to be suppressed and MORE likely
        // to be reported. A missing path can never silently drop a real finding.
        let path = file_path.unwrap_or(""); // LAW10: missing/non-string field => empty/placeholder; recall-safe
        let credential_hash_hex = crate::finding::hex_encode(credential_hash);
        let ctx = FindingContext {
            detector_id,
            service,
            severity,
            path,
            credential_hash: &credential_hash_hex,
        };
        self.rules.iter().any(|rule| evaluate_formula(rule, &ctx))
    }
}

impl std::str::FromStr for RuleSuppressor {
    type Err = RuleSuppressorError;

    fn from_str(toml_text: &str) -> Result<Self, Self::Err> {
        Self::parse(toml_text)
    }
}

/// Single owner for the empty table rejection message.
const NO_CONDITIONS_ERR: &str = "no conditions specified in [[suppress]] entry; \
     use `[[suppress]]\\nliteral_true = true` if you really want \
     to drop every finding";

fn entry_to_formula(entry: &SuppressEntry) -> Result<RuleFormula, String> {
    let mut conditions: Vec<RuleCondition> = Vec::new();

    if entry.literal_true {
        conditions.push(RuleCondition::LiteralTrue);
    }

    if let Some(d) = entry.detector.as_deref() {
        conditions.push(eq_field("detector_id", d));
    }
    if let Some(s) = entry.service.as_deref() {
        conditions.push(eq_field("service", s));
    }
    if let Some(s) = entry.severity.as_deref() {
        let normalized = Severity::from_filter_label(s)
            .map(|sev| sev.as_str())
            .ok_or_else(|| {
                format!(
                    "unknown severity {:?}; expected {}",
                    s.trim().to_ascii_lowercase(),
                    Severity::FILTER_EXPECTED_LABELS
                )
            })?;
        conditions.push(eq_field("severity", normalized));
    }
    if let Some(s) = entry.severity_lte.as_deref() {
        let max = severity_rank_from_str(s)?;
        let allowed: smallvec::SmallVec<[Arc<str>; 4]> = (0..=max)
            .map(|r| Arc::from(Severity::label_for_rank(r)))
            .collect();
        conditions.push(RuleCondition::FieldInSet {
            field: "severity".into(),
            set: allowed,
        });
    }
    if let Some(p) = entry.path_eq.as_deref() {
        conditions.push(RuleCondition::FieldInSet {
            field: "path".into(),
            set: smallvec::smallvec![Arc::from(p)],
        });
    }
    if let Some(p) = entry.path_contains.as_deref() {
        conditions.push(RuleCondition::SubstringMatch {
            haystack: "path".into(),
            needle: Arc::from(p),
        });
    }
    if let Some(p) = entry.path_starts_with.as_deref() {
        conditions.push(RuleCondition::PrefixMatch {
            value: "path".into(),
            prefix: Arc::from(p),
        });
    }
    if let Some(p) = entry.path_ends_with.as_deref() {
        conditions.push(RuleCondition::SuffixMatch {
            value: "path".into(),
            suffix: Arc::from(p),
        });
    }
    if let Some(p) = entry.path_regex.as_deref() {
        // Optimize exact literal path rules to avoid regex allocation and evaluation.
        if p.starts_with('^') && p.ends_with('$') && p.len() >= 2 {
            let inner = &p[1..p.len() - 1];
            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
                conditions.push(RuleCondition::FieldInSet {
                    field: "path".into(),
                    set: smallvec::smallvec![Arc::from(inner)],
                });
            } else {
                conditions.push(RuleCondition::RegexMatch {
                    field: "path".into(),
                    pattern: Arc::from(p),
                });
            }
        } else if p.starts_with('^') && p.ends_with(".*") && p.len() >= 3 {
            let inner = &p[1..p.len() - 2];
            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
                conditions.push(RuleCondition::PrefixMatch {
                    value: "path".into(),
                    prefix: Arc::from(inner),
                });
            } else {
                conditions.push(RuleCondition::RegexMatch {
                    field: "path".into(),
                    pattern: Arc::from(p),
                });
            }
        } else if p.starts_with('^') && p.len() > 1 {
            let inner = &p[1..];
            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
                conditions.push(RuleCondition::PrefixMatch {
                    value: "path".into(),
                    prefix: Arc::from(inner),
                });
            } else {
                conditions.push(RuleCondition::RegexMatch {
                    field: "path".into(),
                    pattern: Arc::from(p),
                });
            }
        } else if p.ends_with('$') && p.len() > 1 {
            let inner = &p[..p.len() - 1];
            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
                conditions.push(RuleCondition::SuffixMatch {
                    value: "path".into(),
                    suffix: Arc::from(inner),
                });
            } else {
                conditions.push(RuleCondition::RegexMatch {
                    field: "path".into(),
                    pattern: Arc::from(p),
                });
            }
        } else if p.starts_with(".*") && p.ends_with(".*") && p.len() >= 4 {
            let inner = &p[2..p.len() - 2];
            if !inner.is_empty() && !inner.chars().any(is_regex_meta) {
                conditions.push(RuleCondition::SubstringMatch {
                    haystack: "path".into(),
                    needle: Arc::from(inner),
                });
            } else {
                conditions.push(RuleCondition::RegexMatch {
                    field: "path".into(),
                    pattern: Arc::from(p),
                });
            }
        } else if !p.is_empty() && !p.chars().any(is_regex_meta) {
            conditions.push(RuleCondition::SubstringMatch {
                haystack: "path".into(),
                needle: Arc::from(p),
            });
        } else {
            conditions.push(RuleCondition::RegexMatch {
                field: "path".into(),
                pattern: Arc::from(p),
            });
        }
    }
    if let Some(h) = entry.credential_hash.as_deref() {
        conditions.push(eq_field("credential_hash", h));
    }

    if conditions.is_empty() {
        return Err(NO_CONDITIONS_ERR.into());
    }

    let mut iter = conditions.into_iter();
    let Some(first) = iter.next() else {
        return Err(NO_CONDITIONS_ERR.into());
    };
    let mut formula = RuleFormula::condition(first);
    for cond in iter {
        formula = RuleFormula::and(formula, RuleFormula::condition(cond));
    }
    Ok(formula)
}

fn eq_field(field: &'static str, value: &str) -> RuleCondition {
    RuleCondition::FieldInSet {
        field: field.into(),
        set: smallvec::smallvec![Arc::from(value)],
    }
}

/// Errors from loading or parsing `.keyhogignore.toml`.
#[derive(Debug)]
pub enum RuleSuppressorError {
    /// Filesystem read failed.
    Io(std::io::Error),
    /// TOML deserialization failed.
    Toml(toml::de::Error),
    /// One `[[suppress]]` entry failed schema validation.
    Schema {
        /// Zero-based index of the offending `[[suppress]]` entry.
        rule_index: usize,
        /// Human-readable message.
        message: String,
    },
}

impl std::fmt::Display for RuleSuppressorError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(e) => write!(f, "reading .keyhogignore.toml: {e}"),
            Self::Toml(e) => write!(f, "parsing .keyhogignore.toml: {e}"),
            Self::Schema {
                rule_index,
                message,
            } => write!(
                f,
                "schema error in [[suppress]] entry {rule_index}: {message}"
            ),
        }
    }
}

impl std::error::Error for RuleSuppressorError {}