Skip to main content

keyhog_core/
allowlist.rs

1//! Allowlist support: `.keyhogignore` file parsing for suppressing known false
2//! positives by path glob, detector ID, or credential hash.
3
4/// Allowlist: known false positives and ignored patterns.
5///
6/// Users can create a `.keyhogignore` file to suppress known FPs.
7/// Format (one per line):
8///   - `hash:<sha256>` - ignore a specific credential by hash
9///   - `detector:<id>` - ignore all findings from a detector
10///   - `path:<glob>` - ignore files matching a glob pattern
11///   - `# comment` - comments
12///   - blank lines are skipped
13use std::collections::HashSet;
14use std::ops::{Deref, DerefMut};
15use std::path::Path;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use crate::merkle_spec_hash::hex_to_array;
19use crate::{CredentialHash, VerifiedFinding};
20
21// Submodules live in `allowlist/` (native resolution), matching the
22// `foo.rs` + `foo/` layout used across the workspace.
23mod metadata;
24use metadata::*;
25
26// Path-glob matching (normalization, segment automaton, first-segment bucketed
27// index) is its own subsystem; the `Allowlist` holds a precompiled index and
28// delegates every path decision to it.
29mod glob;
30use glob::{normalize_path, pattern_matches_path, PathGlobIndex};
31
32static NEXT_OBSERVED_PATHS_ID: AtomicU64 = AtomicU64::new(1);
33
34/// A Vec-compatible path list that records direct mutable access.
35///
36/// `Allowlist::ignored_paths` remains a public collection for compatibility,
37/// but a plain public `Vec` cannot tell its compiled matcher that an indexed
38/// element was replaced. `DerefMut` increments a generation before exposing
39/// the underlying Vec, so pushes, clears, assignments, and other mutable
40/// operations invalidate the matcher in O(1) on the next lookup.
41#[derive(Debug)]
42pub struct ObservedPaths {
43    values: Vec<String>,
44    instance_id: u64,
45    mutation_epoch: AtomicU64,
46}
47
48impl ObservedPaths {
49    fn new(values: Vec<String>) -> Self {
50        Self {
51            values,
52            instance_id: NEXT_OBSERVED_PATHS_ID.fetch_add(1, Ordering::Relaxed),
53            mutation_epoch: AtomicU64::new(0),
54        }
55    }
56
57    pub(crate) fn instance_id(&self) -> u64 {
58        self.instance_id
59    }
60
61    pub(crate) fn mutation_epoch(&self) -> u64 {
62        self.mutation_epoch.load(Ordering::Relaxed)
63    }
64}
65
66impl Default for ObservedPaths {
67    fn default() -> Self {
68        Self::new(Vec::new())
69    }
70}
71
72impl Clone for ObservedPaths {
73    fn clone(&self) -> Self {
74        Self::new(self.values.clone())
75    }
76}
77
78impl Deref for ObservedPaths {
79    type Target = Vec<String>;
80
81    fn deref(&self) -> &Self::Target {
82        &self.values
83    }
84}
85
86impl DerefMut for ObservedPaths {
87    fn deref_mut(&mut self) -> &mut Self::Target {
88        self.mutation_epoch.fetch_add(1, Ordering::Relaxed);
89        &mut self.values
90    }
91}
92
93impl AsRef<[String]> for ObservedPaths {
94    fn as_ref(&self) -> &[String] {
95        &self.values
96    }
97}
98
99impl serde::Serialize for ObservedPaths {
100    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
101        self.values.serialize(serializer)
102    }
103}
104
105impl From<Vec<String>> for ObservedPaths {
106    fn from(values: Vec<String>) -> Self {
107        Self::new(values)
108    }
109}
110
111impl FromIterator<String> for ObservedPaths {
112    fn from_iter<T: IntoIterator<Item = String>>(iter: T) -> Self {
113        Self::new(iter.into_iter().collect())
114    }
115}
116
117impl IntoIterator for ObservedPaths {
118    type Item = String;
119    type IntoIter = std::vec::IntoIter<String>;
120
121    fn into_iter(self) -> Self::IntoIter {
122        self.values.into_iter()
123    }
124}
125
126impl<'a> IntoIterator for &'a ObservedPaths {
127    type Item = &'a String;
128    type IntoIter = std::slice::Iter<'a, String>;
129
130    fn into_iter(self) -> Self::IntoIter {
131        self.values.iter()
132    }
133}
134
135impl PartialEq for ObservedPaths {
136    fn eq(&self, other: &Self) -> bool {
137        self.values == other.values
138    }
139}
140
141impl<T: AsRef<str>> PartialEq<Vec<T>> for ObservedPaths {
142    fn eq(&self, other: &Vec<T>) -> bool {
143        self.values.len() == other.len()
144            && self
145                .values
146                .iter()
147                .zip(other)
148                .all(|(left, right)| left == right.as_ref())
149    }
150}
151
152/// User-defined suppressions loaded from `.keyhogignore`: credential hashes, detector IDs, and path globs.
153///
154/// # Examples
155///
156/// ```rust
157/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
158/// use keyhog_core::Allowlist;
159///
160/// let path = std::env::temp_dir().join(format!(
161///     "keyhog_allowlist_struct_{}.keyhogignore",
162///     std::process::id()
163/// ));
164/// std::fs::write(&path, "detector:demo-token\npath:**/*.md\n")?;
165/// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
166/// std::fs::remove_file(&path)?;
167/// assert!(allowlist.ignored_detectors.contains("demo-token"));
168/// # Ok(()) }
169/// ```
170/// Kind of allowlist rule parsed from `.keyhogignore`.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum AllowlistRuleKind {
173    /// Credential hash match.
174    Hash(CredentialHash),
175    /// Detector ID ignore match.
176    Detector(String),
177    /// File path ignore match.
178    Path(String),
179}
180
181/// Parsed allowlist rule with execution match counter.
182#[derive(Debug, Clone)]
183pub struct AllowlistRule {
184    /// 1-based source line number.
185    pub line_number: usize,
186    /// Raw rule entry text.
187    pub entry: String,
188    /// Parsed rule classification.
189    pub kind: AllowlistRuleKind,
190    /// Atomic match counter incremented upon rule evaluation match.
191    pub matches: std::sync::Arc<std::sync::atomic::AtomicUsize>,
192}
193
194/// Unused allowlist entry report descriptor.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct UnusedAllowlistEntry {
197    /// 1-based source line number.
198    pub line_number: usize,
199    /// Raw rule entry text.
200    pub entry: String,
201    /// Number of times matched during scan (0 for unused).
202    pub match_count: usize,
203}
204
205/// Parsed `.keyhogignore` rules with compiled lookup structures and attribution.
206#[derive(Debug, serde::Serialize)]
207pub struct Allowlist {
208    /// SHA-256 hashes of credentials to ignore.
209    pub credential_hashes: HashSet<CredentialHash>,
210    /// Detector IDs to ignore entirely.
211    pub ignored_detectors: HashSet<String>,
212    /// Glob patterns for paths to ignore (raw, as authored). Kept as the public
213    /// Vec-compatible contract + serialized form; the matcher consumes the
214    /// precompiled [`PathGlobIndex`] built from these in [`Allowlist::parse`].
215    pub ignored_paths: ObservedPaths,
216    /// Precompiled, first-segment-bucketed form of `ignored_paths`. Built once
217    /// in `parse`/`empty` so per-finding path checks neither re-normalize +
218    /// re-split each pattern nor sweep every rule. Skipped by `serde` (it is a
219    /// pure function of `ignored_paths`; rebuilt by the constructors and clone
220    /// implementation) so the serialized shape is unchanged.
221    #[serde(skip)]
222    path_index: PathGlobIndex,
223    /// Expired policy lines found while parsing. They are never active
224    /// suppressions; `load` turns them into a user-visible policy error.
225    #[serde(skip)]
226    expired_entries: Vec<ExpiredAllowlistEntry>,
227    /// Governance-policy violations found while parsing. They are never active
228    /// suppressions; `load_with_policy` turns them into a user-visible policy
229    /// error.
230    #[serde(skip)]
231    policy_violations: Vec<AllowlistPolicyViolation>,
232    /// Parsed suppression rules with match attribution tracking.
233    #[serde(skip)]
234    pub rules: Vec<AllowlistRule>,
235}
236#[derive(Debug, Clone)]
237struct ExpiredAllowlistEntry {
238    line_number: usize,
239    entry: String,
240    expires: String,
241}
242
243#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
244struct AllowlistMetadataPolicy {
245    require_reason: bool,
246    require_approved_by: bool,
247    max_expires_days: Option<u64>,
248}
249
250impl AllowlistMetadataPolicy {
251    fn is_enforced(self) -> bool {
252        self.require_reason || self.require_approved_by || self.max_expires_days.is_some()
253    }
254}
255
256#[derive(Debug, Clone)]
257struct AllowlistPolicyViolation {
258    line_number: usize,
259    entry: String,
260    field: &'static str,
261    detail: String,
262}
263
264impl Allowlist {
265    /// Create an empty allowlist with no suppressed hashes, detectors, or paths.
266    ///
267    /// # Examples
268    ///
269    /// ```rust
270    /// use keyhog_core::Allowlist;
271    ///
272    /// let allowlist = Allowlist::default();
273    /// assert!(allowlist.ignored_paths.is_empty());
274    /// ```
275    pub(crate) fn empty() -> Self {
276        let ignored_paths = ObservedPaths::default();
277        Self {
278            credential_hashes: HashSet::new(),
279            ignored_detectors: HashSet::new(),
280            path_index: PathGlobIndex::build(&ignored_paths),
281            ignored_paths,
282            expired_entries: Vec::new(),
283            policy_violations: Vec::new(),
284            rules: Vec::new(),
285        }
286    }
287
288    /// Load from a `.keyhogignore` file and enforce metadata governance.
289    pub fn load_with_metadata_policy(
290        path: &Path,
291        require_reason: bool,
292        require_approved_by: bool,
293        max_expires_days: Option<u64>,
294    ) -> Result<Self, std::io::Error> {
295        Self::load_with_policy(
296            path,
297            AllowlistMetadataPolicy {
298                require_reason,
299                require_approved_by,
300                max_expires_days,
301            },
302        )
303    }
304
305    fn load_with_policy(
306        path: &Path,
307        policy: AllowlistMetadataPolicy,
308    ) -> Result<Self, std::io::Error> {
309        let bytes = crate::state_file::read_capped(
310            path,
311            crate::state_file::RULE_CONFIG_FILE_BYTES,
312            "allowlist",
313        )?;
314        let contents = String::from_utf8(bytes)
315            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
316        let allowlist = Self::parse_with_policy(&contents, policy);
317        if !allowlist.expired_entries.is_empty() {
318            return Err(allowlist.expired_entries_error(path));
319        }
320        if !allowlist.policy_violations.is_empty() {
321            return Err(allowlist.policy_violations_error(path));
322        }
323        Ok(allowlist)
324    }
325
326    /// Parse allowlist from string content.
327    ///
328    /// # Examples
329    ///
330    /// ```rust
331    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
332    /// use keyhog_core::Allowlist;
333    ///
334    /// let path = std::env::temp_dir().join(format!(
335    ///     "keyhog_allowlist_parse_{}.keyhogignore",
336    ///     std::process::id()
337    /// ));
338    /// std::fs::write(&path, "path:**/.env\ndetector:demo-token\n")?;
339    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
340    /// std::fs::remove_file(&path)?;
341    /// assert!(allowlist.is_path_ignored("app/.env"));
342    /// # Ok(()) }
343    /// ```
344    pub fn parse(content: &str) -> Self {
345        Self::parse_with_policy(content, AllowlistMetadataPolicy::default())
346    }
347
348    fn parse_with_policy(content: &str, policy: AllowlistMetadataPolicy) -> Self {
349        let mut al = Self::empty();
350        let today_days = match try_today_days_since_epoch() {
351            Ok(days) => days,
352            Err(detail) => {
353                al.push_policy_violation(1, "<allowlist>", "system_clock", detail);
354                return al;
355            }
356        };
357        let today = yyyy_mm_dd_from_days(today_days);
358        for (line_number, raw_line) in content.lines().enumerate() {
359            let raw_line = raw_line.trim();
360            if raw_line.is_empty() || raw_line.starts_with('#') {
361                continue;
362            }
363            // Optional inline metadata: `entry; reason="..."; expires=YYYY-MM-DD; approved_by="..."`
364            // Each `;`-separated token after the first is a key=value pair.
365            let mut parts = raw_line.splitn(2, ';');
366            let entry = parts.next().unwrap_or("").trim(); // LAW10: missing/non-string field => empty/placeholder; recall-safe
367            let metadata = parts.next().unwrap_or(""); // LAW10: missing/non-string field => empty/placeholder; recall-safe
368            let parsed_meta = parse_inline_metadata(metadata);
369            for key in &parsed_meta.unknown_keys {
370                al.push_policy_violation(
371                    line_number + 1,
372                    entry,
373                    "metadata",
374                    format!("unknown key `{key}`; supported keys are reason, expires, approved_by"),
375                );
376            }
377            for detail in &parsed_meta.malformed_tokens {
378                al.push_policy_violation(line_number + 1, entry, "metadata", detail.clone());
379            }
380            if entry.is_empty() {
381                al.push_policy_violation(
382                    line_number + 1,
383                    entry,
384                    "entry",
385                    "empty allowlist entry before metadata; add `detector:`, `path:`, `hash:`, or a glob before `;`".to_string(),
386                );
387                continue;
388            }
389
390            // Drop entries whose `expires` is past - keeps `.keyhogignore`
391            // self-cleaning for short-lived approvals (Tier-B #18 governance).
392            if let Some(exp) = parsed_meta.expires.as_deref() {
393                match parse_yyyy_mm_dd_days(exp) {
394                    Some(exp_days) if exp_days < today_days => {
395                        al.expired_entries.push(ExpiredAllowlistEntry {
396                            line_number: line_number + 1,
397                            entry: entry.to_string(),
398                            expires: exp.to_string(),
399                        });
400                        tracing::warn!(
401                            "allowlist entry expired on {} (today is {}): '{}'",
402                            exp,
403                            today,
404                            entry
405                        );
406                        continue;
407                    }
408                    Some(_) => {}
409                    None => {
410                        al.push_policy_violation(
411                            line_number + 1,
412                            entry,
413                            "expires",
414                            "must use YYYY-MM-DD".to_string(),
415                        );
416                        continue;
417                    }
418                }
419            }
420
421            if let Some(hash) = entry.strip_prefix("hash:") {
422                let trimmed = hash.trim();
423                if let Some(valid_hash) = parse_sha256_hex(trimmed) {
424                    if !al.metadata_policy_allows(
425                        line_number + 1,
426                        entry,
427                        &parsed_meta,
428                        policy,
429                        today_days,
430                    ) {
431                        continue;
432                    }
433                    al.credential_hashes.insert(valid_hash);
434                    al.rules.push(AllowlistRule {
435                        line_number: line_number + 1,
436                        entry: entry.to_string(),
437                        kind: AllowlistRuleKind::Hash(valid_hash),
438                        matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
439                    });
440                    log_metadata_audit("hash", trimmed, &parsed_meta);
441                } else {
442                    al.push_invalid_entry_violation(
443                        line_number + 1,
444                        entry,
445                        "hash",
446                        "must be a 64-character SHA-256 hex digest",
447                    );
448                    tracing::warn!(
449                        "invalid hash allowlist entry at line {}: '{}'",
450                        line_number + 1,
451                        trimmed
452                    );
453                }
454            } else if let Some(detector) = entry.strip_prefix("detector:") {
455                let detector = detector.trim();
456                if detector.is_empty() {
457                    al.push_invalid_entry_violation(
458                        line_number + 1,
459                        entry,
460                        "detector",
461                        "detector id must not be empty",
462                    );
463                    tracing::warn!(
464                        "invalid detector allowlist entry at line {}: detector id is empty",
465                        line_number + 1
466                    );
467                } else {
468                    if !al.metadata_policy_allows(
469                        line_number + 1,
470                        entry,
471                        &parsed_meta,
472                        policy,
473                        today_days,
474                    ) {
475                        continue;
476                    }
477                    al.ignored_detectors.insert(detector.to_string());
478                    al.rules.push(AllowlistRule {
479                        line_number: line_number + 1,
480                        entry: entry.to_string(),
481                        kind: AllowlistRuleKind::Detector(detector.to_string()),
482                        matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
483                    });
484                    log_metadata_audit("detector", detector, &parsed_meta);
485                }
486            } else if let Some(path) = entry.strip_prefix("path:") {
487                let path = path.trim();
488                if path.is_empty() {
489                    al.push_invalid_entry_violation(
490                        line_number + 1,
491                        entry,
492                        "path",
493                        "path glob must not be empty",
494                    );
495                    tracing::warn!(
496                        "invalid path allowlist entry at line {}: glob is empty",
497                        line_number + 1
498                    );
499                } else {
500                    if !al.metadata_policy_allows(
501                        line_number + 1,
502                        entry,
503                        &parsed_meta,
504                        policy,
505                        today_days,
506                    ) {
507                        continue;
508                    }
509                    al.ignored_paths.push(path.to_string());
510                    al.rules.push(AllowlistRule {
511                        line_number: line_number + 1,
512                        entry: entry.to_string(),
513                        kind: AllowlistRuleKind::Path(path.to_string()),
514                        matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
515                    });
516                    log_metadata_audit("path", path, &parsed_meta);
517                }
518            } else if let Some(bytes) = parse_sha256_hex(entry) {
519                // Bare 64-char hex hash. Lets the obvious
520                // `keyhog scan ... --format jsonl | jq -r '.credential_hash'
521                // >> .keyhogignore` workflow Just Work without users
522                // learning the `hash:` prefix.
523                if !al.metadata_policy_allows(
524                    line_number + 1,
525                    entry,
526                    &parsed_meta,
527                    policy,
528                    today_days,
529                ) {
530                    continue;
531                }
532                al.credential_hashes.insert(bytes);
533                al.rules.push(AllowlistRule {
534                    line_number: line_number + 1,
535                    entry: entry.to_string(),
536                    kind: AllowlistRuleKind::Hash(bytes),
537                    matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
538                });
539                log_metadata_audit("hash", entry, &parsed_meta);
540            } else if let Some((field, detail)) = invalid_bare_entry(entry) {
541                al.push_invalid_entry_violation(line_number + 1, entry, field, detail);
542                tracing::warn!(
543                    "invalid allowlist entry at line {}: '{}'",
544                    line_number + 1,
545                    entry
546                );
547            } else {
548                // Bare path glob (gitignore-style). Anything that didn't
549                // match an explicit `hash:` / `detector:` / `path:` prefix
550                // and isn't a bare hash is interpreted as a path glob,
551                // matching `.gitignore` UX (`*.log`, `node_modules/`,
552                // `vendor/**/*.json`). kimi-1 dogfood #129 - the prior
553                // behavior emitted a warning and silently dropped the
554                // line, which is the worst of both worlds: every
555                // `.gitignore` users copied over was dead.
556                if !al.metadata_policy_allows(
557                    line_number + 1,
558                    entry,
559                    &parsed_meta,
560                    policy,
561                    today_days,
562                ) {
563                    continue;
564                }
565                al.ignored_paths.push(entry.to_string());
566                al.rules.push(AllowlistRule {
567                    line_number: line_number + 1,
568                    entry: entry.to_string(),
569                    kind: AllowlistRuleKind::Path(entry.to_string()),
570                    matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
571                });
572                log_metadata_audit("path", entry, &parsed_meta);
573            }
574        }
575        // Precompile the path globs ONCE: segments + oversize verdict + the
576        // first-segment bucket index, so per-finding suppression neither
577        // re-normalizes each pattern nor sweeps every rule.
578        al.path_index = PathGlobIndex::build(&al.ignored_paths);
579        al
580    }
581
582    fn metadata_policy_allows(
583        &mut self,
584        line_number: usize,
585        entry: &str,
586        metadata: &InlineMetadata,
587        policy: AllowlistMetadataPolicy,
588        today_days: i64,
589    ) -> bool {
590        if !policy.is_enforced() {
591            return true;
592        }
593        let mut allowed = true;
594        if policy.require_reason && metadata.reason.as_deref().is_none_or(str::is_empty) {
595            self.push_policy_violation(
596                line_number,
597                entry,
598                "reason",
599                "required by [allowlist].require_reason".to_string(),
600            );
601            allowed = false;
602        }
603        if policy.require_approved_by && metadata.approved_by.as_deref().is_none_or(str::is_empty) {
604            self.push_policy_violation(
605                line_number,
606                entry,
607                "approved_by",
608                "required by [allowlist].require_approved_by".to_string(),
609            );
610            allowed = false;
611        }
612        if let Some(max_expires_days) = policy.max_expires_days {
613            match metadata.expires.as_deref() {
614                Some(expires) if !expires.is_empty() => match parse_yyyy_mm_dd_days(expires) {
615                    Some(expires_days) => {
616                        let max_days = match i64::try_from(max_expires_days) {
617                            Ok(days) => days,
618                            Err(error) => {
619                                self.push_policy_violation(
620                                    line_number,
621                                    entry,
622                                    "expires",
623                                    format!(
624                                        "max_expires_days={max_expires_days} is too large to enforce ({error})"
625                                    ),
626                                );
627                                allowed = false;
628                                return allowed;
629                            }
630                        };
631                        if expires_days.saturating_sub(today_days) > max_days {
632                            self.push_policy_violation(
633                                line_number,
634                                entry,
635                                "expires",
636                                format!(
637                                    "expires={expires} is more than {max_expires_days} days out"
638                                ),
639                            );
640                            allowed = false;
641                        }
642                    }
643                    None => {
644                        self.push_policy_violation(
645                            line_number,
646                            entry,
647                            "expires",
648                            "must use YYYY-MM-DD when [allowlist].max_expires_days is set"
649                                .to_string(),
650                        );
651                        allowed = false;
652                    }
653                },
654                _ => {
655                    self.push_policy_violation(
656                        line_number,
657                        entry,
658                        "expires",
659                        "required by [allowlist].max_expires_days".to_string(),
660                    );
661                    allowed = false;
662                }
663            }
664        }
665        allowed
666    }
667
668    fn push_invalid_entry_violation(
669        &mut self,
670        line_number: usize,
671        entry: &str,
672        field: &'static str,
673        detail: &'static str,
674    ) {
675        self.push_policy_violation(line_number, entry, field, detail.to_string());
676    }
677
678    fn push_policy_violation(
679        &mut self,
680        line_number: usize,
681        entry: &str,
682        field: &'static str,
683        detail: String,
684    ) {
685        self.policy_violations.push(AllowlistPolicyViolation {
686            line_number,
687            entry: entry.to_string(),
688            field,
689            detail,
690        });
691    }
692
693    fn expired_entries_error(&self, path: &Path) -> std::io::Error {
694        let first = &self.expired_entries[0];
695        let extra = self.expired_entries.len().saturating_sub(1);
696        let suffix = if extra == 0 {
697            String::new()
698        } else if extra == 1 {
699            " (+1 more expired entry)".to_string()
700        } else {
701            format!(" (+{extra} more expired entries)")
702        };
703        std::io::Error::new(
704            std::io::ErrorKind::InvalidData,
705            format!(
706                "{} contains expired allowlist policy at line {}: '{}' expired on {}{}. \
707                 Remove the entry or renew its expires metadata; refusing to scan with stale suppressions.",
708                path.display(),
709                first.line_number,
710                first.entry,
711                first.expires,
712                suffix
713            ),
714        )
715    }
716
717    fn policy_violations_error(&self, path: &Path) -> std::io::Error {
718        let first = &self.policy_violations[0];
719        let extra = self.policy_violations.len().saturating_sub(1);
720        let suffix = if extra == 0 {
721            String::new()
722        } else if extra == 1 {
723            " (+1 more policy violation)".to_string()
724        } else {
725            format!(" (+{extra} more policy violations)")
726        };
727        std::io::Error::new(
728            std::io::ErrorKind::InvalidData,
729            format!(
730                "{} violates allowlist governance at line {}: '{}' missing/invalid {} ({}){}. \
731                 Add inline metadata like `; reason=\"...\"; approved_by=\"...\"; expires=YYYY-MM-DD` \
732                 or relax the [allowlist] policy in .keyhog.toml; refusing to scan with unapproved suppressions.",
733                path.display(),
734                first.line_number,
735                first.entry,
736                first.field,
737                first.detail,
738                suffix
739            ),
740        )
741    }
742
743    /// Check whether detector or path rules suppress a verified finding.
744    ///
745    /// Hash-based suppression is evaluated earlier on [`crate::RawMatch`] values
746    /// because [`VerifiedFinding`] stores only redacted credentials.
747    ///
748    /// # Examples
749    ///
750    /// ```rust
751    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
752    /// use keyhog_core::Allowlist;
753    ///
754    /// let path = std::env::temp_dir().join(format!(
755    ///     "keyhog_allowlist_allowed_{}.keyhogignore",
756    ///     std::process::id()
757    /// ));
758    /// std::fs::write(&path, "detector:demo-token\npath:src/*.rs\n")?;
759    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
760    /// std::fs::remove_file(&path)?;
761    /// assert!(allowlist.ignored_detectors.contains("demo-token"));
762    /// assert!(allowlist.is_path_ignored("src/main.rs"));
763    /// # Ok(()) }
764    /// ```
765    pub(crate) fn is_allowed(&self, finding: &VerifiedFinding) -> bool {
766        let detector_ignored = self.ignored_detectors.contains(&*finding.detector_id);
767
768        let path_ignored = finding.location.file_path.as_ref().is_some_and(|path| {
769            let normalized_path = normalize_path(path);
770            self.path_matches(&normalized_path)
771        });
772
773        let hash_ignored = self.matches_ignored_hash(&finding.credential_hash);
774
775        detector_ignored || path_ignored || hash_ignored
776    }
777
778    /// Check if a raw credential hash is allowlisted.
779    ///
780    /// # Examples
781    ///
782    /// ```rust
783    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
784    /// use keyhog_core::{Allowlist, CredentialHash};
785    ///
786    /// let path = std::env::temp_dir().join(format!(
787    ///     "keyhog_allowlist_hash_{}.keyhogignore",
788    ///     std::process::id()
789    /// ));
790    /// std::fs::write(&path, "hash:0000000000000000000000000000000000000000000000000000000000000000\n")?;
791    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
792    /// std::fs::remove_file(&path)?;
793    /// assert!(allowlist.credential_hashes.contains(&CredentialHash::from([0u8; 32])));
794    /// # Ok(()) }
795    /// ```
796    pub(crate) fn is_hash_allowed(&self, credential: &str) -> bool {
797        self.matches_ignored_hash_hex(credential)
798    }
799
800    /// Check if a hex-encoded SHA-256 hash is allowlisted.
801    pub(crate) fn is_raw_hash_ignored(&self, hash_hex: &str) -> bool {
802        self.matches_ignored_hash_hex(hash_hex)
803    }
804
805    /// Check whether a raw path matches an ignored-path glob.
806    ///
807    /// # Examples
808    ///
809    /// ```rust
810    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
811    /// use keyhog_core::Allowlist;
812    ///
813    /// let path = std::env::temp_dir().join(format!(
814    ///     "keyhog_allowlist_path_{}.keyhogignore",
815    ///     std::process::id()
816    /// ));
817    /// std::fs::write(&path, "path:**/*.md\n")?;
818    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
819    /// std::fs::remove_file(&path)?;
820    /// assert!(allowlist.is_path_ignored("docs/README.md"));
821    /// # Ok(()) }
822    /// ```
823    pub fn is_path_ignored(&self, path: &str) -> bool {
824        let normalized = normalize_path(path);
825        self.path_matches(&normalized)
826    }
827
828    /// Run the precompiled path-glob index against an already-normalized path,
829    /// rebuilding the index first iff the public `ignored_paths` field was
830    /// mutated directly since construction. The construction paths keep the
831    /// index in sync, so the scanner hot path always takes the fast branch. A
832    /// hand-mutated allowlist rebuilds on every call (the index cannot be cached
833    /// behind `&self`), paying for correctness rather than silently skipping it;
834    /// callers that mutate `ignored_paths` in a loop should re-`parse` instead.
835    fn path_matches(&self, normalized_path: &str) -> bool {
836        if self.path_index.matches_sources(&self.ignored_paths) {
837            self.path_index.matches(normalized_path)
838        } else {
839            PathGlobIndex::build(&self.ignored_paths).matches(normalized_path)
840        }
841    }
842
843    fn matches_ignored_hash(&self, hash: &CredentialHash) -> bool {
844        // Direct byte-set membership. Suppressing `hash:` entries are parsed
845        // from 64-hex into this same `[u8; 32]` form at load time
846        // (`parse_sha256_hex`), and findings carry the raw bytes, so no hex
847        // round-trip happens here. (Earlier versions also hashed raw input as a
848        // fallback, which silently encouraged plaintext in `.keyhogignore` - the
849        // file is often committed by accident; that path is intentionally gone,
850        // see audit release-2026-04-26.)
851        self.credential_hashes.contains(hash)
852    }
853
854    fn matches_ignored_hash_hex(&self, hash_hex: &str) -> bool {
855        parse_sha256_hex(hash_hex).is_some_and(|bytes| self.matches_ignored_hash(&bytes))
856    }
857
858    /// Record a match against allowlist rules for a verified finding.
859    pub fn record_match(&self, finding: &VerifiedFinding) -> bool {
860        let mut matched = false;
861        for rule in &self.rules {
862            match &rule.kind {
863                AllowlistRuleKind::Detector(det) => {
864                    if &*finding.detector_id == det {
865                        rule.matches.fetch_add(1, Ordering::Relaxed);
866                        matched = true;
867                    }
868                }
869                AllowlistRuleKind::Hash(h) => {
870                    if &finding.credential_hash == h {
871                        rule.matches.fetch_add(1, Ordering::Relaxed);
872                        matched = true;
873                    }
874                }
875                AllowlistRuleKind::Path(p) => {
876                    if let Some(path) = finding.location.file_path.as_deref() {
877                        let normalized = normalize_path(path);
878                        if self.path_matches(&normalized) && pattern_matches_path(p, &normalized) {
879                            rule.matches.fetch_add(1, Ordering::Relaxed);
880                            matched = true;
881                        }
882                    }
883                }
884            }
885        }
886        matched
887    }
888
889    /// Record a match on an ignored path.
890    pub fn record_path_match(&self, path: &str) -> bool {
891        let normalized = normalize_path(path);
892        let mut matched = false;
893        for rule in &self.rules {
894            if let AllowlistRuleKind::Path(p) = &rule.kind {
895                if pattern_matches_path(p, &normalized) {
896                    rule.matches.fetch_add(1, Ordering::Relaxed);
897                    matched = true;
898                }
899            }
900        }
901        matched
902    }
903
904    /// Record a match on an ignored detector.
905    pub fn record_detector_match(&self, detector_id: &str) -> bool {
906        let mut matched = false;
907        for rule in &self.rules {
908            if let AllowlistRuleKind::Detector(d) = &rule.kind {
909                if d == detector_id {
910                    rule.matches.fetch_add(1, Ordering::Relaxed);
911                    matched = true;
912                }
913            }
914        }
915        matched
916    }
917
918    /// Record a match on an ignored credential hash.
919    pub fn record_hash_match(&self, hash: &CredentialHash) -> bool {
920        let mut matched = false;
921        for rule in &self.rules {
922            if let AllowlistRuleKind::Hash(h) = &rule.kind {
923                if h == hash {
924                    rule.matches.fetch_add(1, Ordering::Relaxed);
925                    matched = true;
926                }
927            }
928        }
929        matched
930    }
931
932    /// Retrieve all allowlist entries that matched zero times during the scan.
933    pub fn unused_entries(&self) -> Vec<UnusedAllowlistEntry> {
934        self.rules
935            .iter()
936            .filter_map(|r| {
937                let count = r.matches.load(Ordering::Relaxed);
938                if count == 0 {
939                    Some(UnusedAllowlistEntry {
940                        line_number: r.line_number,
941                        entry: r.entry.clone(),
942                        match_count: 0,
943                    })
944                } else {
945                    None
946                }
947            })
948            .collect()
949    }
950
951    /// Retrieve match attribution for every registered allowlist entry.
952    pub fn attributed_match_counts(&self) -> Vec<(String, usize)> {
953        self.rules
954            .iter()
955            .map(|r| (r.entry.clone(), r.matches.load(Ordering::Relaxed)))
956            .collect()
957    }
958}
959
960impl Default for Allowlist {
961    fn default() -> Self {
962        Self::empty()
963    }
964}
965
966impl Clone for Allowlist {
967    fn clone(&self) -> Self {
968        let ignored_paths = self.ignored_paths.clone();
969        Self {
970            credential_hashes: self.credential_hashes.clone(),
971            ignored_detectors: self.ignored_detectors.clone(),
972            path_index: PathGlobIndex::build(&ignored_paths),
973            ignored_paths,
974            expired_entries: self.expired_entries.clone(),
975            policy_violations: self.policy_violations.clone(),
976            rules: self.rules.clone(),
977        }
978    }
979}
980
981fn parse_sha256_hex(input: &str) -> Option<CredentialHash> {
982    hex_to_array(input.trim()).map(CredentialHash::from_bytes)
983}
984
985fn invalid_bare_entry(entry: &str) -> Option<(&'static str, &'static str)> {
986    if entry.contains(':') {
987        return Some((
988            "entry",
989            "entry contains `:` but does not start with a valid prefix (`hash:`, `detector:`, or `path:`); use `path:` for literal path globs containing `:`",
990        ));
991    }
992    let bytes = entry.as_bytes();
993    if bytes.len() == crate::git_lfs::SHA256_HEX_LEN {
994        return Some((
995            "hash",
996            "bare 64-byte entry must be a valid SHA-256 hex digest; use `path:` for a literal 64-byte path glob",
997        ));
998    }
999    if bytes.len() >= 32 && bytes.iter().all(u8::is_ascii_hexdigit) {
1000        return Some((
1001            "hash",
1002            "hex-like bare entry must be exactly a 64-character SHA-256 digest; use `path:` for a literal hex path glob",
1003        ));
1004    }
1005    None
1006}
1007
1008pub(crate) fn allowlist_days_since_epoch_for_test(
1009    now: std::time::SystemTime,
1010) -> Result<i64, String> {
1011    metadata::days_since_epoch_for_test(now)
1012}
1013
1014/// Inline metadata parsed from a `.keyhogignore` line trailer. Used to
1015/// implement enterprise governance fields (`reason`, `expires`,
1016/// `approved_by`) per the internal design notes Tier-B #18.
1017#[derive(Default, Debug)]
1018struct InlineMetadata {
1019    reason: Option<String>,
1020    expires: Option<String>,
1021    approved_by: Option<String>,
1022    unknown_keys: Vec<String>,
1023    malformed_tokens: Vec<String>,
1024}