Skip to main content

envvault/git/
mod.rs

1//! Git integration — pre-commit hook for secret leak prevention.
2//!
3//! The pre-commit hook scans staged files for patterns that look like
4//! hardcoded secrets (API keys, tokens, passwords). If a match is found,
5//! the commit is blocked with a descriptive error message.
6
7use std::fs;
8use std::path::Path;
9
10use crate::errors::{EnvVaultError, Result};
11
12/// The filename of the pre-commit hook.
13const HOOK_NAME: &str = "pre-commit";
14
15/// Common patterns that indicate hardcoded secrets.
16/// Each entry is (pattern_name, regex_pattern).
17pub const SECRET_PATTERNS: &[(&str, &str)] = &[
18    ("AWS Access Key", r"AKIA[0-9A-Z]{16}"),
19    (
20        "AWS Secret Key",
21        r#"(?i)(aws_secret|secret_key)\s*[=:]\s*["']?[A-Za-z0-9/+=]{40}"#,
22    ),
23    ("GitHub Token", r"gh[ps]_[A-Za-z0-9_]{36,}"),
24    (
25        "Generic API Key",
26        r#"(?i)(api[_-]?key|apikey)\s*[=:]\s*["']?[A-Za-z0-9_\-]{20,}"#,
27    ),
28    (
29        "Generic Secret",
30        r#"(?i)(secret|password|passwd|token)\s*[=:]\s*["']?[^\s'"]{8,}"#,
31    ),
32    ("Stripe Key", r"sk_(?:live|test)_[A-Za-z0-9]{24,}"),
33    ("GitHub Fine-Grained Token", r"github_pat_[A-Za-z0-9_]{82}"),
34    ("Slack Token", r"xox[bpas]-[A-Za-z0-9\-]+"),
35    ("Anthropic API Key", r"sk-ant-[A-Za-z0-9\-]+"),
36    (
37        "Private Key Header",
38        r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
39    ),
40];
41
42/// Generate the shell script content for the pre-commit hook.
43fn hook_script() -> String {
44    use std::fmt::Write;
45    let mut patterns = String::new();
46    for (name, pattern) in SECRET_PATTERNS {
47        let _ = write!(
48            patterns,
49            "    if echo \"$staged_content\" | grep -qE '{pattern}'; then\n\
50             \x20       echo \"  [!] Possible {name} found in staged files\"\n\
51             \x20       found=1\n\
52             \x20   fi\n",
53        );
54    }
55
56    format!(
57        r#"#!/bin/sh
58# EnvVault pre-commit hook — blocks commits containing hardcoded secrets.
59# Auto-installed by `envvault init`. Remove this file to disable.
60
61staged_content=$(git diff --cached --diff-filter=ACM -U0)
62found=0
63
64{patterns}
65if [ "$found" -eq 1 ]; then
66    echo ""
67    echo "  EnvVault: Potential secrets detected in staged files!"
68    echo "  Use 'envvault set <KEY>' to store secrets securely."
69    echo "  To bypass this check: git commit --no-verify"
70    echo ""
71    exit 1
72fi
73
74exit 0
75"#
76    )
77}
78
79/// Install the EnvVault pre-commit hook into the project's `.git/hooks/`.
80///
81/// If a pre-commit hook already exists, it is left untouched and a
82/// warning is returned instead of overwriting.
83pub fn install_hook(project_dir: &Path) -> Result<InstallResult> {
84    let git_dir = project_dir.join(".git");
85    if !git_dir.is_dir() {
86        return Ok(InstallResult::NotAGitRepo);
87    }
88
89    let hooks_dir = git_dir.join("hooks");
90    if !hooks_dir.exists() {
91        fs::create_dir_all(&hooks_dir).map_err(|e| {
92            EnvVaultError::CommandFailed(format!("failed to create hooks dir: {e}"))
93        })?;
94    }
95
96    let hook_path = hooks_dir.join(HOOK_NAME);
97
98    if hook_path.exists() {
99        // Check if it's our hook (contains our marker comment).
100        let existing = fs::read_to_string(&hook_path).unwrap_or_default();
101        if existing.contains("EnvVault pre-commit hook") {
102            return Ok(InstallResult::AlreadyInstalled);
103        }
104        return Ok(InstallResult::ExistingHookFound);
105    }
106
107    let script = hook_script();
108    fs::write(&hook_path, script).map_err(|e| {
109        EnvVaultError::CommandFailed(format!("failed to write pre-commit hook: {e}"))
110    })?;
111
112    // Make the hook executable on Unix.
113    #[cfg(unix)]
114    {
115        use std::os::unix::fs::PermissionsExt;
116        let perms = fs::Permissions::from_mode(0o755);
117        fs::set_permissions(&hook_path, perms).map_err(|e| {
118            EnvVaultError::CommandFailed(format!("failed to set hook permissions: {e}"))
119        })?;
120    }
121
122    Ok(InstallResult::Installed)
123}
124
125/// Result of attempting to install the pre-commit hook.
126pub enum InstallResult {
127    /// Hook was installed successfully.
128    Installed,
129    /// Our hook is already installed.
130    AlreadyInstalled,
131    /// A different pre-commit hook already exists (not ours).
132    ExistingHookFound,
133    /// Not inside a git repository.
134    NotAGitRepo,
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use tempfile::TempDir;
141
142    #[test]
143    fn install_hook_in_non_git_dir() {
144        let dir = TempDir::new().unwrap();
145        match install_hook(dir.path()).unwrap() {
146            InstallResult::NotAGitRepo => {}
147            _ => panic!("expected NotAGitRepo"),
148        }
149    }
150
151    #[test]
152    fn install_hook_creates_hook_file() {
153        let dir = TempDir::new().unwrap();
154        // Create a fake .git/hooks directory.
155        fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
156
157        match install_hook(dir.path()).unwrap() {
158            InstallResult::Installed => {}
159            _ => panic!("expected Installed"),
160        }
161
162        let hook_path = dir.path().join(".git/hooks/pre-commit");
163        assert!(hook_path.exists());
164
165        let content = fs::read_to_string(&hook_path).unwrap();
166        assert!(content.contains("EnvVault pre-commit hook"));
167    }
168
169    #[test]
170    fn install_hook_twice_returns_already_installed() {
171        let dir = TempDir::new().unwrap();
172        fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
173
174        install_hook(dir.path()).unwrap();
175
176        match install_hook(dir.path()).unwrap() {
177            InstallResult::AlreadyInstalled => {}
178            _ => panic!("expected AlreadyInstalled"),
179        }
180    }
181
182    #[test]
183    fn install_hook_respects_existing_hook() {
184        let dir = TempDir::new().unwrap();
185        let hooks_dir = dir.path().join(".git/hooks");
186        fs::create_dir_all(&hooks_dir).unwrap();
187
188        // Write a foreign pre-commit hook.
189        fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho hi\n").unwrap();
190
191        match install_hook(dir.path()).unwrap() {
192            InstallResult::ExistingHookFound => {}
193            _ => panic!("expected ExistingHookFound"),
194        }
195    }
196
197    #[test]
198    fn hook_script_contains_secret_patterns() {
199        let script = hook_script();
200        assert!(script.contains("AWS Access Key"));
201        assert!(script.contains("Stripe Key"));
202        assert!(script.contains("GitHub Fine-Grained Token"));
203        assert!(script.contains("Slack Token"));
204        assert!(script.contains("Anthropic API Key"));
205        assert!(script.contains("Private Key Header"));
206        assert!(script.contains("EnvVault"));
207    }
208}