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:
parent
0754cf75d0
commit
1f45e0c611
3 changed files with 96 additions and 0 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1140,6 +1140,7 @@ dependencies = [
|
|||
"hound-defs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -11,3 +11,6 @@ flate2 = "1"
|
|||
hound-defs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
sha1 = "0.10"
|
||||
|
|
|
|||
|
|
@ -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<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]
|
||||
fn scanning_this_repository_walks_history_and_finds_nothing() {
|
||||
let r = scan(&workspace());
|
||||
|
|
|
|||
Loading…
Reference in a new issue