use anyhow::{Context, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use sysinfo::{Pid, System};
const BYPASS_ENV: &str = "DID_GIT_SIGN_BYPASS_POLICY";
const ALLOWED_PARENTS: &[&str] = &["git", "ssh-keygen"];
fn parent_is_allowed(name: &str) -> bool {
let token = name.strip_suffix(".exe").unwrap_or(name);
ALLOWED_PARENTS
.iter()
.any(|allowed| token == *allowed || (*allowed == "git" && token.starts_with("git-")))
}
#[derive(Debug, Clone, Serialize)]
pub struct AuditEntry {
pub timestamp_utc: String,
pub action: &'static str,
pub allowed: bool,
pub parent_pid: Option<u32>,
pub parent_name: Option<String>,
pub namespace: String,
pub buffer_path: Option<String>,
pub buffer_sha256: String,
pub bypass: bool,
}
pub fn evaluate(
namespace: &str,
buffer_path: Option<&std::path::Path>,
buffer: &[u8],
) -> AuditEntry {
let bypass =
std::env::var(BYPASS_ENV).is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let (parent_pid, parent_name) = parent_process_info();
let parent_token = parent_name
.as_deref()
.and_then(|n| n.split_whitespace().next())
.map(|s| s.to_lowercase());
let parent_ok = parent_token.as_deref().is_some_and(parent_is_allowed);
let allowed = bypass || parent_ok;
let mut hasher = Sha256::new();
hasher.update(buffer);
let buffer_sha256 = hex::encode(hasher.finalize());
AuditEntry {
timestamp_utc: chrono::Utc::now().to_rfc3339(),
action: "sign",
allowed,
parent_pid,
parent_name,
namespace: namespace.to_string(),
buffer_path: buffer_path.map(|p| p.display().to_string()),
buffer_sha256,
bypass,
}
}
pub fn write_audit(entry: &AuditEntry) {
if let Err(e) = try_write_audit(entry) {
let path = audit_log_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<audit log unavailable>".to_string());
eprintln!(
"did-git-sign: WARNING — could not record this signing attempt in {path}: {e}\n\
did-git-sign: the signature was still produced; the audit trail is incomplete."
);
tracing::warn!("did-git-sign audit log write failed: {e}");
}
}
fn try_write_audit(entry: &AuditEntry) -> Result<()> {
let path = audit_log_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create audit dir {}", parent.display()))?;
}
let line = serde_json::to_string(entry)?;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
use std::io::Write as _;
let mut f = opts
.open(&path)
.with_context(|| format!("open audit log {}", path.display()))?;
writeln!(f, "{line}").with_context(|| format!("write audit log {}", path.display()))?;
Ok(())
}
pub fn audit_log_path() -> Result<PathBuf> {
let dir = dirs::config_dir().context("could not determine config directory")?;
Ok(dir.join("did-git-sign").join("audit.log"))
}
fn parent_process_info() -> (Option<u32>, Option<String>) {
let mut sys = System::new();
let self_pid = std::process::id();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, false);
let parent_pid = sys
.process(Pid::from_u32(self_pid))
.and_then(|p| p.parent())
.map(|p| p.as_u32());
let parent_name = parent_pid.and_then(|pid| {
sys.process(Pid::from_u32(pid))
.map(|p| p.name().to_string_lossy().to_string())
});
(parent_pid, parent_name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_and_its_subcommand_binaries_may_sign() {
for name in ["git", "git-remote-https", "git-lfs", "ssh-keygen"] {
assert!(parent_is_allowed(name), "must still sign for {name}");
}
}
#[test]
fn a_windows_exe_suffix_is_stripped() {
assert!(parent_is_allowed("git.exe"));
assert!(parent_is_allowed("ssh-keygen.exe"));
}
#[test]
fn programs_that_merely_start_with_an_allowed_name_may_not_sign() {
for name in [
"gitleaks",
"github-desktop",
"gitfoo",
"gitk-evil",
"ssh-keygen-wrapper",
"not-git",
"",
] {
assert!(
!parent_is_allowed(name),
"{name} must not satisfy the parent check"
);
}
}
#[test]
fn audit_entry_is_json_serializable() {
let entry = AuditEntry {
timestamp_utc: "2026-05-05T00:00:00Z".to_string(),
action: "sign",
allowed: true,
parent_pid: Some(123),
parent_name: Some("git".to_string()),
namespace: "git".to_string(),
buffer_path: Some("/tmp/buffer".to_string()),
buffer_sha256: "deadbeef".to_string(),
bypass: false,
};
let s = serde_json::to_string(&entry).unwrap();
assert!(s.contains("\"allowed\":true"));
assert!(s.contains("\"parent_name\":\"git\""));
}
#[test]
fn evaluate_records_buffer_hash() {
let entry = evaluate("git", None, b"hello");
assert_eq!(
entry.buffer_sha256,
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn evaluate_bypass_env_allows_unknown_parent() {
unsafe { std::env::set_var(BYPASS_ENV, "1") };
let entry = evaluate("git", None, b"x");
unsafe { std::env::remove_var(BYPASS_ENV) };
assert!(entry.allowed);
assert!(entry.bypass);
}
}