keyhog 0.5.73

GPU-accelerated secret scanner for code, Git history, cloud, containers, browser assets, and live credential verification
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
//! Regression: `keyhog scan --stdin` positional-chunk semantics that the
//! sibling `regression_cli_scan_stdin.rs` (format/exit-code matrix) does NOT
//! cover: LINE/OFFSET fidelity over multi-line and multi-secret chunks, the
//! oversized-stdin fail-closed, byte-size flag validation, and the scan-path
//! CONTROL-BYTE sanitization contract observed end-to-end through the shipped
//! binary over a piped stdin.
//!
//! Every value below is asserted through the real binary. The piped secret is
//! a Slack **bot** token (`xoxb-` + two 13-digit groups + 24-character secret)
//! which fires `slack-bot-token` (service `slack`, severity `critical`,
//! confidence 1.0) deterministically on the path-less stdin chunk. Synthetic
//! fixture suppression is disabled explicitly so multi-finding tests exercise
//! repeated-detector behavior rather than known-example policy.
//!
//! Control-byte truth (proven here, not asserted from memory): the scan path
//! STRIPS non-whitespace C0 control bytes (0x08 backspace, 0x0C form-feed)
//! so a leading 0x0C leaves the token at offset 0 and a 0x08 spliced INTO the
//! token rejoins it to the exact same value/hash (an evasion that fails)
//! while it PRESERVES the whitespace controls (0x09 tab, 0x0D carriage
//! return), which shift the token to offset 1.
//!
//! Host-independent: uses `--backend cpu` (always available); the one `simd`
//! test asserts finding-parity OR the exit-3 fail-closed, never assuming an
//! accelerator. Every assertion pins a concrete value.

use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};

/// A Slack bot token proven to fire `slack-bot-token` on its own stdin bytes:
/// `xoxb-` + 13-digit + 13-digit + 24 alnum secret.
const TOKEN: &str = "xoxb-1234567890123-1234567890123-abcdefghijklmnopqrstuvwx";
/// A second, distinct valid bot token (different numeric groups + secret).
const TOKEN2: &str = "xoxb-9876543210987-8765432109876-Ab3Cd5Ef7Gh9Jk2Lm4Np6Qr8";

const DETECTOR_ID: &str = "slack-bot-token";
const DETECTOR_NAME: &str = "Slack Bot Token";
/// SHA-256 of the exact `TOKEN` bytes (`credential_hash` == sha256(value)).
const TOKEN_SHA256: &str = "a8dd917042994f6c6f183c6f0718ab4241065165b299050b51302d3167cc3901";
/// SHA-256 of the exact `TOKEN2` bytes.
const TOKEN2_SHA256: &str = "3b67577d54380c9ef8ac608f95f411da22f05eff991898431d88e5cde9e9749c";
const REDACTED: &str = "xoxb...uvwx";

fn binary() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}

/// Run `keyhog scan --daemon=off --backend <backend> --stdin --format <format>`
/// with `input` piped over stdin. Returns (exit code, stdout, stderr).
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,
        ],
    )
}

/// Run the binary with an explicit arg vector, piping `input` over stdin.
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
}

/// Parse stdout as the top-level JSON findings array.
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()
}

// ---------------------------------------------------------------------------
// LINE / OFFSET fidelity
// ---------------------------------------------------------------------------

/// A token on the THIRD line of a piped chunk must report `line: 3` and the
/// byte offset of the token within the chunk. The two preceding lines are
/// `"line one plain text\n"` (20 bytes) + `"second line also plain\n"`
/// (23 bytes) = 43, so the token starts at offset 43, proving stdin line/offset
/// is computed from the chunk, not reset to line 1 / offset 0.
#[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"
    );
}

/// Two DISTINCT valid tokens on two lines of one piped chunk yield exactly two
/// findings, each with its own hash and (line, offset): token1 at line 1 /
/// offset 0, token2 at line 2 / offset 58 (token1 is 57 bytes + one `\n`).
#[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)"
    );
}

/// SARIF path agrees with JSON on the two-secret chunk: exactly two results,
/// both `ruleId` == the slack detector. Guards a per-format recall hole where a
/// serializer collapses or drops one of two same-rule findings.
#[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"
    );
}

/// CSV path carries the multi-line token's exact `line`/`offset` cells (columns
/// 9 and 10 of the 20-field row): a token on line 3 at offset 43 must appear in
/// the sole data row as `...,stdin,,3,43,...`.
#[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");
}

// ---------------------------------------------------------------------------
// Control-byte sanitization (scan-path contract, observed over stdin)
// ---------------------------------------------------------------------------

/// A leading FORM-FEED (0x0C, a non-whitespace C0 control) is STRIPPED before
/// scanning: the token that followed the 0x0C byte reports offset 0 (not 1),
/// and hashes to the exact clean-token value. Proves 0x0C is removed, not kept.
#[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"
    );
}

/// A leading TAB (0x09, a whitespace control) is PRESERVED: the token reports
/// offset 1, the tab occupies byte 0. This is the negative twin of the 0x0C
/// case and proves whitespace controls are NOT stripped.
#[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"
    );
}

/// A leading CARRIAGE-RETURN (0x0D, a whitespace control) is likewise PRESERVED:
/// the token reports offset 1. Distinguishes 0x0D (kept) from 0x0C (stripped).
#[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"
    );
}

/// Adversarial evasion: a BACKSPACE (0x08) spliced INTO the middle of the token
/// is stripped, rejoining the two halves into the exact valid token, so the
/// split does NOT evade detection. The finding reports offset 0 and the clean
/// token's hash, proving the sanitizer defeats control-byte splitting.
#[test]
fn stdin_backspace_0x08_split_token_still_detected_same_hash() {
    // "xoxb-1234567890123-1234567890123" + 0x08 + "-abcdefghijklmnopqrstuvwx"
    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"
    );
}

// ---------------------------------------------------------------------------
// Byte-limit fail-closed + flag validation
// ---------------------------------------------------------------------------

/// Oversized stdin fails CLOSED: piping 100 bytes with `--limit-stdin-bytes 8B`
/// exits 13 (EXIT_SOURCE_FAILED), the scanner refuses to report "clean" for a
/// source it could not fully read, and stdout is the empty JSON array (no
/// partial findings).
#[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:?}"
    );
}

/// The oversized-stdin error is WRAPPED: the operator sees the inner reason
/// (`stdin exceeds 8 byte limit`) inside the `failed to read source: ... Fix:`
/// envelope, plus the top-level "Not reporting \"clean\"" refusal. Asserted via
/// `.contains()` on the inner reason (never a whole-string ==).
#[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}"
    );
}

/// Under-limit stdin scans cleanly: `abc\n` (4 bytes) with `--limit-stdin-bytes
/// 8B` is within cap, contains no secret, exits 0 with an empty array. Boundary
/// twin of the fail-closed test.
#[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");
}

/// `--limit-stdin-bytes` requires a unit suffix: a bare `8` (no `B`/`K`/...) is
/// a clap value-parser error -> exit 2 (EXIT_USER_ERROR) with the actionable
/// "missing a unit" message, and NO scan runs.
#[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}"
    );
}

// ---------------------------------------------------------------------------
// Backend host-independence
// ---------------------------------------------------------------------------

/// `--backend simd` over stdin is host-independent: on a build WITH the
/// Hyperscan prefilter it surfaces the same finding (exit 1, same detector id);
/// on a `ci`/no-prefilter build it FAILS CLOSED (exit 3) with the
/// "silent cpu-fallback execution is forbidden" refusal, never a silent
/// downgrade. Exactly one of those two outcomes must hold.
#[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}"
        ),
    }
}