Skip to main content

solid_pod_rs_git/
hardened.rs

1//! Hardened `git` subprocess constructors.
2//!
3//! Pod repositories live in user-writable data trees, so a repository's own
4//! `.git/config` and `.git/hooks` are attacker-influenced. Every production
5//! `git` spawn in the workspace goes through [`hardened_git`] (std) or
6//! [`hardened_git_async`] (tokio) so that:
7//!
8//! * repo-local hooks never run (`core.hooksPath=/dev/null`),
9//! * `core.fsmonitor` cannot name a program (`core.fsmonitor=false`),
10//! * `core.sshCommand` and `commit.gpgSign` cannot spawn helpers,
11//! * system and global config are ignored (`GIT_CONFIG_NOSYSTEM=1`,
12//!   `GIT_CONFIG_GLOBAL=/dev/null`),
13//! * git never blocks on a credential prompt (`GIT_TERMINAL_PROMPT=0`).
14//!
15//! # Example
16//!
17//! ```
18//! let mut cmd = solid_pod_rs_git::hardened_git();
19//! cmd.arg("--version");
20//! # let _ = cmd;
21//! ```
22
23use std::process::Command;
24
25/// `-c key=value` overrides applied before any caller arguments.
26pub const HARDENED_CONFIG: [(&str, &str); 4] = [
27    ("core.fsmonitor", "false"),
28    ("core.hooksPath", "/dev/null"),
29    ("core.sshCommand", "false"),
30    ("commit.gpgSign", "false"),
31];
32
33/// Environment variables applied to every hardened invocation.
34pub const HARDENED_ENV: [(&str, &str); 3] = [
35    ("GIT_CONFIG_NOSYSTEM", "1"),
36    ("GIT_CONFIG_GLOBAL", "/dev/null"),
37    ("GIT_TERMINAL_PROMPT", "0"),
38];
39
40/// Build a `git` [`std::process::Command`] with hooks, fsmonitor, ssh/gpg
41/// helpers and ambient config disabled. Append the subcommand and arguments
42/// as usual.
43#[must_use]
44pub fn hardened_git() -> Command {
45    let mut cmd = Command::new("git");
46    for (k, v) in HARDENED_CONFIG {
47        cmd.arg("-c").arg(format!("{k}={v}"));
48    }
49    for (k, v) in HARDENED_ENV {
50        cmd.env(k, v);
51    }
52    cmd
53}
54
55/// Tokio flavour of [`hardened_git`].
56#[must_use]
57pub fn hardened_git_async() -> tokio::process::Command {
58    tokio::process::Command::from(hardened_git())
59}
60
61/// Environment entries that harden an externally-spawned git program (the
62/// `git-http-backend` CGI) whose environment is built by hand. Uses git's
63/// `GIT_CONFIG_COUNT` mechanism because the CGI cannot take `-c` flags.
64#[must_use]
65pub fn hardened_cgi_env() -> Vec<(String, String)> {
66    let mut env: Vec<(String, String)> = HARDENED_ENV
67        .iter()
68        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
69        .collect();
70    env.push(("GIT_CONFIG_COUNT".into(), HARDENED_CONFIG.len().to_string()));
71    for (i, (k, v)) in HARDENED_CONFIG.iter().enumerate() {
72        env.push((format!("GIT_CONFIG_KEY_{i}"), (*k).to_string()));
73        env.push((format!("GIT_CONFIG_VALUE_{i}"), (*v).to_string()));
74    }
75    env
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::process::Stdio;
82
83    fn git_ok(dir: &std::path::Path, args: &[&str]) -> bool {
84        hardened_git()
85            .args(args)
86            .current_dir(dir)
87            .stdout(Stdio::null())
88            .stderr(Stdio::null())
89            .status()
90            .map(|s| s.success())
91            .unwrap_or(false)
92    }
93
94    #[test]
95    fn repo_local_hooks_do_not_fire() {
96        let td = tempfile::TempDir::new().unwrap();
97        let repo = td.path();
98        if Command::new("git").arg("--version").output().is_err() {
99            return;
100        }
101        assert!(git_ok(repo, &["init", "-q", "-b", "main"]));
102        let marker = repo.join("HOOK_FIRED");
103        for name in ["pre-commit", "post-checkout", "post-commit"] {
104            let hook = repo.join(".git/hooks").join(name);
105            std::fs::write(&hook, format!("#!/bin/sh\ntouch '{}'\n", marker.display())).unwrap();
106            #[cfg(unix)]
107            {
108                use std::os::unix::fs::PermissionsExt;
109                std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
110            }
111        }
112        std::fs::write(repo.join("f.txt"), "x").unwrap();
113        assert!(git_ok(repo, &["add", "f.txt"]));
114        assert!(git_ok(
115            repo,
116            &[
117                "-c",
118                "user.name=t",
119                "-c",
120                "user.email=t@e",
121                "commit",
122                "-q",
123                "-m",
124                "m"
125            ]
126        ));
127        assert!(git_ok(repo, &["checkout", "-q", "-b", "other"]));
128        assert!(!marker.exists(), "a repo-local hook fired");
129
130        // Control: the plain constructor does fire the hook (proves the hook
131        // is genuinely executable, so the assertion above is meaningful).
132        let ok = Command::new("git")
133            .args(["checkout", "-q", "main"])
134            .current_dir(repo)
135            .env("GIT_CONFIG_GLOBAL", "/dev/null")
136            .status()
137            .unwrap()
138            .success();
139        assert!(ok);
140        #[cfg(unix)]
141        assert!(marker.exists(), "control: plain git should fire the hook");
142    }
143
144    #[test]
145    fn repo_local_fsmonitor_is_neutralised() {
146        let td = tempfile::TempDir::new().unwrap();
147        let repo = td.path();
148        if Command::new("git").arg("--version").output().is_err() {
149            return;
150        }
151        assert!(git_ok(repo, &["init", "-q"]));
152        let marker = repo.join("FSMON_FIRED");
153        let script = repo.join("fsmon.sh");
154        std::fs::write(
155            &script,
156            format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
157        )
158        .unwrap();
159        #[cfg(unix)]
160        {
161            use std::os::unix::fs::PermissionsExt;
162            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
163        }
164        assert!(Command::new("git")
165            .args(["config", "--local", "core.fsmonitor"])
166            .arg(script.display().to_string())
167            .current_dir(repo)
168            .status()
169            .unwrap()
170            .success());
171        assert!(git_ok(repo, &["status"]));
172        assert!(!marker.exists(), "repo-local fsmonitor fired");
173    }
174
175    #[test]
176    fn cgi_env_has_matching_count() {
177        let env = hardened_cgi_env();
178        let count: usize = env
179            .iter()
180            .find(|(k, _)| k == "GIT_CONFIG_COUNT")
181            .unwrap()
182            .1
183            .parse()
184            .unwrap();
185        assert_eq!(count, HARDENED_CONFIG.len());
186        assert!(env
187            .iter()
188            .any(|(k, v)| k == "GIT_CONFIG_KEY_1" && v == "core.hooksPath"));
189    }
190}