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
//! Regression: `keyhog scan --format {json,sarif,text,csv}` render the SAME
//! planted secret through the REAL shipped binary, each in its format's exact
//! structure, with exit codes that agree across formats.
//!
//! One secret is planted: a GitHub classic PAT with a valid trailing byte-run
//! (`ghp_` + 36 alnum), which fires the `github-classic-pat` detector
//! (severity `critical`, service `github`, name "GitHub Classic PAT"). Every
//! format must surface THAT detector id, and:
//!   * json  -> a JSON ARRAY whose [0].detector_id is the id
//!   * sarif -> runs[0].results[0].ruleId is the id, level `error` (critical),
//!              rule security-severity band "9.5"
//!   * text  -> the "1 secret found" roll-up + "CRITICAL" label + detector name
//!   * csv   -> the exact documented header, then a data row whose first cells
//!              are `github-classic-pat,GitHub Classic PAT,github,critical,...`
//!
//! Negative twins: a clean file must exit 0 in every format and produce that
//! format's honest empty shape (`[]`, CSV metadata plus header-only data, zero SARIF results, the
//! "No secrets detected" text line).
//!
//! Every assertion pins a concrete value (exact bool / int / string / bytes /
//! exit code). None is a bare `!is_empty` / `is_ok`. Deterministic: one planted
//! secret, `--daemon=off`, `--backend cpu`.

use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;

/// A planted GitHub classic PAT proven (by the format/backend parity e2e) to
/// fire `github-classic-pat` on its own bytes: `ghp_` + 36 alphanumerics with a
/// clean right boundary.
const PLANTED: &str = "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";

/// The detector id every format must carry for the planted secret.
const DETECTOR_ID: &str = "github-classic-pat";
/// The human-facing detector name (CSV column 2, text block).
const DETECTOR_NAME: &str = "GitHub Classic PAT";
/// The exact CSV header line the reporter writes (from `CsvReporter::new`).
const CSV_HEADER: &str = "detector_id,detector_name,service,severity,credential_redacted,credential_hash,companions_redacted,source,file_path,line,offset,commit,author,date,verification,confidence,entropy,remediation,metadata,additional_locations";

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

/// Plant the PAT in a `.env` file inside a fresh tempdir.
fn leak_fixture() -> (TempDir, PathBuf) {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("dump.txt");
    // Bare token on its own line: fires github-classic-pat on the literal shape
    // and carries NO key=value keyword context, so no generic keyword detector
    // co-fires. Exactly one finding is produced.
    std::fs::write(&path, format!("{PLANTED}\n")).expect("write leak fixture");
    (dir, path)
}

/// A file with no credential-shaped content at all.
fn clean_fixture() -> (TempDir, PathBuf) {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("notes.txt");
    // Deliberately avoids credential-bridge keywords (secret/key/token/
    // password/api) so nothing fires (a true negative twin).
    std::fs::write(
        &path,
        "just ordinary prose with plain everyday words here\n",
    )
    .expect("write clean fixture");
    (dir, path)
}

/// Run `keyhog scan --daemon=off --backend cpu --format <format> <path>`.
/// Returns (exit code, stdout, stderr).
fn run(path: &PathBuf, format: &str) -> (Option<i32>, String, String) {
    let output = Command::new(binary())
        .args([
            "scan",
            "--daemon=off",
            "--backend",
            "cpu",
            "--no-suppress-test-fixtures",
            "--format",
            format,
        ])
        .arg(path)
        .output()
        .expect("spawn keyhog scan");
    (
        output.status.code(),
        String::from_utf8_lossy(&output.stdout).into_owned(),
        String::from_utf8_lossy(&output.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
}

// ---------------------------------------------------------------------------
// JSON
// ---------------------------------------------------------------------------

/// json: findings present -> exit 1, a JSON ARRAY (not an object) whose single
/// element carries the exact detector id / severity / service.
#[test]
fn json_format_is_array_with_exact_detector_fields() {
    let (_dir, path) = leak_fixture();
    let (code, out, err) = run(&path, "json");
    assert_eq!(
        code,
        Some(1),
        "json scan with a finding must exit 1; stderr={err}"
    );

    let v: serde_json::Value = serde_json::from_str(&out).expect("json stdout must parse");
    let arr = v.as_array().expect("json report must be a top-level ARRAY");
    assert_eq!(
        arr.len(),
        1,
        "exactly one secret planted -> one json element"
    );

    let obj = &arr[0];
    assert_eq!(
        obj.get("detector_id").and_then(|x| x.as_str()),
        Some(DETECTOR_ID),
        "json[0].detector_id must be the planted detector id"
    );
    assert_eq!(
        obj.get("severity").and_then(|x| x.as_str()),
        Some("critical"),
        "github-classic-pat is Critical -> kebab token `critical`"
    );
    assert_eq!(
        obj.get("service").and_then(|x| x.as_str()),
        Some("github"),
        "service field must be `github`"
    );
    assert_eq!(
        obj.get("detector_name").and_then(|x| x.as_str()),
        Some(DETECTOR_NAME),
        "detector_name must be the human label"
    );
}

/// json negative twin: a clean file exits 0 and the report is EXACTLY the two
/// bytes `[]` (array opened + closed with no elements).
#[test]
fn json_clean_run_is_exactly_bracket_pair() {
    let (_dir, path) = clean_fixture();
    let (code, out, err) = run(&path, "json");
    assert_eq!(code, Some(0), "clean json scan must exit 0; stderr={err}");
    assert_eq!(
        out.trim_end(),
        "[]",
        "an empty json run must be exactly the bracket pair, got: {out:?}"
    );
}

// ---------------------------------------------------------------------------
// SARIF
// ---------------------------------------------------------------------------

/// sarif: the single result's `ruleId` is the detector id and `level` is
/// `error` (critical maps to error).
#[test]
fn sarif_format_ruleid_and_error_level() {
    let (_dir, path) = leak_fixture();
    let (code, out, err) = run(&path, "sarif");
    assert_eq!(
        code,
        Some(1),
        "sarif scan with a finding must exit 1; stderr={err}"
    );

    let v: serde_json::Value = serde_json::from_str(&out).expect("sarif stdout must parse as JSON");
    assert_eq!(
        v.pointer("/runs/0/results/0/ruleId")
            .and_then(|x| x.as_str()),
        Some(DETECTOR_ID),
        "sarif results[0].ruleId must be the detector id"
    );
    assert_eq!(
        v.pointer("/runs/0/results/0/level")
            .and_then(|x| x.as_str()),
        Some("error"),
        "critical severity -> SARIF level `error`"
    );
    let results = v
        .pointer("/runs/0/results")
        .and_then(|r| r.as_array())
        .expect("sarif runs[0].results must be an array");
    assert_eq!(results.len(), 1, "one planted secret -> one SARIF result");
}

/// sarif: the accumulated rule for the finding carries the code-scanning
/// `security-severity` band "9.5" for a Critical detector.
#[test]
fn sarif_rule_security_severity_band_critical() {
    let (_dir, path) = leak_fixture();
    let (_code, out, _err) = run(&path, "sarif");
    let v: serde_json::Value = serde_json::from_str(&out).expect("sarif parse");

    assert_eq!(
        v.pointer("/runs/0/tool/driver/rules/0/id")
            .and_then(|x| x.as_str()),
        Some(DETECTOR_ID),
        "the accumulated rule id must be the detector id"
    );
    assert_eq!(
        v.pointer("/runs/0/tool/driver/rules/0/properties/security-severity")
            .and_then(|x| x.as_str()),
        Some("9.5"),
        "Critical -> security-severity band 9.5"
    );
}

/// sarif negative twin: a clean file exits 0 and produces ZERO results.
#[test]
fn sarif_clean_run_has_zero_results() {
    let (_dir, path) = clean_fixture();
    let (code, out, err) = run(&path, "sarif");
    assert_eq!(code, Some(0), "clean sarif scan must exit 0; stderr={err}");
    let v: serde_json::Value = serde_json::from_str(&out).expect("sarif parse");
    let results = v
        .pointer("/runs/0/results")
        .and_then(|r| r.as_array())
        .expect("sarif results array must exist even when empty");
    assert_eq!(results.len(), 0, "clean scan -> zero SARIF results");
}

// ---------------------------------------------------------------------------
// TEXT
// ---------------------------------------------------------------------------

/// text: the human roll-up names the count, the CRITICAL label, and the
/// detector. (Summary is written to stdout by the Text reporter; stderr is
/// folded in defensively.)
#[test]
fn text_format_summary_and_labels() {
    let (_dir, path) = leak_fixture();
    let (code, out, err) = run(&path, "text");
    assert_eq!(code, Some(1), "text scan with a finding must exit 1");
    let combined = format!("{out}\n{err}");

    assert!(
        combined.contains("1 secret found"),
        "text summary must read '1 secret found', got:\n{combined}"
    );
    assert!(
        combined.contains("CRITICAL"),
        "text block must carry the CRITICAL severity label, got:\n{combined}"
    );
    assert!(
        combined.contains(DETECTOR_NAME),
        "text block must name the detector 'GitHub Classic PAT', got:\n{combined}"
    );
}

/// text negative twin: a clean file exits 0 and prints the honest
/// "No secrets detected" line, never claiming the tree is "clean".
#[test]
fn text_clean_run_honest_no_secrets_line() {
    let (_dir, path) = clean_fixture();
    let (code, out, err) = run(&path, "text");
    assert_eq!(code, Some(0), "clean text scan must exit 0");
    let combined = format!("{out}\n{err}");
    assert!(
        combined.contains("No secrets detected"),
        "clean text scan must print the honest no-secrets line, got:\n{combined}"
    );
}

// ---------------------------------------------------------------------------
// CSV
// ---------------------------------------------------------------------------

/// csv: the first non-comment line is EXACTLY the documented 20-field header.
#[test]
fn csv_format_header_is_exact() {
    let (_dir, path) = leak_fixture();
    let (code, out, err) = run(&path, "csv");
    assert_eq!(
        code,
        Some(1),
        "csv scan with a finding must exit 1; stderr={err}"
    );
    let header = out
        .lines()
        .find(|line| !line.starts_with("# keyhog.scan.metadata="))
        .expect("csv must have a header line");
    assert_eq!(
        header.trim_end(),
        CSV_HEADER,
        "csv header must be the exact documented field list in order"
    );
}

/// csv: exactly one data row, and its leading cells are the detector id, name,
/// service, and severity in order; the RFC-4180 row has exactly 20 fields.
#[test]
fn csv_format_single_data_row_fields() {
    let (_dir, path) = leak_fixture();
    let (_code, out, _err) = run(&path, "csv");
    let lines: Vec<&str> = out
        .lines()
        .filter(|l| !l.starts_with("# keyhog.scan.metadata="))
        .collect();
    let data: Vec<&str> = lines
        .iter()
        .skip(1)
        .filter(|l| !l.is_empty())
        .copied()
        .collect();
    assert_eq!(
        data.len(),
        1,
        "one planted secret -> exactly one csv data row"
    );

    let row = data[0];
    assert!(
        row.starts_with("github-classic-pat,GitHub Classic PAT,github,critical,"),
        "csv row must begin with id,name,service,severity in order, got: {row}"
    );
    let field_count = parse_csv_row(row).len();
    assert_eq!(
        field_count, 20,
        "csv data row must have exactly 20 fields, got {field_count}"
    );
    let fields = parse_csv_row(row);
    assert_eq!(fields[18], "{}");
    assert_eq!(fields[19], "[]");
}

/// csv negative twin: a clean file exits 0 and emits metadata plus ONLY the
/// header (no data rows).
#[test]
fn csv_clean_run_is_header_only() {
    let (_dir, path) = clean_fixture();
    let (code, out, err) = run(&path, "csv");
    assert_eq!(code, Some(0), "clean csv scan must exit 0; stderr={err}");
    let lines: Vec<&str> = out
        .lines()
        .filter(|l| !l.is_empty() && !l.starts_with("# keyhog.scan.metadata="))
        .collect();
    assert_eq!(
        lines.len(),
        1,
        "clean csv must be header-only, got: {lines:?}"
    );
    assert_eq!(
        lines[0].trim_end(),
        CSV_HEADER,
        "the sole line must be the header"
    );
}

// ---------------------------------------------------------------------------
// CROSS-FORMAT INVARIANTS
// ---------------------------------------------------------------------------

/// Exit codes agree across all four formats for the SAME finding: all exit 1.
#[test]
fn exit_codes_match_across_all_formats_with_finding() {
    let (_dir, path) = leak_fixture();
    let codes: Vec<Option<i32>> = ["json", "sarif", "text", "csv"]
        .iter()
        .map(|f| run(&path, f).0)
        .collect();
    assert_eq!(
        codes,
        vec![Some(1), Some(1), Some(1), Some(1)],
        "every format must exit 1 for the same planted finding, got {codes:?}"
    );
}

/// Exit codes agree across all four formats for a clean tree: all exit 0.
#[test]
fn exit_codes_match_across_all_formats_when_clean() {
    let (_dir, path) = clean_fixture();
    let codes: Vec<Option<i32>> = ["json", "sarif", "text", "csv"]
        .iter()
        .map(|f| run(&path, f).0)
        .collect();
    assert_eq!(
        codes,
        vec![Some(0), Some(0), Some(0), Some(0)],
        "every format must exit 0 for a clean tree, got {codes:?}"
    );
}

/// The SAME detector id is surfaced by the json, sarif, and csv paths for the
/// one planted secret, a serializer dropping the finding on one path is a
/// silent recall hole this catches.
#[test]
fn all_structured_formats_surface_same_detector_id() {
    let (_dir, path) = leak_fixture();

    let (_c1, json_out, _e1) = run(&path, "json");
    let jv: serde_json::Value = serde_json::from_str(&json_out).expect("json parse");
    let json_id = jv
        .as_array()
        .and_then(|a| a.first())
        .and_then(|o| o.get("detector_id"))
        .and_then(|x| x.as_str());

    let (_c2, sarif_out, _e2) = run(&path, "sarif");
    let sv: serde_json::Value = serde_json::from_str(&sarif_out).expect("sarif parse");
    let sarif_id = sv
        .pointer("/runs/0/results/0/ruleId")
        .and_then(|x| x.as_str());

    let (_c3, csv_out, _e3) = run(&path, "csv");
    let csv_lines: Vec<&str> = csv_out
        .lines()
        .filter(|l| !l.is_empty() && !l.starts_with("# keyhog.scan.metadata="))
        .skip(1)
        .collect();
    let csv_id = csv_lines.first().and_then(|row| row.split(',').next());

    assert_eq!(json_id, Some(DETECTOR_ID), "json detector id");
    assert_eq!(sarif_id, Some(DETECTOR_ID), "sarif ruleId");
    assert_eq!(csv_id, Some(DETECTOR_ID), "csv first column");
}

/// The redacted credential form is identical across json and csv (the reporter
/// must not redact differently per format). Derived at runtime from json, then
/// required to appear verbatim as the csv `credential_redacted` cell.
#[test]
fn redacted_credential_consistent_between_json_and_csv() {
    let (_dir, path) = leak_fixture();

    let (_c1, json_out, _e1) = run(&path, "json");
    let jv: serde_json::Value = serde_json::from_str(&json_out).expect("json parse");
    let redacted = jv
        .as_array()
        .and_then(|a| a.first())
        .and_then(|o| o.get("credential_redacted"))
        .and_then(|x| x.as_str())
        .expect("json must carry credential_redacted")
        .to_string();
    // The redaction must be a non-trivial masked form, not the raw secret.
    assert_ne!(
        redacted, PLANTED,
        "the reported credential must be redacted, not raw"
    );
    assert!(
        !redacted.is_empty(),
        "credential_redacted must be populated"
    );

    let (_c2, csv_out, _e2) = run(&path, "csv");
    let data_row = csv_out
        .lines()
        .filter(|line| !line.starts_with("# keyhog.scan.metadata="))
        .nth(1)
        .expect("csv must have a data row");
    let cell = data_row
        .split(',')
        .nth(4)
        .expect("csv column 5 = credential_redacted");
    assert_eq!(
        cell,
        redacted.as_str(),
        "csv credential_redacted cell must equal the json redacted form"
    );
}