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
//! Bounded previews of detected values.
//!
//! **This module is why the tool is safe to run in CI.** Everything else
//! finds credentials; this is what stops the finding from becoming a
//! second disclosure — one to a log that is archived, often
//! world-readable, and outlives the credential.
//!
//! The rule, from SPEC.md: a preview is capped at eight characters
//! **and** at half the value's length, and always carries the length.
//! Half-length is what makes the cap hold for short values — an
//! eight-character cap on an eight-character password is the password,
//! and the password detector matches from eight characters up.
//!
//! There is no option, flag or code path that turns this off.

use super::heuristics::{js_trim_end, js_trim_start};

const MAX_PREVIEW: usize = 8;

/// A preview that can never be the whole value.
///
/// The length is included because it is what lets a reader tell two
/// similar findings apart without revealing more of either.
pub(crate) fn mask_secret_value(value: &str) -> String {
    if value.is_empty() {
        return "(empty)".to_string();
    }

    // Lengths are counted in UTF-16 code units, matching the extension's
    // `String.length`, so a value containing an emoji is described the
    // same way by both frontends.
    let length = value.encode_utf16().count();

    // Below three characters any preview at all is the whole value, so
    // give the length only. Real findings are far longer than this, but
    // the property has to hold unconditionally or it is not a property.
    if length < 3 {
        return format!("({length} chars)");
    }

    let shown = MAX_PREVIEW.min(length / 2);
    format!("{}… ({length} chars)", take_utf16(value, shown))
}

/// Redact every occurrence of `value` from a line of source.
///
/// The context line is taken verbatim from the file, so it contains the
/// secret it is providing context for.
pub(crate) fn mask_within(context: &str, value: &str) -> String {
    if value.is_empty() {
        return context.to_string();
    }
    context.replace(value, &mask_secret_value(value))
}

/// How much of the source line either side of the value the context
/// keeps, counted in UTF-16 code units so both frontends cut in the same
/// place.
///
/// A context line used to be the *whole* source line. On an ordinary
/// file that is a sentence; on a minified one it is the entire file, and
/// a file with a thousand findings on its single line produced a
/// hundred megabytes of report — the same line, a thousand times. The
/// cost of a scan stopped being proportional to what was in it.
const CONTEXT_MARGIN: usize = 60;

/// `line[from..to]`, with any part of it that overlaps a finding's span
/// replaced by a marker rather than shown.
///
/// Spans are byte offsets into the same line. Overlap is what matters,
/// not containment: a window edge can cut through a finding, and the
/// half that lands inside the window is still credential text.
fn redact_spans(line: &str, from: usize, to: usize, spans: &[(usize, usize)]) -> String {
    let mut out = String::new();
    let mut cursor = from;
    for (start, end) in spans.iter().copied() {
        let start = start.max(from);
        let end = end.min(to);
        if start >= end || end <= cursor {
            continue;
        }
        if start > cursor {
            out.push_str(&line[cursor..start]);
        }
        out.push('\u{2026}');
        cursor = end;
    }
    if cursor < to {
        out.push_str(&line[cursor..to]);
    }
    out
}

/// The context line for one finding: a bounded window of its source
/// line, with **every** detected value in it masked.
///
/// The second half is the part that matters. Masking only the finding's
/// own value left every *other* credential on that line in the clear:
///
/// ```text
/// DB_PASSWORD=hunter2… (14 chars) API_KEY=abcdefghijklmnopqrstuvwx
/// ```
///
/// which is a complete API key, printed by a tool whose one promise is
/// that it never prints one — into a CI log, which is archived and
/// outlives the credential. A line holding two credentials is not
/// exotic: it is what a compact JSON config looks like.
///
/// `values` is every distinct value the document yielded. Longest first,
/// so a value containing a shorter one is replaced whole rather than
/// broken into pieces by its own substring.
pub(crate) fn mask_context(
    line: &str,
    value_start: usize,
    value_length: usize,
    value: &str,
    values: &[String],
    spans: &[(usize, usize)],
) -> String {
    // Assembled from three parts rather than cut out and searched.
    //
    // Searching only works when the value is present in the window whole.
    // A PEM block is not: it runs past the end of its own line, so the
    // line holds a *prefix* of it, which no replacement matches — and the
    // context came out carrying seventeen hundred characters of key
    // material in the clear. Building the middle from the preview means
    // the value's own text has nowhere to appear from.
    let value_end = value_start.saturating_add(value_length).min(line.len());
    let before_start = back_from(line, value_start, CONTEXT_MARGIN);
    let after_end = forward_from(line, value_end, CONTEXT_MARGIN);

    // Masked by **span** and not only by text. A value is replaced by
    // searching for it, which needs it to be present whole — and a
    // credential can be reported only as part of a longer run, so the
    // window shows a *prefix* of a reported value that no replacement
    // matches. The fuzzer found a planted connection string surviving
    // inside a 1,595-character database URL that way. Blanking the span
    // first means the window cannot show source that overlaps any
    // finding, whatever the text happens to be; the text pass after it
    // still covers a value repeated somewhere the spans do not reach.
    let before_raw = redact_spans(line, before_start, value_start, spans);
    let after_raw = redact_spans(line, value_end, after_end, spans);
    let before = mask_all(js_trim_start(&before_raw), values);
    let after = mask_all(js_trim_end(&after_raw), values);

    let mut context = String::new();
    if before_start > 0 {
        context.push('');
    }
    context.push_str(&before);
    context.push_str(&mask_secret_value(value));
    context.push_str(&after);
    if after_end < line.len() {
        context.push('');
    }
    context
}

/// Every value in `values`, replaced wherever it appears in `text`.
///
/// Used for the context window and for the **key name**, which is a
/// verbatim slice of the document and not the tidy identifier it looks
/// like. Every key pattern in the table begins `[A-Za-z0-9_-]*`, so the
/// key group swallows whatever word characters run up to the keyword —
/// and a token abutting the name ends up reported as part of it:
///
/// ```text
/// "key": "ghp_1234567890abcdefghijklmnopqrstuvwxyz----session_id"
/// ```
///
/// A complete credential, in a field nothing was masking.
pub(crate) fn mask_all(text: &str, values: &[String]) -> String {
    let mut masked = text.to_string();
    for value in values {
        // Asked before replacing rather than after. `replace` allocates a
        // new string whether or not it changed anything, and a document
        // with a thousand distinct values would pay that a thousand times
        // per finding — the scan's cost stops being proportional to the
        // document and starts being proportional to its square. Skipping
        // a replacement that would have changed nothing cannot change the
        // answer.
        if !masked.contains(value.as_str()) {
            continue;
        }
        masked = mask_within(&masked, value);
    }
    masked
}

/// Every distinct value, longest first — the order `mask_context`
/// depends on, prepared once for a whole document rather than per
/// finding.
pub(crate) fn masking_order(values: &[String]) -> Vec<String> {
    let mut ordered: Vec<String> = values.to_vec();
    // Length descending, then by content, so the order is total and the
    // output cannot depend on the order values happened to be found in.
    ordered.sort_by(|a, b| {
        b.encode_utf16()
            .count()
            .cmp(&a.encode_utf16().count())
            .then_with(|| a.cmp(b))
    });
    ordered.dedup();
    ordered
}

/// The byte offset `units` UTF-16 code units before `from`, floored to a
/// character boundary.
fn back_from(line: &str, from: usize, units: usize) -> usize {
    let mut seen = 0;
    for (offset, character) in line[..from].char_indices().rev() {
        seen += character.len_utf16();
        if seen > units {
            return offset + character.len_utf8();
        }
    }
    0
}

/// The byte offset `units` code units past `from`, or the end of the
/// line, whichever comes first.
///
/// A character that would take the window past `units` is left out
/// rather than included whole: an astral character is two code units,
/// and the two frontends have to cut in the same place or the same
/// document reads differently on each.
fn forward_from(line: &str, from: usize, units: usize) -> usize {
    let mut seen = 0;
    for (offset, character) in line[from..].char_indices() {
        if seen + character.len_utf16() > units {
            return from + offset;
        }
        seen += character.len_utf16();
    }
    line.len()
}

/// The first `units` UTF-16 code units of `value`, never splitting a
/// character. `String.prototype.slice` counts code units, and a preview
/// that differed between the two frontends would be a parity break in
/// the one place it matters most.
fn take_utf16(value: &str, units: usize) -> &str {
    let mut seen = 0;
    for (offset, character) in value.char_indices() {
        if seen >= units {
            return &value[..offset];
        }
        seen += character.len_utf16();
    }
    value
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_empty_value_is_named_rather_than_previewed() {
        assert_eq!(mask_secret_value(""), "(empty)");
    }

    #[test]
    fn a_value_too_short_to_preview_gives_only_its_length() {
        assert_eq!(mask_secret_value("a"), "(1 chars)");
        assert_eq!(mask_secret_value("ab"), "(2 chars)");
    }

    #[test]
    fn the_preview_is_capped_at_half_the_length() {
        // Four characters would allow two, not four.
        assert_eq!(mask_secret_value("abcd"), "ab… (4 chars)");
        assert_eq!(mask_secret_value("abcdef"), "abc… (6 chars)");
    }

    #[test]
    fn the_preview_is_capped_at_eight_characters() {
        let value = "a".repeat(200);
        assert_eq!(mask_secret_value(&value), "aaaaaaaa… (200 chars)");
    }

    #[test]
    fn every_occurrence_in_a_context_line_is_masked() {
        let masked = mask_within("a=secret and again secret", "secret");
        assert!(!masked.contains("secret and"), "{masked}");
        assert_eq!(masked.matches("sec…").count(), 2, "{masked}");
    }

    #[test]
    fn a_context_without_the_value_is_unchanged() {
        assert_eq!(mask_within("nothing here", "absent"), "nothing here");
        assert_eq!(mask_within("empty value", ""), "empty value");
    }

    /// A one- or two-character value is described **only** by its
    /// length: the output contains no part of it at all, which is the
    /// strongest form the property can take.
    ///
    /// Checked by construction rather than by substring, because at that
    /// size a substring test is meaningless — the literal word "chars"
    /// contains an `a`, so any single-letter value "appears" in it.
    #[test]
    fn a_value_too_short_to_preview_discloses_nothing_but_its_length() {
        for value in ["a", "x", "1", "ab", "xy", "()"] {
            let length = value.encode_utf16().count();
            assert_eq!(mask_secret_value(value), format!("({length} chars)"));
        }
    }

    /// **The property the whole tool rests on.** Exhaustive over lengths
    /// rather than a handful of examples, because a cap that holds for
    /// the cases someone thought of is not a guarantee.
    #[test]
    fn no_preview_ever_contains_its_whole_value() {
        for length in 3..=300 {
            let value: String = std::iter::repeat_n('x', length).collect();
            let preview = mask_secret_value(&value);
            assert!(
                !preview.contains(&value),
                "a {length}-character value leaked through its preview: {preview}"
            );
            let context = format!("KEY={value}");
            let masked = mask_within(&context, &value);
            assert!(
                !masked.contains(&value),
                "a {length}-character value leaked through its context line: {masked}"
            );
        }
    }

    /// The same property over values that are not a single repeated
    /// character — a repeated run is the easiest case for a substring
    /// check to pass by accident.
    #[test]
    fn no_preview_leaks_a_varied_value() {
        let alphabet: Vec<char> = "aB3xY7zQ9mK2pL5vN8wR4tS6/+=-_.".chars().collect();
        for length in 3..=300 {
            let value: String = (0..length).map(|i| alphabet[i % alphabet.len()]).collect();
            assert!(!mask_secret_value(&value).contains(&value), "{length}");
            let context = format!("KEY={value} trailing");
            assert!(!mask_within(&context, &value).contains(&value), "{length}");
        }
    }

    /// A preview must not split a character in half, and its length must
    /// be counted the way the extension counts it. Fifteen characters
    /// allow seven — half, rounded down — not the eight-character cap.
    #[test]
    fn multibyte_values_are_previewed_by_code_unit() {
        let value = "ééééééééééééééé";
        let preview = mask_secret_value(value);
        assert_eq!(preview, "ééééééé… (15 chars)");
        assert!(!preview.contains(value));
    }

    /// **The leak this window and this ordering exist for.** Masking a
    /// finding's own value left every *other* credential on the line in
    /// the clear, and a line holding two credentials is what a compact
    /// JSON config looks like.
    #[test]
    fn a_context_masks_every_value_on_the_line_not_only_its_own() {
        let line = "DB_PASSWORD=hunter2hunter2 API_KEY=abcdefghijklmnopqrstuvwx";
        let values = masking_order(&[
            "hunter2hunter2".to_string(),
            "abcdefghijklmnopqrstuvwx".to_string(),
        ]);
        let context = mask_context(line, 12, 14, "hunter2hunter2", &values, &[]);
        assert!(!context.contains("hunter2hunter2"), "{context}");
        assert!(
            !context.contains("abcdefghijklmnopqrstuvwx"),
            "the neighbouring key survived: {context}"
        );
    }

    /// A line short enough to fit reads exactly as it did before the
    /// window existed: no ellipsis, same trim.
    #[test]
    fn a_short_line_is_not_windowed_at_all() {
        let line = "  DATABASE_PASSWORD=hunter2hunter2  ";
        let values = masking_order(&["hunter2hunter2".to_string()]);
        assert_eq!(
            mask_context(line, 20, 14, "hunter2hunter2", &values, &[]),
            "DATABASE_PASSWORD=hunter2… (14 chars)"
        );
    }

    /// The quadratic: a minified line is the whole file, and one context
    /// line per finding made the report grow with findings *times* line
    /// length. Ninety-eight megabytes of stdout for one file.
    #[test]
    fn a_long_line_is_cut_down_to_a_window_around_the_value() {
        let filler = "z".repeat(5_000);
        let line = format!("{filler} DATABASE_PASSWORD=hunter2hunter2 {filler}");
        let values = masking_order(&["hunter2hunter2".to_string()]);
        let context = mask_context(&line, 5_019, 14, "hunter2hunter2", &values, &[]);
        assert!(context.len() < 200, "{} bytes", context.len());
        assert!(context.starts_with(''), "{context}");
        assert!(context.ends_with(''), "{context}");
        assert!(context.contains("DATABASE_PASSWORD="), "{context}");
        assert!(!context.contains("hunter2hunter2"), "{context}");
    }

    /// A value longer than the window is still inside it, whole — a cut
    /// through a value leaves a fragment no mask matches, which is a
    /// partial disclosure dressed up as a redaction.
    #[test]
    fn a_value_longer_than_the_window_is_still_masked_entirely() {
        let value = "aB3xY7zQ9mK2pL5vN8wR4tS6".repeat(20);
        let line = format!("token = {value} trailing");
        let values = masking_order(std::slice::from_ref(&value));
        let context = mask_context(&line, 8, value.len(), &value, &values, &[]);
        assert!(!context.contains(&value), "{context}");
        assert!(context.contains("trailing"), "{context}");
    }

    /// Longest first, so a value that contains a shorter one is replaced
    /// whole instead of being broken into pieces by its own substring.
    #[test]
    fn the_masking_order_puts_the_longest_value_first() {
        let order = masking_order(&[
            "hunter2hunter2".to_string(),
            "hunter2hunter2hunter2".to_string(),
            "hunter2hunter2".to_string(),
        ]);
        assert_eq!(
            order,
            [
                "hunter2hunter2hunter2".to_string(),
                "hunter2hunter2".to_string()
            ]
        );
        let line = "a=hunter2hunter2hunter2 b=hunter2hunter2";
        let context = mask_context(line, 2, 21, "hunter2hunter2hunter2", &order, &[]);
        assert!(!context.contains("hunter2hunter2"), "{context}");
    }

    #[test]
    fn an_astral_value_counts_in_utf16_units_like_the_extension() {
        // Four emoji are eight UTF-16 code units, so the preview shows
        // four of them — half of eight — not four of a count of four.
        let value = "🎯🎯🎯🎯";
        let preview = mask_secret_value(value);
        assert!(preview.contains("(8 chars)"), "{preview}");
        assert!(!preview.contains(value), "{preview}");
    }
}