secrets-le 0.2.0

Find hardcoded credentials in a codebase, and never print one into the report
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
520
521
522
523
524
525
//! A standing net over the detector table.
//!
//! Nineteen patterns from `signatures/patterns.toml`, compiled by
//! `fancy-regex` with a raised backtracking budget, run over documents
//! nobody wrote. The corpus pins the cases somebody thought of; this
//! looks for the one nobody did.
//!
//! Documents go in through `--stdin`, which is the shortest path to the
//! pure layer: no walk, no filesystem, one document straight into
//! `detect`. Every input is valid UTF-8 by construction — a few
//! deliberately are not, and those must be *refused*, not survived by
//! accident.
//!
//! What counts as a failure:
//!
//! - **A panic or a signal.** A SIGABRT slicing a multi-byte character
//!   is how this class of bug shows up in this family.
//! - **A hang.** Enforced, not hoped for.
//! - **Any exit code other than 0, 1 or 2.**
//! - **`incomplete`.** The backtracking budget is meant to survive
//!   anything under 64 KB, which is all this generates. A refusal here
//!   is a document shape the budget does not cover, and the report names
//!   it. (The crafted million-character word run that legitimately
//!   exhausts it lives in `hazards.rs`, where the refusal is the
//!   assertion.)
//! - **A value the run *detected*, appearing anywhere in the output.**
//!   This is the one that matters. A secret scanner that prints what it
//!   found has leaked it to a CI log, which is archived, often
//!   world-readable, and outlives the credential — and a fuzzer is
//!   exactly what finds the one document shape that slips past the
//!   masking. A finding is recognised here by its preview, which is a
//!   deterministic function of the value.
//!
//!   Text the run did **not** detect is a different thing, and the line
//!   between them is drawn in SPEC.md under "What a context line can
//!   still contain": a context is a bounded excerpt of the source line,
//!   every detected value is masked out of it, and this tool cannot mask
//!   what it never recognised. So the size of that excerpt is pinned
//!   here too — it is the whole exposure surface.
//!
//! Not run to convergence: 60 seconds in CI, a fixed handful of
//! iterations locally so `cargo test` stays quick. The point is a net,
//! not a proof.
//!
//! Every credential below is invented for this file.

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");

/// Locally: enough to be worth running on every `cargo test`, quick
/// enough that nobody stops running it. CI raises it with
/// `SECRETS_LE_FUZZ_SECONDS`.
const DEFAULT_ITERATIONS: usize = 50;

const PATIENCE: Duration = Duration::from_secs(30);

/// The exposure surface, bounded.
///
/// `detect/mask.rs` keeps 60 code units of source either side of the
/// value and puts the value's preview between them, so a context is two
/// margins, one preview and at most two ellipses.
///
/// Masking **grows** a margin rather than shrinking it: the shortest
/// value any pattern in the table will report is eight characters (the
/// password detector's floor) and its preview is fifteen, so 60 units of
/// source can become at most 120. Two of those, plus a preview of at
/// most 26 for a value of any length this can generate, plus the two
/// ellipses, is 266. Rounded up, with the arithmetic written down so
/// the next person can check it rather than trust it.
///
/// The number is not the point — the bound is. Before the window
/// existed, a context was the *whole* source line, which on a minified
/// file is the whole file.
const MAX_CONTEXT_UNITS: usize = 300;

/// `mask_secret_value`, as a caller outside the crate can compute it: at
/// most eight characters, at most half the value, and the length. Used
/// to recognise that a run reported a particular value without ever
/// being handed the value back.
fn preview_of(value: &str) -> String {
    let length = value.encode_utf16().count();
    if length < 3 {
        return format!("({length} chars)");
    }
    let shown = 8.min(length / 2);
    format!("{}… ({length} chars)", &value[..shown])
}

/// Long enough that no eight-character preview can contain one, so
/// "this value appears in the output" means a leak and never a preview.
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",
];

/// Characters that have broken a scanner somewhere: quotes, separators,
/// a byte-order mark, an astral character, a lone carriage return.
const AWKWARD: [&str; 14] = [
    "'",
    "\"",
    "=",
    ":",
    " ",
    "\t",
    "\r",
    "\n",
    "\u{feff}",
    "\u{1f3af}",
    "\u{e9}",
    ";",
    "-",
    "_",
];

/// Mulberry32 — deterministic, seeded, three lines, no dependency.
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 documents: Vec<String> = std::fs::read_dir(CORPUS)
        .expect("the corpus is readable")
        .filter_map(Result::ok)
        .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
        .collect();
    assert!(!documents.is_empty(), "the corpus seeded nothing");
    // Shapes the corpus does not carry, so the first mutations have
    // something hostile to work from.
    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
}

/// One generated document, and the planted values it actually contains.
struct Case {
    content: String,
    present: Vec<&'static str>,
}

/// Mutate a seed into something the corpus does not contain, keeping the
/// result valid UTF-8 — the invalid-input path is asserted separately,
/// because it must be a refusal rather than a survival.
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) {
            // Splice in a key/value pair the table should claim.
            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);
            }
            // Splice in a bare value with no key at all.
            1 => {
                let value = *random.pick(&PLANTED);
                let at = random.below(content.len() + 1);
                content.splice(at..at, value.chars().collect::<Vec<_>>());
            }
            // A character that has broken a scanner somewhere.
            2 => {
                let at = random.below(content.len() + 1);
                let insert: Vec<char> = random.pick(&AWKWARD).chars().collect();
                content.splice(at..at, insert);
            }
            // A run of word characters, which is what the thirteen
            // `[A-Za-z0-9_-]*` patterns backtrack over.
            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);
            }
            // Cut a piece out, which is how a value ends up truncated
            // mid-character.
            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);
            }
            // Duplicate what is there.
            5 if content.len() < 16_000 => {
                let copy = content.clone();
                content.extend(copy);
            }
            // Strip the newlines, turning the document into one long line.
            6 => content.retain(|character| *character != '\n'),
            // Two credentials on one line — the shape that carried a
            // real leak through a context line.
            _ => {
                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);
            }
        }
    }

    // Bounded on purpose: the budget is meant to cover anything this
    // size, so a refusal within it is a bug rather than a crafted file.
    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();
    // Never asserted on: a child that refuses before reading closes the
    // pipe under the write, and that race is this process's problem
    // rather than part of the answer.
    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();
}

/// Enough of the document to act on, written to a file when it is too
/// long to read in a log. A failing fuzz case nobody can reproduce is a
/// failing build somebody reruns instead of reading.
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()
        }
    )
}

/// The head of a preview — everything before the ellipsis — is a verbatim
/// slice of the value, so it must equal the document text at the line and
/// column the same finding reports.
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; // a value too short to preview shows only its length
    };
    let line_number = finding["line"].as_u64().expect("a line") as usize;
    let column = finding["column"].as_u64().expect("a column") as usize;

    // The scan drops a leading byte-order mark before it counts, so the
    // check has to read the same document the scan did.
    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()
            );
            // The exposure surface, pinned. A context is a bounded
            // excerpt of the source line; it used to be the whole line,
            // which on a minified file is the whole file.
            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()
                );
            }
        }

        // **The preview is cut from the offsets the finding reports.**
        // A sibling aborted its process because a span was taken from a
        // `to_lowercase()` copy and used against the original — lowercasing
        // is not length-preserving, so every span after one slid. Here a
        // slid span would not crash, it would cut the preview from the
        // wrong place, and a preview cut from the wrong place can show
        // bytes the mask was meant to cover. So the two are checked
        // against each other: the visible head of every preview must be
        // exactly what stands at the line and column the finding reports.
        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);
            }
        }

        // **The property.** Not "the finding's own value" — every value
        // this run reported, checked against everything it wrote. A
        // finding on one line whose context carries the credential from
        // the finding beside it is the shape that slipped through for a
        // release.
        for value in &case.present {
            if !answer.stdout.contains(&preview_of(value)) {
                // Never reported, so there is nothing the masking was
                // asked to cover. See the module note.
                continue;
            }
            assert!(
                !answer.stdout.contains(value),
                "a reported 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()
    );
}

/// stdin is a byte stream and a byte stream is not always text. Every
/// one of these must be refused by name, never survived by accident and
/// never crashed on.
#[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"
        );
    }
}