vivac 0.10.0

Provenance tree for work: every node knows which node it was born from
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
//! The redaction guard. Security pillar, and it holds a veto.
//!
//! This tree is a map of where a system is weak and not yet fixed. What
//! never gets in never leaks. Three things never get in: keys, personal
//! data, and file contents.
//!
//! All three are checked here: the first two by pattern, the third by
//! refusing a write that opens a fenced code block.
//!
//! **In doubt it refuses and says why. It never stores in silence.**
//! There is no `--force`: if the guard gets it wrong, reword the sentence.
//! A manual escape hatch would be the road by which the very thing this
//! exists to keep out would eventually get in.

/// What the guard found. It never carries the whole secret.
#[derive(Debug)]
pub struct Finding {
    pub rule: &'static str,
    pub field: String,
    pub sample: String,
    pub advice: &'static str,
}

/// Prefixes published by whoever issues the credential. Zero ambiguity: if
/// they show up, it is a key. The number is the minimum count of characters
/// that must follow the prefix for it to count.
const PREFIXES: &[(&str, usize)] = &[
    ("sqa_", 20),
    ("squ_", 20),
    ("sqp_", 20),
    ("ghp_", 30),
    ("gho_", 30),
    ("ghu_", 30),
    ("ghs_", 30),
    ("ghr_", 30),
    ("github_pat_", 20),
    ("glpat-", 15),
    ("sk-ant-", 20),
    ("sk-", 20),
    ("xoxb-", 15),
    ("xoxp-", 15),
    ("xoxa-", 15),
    ("xoxs-", 15),
    ("xapp-", 15),
    ("AIza", 30),
    ("ya29.", 20),
    ("npm_", 30),
    ("dop_v1_", 20),
    ("doo_v1_", 20),
    ("hf_", 30),
    ("sk_live_", 20),
    ("pk_live_", 20),
    ("rk_live_", 20),
    ("SG.", 30),
];

/// AWS access keys: fixed prefix and an exact length of 20.
const AWS: &[&str] = &["AKIA", "ASIA", "AIDA", "AROA", "AGPA", "ANPA", "ANVA"];

const ADVICE_KEY: &str = "Write down which credential it was and where it lives, never \
     its value. E.g.: rotate the CI token, it is in the SONAR_TOKEN secret.";
const ADVICE_PII: &str = "Refer to the role, not to the person nor to their path. \
     E.g.: the PR reviewer, the user home directory.";
const ADVICE_ENTROPY: &str = "If it is not a key, give it a name instead of pasting the \
     value. If it is one, it does not get in: store where it lives, not what it is.";
const ADVICE_BODY: &str = "Name the file and what was decided about it, never its content. \
     A path and a sentence survive the paste; the body of the file does not.";

/// Checks one field. `None` means it may be written.
pub fn check_field(field: &str, text: &str) -> Option<Finding> {
    if text.contains("-----BEGIN") && text.contains("PRIVATE KEY") {
        return Some(Finding {
            rule: "private key in PEM format",
            field: field.to_string(),
            sample: "-----BEGIN ... PRIVATE KEY-----".into(),
            advice: ADVICE_KEY,
        });
    }
    if let Some(fence) = detect_fence(text) {
        return Some(Finding {
            rule: "fenced code block (file contents)",
            field: field.to_string(),
            sample: fence.sample(),
            advice: ADVICE_BODY,
        });
    }
    tokens(text).find_map(|tok| check_token(field, tok))
}

/// Checks several fields at once. Returns the first that fails, which is all
/// that is needed: the operation is refused whole.
pub fn check_fields(fields: &[(&str, &str)]) -> Option<Finding> {
    fields.iter().find_map(|(c, t)| check_field(c, t))
}

/// An open fence, enough of it to describe without repeating it: which
/// character opened it, the info string (a language tag, usually), and how
/// many lines the block runs -- to the matching close if there is one, to
/// the end of the text if there is not.
struct Fence {
    marker: char,
    tag: String,
    lines: usize,
}

/// The language tag is the caller's own text, so it is capped rather than
/// carried whole: enough to name the language, not enough to smuggle a line
/// of the body through the info string.
const TAG_LIMIT: usize = 16;

impl Fence {
    fn sample(&self) -> String {
        let marker: String = std::iter::repeat(self.marker).take(3).collect();
        format!("{marker}{}, {} lines", self.tag, self.lines)
    }
}

/// The mechanical proxy for "no file contents": a line that opens with three
/// or more backticks or tildes, indented by at most three spaces -- a
/// CommonMark fenced code block. Opening is enough; the fence does not have
/// to close.
///
/// **What it does not promise.** A paste with the fence stripped out gets
/// through untouched, and so does one indented by four spaces or more --
/// CommonMark itself stops calling that a fence at four. Neither is an
/// oversight. This guard has no override (see the module doc), so a rule
/// with no way around it can only afford zero false positives, and three
/// fence characters at the very start of a line is close to that: writing
/// it is declaring a code block, not writing prose that happens to look
/// like one. A threshold on line count would not be: it is a number
/// invented today that some day rejects a legitimate note with no way to
/// write it.
fn detect_fence(text: &str) -> Option<Fence> {
    let lines: Vec<&str> = text.split('\n').collect();
    for (i, line) in lines.iter().enumerate() {
        let Some((marker, run, tag)) = open_fence(line) else {
            continue;
        };
        let end = lines
            .iter()
            .skip(i + 1)
            .position(|l| is_fence_close(l, marker, run))
            .map(|p| i + 1 + p)
            .unwrap_or(lines.len() - 1);
        return Some(Fence {
            marker,
            tag: tag.trim().chars().take(TAG_LIMIT).collect(),
            lines: end - i + 1,
        });
    }
    None
}

/// A line opens a fence when, after at most three leading spaces, it starts
/// with a run of three or more of the same fence character. Returns the
/// character, how many of it opened the fence, and whatever text follows on
/// the line -- the info string.
fn open_fence(line: &str) -> Option<(char, usize, &str)> {
    let indent = line.chars().take_while(|c| *c == ' ').count();
    if indent > 3 {
        return None;
    }
    let rest = &line[indent..];
    let marker = rest.chars().next()?;
    if marker != '`' && marker != '~' {
        return None;
    }
    let run = rest.chars().take_while(|c| *c == marker).count();
    (run >= 3).then(|| (marker, run, &rest[run..]))
}

/// A line closes a fence when it matches the same shape as the opening --
/// at most three leading spaces, a run of the same character at least as
/// long -- with nothing but whitespace after it.
fn is_fence_close(line: &str, marker: char, min_run: usize) -> bool {
    let indent = line.chars().take_while(|c| *c == ' ').count();
    if indent > 3 {
        return false;
    }
    let rest = &line[indent..];
    let run = rest.chars().take_while(|c| *c == marker).count();
    run >= min_run && rest[run..].chars().all(char::is_whitespace)
}

fn check_token(field: &str, tok: &str) -> Option<Finding> {
    let finding = |rule, advice| {
        Some(Finding {
            rule,
            field: field.to_string(),
            sample: mask(tok),
            advice,
        })
    };

    for (p, min) in PREFIXES {
        if tok.starts_with(p) && tok.len() >= p.len() + min {
            return finding("known credential prefix", ADVICE_KEY);
        }
    }
    if tok.len() == 20
        && AWS.iter().any(|p| tok.starts_with(p))
        && tok
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
    {
        return finding("AWS access key", ADVICE_KEY);
    }
    if is_jwt(tok) {
        return finding("JSON Web Token", ADVICE_KEY);
    }
    if is_email(tok) {
        return finding("email address (personal data)", ADVICE_PII);
    }
    if is_home_path(tok) {
        return finding("path to a user home directory (personal data)", ADVICE_PII);
    }
    if suspicious_entropy(tok) {
        return finding("high-entropy string with no known shape", ADVICE_ENTROPY);
    }
    None
}

/// Splits on whitespace and on the signs that are never part of a
/// credential. Dashes, dots, slashes and underscores are kept, because they are.
fn tokens(text: &str) -> impl Iterator<Item = &str> {
    const SEPARATORS: &[char] = &[
        ',', ';', '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', '`',
    ];
    const EDGES: &[char] = &['.', ':', '!', '?'];
    text.split(|c: char| c.is_whitespace() || SEPARATORS.contains(&c))
        .map(|t| t.trim_matches(|c: char| EDGES.contains(&c)))
        .filter(|t| !t.is_empty())
}

fn is_jwt(tok: &str) -> bool {
    if !tok.starts_with("eyJ") {
        return false;
    }
    let parts: Vec<&str> = tok.split('.').collect();
    parts.len() == 3 && parts.iter().all(|p| p.len() >= 8) && parts[1].starts_with("eyJ")
}

fn is_email(tok: &str) -> bool {
    let Some((user, domain)) = tok.split_once('@') else {
        return false;
    };
    if user.is_empty() {
        return false;
    }
    let Some((host, tld)) = domain.rsplit_once('.') else {
        return false;
    };
    !host.is_empty()
        && (2..=24).contains(&tld.len())
        && tld.bytes().all(|b| b.is_ascii_alphabetic())
        && host
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
}

/// A home path carries the name of whoever owns it, which is personal data.
/// `~` does not: it resolves on the reader's machine and identifies nobody.
fn is_home_path(tok: &str) -> bool {
    let lower = tok.to_ascii_lowercase().replace('\\', "/");
    ["/users/", "/home/"].iter().any(|p| {
        lower
            .find(p)
            .map(|i| lower[i + p.len()..].split('/').next().unwrap_or("").len() > 1)
            .unwrap_or(false)
    })
}

/// The uncertain heuristic, and for that reason the most conservative of the five.
///
/// Entropy alone does not separate a secret from a long identifier:
/// `PermissionServiceAdapter.cs:278` scores almost the same as a key of the
/// same length. Two more filters are needed, and both came out of a test in
/// this very batch demanding them:
///
/// - **credential alphabet**: credentials are written in base64 or hex, with
///   no dots and no colons. A `File.cs:278` is ruled out by shape alone,
///   without looking at entropy.
/// - **vowel ratio**: in a random string vowels sit around 16 %; in something
///   somebody wrote to be read, 35 % or more. It is the cheapest
///   discriminant that separates `MeetingV2PolicyMaskCalculator` from
///   `Xk7fQ2mZp9RtLw4sVb8N`.
fn suspicious_entropy(tok: &str) -> bool {
    if !(24..=512).contains(&tok.len()) || known_shape(tok) || !credential_alphabet(tok) {
        return false;
    }
    let digit = tok.bytes().any(|b| b.is_ascii_digit());
    let lower = tok.bytes().any(|b| b.is_ascii_lowercase());
    let upper = tok.bytes().any(|b| b.is_ascii_uppercase());
    digit && lower && upper && vowels(tok) < 0.26 && shannon(tok) >= 3.8
}

fn credential_alphabet(tok: &str) -> bool {
    tok.bytes()
        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+' | b'=' | b'~'))
}

fn vowels(tok: &str) -> f64 {
    let n = tok.bytes().filter(|b| b.is_ascii_alphabetic()).count();
    if n == 0 {
        return 0.0;
    }
    let v = tok.bytes().filter(|b| b"aeiouAEIOU".contains(b)).count();
    v as f64 / n as f64
}

/// Long random-looking things that are not secrets and turn up constantly in
/// a real provenance tree: SHAs, UUIDs, ULIDs, paths and URLs.
fn known_shape(tok: &str) -> bool {
    let no_dashes: String = tok.chars().filter(|c| *c != '-').collect();
    if !no_dashes.is_empty() && no_dashes.bytes().all(|b| b.is_ascii_hexdigit()) {
        return true; // SHA, checksum, UUID
    }
    if tok.len() == 26
        && tok
            .bytes()
            .all(|b| b.is_ascii_digit() || (b.is_ascii_lowercase() && !b"ilou".contains(&b)))
    {
        return true; // ULID
    }
    tok.contains('/') || tok.contains('\\')
}

fn shannon(s: &str) -> f64 {
    let mut count = [0u32; 256];
    for b in s.as_bytes() {
        count[*b as usize] += 1;
    }
    let n = s.len() as f64;
    -count
        .iter()
        .filter(|c| **c > 0)
        .map(|c| {
            let p = f64::from(*c) / n;
            p * p.log2()
        })
        .sum::<f64>()
}

/// Shows what it is without reproducing it. It goes to the screen, which is
/// already less bad than storing it, but there is no need to show it whole.
fn mask(tok: &str) -> String {
    let visible: String = tok.chars().take(4).collect();
    format!("{visible}******** ({} characters)", tok.chars().count())
}

impl std::fmt::Display for Finding {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "  Refused: {}\n\n      field   {}\n      found   {}\n\n  {}",
            self.rule, self.field, self.sample, self.advice
        )
    }
}

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

    fn refuses(t: &str) -> bool {
        check_field("title", t).is_some()
    }

    #[test]
    fn known_keys() {
        assert!(refuses(
            "the token is sqa_9f3c1d7e5b2a48c6d0e1f2a3b4c5d6e7f8091a2b"
        ));
        assert!(refuses("ghp_16C7e42F292c6912E7710c838347Ae178B4a"));
        assert!(refuses(
            "usar sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345"
        ));
        assert!(refuses("AKIAIOSFODNN7EXAMPLE"));
        assert!(refuses("xoxb-2444-8172-abcdefghijkl"));
        assert!(refuses("-----BEGIN RSA PRIVATE KEY-----"));
    }

    #[test]
    fn jwt() {
        assert!(refuses(
            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r"
        ));
    }

    #[test]
    fn personal_data() {
        assert!(refuses("preguntarle a alguien@ejemplo.com"));
        assert!(refuses("it lives in C:\\Users\\somename\\projects"));
        assert!(refuses("/home/unnombre/.config/vivac"));
        assert!(refuses("/Users/unnombre/Library"));
    }

    #[test]
    fn what_has_to_get_through() {
        // Ordinary prose from a real tree.
        assert!(!refuses("Port to Rust in the public vivac/ repo"));
        assert!(!refuses(
            "csharpsquid:S1192 literals duplicados entre archivos"
        ));
        assert!(!refuses("PermissionServiceAdapter.cs:278 is out of scope"));
        // Technical references that look random and are not secrets.
        assert!(!refuses("commit e90b4832f1a4c6d8b0e2f4a6c8d0e2f4a6c8d0e2"));
        assert!(!refuses("node 01j8xq2m4k7pabcdefghijklmn"));
        assert!(!refuses("id 550e8400-e29b-41d4-a716-446655440000"));
        assert!(!refuses(
            "ver https://github.com/rust-lang/rust/issues/12345"
        ));
        // The tilde identifies nobody: it resolves on the reading machine.
        assert!(!refuses("the hook lives in ~/.claude/settings.json"));
        // Long camelCase names, the likeliest source of a false positive.
        assert!(!refuses("MeetingPolicyMaskCalculatorFactoryProvider"));
        assert!(!refuses("ApplyVisibilityPolicyPerMeetingHandler"));
        assert!(!refuses("ReunionV2PolicyMaskCalculatorFactory"));
    }

    #[test]
    fn the_entropy_heuristic_still_hunts() {
        // No known prefix: only the shape is left. These have to fall.
        assert!(refuses("Xk7fQ2mZp9RtLw4sVb8NcJ3hGd6y"));
        assert!(refuses("p8KdReQvXnLYtSbGmZwHfJcT3x9Wq2Vz"));
    }

    #[test]
    fn the_sample_does_not_carry_the_secret() {
        let h = check_field("title", "sqa_9f3c1d7e5b2a48c6d0e1f2a3b4c5d6e7f8091a2b").unwrap();
        assert!(!h.sample.contains("9f3c1d7e"));
        assert!(h.sample.starts_with("sqa_"));
    }

    #[test]
    fn it_returns_the_first_field_that_fails() {
        let h = check_fields(&[
            ("title", "all fine"),
            ("why", "ghp_16C7e42F292c6912E7710c838347Ae178B4a"),
        ]);
        assert_eq!(h.unwrap().field, "why");
    }

    #[test]
    fn fenced_code_is_refused() {
        assert!(refuses("```rust\nfn parse() -> bool {\n    true\n}\n```"));
        assert!(refuses("~~~\nsome code\n~~~"));
        // CommonMark still counts a fence indented by up to three spaces.
        assert!(refuses("   ```\ncode\n```"));
    }

    #[test]
    fn code_that_is_not_fenced_gets_through() {
        // A single backtick is inline code, not a fence, and the project's
        // own prose is full of it.
        assert!(!refuses("run `cargo test` before pushing"));
        // Three backticks not at the start of a line are not a fence either.
        assert!(!refuses("it prints ```like this``` inline, not fenced"));
    }

    #[test]
    fn the_fence_sample_does_not_carry_the_body() {
        let h = check_field(
            "note",
            "```rust\nfn super_secret_function_name() -> u8 { 42 }\n```",
        )
        .unwrap();
        assert!(!h.sample.contains("super"));
        assert!(!h.sample.contains("secret"));
        assert!(!h.sample.contains("function"));
        assert_eq!(h.sample, "```rust, 3 lines");
    }

    #[test]
    fn the_fence_is_found_past_the_first_line() {
        let h = check_field("note", "Some prose here.\n```\ncode line\n```");
        assert!(h.is_some());
    }
}