diff --git a/Cargo.lock b/Cargo.lock index f2bb0d5..c041c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,6 +1140,7 @@ dependencies = [ "hound-defs", "serde", "serde_json", + "sha1", ] [[package]] diff --git a/crates/hound-supply/Cargo.toml b/crates/hound-supply/Cargo.toml index 7de08df..a43841e 100644 --- a/crates/hound-supply/Cargo.toml +++ b/crates/hound-supply/Cargo.toml @@ -11,3 +11,6 @@ flate2 = "1" hound-defs.workspace = true serde.workspace = true serde_json.workspace = true + +[dev-dependencies] +sha1 = "0.10" diff --git a/crates/hound-supply/src/history.rs b/crates/hound-supply/src/history.rs index d29ec76..76de175 100644 --- a/crates/hound-supply/src/history.rs +++ b/crates/hound-supply/src/history.rs @@ -311,6 +311,98 @@ mod tests { /// The real thing, against this repository. It should walk a meaningful /// amount of history and find no credentials — this project has never /// committed one, and a false positive here would be caught immediately. + /// Build a small repository with a secret committed and then deleted, + /// entirely by writing git objects — no subprocess, for the same reason + /// the scanner uses none. + /// + /// This is the direction that matters. Every other test here proves the + /// scanner stays quiet; this one proves it can speak. A detector only + /// ever tested against clean input is a function that returns the empty + /// vector. + fn planted_repo(dir: &Path, secret: &str) -> std::io::Result<()> { + use flate2::write::ZlibEncoder; + use flate2::Compression; + use sha1::{Digest, Sha1}; + use std::io::Write as _; + + let objects = dir.join(".git").join("objects"); + std::fs::create_dir_all(&objects)?; + std::fs::create_dir_all(dir.join(".git").join("refs").join("heads"))?; + + // Write one object and return its id, exactly as git stores them. + let write_object = |kind: &str, body: &[u8]| -> std::io::Result { + let mut raw = format!("{kind} {}\0", body.len()).into_bytes(); + raw.extend_from_slice(body); + let oid: String = Sha1::digest(&raw).iter().map(|b| format!("{b:02x}")).collect(); + let d = objects.join(&oid[0..2]); + std::fs::create_dir_all(&d)?; + let mut e = ZlibEncoder::new(Vec::new(), Compression::default()); + e.write_all(&raw)?; + std::fs::write(d.join(&oid[2..]), e.finish()?)?; + Ok(oid) + }; + + let blob = write_object("blob", format!("api_key = \"{secret}\"\n").as_bytes())?; + let mut tree = Vec::new(); + tree.extend_from_slice(b"100644 config.py\0"); + for i in (0..40).step_by(2) { + tree.push(u8::from_str_radix(&blob[i..i + 2], 16).unwrap()); + } + let tree_oid = write_object("tree", &tree)?; + let commit = format!( + "tree {tree_oid}\nauthor T 1000000000 +0000\ncommitter T 1000000000 +0000\n\nadd config\n" + ); + let commit_oid = write_object("commit", commit.as_bytes())?; + std::fs::write(dir.join(".git").join("HEAD"), "ref: refs/heads/main\n")?; + std::fs::write( + dir.join(".git").join("refs").join("heads").join("main"), + format!("{commit_oid}\n"), + )?; + Ok(()) + } + + /// A key committed and then deleted is still in the history, still in + /// every clone, and still working unless somebody revoked it. Note there + /// is no working-tree copy here at all — only history. + #[test] + fn a_secret_committed_and_deleted_is_still_found() { + let dir = std::env::temp_dir().join(format!("hound-hist-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // Assembled at runtime so this source file does not itself contain a + // credential-shaped string. + let secret = format!("ghp_{}", "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8"); + planted_repo(&dir, &secret).unwrap(); + + assert!( + !dir.join("config.py").exists(), + "the file is gone from the working tree; only history has it" + ); + + let r = scan(&dir); + assert_eq!(r.commits_walked, 1, "one commit to walk"); + assert_eq!( + r.findings.len(), + 1, + "the planted key must be found, got {:?}", + r.findings.iter().map(|f| &f.subject).collect::>() + ); + let f = &r.findings[0]; + assert_eq!(f.severity, Severity::Critical); + assert!(f.subject.contains("config.py"), "and name the file: {}", f.subject); + assert!( + f.advice.to_lowercase().contains("revoke"), + "the only real remedy is revocation: {}", + f.advice + ); + // The finding must not carry the key itself. + assert!( + !format!("{f:?}").contains(&secret), + "the finding leaked the credential it found" + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn scanning_this_repository_walks_history_and_finds_nothing() { let r = scan(&workspace());