use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const BINARY: &str = env!("CARGO_BIN_EXE_secrets-le");
const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/documents");
const DEFAULT_ITERATIONS: usize = 50;
const PATIENCE: Duration = Duration::from_secs(30);
const MAX_CONTEXT_UNITS: usize = 300;
const PLANTED: [&str; 6] = [
"hunter2hunter2hunter2",
"aB3xY7zQ9mK2pL5vN8wR4tS6",
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"ghp_1234567890abcdefghijklmnopqrstuvwxyz",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N",
"postgres://user:pass@db.example.invalid:5432/app",
];
const KEYS: [&str; 12] = [
"password",
"DATABASE_PASSWORD",
"pwd",
"api_key",
"aws_secret_access_key",
"access_token",
"refresh_token",
"jwt",
"token",
"session_id",
"cookie",
"connection_string",
];
const AWKWARD: [&str; 14] = [
"'",
"\"",
"=",
":",
" ",
"\t",
"\r",
"\n",
"\u{feff}",
"\u{1f3af}",
"\u{e9}",
";",
"-",
"_",
];
struct Seeded(u32);
impl Seeded {
fn next(&mut self) -> u32 {
self.0 = self.0.wrapping_add(0x6d2b_79f5);
let mut t = self.0;
t = (t ^ (t >> 15)).wrapping_mul(t | 1);
t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 0x3d));
t ^ (t >> 14)
}
fn below(&mut self, limit: usize) -> usize {
(self.next() as usize) % limit.max(1)
}
fn pick<'a, T>(&mut self, from: &'a [T]) -> &'a T {
&from[self.below(from.len())]
}
}
fn seeds() -> Vec<String> {
let mut paths: Vec<std::path::PathBuf> = std::fs::read_dir(CORPUS)
.expect("the corpus is readable")
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
paths.sort();
let mut documents: Vec<String> = paths
.iter()
.filter_map(|path| std::fs::read_to_string(path).ok())
.collect();
assert!(!documents.is_empty(), "the corpus seeded nothing");
documents.push(String::new());
documents.push("\u{feff}".to_string());
documents.push("password=".to_string());
documents.push("-----BEGIN RSA PRIVATE KEY-----".to_string());
documents.push("\u{1f3af}".repeat(64));
documents.push("a".repeat(4_096));
documents
}
struct Case {
content: String,
present: Vec<&'static str>,
}
fn mutate(random: &mut Seeded, seeds: &[String]) -> Case {
let mut content: Vec<char> = random.pick(seeds).chars().collect();
for _ in 0..=random.below(6) {
match random.below(8) {
0 => {
let key = *random.pick(&KEYS);
let value = *random.pick(&PLANTED);
let at = random.below(content.len() + 1);
let insert: Vec<char> = format!("{key}={value}").chars().collect();
content.splice(at..at, insert);
}
1 => {
let value = *random.pick(&PLANTED);
let at = random.below(content.len() + 1);
content.splice(at..at, value.chars().collect::<Vec<_>>());
}
2 => {
let at = random.below(content.len() + 1);
let insert: Vec<char> = random.pick(&AWKWARD).chars().collect();
content.splice(at..at, insert);
}
3 => {
let run = 1 + random.below(2_000);
let at = random.below(content.len() + 1);
let insert: Vec<char> = std::iter::repeat_n('x', run).collect();
content.splice(at..at, insert);
}
4 if !content.is_empty() => {
let at = random.below(content.len());
let to = (at + 1 + random.below(64)).min(content.len());
content.drain(at..to);
}
5 if content.len() < 16_000 => {
let copy = content.clone();
content.extend(copy);
}
6 => content.retain(|character| *character != '\n'),
_ => {
let one = *random.pick(&PLANTED);
let two = *random.pick(&PLANTED);
let at = random.below(content.len() + 1);
let insert: Vec<char> = format!("password={one} api_key={two}\n").chars().collect();
content.splice(at..at, insert);
}
}
}
content.truncate(64_000);
let content: String = content.into_iter().collect();
let present = PLANTED
.iter()
.copied()
.filter(|value| content.contains(value))
.collect();
Case { content, present }
}
struct Answer {
code: Option<i32>,
stdout: String,
stderr: String,
}
fn ask(document: &[u8]) -> Answer {
let mut child = Command::new(BINARY)
.args(["--stdin", "--sensitivity", "low"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary runs");
let pid = child.id();
let _ = child.stdin.as_mut().expect("stdin").write_all(document);
drop(child.stdin.take());
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(child.wait_with_output());
});
match receiver.recv_timeout(PATIENCE) {
Ok(Ok(output)) => Answer {
code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
Ok(Err(error)) => panic!("the binary could not be run: {error}"),
Err(_) => {
terminate(pid);
panic!("a document took longer than {PATIENCE:?} — treat this as a hang");
}
}
}
fn terminate(pid: u32) {
#[cfg(unix)]
let _ = Command::new("kill").args(["-9", &pid.to_string()]).status();
#[cfg(windows)]
let _ = Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status();
}
fn preserve(seed: u32, iteration: usize, content: &str) -> String {
let path: PathBuf =
std::env::temp_dir().join(format!("secrets-le-fuzz-{seed}-{iteration}.txt"));
let written = std::fs::write(&path, content).is_ok();
let head: String = content.chars().take(300).collect();
format!(
"seed {seed}, iteration {iteration}\n reproduce: SECRETS_LE_FUZZ_SEED={seed}\n \
document ({} chars, first 300 shown): {head:?}\n full document: {}",
content.chars().count(),
if written {
path.display().to_string()
} else {
"could not be written".to_string()
}
)
}
fn assert_preview_sits_where_it_says(
document: &str,
finding: &serde_json::Value,
where_from: &dyn Fn() -> String,
) {
let preview = finding["preview"].as_str().expect("a preview");
let Some(head) = preview.split('…').next().filter(|head| !head.is_empty()) else {
return; };
let line_number = finding["line"].as_u64().expect("a line") as usize;
let column = finding["column"].as_u64().expect("a column") as usize;
let scanned = document.strip_prefix('\u{feff}').unwrap_or(document);
let Some(line) = scanned.split('\n').nth(line_number - 1) else {
panic!(
"the finding names line {line_number}, which is past the end\n{}",
where_from()
);
};
let units: Vec<u16> = line.encode_utf16().collect();
let at = column - 1;
let expected: Vec<u16> = head.encode_utf16().collect();
assert!(
at + expected.len() <= units.len(),
"the preview runs past the end of line {line_number}\n{}",
where_from()
);
assert_eq!(
&units[at..at + expected.len()],
expected.as_slice(),
"the preview does not match the document at {line_number}:{column} — the span the \
finding reports and the span the preview was cut from are not the same span\n{}",
where_from()
);
}
#[test]
fn no_generated_document_crashes_hangs_or_leaks() {
let seed: u32 = std::env::var("SECRETS_LE_FUZZ_SEED")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(0x5EC2_E751);
let budget: Option<Duration> = std::env::var("SECRETS_LE_FUZZ_SECONDS")
.ok()
.and_then(|value| value.parse().ok())
.map(Duration::from_secs);
let corpus = seeds();
let mut random = Seeded(seed);
let started = Instant::now();
let mut iteration = 0;
loop {
match budget {
Some(limit) if started.elapsed() >= limit => break,
None if iteration >= DEFAULT_ITERATIONS => break,
_ => {}
}
let case = mutate(&mut random, &corpus);
let answer = ask(case.content.as_bytes());
let where_from = || preserve(seed, iteration, &case.content);
let Some(code) = answer.code else {
panic!(
"the process died on a signal rather than answering\n{}\n{}",
where_from(),
answer.stderr
);
};
assert!(
(0..=2).contains(&code),
"exit {code} is not one of 0, 1, 2\n{}\n{}",
where_from(),
answer.stderr
);
for line in answer.stdout.lines().filter(|line| !line.trim().is_empty()) {
let report: serde_json::Value = serde_json::from_str(line).unwrap_or_else(|error| {
panic!("stdout is not JSON Lines: {error}\n{}", where_from())
});
assert!(
!report["diagnostics"]
.as_array()
.is_some_and(|list| list.iter().any(|d| d["code"] == "incomplete")),
"a detector gave up on a document under 64 KB, so this run reported no findings \
for a file it never finished reading\n{}\n{line}",
where_from()
);
for finding in report["findings"].as_array().into_iter().flatten() {
let Some(context) = finding["context"].as_str() else {
continue;
};
let units = context.encode_utf16().count();
assert!(
units <= MAX_CONTEXT_UNITS,
"a context line ran to {units} code units, past the {MAX_CONTEXT_UNITS} the \
window allows — every character past the window is source nobody asked to \
have printed\n{}",
where_from()
);
}
}
for line in answer.stdout.lines().filter(|line| !line.trim().is_empty()) {
let report: serde_json::Value =
serde_json::from_str(line).expect("stdout carries only JSON");
for finding in report["findings"].as_array().into_iter().flatten() {
assert_preview_sits_where_it_says(&case.content, finding, &where_from);
}
}
for value in &case.present {
assert!(
!answer.stdout.contains(value),
"a planted value reached stdout: {value}\n{}",
where_from()
);
assert!(
!answer.stderr.contains(value),
"a reported value reached stderr: {value}\n{}",
where_from()
);
}
iteration += 1;
}
assert!(iteration > 0, "the fuzzer ran no iterations at all");
eprintln!(
"fuzz: {iteration} documents, seed {seed}, {:?}",
started.elapsed()
);
}
#[test]
fn invalid_input_is_refused_rather_than_survived() {
for (name, bytes) in [
("a lone continuation byte", vec![0x80]),
("a truncated three-byte sequence", vec![0xe2, 0x82]),
("a truncated four-byte sequence", vec![0xf0, 0x9f, 0x8e]),
("an overlong encoding", vec![0xc0, 0xaf]),
("a surrogate half", vec![0xed, 0xa0, 0x80]),
(
"invalid bytes after a credential",
[
b"DATABASE_PASSWORD=hunter2hunter2\n".as_slice(),
&[0xff, 0xfe],
]
.concat(),
),
] {
let answer = ask(&bytes);
assert_eq!(
answer.code,
Some(2),
"{name}: a document that is not text is a malformed question\n{}",
answer.stderr
);
assert!(
answer.stdout.is_empty(),
"{name}: a refusal writes no report"
);
assert!(
!answer.stderr.contains("hunter2hunter2"),
"{name}: the refusal quoted the document back"
);
}
}