1use 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#[derive(Debug, Default)]
46pub struct RuleSuppressor {
47 rules: Vec<RuleFormula>,
48}
49
50#[derive(Debug, Default, Deserialize)]
52#[serde(deny_unknown_fields)]
53struct SuppressEntry {
54 #[serde(default)]
58 literal_true: bool,
59 detector: Option<String>,
61 service: Option<String>,
63 severity: Option<String>,
65 severity_lte: Option<String>,
67 path_eq: Option<String>,
69 path_contains: Option<String>,
71 path_starts_with: Option<String>,
73 path_ends_with: Option<String>,
75 path_regex: Option<String>,
77 credential_hash: Option<String>,
79}
80
81struct 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" => Some(self.severity.as_str()),
101 _ => None,
102 }
103 }
104}
105
106pub(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#[inline]
128fn is_regex_meta(c: char) -> bool {
129 matches!(
130 c,
131 '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
132 )
133}
134
135impl RuleSuppressor {
136 pub fn empty() -> Self {
138 Self::default()
139 }
140
141 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 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 #[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 #[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 #[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 let path = file_path.unwrap_or(""); 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
241const 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 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#[derive(Debug)]
412pub enum RuleSuppressorError {
413 Io(std::io::Error),
415 Toml(toml::de::Error),
417 Schema {
419 rule_index: usize,
421 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 {}