Skip to main content

kovra_core/
hooks.rs

1//! Pre-commit secret-scan hook generation (L12, KOV-19).
2//!
3//! Generates a git `pre-commit` hook that runs a secret scanner (gitleaks or
4//! trufflehog) over the **staged** changes and **fails the commit** when a
5//! secret-like pattern is found (spec §13). It is the safety net for when a
6//! value escapes every other control — cheap and disproportionately valuable
7//! because the agent commits often.
8//!
9//! The hook **fails closed**: if the scanner is not installed it aborts the
10//! commit rather than letting an unscanned commit through. The generated script
11//! and config are pure data here (no I/O), so they are unit-tested; the CLI
12//! `kovra hooks install` writes them into a repo's `.git/hooks`.
13
14/// A secret scanner the generated hook can drive.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Scanner {
17    /// gitleaks (default) — <https://github.com/gitleaks/gitleaks>.
18    Gitleaks,
19    /// trufflehog — <https://github.com/trufflesecurity/trufflehog>.
20    Trufflehog,
21}
22
23impl Scanner {
24    /// The executable the hook invokes.
25    pub fn binary(&self) -> &'static str {
26        match self {
27            Scanner::Gitleaks => "gitleaks",
28            Scanner::Trufflehog => "trufflehog",
29        }
30    }
31}
32
33/// A marker line embedded in every generated hook so `kovra hooks install` can
34/// recognize (and safely replace) a hook it wrote earlier, without clobbering a
35/// hand-written one.
36pub const HOOK_MARKER: &str = "kovra-pre-commit-secret-scan";
37
38/// The git `pre-commit` hook script for `scanner`. Scans only the staged diff,
39/// exits non-zero on a finding (which aborts the commit), and fails closed when
40/// the scanner binary is absent.
41pub 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
77/// A `.gitleaks.toml` that extends the default ruleset and allowlists
78/// `.env.refs` — which holds only coordinates (addresses), never values — so the
79/// committable secret *contract* never trips the scanner.
80pub 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        // Fails closed: a missing scanner exits non-zero (no unscanned commit).
102        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        // Must scan the working tree (which holds the staged-but-uncommitted
113        // content), NOT `trufflehog git` — that scans committed history and would
114        // miss the very secret being committed (a fail-open hole).
115        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}