1use 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
21mod metadata;
24use metadata::*;
25
26mod glob;
30use glob::{normalize_path, PathGlobIndex};
31
32static NEXT_OBSERVED_PATHS_ID: AtomicU64 = AtomicU64::new(1);
33
34#[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#[derive(Debug, serde::Serialize)]
171pub struct Allowlist {
172 pub credential_hashes: HashSet<CredentialHash>,
174 pub ignored_detectors: HashSet<String>,
176 pub ignored_paths: ObservedPaths,
180 #[serde(skip)]
186 path_index: PathGlobIndex,
187 #[serde(skip)]
190 expired_entries: Vec<ExpiredAllowlistEntry>,
191 #[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 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 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 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 let mut parts = raw_line.splitn(2, ';');
327 let entry = parts.next().unwrap_or("").trim(); let metadata = parts.next().unwrap_or(""); 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 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 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 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 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 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 pub(crate) fn is_hash_allowed(&self, credential: &str) -> bool {
728 self.matches_ignored_hash_hex(credential)
729 }
730
731 pub(crate) fn is_raw_hash_ignored(&self, hash_hex: &str) -> bool {
733 self.matches_ignored_hash_hex(hash_hex)
734 }
735
736 pub fn is_path_ignored(&self, path: &str) -> bool {
755 let normalized = normalize_path(path);
756 self.path_matches(&normalized)
757 }
758
759 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 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#[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}