1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Scanner {
17 Gitleaks,
19 Trufflehog,
21}
22
23impl Scanner {
24 pub fn binary(&self) -> &'static str {
26 match self {
27 Scanner::Gitleaks => "gitleaks",
28 Scanner::Trufflehog => "trufflehog",
29 }
30 }
31}
32
33pub const HOOK_MARKER: &str = "kovra-pre-commit-secret-scan";
37
38pub fn hook_script(scanner: Scanner) -> String {
42 let common = format!(
43 "#!/usr/bin/env bash\n\
44 # {marker} (L12) — blocks a commit when a secret-like pattern is found\n\
45 # in the STAGED changes. Fails closed: a missing scanner aborts the commit.\n\
46 set -euo pipefail\n\n",
47 marker = HOOK_MARKER
48 );
49 match scanner {
50 Scanner::Gitleaks => format!(
51 "{common}\
52 if ! command -v gitleaks >/dev/null 2>&1; then\n\
53 \x20 echo \"kovra pre-commit: gitleaks not on PATH — install it \
54 (https://github.com/gitleaks/gitleaks) or remove .git/hooks/pre-commit.\" >&2\n\
55 \x20 exit 1\n\
56 fi\n\n\
57 # Scan only the staged diff. gitleaks exits non-zero on a finding,\n\
58 # aborting the commit; --redact keeps any matched value out of the log.\n\
59 exec gitleaks git --staged --redact --no-banner\n"
60 ),
61 Scanner::Trufflehog => format!(
62 "{common}\
63 if ! command -v trufflehog >/dev/null 2>&1; then\n\
64 \x20 echo \"kovra pre-commit: trufflehog not on PATH — install it \
65 (https://github.com/trufflesecurity/trufflehog) or remove .git/hooks/pre-commit.\" >&2\n\
66 \x20 exit 1\n\
67 fi\n\n\
68 # Scan the working tree (which holds the STAGED-but-uncommitted\n\
69 # content) — NOT `trufflehog git`, which scans committed history and\n\
70 # would miss the secret being committed right now. --fail aborts the\n\
71 # commit on any detection; --no-update avoids a network self-update.\n\
72 exec trufflehog filesystem . --fail --no-update\n"
73 ),
74 }
75}
76
77pub fn gitleaks_config() -> &'static str {
81 "# kovra gitleaks config. Extends the default rules; allowlists `.env.refs`,\n\
82 # which holds only coordinates (addresses), never secret values.\n\
83 [extend]\n\
84 useDefault = true\n\n\
85 [[allowlists]]\n\
86 description = \"kovra .env.refs holds only coordinates, never values\"\n\
87 paths = ['''\\.env\\.refs$''']\n"
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn gitleaks_hook_scans_staged_and_fails_closed() {
96 let s = hook_script(Scanner::Gitleaks);
97 assert!(s.starts_with("#!/usr/bin/env bash"));
98 assert!(s.contains(HOOK_MARKER));
99 assert!(s.contains("gitleaks"));
100 assert!(s.contains("--staged"), "must scan only the staged diff");
101 assert!(s.contains("exit 1"));
103 assert!(s.contains("set -euo pipefail"));
104 }
105
106 #[test]
107 fn trufflehog_hook_scans_working_tree_not_committed_history() {
108 let s = hook_script(Scanner::Trufflehog);
109 assert!(s.contains("trufflehog"));
110 assert!(s.contains("--fail"));
111 assert!(s.contains("exit 1"));
112 assert!(s.contains("filesystem"));
116 assert!(
117 !s.contains("git file://"),
118 "must not scan committed history (would miss the staged secret)"
119 );
120 }
121
122 #[test]
123 fn gitleaks_config_allowlists_env_refs() {
124 let cfg = gitleaks_config();
125 assert!(cfg.contains("useDefault = true"));
126 assert!(cfg.contains(".env.refs") || cfg.contains(r"\.env\.refs"));
127 }
128
129 #[test]
130 fn scanner_binaries() {
131 assert_eq!(Scanner::Gitleaks.binary(), "gitleaks");
132 assert_eq!(Scanner::Trufflehog.binary(), "trufflehog");
133 }
134}