use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
const TOKEN: &str = "xoxb-1234567890123-1234567890123-abcdefghijklmnopqrstuvwx";
const TOKEN2: &str = "xoxb-9876543210987-8765432109876-Ab3Cd5Ef7Gh9Jk2Lm4Np6Qr8";
const DETECTOR_ID: &str = "slack-bot-token";
const DETECTOR_NAME: &str = "Slack Bot Token";
const TOKEN_SHA256: &str = "a8dd917042994f6c6f183c6f0718ab4241065165b299050b51302d3167cc3901";
const TOKEN2_SHA256: &str = "3b67577d54380c9ef8ac608f95f411da22f05eff991898431d88e5cde9e9749c";
const REDACTED: &str = "xoxb...uvwx";
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
fn run(input: &[u8], backend: &str, format: &str) -> (Option<i32>, String, String) {
run_args(
input,
&[
"scan",
"--daemon=off",
"--backend",
backend,
"--no-suppress-test-fixtures",
"--stdin",
"--format",
format,
],
)
}
fn run_args(input: &[u8], args: &[&str]) -> (Option<i32>, String, String) {
let mut child = Command::new(binary())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn keyhog scan --stdin");
child
.stdin
.take()
.expect("child stdin handle")
.write_all(input)
.expect("pipe input to stdin");
let out = child.wait_with_output().expect("wait keyhog scan --stdin");
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn parse_csv_row(row: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut chars = row.chars().peekable();
while let Some(ch) = chars.next() {
if quoted {
match ch {
'"' if chars.peek() == Some(&'"') => {
field.push('"');
chars.next();
}
'"' => quoted = false,
_ => field.push(ch),
}
} else {
match ch {
'"' if field.is_empty() => quoted = true,
',' => fields.push(std::mem::take(&mut field)),
_ => field.push(ch),
}
}
}
fields.push(field);
fields
}
fn json_findings(out: &str) -> Vec<serde_json::Value> {
let v: serde_json::Value = serde_json::from_str(out).expect("stdin json stdout must parse");
v.as_array()
.expect("stdin json report must be a top-level ARRAY")
.clone()
}
#[test]
fn stdin_multiline_token_reports_exact_line_3_and_offset_43() {
let input = format!("line one plain text\nsecond line also plain\n{TOKEN}\n");
let (code, out, err) = run(input.as_bytes(), "cpu", "json");
assert_eq!(
code,
Some(1),
"multiline stdin finding exits 1; stderr={err}"
);
let f = json_findings(&out);
assert_eq!(f.len(), 1, "one token on line 3 -> one finding, got {f:?}");
assert_eq!(
f[0].pointer("/location/line").and_then(|x| x.as_u64()),
Some(3),
"token on the third piped line must report line 3"
);
assert_eq!(
f[0].pointer("/location/offset").and_then(|x| x.as_u64()),
Some(43),
"token must report byte offset 43 (20 + 23 bytes of preceding lines)"
);
assert_eq!(
f[0].get("credential_hash").and_then(|x| x.as_str()),
Some(TOKEN_SHA256),
"the exact token bytes are hashed regardless of chunk position"
);
}
#[test]
fn stdin_two_secrets_yield_two_findings_distinct_hash_line_offset() {
let input = format!("{TOKEN}\n{TOKEN2}\n");
let (code, out, _err) = run(input.as_bytes(), "cpu", "json");
assert_eq!(code, Some(1), "two-secret stdin scan exits 1");
let f = json_findings(&out);
assert_eq!(f.len(), 2, "two distinct tokens -> two findings, got {f:?}");
let first = f
.iter()
.find(|finding| {
finding
.get("credential_hash")
.and_then(|value| value.as_str())
== Some(TOKEN_SHA256)
})
.expect("TOKEN finding");
let second = f
.iter()
.find(|finding| {
finding
.get("credential_hash")
.and_then(|value| value.as_str())
== Some(TOKEN2_SHA256)
})
.expect("TOKEN2 finding");
assert_eq!(
first.pointer("/location/line").and_then(|x| x.as_u64()),
Some(1),
"first token on line 1"
);
assert_eq!(
first.pointer("/location/offset").and_then(|x| x.as_u64()),
Some(0),
"first token at offset 0"
);
assert_eq!(
second.pointer("/location/line").and_then(|x| x.as_u64()),
Some(2),
"second token on line 2"
);
assert_eq!(
second.pointer("/location/offset").and_then(|x| x.as_u64()),
Some(58),
"second token at offset 58 (57-byte token1 + newline)"
);
}
#[test]
fn stdin_sarif_two_secrets_produce_two_results_same_ruleid() {
let input = format!("{TOKEN}\n{TOKEN2}\n");
let (code, out, _err) = run(input.as_bytes(), "cpu", "sarif");
assert_eq!(code, Some(1), "two-secret sarif scan exits 1");
let v: serde_json::Value = serde_json::from_str(&out).expect("sarif must parse");
let results = v
.pointer("/runs/0/results")
.and_then(|r| r.as_array())
.expect("sarif runs[0].results array");
assert_eq!(results.len(), 2, "two piped secrets -> two SARIF results");
let ids: Vec<Option<&str>> = results
.iter()
.map(|r| r.get("ruleId").and_then(|x| x.as_str()))
.collect();
assert_eq!(
ids,
vec![Some(DETECTOR_ID), Some(DETECTOR_ID)],
"both SARIF results carry the slack-bot-token ruleId"
);
}
#[test]
fn stdin_csv_multiline_row_has_line_3_offset_43_cells() {
let input = format!("line one plain text\nsecond line also plain\n{TOKEN}\n");
let (code, out, _err) = run(input.as_bytes(), "cpu", "csv");
assert_eq!(code, Some(1), "multiline csv scan exits 1");
let row = out
.lines()
.filter(|l| !l.is_empty() && !l.starts_with("# keyhog.scan.metadata="))
.nth(1)
.expect("csv must have one data row after the header");
let expected_prefix = format!(
"{DETECTOR_ID},{DETECTOR_NAME},slack,critical,{REDACTED},{TOKEN_SHA256},{{}},stdin,,3,43,"
);
assert!(
row.starts_with(&expected_prefix),
"csv data row must encode line 3 / offset 43 for the piped token;\ngot: {row}\nwant: {expected_prefix}"
);
let field_count = parse_csv_row(row).len();
assert_eq!(field_count, 20, "csv data row must have exactly 20 fields");
}
#[test]
fn stdin_leading_formfeed_0x0c_stripped_token_at_offset_0() {
let mut input = vec![0x0Cu8];
input.extend_from_slice(TOKEN.as_bytes());
input.push(b'\n');
let (code, out, _err) = run(&input, "cpu", "json");
assert_eq!(code, Some(1), "form-feed + token scan exits 1");
let f = json_findings(&out);
assert_eq!(f.len(), 1, "one token after a stripped 0x0C -> one finding");
assert_eq!(
f[0].pointer("/location/offset").and_then(|x| x.as_u64()),
Some(0),
"leading 0x0C is stripped, so the token sits at offset 0"
);
assert_eq!(
f[0].get("credential_hash").and_then(|x| x.as_str()),
Some(TOKEN_SHA256),
"the stripped control byte is not part of the hashed value"
);
}
#[test]
fn stdin_leading_tab_0x09_preserved_token_at_offset_1() {
let mut input = vec![b'\t'];
input.extend_from_slice(TOKEN.as_bytes());
input.push(b'\n');
let (code, out, _err) = run(&input, "cpu", "json");
assert_eq!(code, Some(1), "tab + token scan exits 1");
let f = json_findings(&out);
assert_eq!(
f[0].pointer("/location/offset").and_then(|x| x.as_u64()),
Some(1),
"leading 0x09 tab is kept, shifting the token to offset 1"
);
assert_eq!(
f[0].get("credential_hash").and_then(|x| x.as_str()),
Some(TOKEN_SHA256),
"the token value is unaffected by the preserved tab"
);
}
#[test]
fn stdin_leading_cr_0x0d_preserved_token_at_offset_1() {
let mut input = vec![b'\r'];
input.extend_from_slice(TOKEN.as_bytes());
input.push(b'\n');
let (code, out, _err) = run(&input, "cpu", "json");
assert_eq!(code, Some(1), "cr + token scan exits 1");
let f = json_findings(&out);
assert_eq!(
f[0].pointer("/location/offset").and_then(|x| x.as_u64()),
Some(1),
"leading 0x0D carriage-return is kept, shifting the token to offset 1"
);
}
#[test]
fn stdin_backspace_0x08_split_token_still_detected_same_hash() {
let mut input = b"xoxb-1234567890123-1234567890123".to_vec();
input.push(0x08);
input.extend_from_slice(b"-abcdefghijklmnopqrstuvwx\n");
let (code, out, _err) = run(&input, "cpu", "json");
assert_eq!(
code,
Some(1),
"a 0x08-split token must still be detected (exit 1), not evade"
);
let f = json_findings(&out);
assert_eq!(f.len(), 1, "the rejoined token yields exactly one finding");
assert_eq!(
f[0].get("credential_hash").and_then(|x| x.as_str()),
Some(TOKEN_SHA256),
"stripping the 0x08 rejoins the exact clean token -> identical hash"
);
assert_eq!(
f[0].pointer("/location/offset").and_then(|x| x.as_u64()),
Some(0),
"the rejoined token sits at offset 0"
);
}
#[test]
fn stdin_oversized_input_fails_closed_exit_13_empty_stdout() {
let big = vec![b'a'; 100];
let (code, out, _err) = run_args(
&big,
&[
"scan",
"--daemon=off",
"--backend",
"cpu",
"--stdin",
"--limit-stdin-bytes",
"8B",
"--format",
"json",
],
);
assert_eq!(
code,
Some(13),
"stdin over the byte cap must fail closed with EXIT_SOURCE_FAILED (13)"
);
assert_eq!(
out.trim_end(),
"",
"a failed-closed stdin scan (exit 13) emits nothing on stdout, the error is \
reported on stderr, not an empty JSON array; got: {out:?}"
);
}
#[test]
fn stdin_oversized_error_surfaces_inner_reason_and_refusal() {
let big = vec![b'a'; 100];
let (_code, _out, err) = run_args(
&big,
&[
"scan",
"--daemon=off",
"--backend",
"cpu",
"--stdin",
"--limit-stdin-bytes",
"8B",
"--format",
"json",
],
);
assert!(
err.contains("failed to read source:"),
"source error must be wrapped in the 'failed to read source:' envelope; stderr:\n{err}"
);
assert!(
err.contains("stdin exceeds 8 byte limit"),
"the inner reason (byte-limit) must be surfaced; stderr:\n{err}"
);
assert!(
err.contains("Not reporting \"clean\""),
"an incomplete stdin scan must loudly refuse to report clean; stderr:\n{err}"
);
}
#[test]
fn stdin_under_byte_limit_scans_clean_exit_0() {
let (code, out, err) = run_args(
b"abc\n",
&[
"scan",
"--daemon=off",
"--backend",
"cpu",
"--stdin",
"--limit-stdin-bytes",
"8B",
"--format",
"json",
],
);
assert_eq!(code, Some(0), "under-cap clean stdin exits 0; stderr={err}");
assert_eq!(out.trim_end(), "[]", "under-cap clean stdin -> empty array");
}
#[test]
fn stdin_bad_byte_limit_missing_unit_exit_2() {
let (code, _out, err) = run_args(
b"abc\n",
&[
"scan",
"--daemon=off",
"--backend",
"cpu",
"--stdin",
"--limit-stdin-bytes",
"8",
"--format",
"json",
],
);
assert_eq!(
code,
Some(2),
"an unparseable --limit-stdin-bytes is a user error (exit 2)"
);
assert!(
err.contains("missing a unit"),
"the byte-size parse error must name the missing unit; stderr:\n{err}"
);
}
#[test]
fn stdin_simd_backend_surfaces_finding_or_fails_closed() {
let input = format!("{TOKEN}\n");
let (code, out, err) = run(input.as_bytes(), "simd", "json");
match code {
Some(1) => {
let f = json_findings(&out);
assert_eq!(
f[0].get("detector_id").and_then(|x| x.as_str()),
Some(DETECTOR_ID),
"simd path (when available) surfaces the same slack-bot-token id"
);
}
Some(3) => {
assert!(
err.contains("silent cpu-fallback execution is forbidden"),
"a simd build without a prefilter must fail closed (exit 3) with the \
forbidden-fallback message, not degrade silently; stderr:\n{err}"
);
}
other => panic!(
"simd stdin scan must either surface the finding (exit 1) or fail closed \
(exit 3); got exit {other:?}\nstdout:\n{out}\nstderr:\n{err}"
),
}
}