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, 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#[derive(Debug, serde::Serialize)]
171pub struct Allowlist {
172    /// SHA-256 hashes of credentials to ignore.
173    pub credential_hashes: HashSet<CredentialHash>,
174    /// Detector IDs to ignore entirely.
175    pub ignored_detectors: HashSet<String>,
176    /// Glob patterns for paths to ignore (raw, as authored). Kept as the public
177    /// Vec-compatible contract + serialized form; the matcher consumes the
178    /// precompiled [`PathGlobIndex`] built from these in [`Allowlist::parse`].
179    pub ignored_paths: ObservedPaths,
180    /// Precompiled, first-segment-bucketed form of `ignored_paths`. Built once
181    /// in `parse`/`empty` so per-finding path checks neither re-normalize +
182    /// re-split each pattern nor sweep every rule. Skipped by `serde` (it is a
183    /// pure function of `ignored_paths`; rebuilt by the constructors and clone
184    /// implementation) so the serialized shape is unchanged.
185    #[serde(skip)]
186    path_index: PathGlobIndex,
187    /// Expired policy lines found while parsing. They are never active
188    /// suppressions; `load` turns them into a user-visible policy error.
189    #[serde(skip)]
190    expired_entries: Vec<ExpiredAllowlistEntry>,
191    /// Governance-policy violations found while parsing. They are never active
192    /// suppressions; `load_with_policy` turns them into a user-visible policy
193    /// error.
194    #[serde(skip)]
195    policy_violations: Vec<AllowlistPolicyViolation>,
196}
197
198#[derive(Debug, Clone)]
199struct ExpiredAllowlistEntry {
200    line_number: usize,
201    entry: String,
202    expires: String,
203}
204
205#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
206struct AllowlistMetadataPolicy {
207    require_reason: bool,
208    require_approved_by: bool,
209    max_expires_days: Option<u64>,
210}
211
212impl AllowlistMetadataPolicy {
213    fn is_enforced(self) -> bool {
214        self.require_reason || self.require_approved_by || self.max_expires_days.is_some()
215    }
216}
217
218#[derive(Debug, Clone)]
219struct AllowlistPolicyViolation {
220    line_number: usize,
221    entry: String,
222    field: &'static str,
223    detail: String,
224}
225
226impl Allowlist {
227    /// Create an empty allowlist with no suppressed hashes, detectors, or paths.
228    ///
229    /// # Examples
230    ///
231    /// ```rust
232    /// use keyhog_core::Allowlist;
233    ///
234    /// let allowlist = Allowlist::default();
235    /// assert!(allowlist.ignored_paths.is_empty());
236    /// ```
237    pub(crate) fn empty() -> Self {
238        let ignored_paths = ObservedPaths::default();
239        Self {
240            credential_hashes: HashSet::new(),
241            ignored_detectors: HashSet::new(),
242            path_index: PathGlobIndex::build(&ignored_paths),
243            ignored_paths,
244            expired_entries: Vec::new(),
245            policy_violations: Vec::new(),
246        }
247    }
248
249    /// Load from a `.keyhogignore` file and enforce metadata governance.
250    pub fn load_with_metadata_policy(
251        path: &Path,
252        require_reason: bool,
253        require_approved_by: bool,
254        max_expires_days: Option<u64>,
255    ) -> Result<Self, std::io::Error> {
256        Self::load_with_policy(
257            path,
258            AllowlistMetadataPolicy {
259                require_reason,
260                require_approved_by,
261                max_expires_days,
262            },
263        )
264    }
265
266    fn load_with_policy(
267        path: &Path,
268        policy: AllowlistMetadataPolicy,
269    ) -> Result<Self, std::io::Error> {
270        let bytes = crate::state_file::read_capped(
271            path,
272            crate::state_file::RULE_CONFIG_FILE_BYTES,
273            "allowlist",
274        )?;
275        let contents = String::from_utf8(bytes)
276            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
277        let allowlist = Self::parse_with_policy(&contents, policy);
278        if !allowlist.expired_entries.is_empty() {
279            return Err(allowlist.expired_entries_error(path));
280        }
281        if !allowlist.policy_violations.is_empty() {
282            return Err(allowlist.policy_violations_error(path));
283        }
284        Ok(allowlist)
285    }
286
287    /// Parse allowlist from string content.
288    ///
289    /// # Examples
290    ///
291    /// ```rust
292    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
293    /// use keyhog_core::Allowlist;
294    ///
295    /// let path = std::env::temp_dir().join(format!(
296    ///     "keyhog_allowlist_parse_{}.keyhogignore",
297    ///     std::process::id()
298    /// ));
299    /// std::fs::write(&path, "path:**/.env\ndetector:demo-token\n")?;
300    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
301    /// std::fs::remove_file(&path)?;
302    /// assert!(allowlist.is_path_ignored("app/.env"));
303    /// # Ok(()) }
304    /// ```
305    pub(crate) fn parse(content: &str) -> Self {
306        Self::parse_with_policy(content, AllowlistMetadataPolicy::default())
307    }
308
309    fn parse_with_policy(content: &str, policy: AllowlistMetadataPolicy) -> Self {
310        let mut al = Self::empty();
311        let today_days = match try_today_days_since_epoch() {
312            Ok(days) => days,
313            Err(detail) => {
314                al.push_policy_violation(1, "<allowlist>", "system_clock", detail);
315                return al;
316            }
317        };
318        let today = yyyy_mm_dd_from_days(today_days);
319        for (line_number, raw_line) in content.lines().enumerate() {
320            let raw_line = raw_line.trim();
321            if raw_line.is_empty() || raw_line.starts_with('#') {
322                continue;
323            }
324            // Optional inline metadata: `entry; reason="..."; expires=YYYY-MM-DD; approved_by="..."`
325            // Each `;`-separated token after the first is a key=value pair.
326            let mut parts = raw_line.splitn(2, ';');
327            let entry = parts.next().unwrap_or("").trim(); // LAW10: missing/non-string field => empty/placeholder; recall-safe
328            let metadata = parts.next().unwrap_or(""); // LAW10: missing/non-string field => empty/placeholder; recall-safe
329            let parsed_meta = parse_inline_metadata(metadata);
330            for key in &parsed_meta.unknown_keys {
331                al.push_policy_violation(
332                    line_number + 1,
333                    entry,
334                    "metadata",
335                    format!("unknown key `{key}`; supported keys are reason, expires, approved_by"),
336                );
337            }
338            for detail in &parsed_meta.malformed_tokens {
339                al.push_policy_violation(line_number + 1, entry, "metadata", detail.clone());
340            }
341            if entry.is_empty() {
342                al.push_policy_violation(
343                    line_number + 1,
344                    entry,
345                    "entry",
346                    "empty allowlist entry before metadata; add `detector:`, `path:`, `hash:`, or a glob before `;`".to_string(),
347                );
348                continue;
349            }
350
351            // Drop entries whose `expires` is past - keeps `.keyhogignore`
352            // self-cleaning for short-lived approvals (Tier-B #18 governance).
353            if let Some(exp) = parsed_meta.expires.as_deref() {
354                match parse_yyyy_mm_dd_days(exp) {
355                    Some(exp_days) if exp_days < today_days => {
356                        al.expired_entries.push(ExpiredAllowlistEntry {
357                            line_number: line_number + 1,
358                            entry: entry.to_string(),
359                            expires: exp.to_string(),
360                        });
361                        tracing::warn!(
362                            "allowlist entry expired on {} (today is {}): '{}'",
363                            exp,
364                            today,
365                            entry
366                        );
367                        continue;
368                    }
369                    Some(_) => {}
370                    None => {
371                        al.push_policy_violation(
372                            line_number + 1,
373                            entry,
374                            "expires",
375                            "must use YYYY-MM-DD".to_string(),
376                        );
377                        continue;
378                    }
379                }
380            }
381
382            if let Some(hash) = entry.strip_prefix("hash:") {
383                let trimmed = hash.trim();
384                if let Some(valid_hash) = parse_sha256_hex(trimmed) {
385                    if !al.metadata_policy_allows(
386                        line_number + 1,
387                        entry,
388                        &parsed_meta,
389                        policy,
390                        today_days,
391                    ) {
392                        continue;
393                    }
394                    al.credential_hashes.insert(valid_hash);
395                    log_metadata_audit("hash", trimmed, &parsed_meta);
396                } else {
397                    al.push_invalid_entry_violation(
398                        line_number + 1,
399                        entry,
400                        "hash",
401                        "must be a 64-character SHA-256 hex digest",
402                    );
403                    tracing::warn!(
404                        "invalid hash allowlist entry at line {}: '{}'",
405                        line_number + 1,
406                        trimmed
407                    );
408                }
409            } else if let Some(detector) = entry.strip_prefix("detector:") {
410                let detector = detector.trim();
411                if detector.is_empty() {
412                    al.push_invalid_entry_violation(
413                        line_number + 1,
414                        entry,
415                        "detector",
416                        "detector id must not be empty",
417                    );
418                    tracing::warn!(
419                        "invalid detector allowlist entry at line {}: detector id is empty",
420                        line_number + 1
421                    );
422                } else {
423                    if !al.metadata_policy_allows(
424                        line_number + 1,
425                        entry,
426                        &parsed_meta,
427                        policy,
428                        today_days,
429                    ) {
430                        continue;
431                    }
432                    al.ignored_detectors.insert(detector.to_string());
433                    log_metadata_audit("detector", detector, &parsed_meta);
434                }
435            } else if let Some(path) = entry.strip_prefix("path:") {
436                let path = path.trim();
437                if path.is_empty() {
438                    al.push_invalid_entry_violation(
439                        line_number + 1,
440                        entry,
441                        "path",
442                        "path glob must not be empty",
443                    );
444                    tracing::warn!(
445                        "invalid path allowlist entry at line {}: glob is empty",
446                        line_number + 1
447                    );
448                } else {
449                    if !al.metadata_policy_allows(
450                        line_number + 1,
451                        entry,
452                        &parsed_meta,
453                        policy,
454                        today_days,
455                    ) {
456                        continue;
457                    }
458                    al.ignored_paths.push(path.to_string());
459                    log_metadata_audit("path", path, &parsed_meta);
460                }
461            } else if let Some(bytes) = parse_sha256_hex(entry) {
462                // Bare 64-char hex hash. Lets the obvious
463                // `keyhog scan ... --format jsonl | jq -r '.credential_hash'
464                // >> .keyhogignore` workflow Just Work without users
465                // learning the `hash:` prefix.
466                if !al.metadata_policy_allows(
467                    line_number + 1,
468                    entry,
469                    &parsed_meta,
470                    policy,
471                    today_days,
472                ) {
473                    continue;
474                }
475                al.credential_hashes.insert(bytes);
476                log_metadata_audit("hash", entry, &parsed_meta);
477            } else if let Some((field, detail)) = invalid_bare_entry(entry) {
478                al.push_invalid_entry_violation(line_number + 1, entry, field, detail);
479                tracing::warn!(
480                    "invalid allowlist entry at line {}: '{}'",
481                    line_number + 1,
482                    entry
483                );
484            } else {
485                // Bare path glob (gitignore-style). Anything that didn't
486                // match an explicit `hash:` / `detector:` / `path:` prefix
487                // and isn't a bare hash is interpreted as a path glob,
488                // matching `.gitignore` UX (`*.log`, `node_modules/`,
489                // `vendor/**/*.json`). kimi-1 dogfood #129 - the prior
490                // behavior emitted a warning and silently dropped the
491                // line, which is the worst of both worlds: every
492                // `.gitignore` users copied over was dead.
493                if !al.metadata_policy_allows(
494                    line_number + 1,
495                    entry,
496                    &parsed_meta,
497                    policy,
498                    today_days,
499                ) {
500                    continue;
501                }
502                al.ignored_paths.push(entry.to_string());
503                log_metadata_audit("path", entry, &parsed_meta);
504            }
505        }
506        // Precompile the path globs ONCE: segments + oversize verdict + the
507        // first-segment bucket index, so per-finding suppression neither
508        // re-normalizes each pattern nor sweeps every rule.
509        al.path_index = PathGlobIndex::build(&al.ignored_paths);
510        al
511    }
512
513    fn metadata_policy_allows(
514        &mut self,
515        line_number: usize,
516        entry: &str,
517        metadata: &InlineMetadata,
518        policy: AllowlistMetadataPolicy,
519        today_days: i64,
520    ) -> bool {
521        if !policy.is_enforced() {
522            return true;
523        }
524        let mut allowed = true;
525        if policy.require_reason && metadata.reason.as_deref().is_none_or(str::is_empty) {
526            self.push_policy_violation(
527                line_number,
528                entry,
529                "reason",
530                "required by [allowlist].require_reason".to_string(),
531            );
532            allowed = false;
533        }
534        if policy.require_approved_by && metadata.approved_by.as_deref().is_none_or(str::is_empty) {
535            self.push_policy_violation(
536                line_number,
537                entry,
538                "approved_by",
539                "required by [allowlist].require_approved_by".to_string(),
540            );
541            allowed = false;
542        }
543        if let Some(max_expires_days) = policy.max_expires_days {
544            match metadata.expires.as_deref() {
545                Some(expires) if !expires.is_empty() => match parse_yyyy_mm_dd_days(expires) {
546                    Some(expires_days) => {
547                        let max_days = match i64::try_from(max_expires_days) {
548                            Ok(days) => days,
549                            Err(error) => {
550                                self.push_policy_violation(
551                                    line_number,
552                                    entry,
553                                    "expires",
554                                    format!(
555                                        "max_expires_days={max_expires_days} is too large to enforce ({error})"
556                                    ),
557                                );
558                                allowed = false;
559                                return allowed;
560                            }
561                        };
562                        if expires_days.saturating_sub(today_days) > max_days {
563                            self.push_policy_violation(
564                                line_number,
565                                entry,
566                                "expires",
567                                format!(
568                                    "expires={expires} is more than {max_expires_days} days out"
569                                ),
570                            );
571                            allowed = false;
572                        }
573                    }
574                    None => {
575                        self.push_policy_violation(
576                            line_number,
577                            entry,
578                            "expires",
579                            "must use YYYY-MM-DD when [allowlist].max_expires_days is set"
580                                .to_string(),
581                        );
582                        allowed = false;
583                    }
584                },
585                _ => {
586                    self.push_policy_violation(
587                        line_number,
588                        entry,
589                        "expires",
590                        "required by [allowlist].max_expires_days".to_string(),
591                    );
592                    allowed = false;
593                }
594            }
595        }
596        allowed
597    }
598
599    fn push_invalid_entry_violation(
600        &mut self,
601        line_number: usize,
602        entry: &str,
603        field: &'static str,
604        detail: &'static str,
605    ) {
606        self.push_policy_violation(line_number, entry, field, detail.to_string());
607    }
608
609    fn push_policy_violation(
610        &mut self,
611        line_number: usize,
612        entry: &str,
613        field: &'static str,
614        detail: String,
615    ) {
616        self.policy_violations.push(AllowlistPolicyViolation {
617            line_number,
618            entry: entry.to_string(),
619            field,
620            detail,
621        });
622    }
623
624    fn expired_entries_error(&self, path: &Path) -> std::io::Error {
625        let first = &self.expired_entries[0];
626        let extra = self.expired_entries.len().saturating_sub(1);
627        let suffix = if extra == 0 {
628            String::new()
629        } else if extra == 1 {
630            " (+1 more expired entry)".to_string()
631        } else {
632            format!(" (+{extra} more expired entries)")
633        };
634        std::io::Error::new(
635            std::io::ErrorKind::InvalidData,
636            format!(
637                "{} contains expired allowlist policy at line {}: '{}' expired on {}{}. \
638                 Remove the entry or renew its expires metadata; refusing to scan with stale suppressions.",
639                path.display(),
640                first.line_number,
641                first.entry,
642                first.expires,
643                suffix
644            ),
645        )
646    }
647
648    fn policy_violations_error(&self, path: &Path) -> std::io::Error {
649        let first = &self.policy_violations[0];
650        let extra = self.policy_violations.len().saturating_sub(1);
651        let suffix = if extra == 0 {
652            String::new()
653        } else if extra == 1 {
654            " (+1 more policy violation)".to_string()
655        } else {
656            format!(" (+{extra} more policy violations)")
657        };
658        std::io::Error::new(
659            std::io::ErrorKind::InvalidData,
660            format!(
661                "{} violates allowlist governance at line {}: '{}' missing/invalid {} ({}){}. \
662                 Add inline metadata like `; reason=\"...\"; approved_by=\"...\"; expires=YYYY-MM-DD` \
663                 or relax the [allowlist] policy in .keyhog.toml; refusing to scan with unapproved suppressions.",
664                path.display(),
665                first.line_number,
666                first.entry,
667                first.field,
668                first.detail,
669                suffix
670            ),
671        )
672    }
673
674    /// Check whether detector or path rules suppress a verified finding.
675    ///
676    /// Hash-based suppression is evaluated earlier on [`crate::RawMatch`] values
677    /// because [`VerifiedFinding`] stores only redacted credentials.
678    ///
679    /// # Examples
680    ///
681    /// ```rust
682    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
683    /// use keyhog_core::Allowlist;
684    ///
685    /// let path = std::env::temp_dir().join(format!(
686    ///     "keyhog_allowlist_allowed_{}.keyhogignore",
687    ///     std::process::id()
688    /// ));
689    /// std::fs::write(&path, "detector:demo-token\npath:src/*.rs\n")?;
690    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
691    /// std::fs::remove_file(&path)?;
692    /// assert!(allowlist.ignored_detectors.contains("demo-token"));
693    /// assert!(allowlist.is_path_ignored("src/main.rs"));
694    /// # Ok(()) }
695    /// ```
696    pub(crate) fn is_allowed(&self, finding: &VerifiedFinding) -> bool {
697        let detector_ignored = self.ignored_detectors.contains(&*finding.detector_id);
698
699        let path_ignored = finding.location.file_path.as_ref().is_some_and(|path| {
700            let normalized_path = normalize_path(path);
701            self.path_matches(&normalized_path)
702        });
703
704        let hash_ignored = self.matches_ignored_hash(&finding.credential_hash);
705
706        detector_ignored || path_ignored || hash_ignored
707    }
708
709    /// Check if a raw credential hash is allowlisted.
710    ///
711    /// # Examples
712    ///
713    /// ```rust
714    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
715    /// use keyhog_core::{Allowlist, CredentialHash};
716    ///
717    /// let path = std::env::temp_dir().join(format!(
718    ///     "keyhog_allowlist_hash_{}.keyhogignore",
719    ///     std::process::id()
720    /// ));
721    /// std::fs::write(&path, "hash:0000000000000000000000000000000000000000000000000000000000000000\n")?;
722    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
723    /// std::fs::remove_file(&path)?;
724    /// assert!(allowlist.credential_hashes.contains(&CredentialHash::from([0u8; 32])));
725    /// # Ok(()) }
726    /// ```
727    pub(crate) fn is_hash_allowed(&self, credential: &str) -> bool {
728        self.matches_ignored_hash_hex(credential)
729    }
730
731    /// Check if a hex-encoded SHA-256 hash is allowlisted.
732    pub(crate) fn is_raw_hash_ignored(&self, hash_hex: &str) -> bool {
733        self.matches_ignored_hash_hex(hash_hex)
734    }
735
736    /// Check whether a raw path matches an ignored-path glob.
737    ///
738    /// # Examples
739    ///
740    /// ```rust
741    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
742    /// use keyhog_core::Allowlist;
743    ///
744    /// let path = std::env::temp_dir().join(format!(
745    ///     "keyhog_allowlist_path_{}.keyhogignore",
746    ///     std::process::id()
747    /// ));
748    /// std::fs::write(&path, "path:**/*.md\n")?;
749    /// let allowlist = Allowlist::load_with_metadata_policy(&path, false, false, None)?;
750    /// std::fs::remove_file(&path)?;
751    /// assert!(allowlist.is_path_ignored("docs/README.md"));
752    /// # Ok(()) }
753    /// ```
754    pub fn is_path_ignored(&self, path: &str) -> bool {
755        let normalized = normalize_path(path);
756        self.path_matches(&normalized)
757    }
758
759    /// Run the precompiled path-glob index against an already-normalized path,
760    /// rebuilding the index first iff the public `ignored_paths` field was
761    /// mutated directly since construction. The construction paths keep the
762    /// index in sync, so the scanner hot path always takes the fast branch. A
763    /// hand-mutated allowlist rebuilds on every call (the index cannot be cached
764    /// behind `&self`), paying for correctness rather than silently skipping it;
765    /// callers that mutate `ignored_paths` in a loop should re-`parse` instead.
766    fn path_matches(&self, normalized_path: &str) -> bool {
767        if self.path_index.matches_sources(&self.ignored_paths) {
768            self.path_index.matches(normalized_path)
769        } else {
770            PathGlobIndex::build(&self.ignored_paths).matches(normalized_path)
771        }
772    }
773
774    fn matches_ignored_hash(&self, hash: &CredentialHash) -> bool {
775        // Direct byte-set membership. Suppressing `hash:` entries are parsed
776        // from 64-hex into this same `[u8; 32]` form at load time
777        // (`parse_sha256_hex`), and findings carry the raw bytes, so no hex
778        // round-trip happens here. (Earlier versions also hashed raw input as a
779        // fallback, which silently encouraged plaintext in `.keyhogignore` - the
780        // file is often committed by accident; that path is intentionally gone,
781        // see audit release-2026-04-26.)
782        self.credential_hashes.contains(hash)
783    }
784
785    fn matches_ignored_hash_hex(&self, hash_hex: &str) -> bool {
786        parse_sha256_hex(hash_hex).is_some_and(|bytes| self.matches_ignored_hash(&bytes))
787    }
788}
789
790impl Default for Allowlist {
791    fn default() -> Self {
792        Self::empty()
793    }
794}
795
796impl Clone for Allowlist {
797    fn clone(&self) -> Self {
798        let ignored_paths = self.ignored_paths.clone();
799        Self {
800            credential_hashes: self.credential_hashes.clone(),
801            ignored_detectors: self.ignored_detectors.clone(),
802            path_index: PathGlobIndex::build(&ignored_paths),
803            ignored_paths,
804            expired_entries: self.expired_entries.clone(),
805            policy_violations: self.policy_violations.clone(),
806        }
807    }
808}
809
810fn parse_sha256_hex(input: &str) -> Option<CredentialHash> {
811    hex_to_array(input.trim()).map(CredentialHash::from_bytes)
812}
813
814fn invalid_bare_entry(entry: &str) -> Option<(&'static str, &'static str)> {
815    if entry.contains(':') {
816        return Some((
817            "entry",
818            "entry contains `:` but does not start with a valid prefix (`hash:`, `detector:`, or `path:`); use `path:` for literal path globs containing `:`",
819        ));
820    }
821    let bytes = entry.as_bytes();
822    if bytes.len() == crate::git_lfs::SHA256_HEX_LEN {
823        return Some((
824            "hash",
825            "bare 64-byte entry must be a valid SHA-256 hex digest; use `path:` for a literal 64-byte path glob",
826        ));
827    }
828    if bytes.len() >= 32 && bytes.iter().all(u8::is_ascii_hexdigit) {
829        return Some((
830            "hash",
831            "hex-like bare entry must be exactly a 64-character SHA-256 digest; use `path:` for a literal hex path glob",
832        ));
833    }
834    None
835}
836
837pub(crate) fn allowlist_days_since_epoch_for_test(
838    now: std::time::SystemTime,
839) -> Result<i64, String> {
840    metadata::days_since_epoch_for_test(now)
841}
842
843/// Inline metadata parsed from a `.keyhogignore` line trailer. Used to
844/// implement enterprise governance fields (`reason`, `expires`,
845/// `approved_by`) per the internal design notes Tier-B #18.
846#[derive(Default, Debug)]
847struct InlineMetadata {
848    reason: Option<String>,
849    expires: Option<String>,
850    approved_by: Option<String>,
851    unknown_keys: Vec<String>,
852    malformed_tokens: Vec<String>,
853}