history: prove the scanner can find a secret, not only that it stays quiet

Every existing test asserted the history scan reports nothing — on this
repository, which has never committed a credential. That is the wrong
direction to test alone: a detector exercised only against clean input
is indistinguishable from a function that returns an empty vector, and
this one has already shipped four false-negative-shaped bugs today.

The new test builds a repository from scratch by writing zlib-compressed
git objects directly — no subprocess, for the same reason the scanner
uses none — commits a key, and leaves no working-tree copy at all. Only
the history has it, which is the situation the whole module exists for.

It asserts the scan finds it, names the file, says revoke, and does not
carry the credential's value into the finding. The fixture key is
assembled at runtime so this source file does not itself contain a
credential-shaped string, which is the trap that made the detector
report its own definitions earlier today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dev 2026-08-21 13:47:55 -05:00
parent 0754cf75d0
commit 1f45e0c611
3 changed files with 96 additions and 0 deletions

1
Cargo.lock generated
View file

@ -1140,6 +1140,7 @@ dependencies = [
"hound-defs", "hound-defs",
"serde", "serde",
"serde_json", "serde_json",
"sha1",
] ]
[[package]] [[package]]

View file

@ -11,3 +11,6 @@ flate2 = "1"
hound-defs.workspace = true hound-defs.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
[dev-dependencies]
sha1 = "0.10"

View file

@ -311,6 +311,98 @@ mod tests {
/// The real thing, against this repository. It should walk a meaningful /// The real thing, against this repository. It should walk a meaningful
/// amount of history and find no credentials — this project has never /// amount of history and find no credentials — this project has never
/// committed one, and a false positive here would be caught immediately. /// 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<String> {
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 <t@t> 1000000000 +0000\ncommitter T <t@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::<Vec<_>>()
);
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] #[test]
fn scanning_this_repository_walks_history_and_finds_nothing() { fn scanning_this_repository_walks_history_and_finds_nothing() {
let r = scan(&workspace()); let r = scan(&workspace());