OXYGENHUB
# 69e4f27c5633 oxygenhub / crates / hub / src / push_protection.rs
23.3 KiB · Rust · last changed Land Drops (#279) beside the settings pages (#278) 16 days ago source blame history raw
1
//! Push protection (hub spec §15.4, oxygenhub#268): a push that adds
2
//! something that looks like a credential is refused before any of it is
3
//! stored, saying where, so it never lands in a repository's history.
4
//!
5
//! Only what a push adds is read: the lines its changes insert and the new
6
//! blobs it carries, never history the hub already holds. The scan has a
7
//! budget ([`Budget`]: bytes per push, the largest file, time), so a push
8
//! stays fast; past it the rest goes unscanned, the push is accepted and its
9
//! log entry says how much was left (a refusal would block every large
10
//! import for what is most likely nothing). Rules are data, in
11
//! `push_protection.toml`, compiled in and versioned. What a repository
12
//! already holds is scanned with the same rules by the history scan
13
//! ([`crate::history_scan`], #277).
14
//!
15
//! A finding names its rule, file and line, a fingerprint (BLAKE3 of the
16
//! rule id and the secret, the first 16 hex digits) and an excerpt with the
17
//! secret masked. A maintainer lets one finding through by its fingerprint
18
//! (Settings › Push protection); the setting itself is per repository and
19
//! on by default.
20
21
use oxy_core::hash::ObjectType;
22
use oxy_core::hash::{ChangeHash, ObjectRef};
23
use oxy_core::ids::NodeId;
24
use oxy_core::objects::{Edit, TreeOp};
25
use oxy_core::protocol::Bundle;
26
use oxy_core::repo::Repo;
27
use regex::bytes::Regex;
28
use serde::Deserialize;
29
use std::collections::{HashMap, HashSet};
30
use std::sync::OnceLock;
31
use std::time::{Duration, Instant};
32
33
/// The rules, as compiled in.
34
const RULES: &str = include_str!("push_protection.toml");
35
36
/// How much of a push is scanned: past `bytes` in all, or `time`, the rest
37
/// is not; a file or inserted text over `file` bytes, or binary, is skipped.
38
#[derive(Clone, Copy, Debug)]
39
pub struct Budget {
40
    pub bytes: u64,
41
    pub file: u64,
42
    pub time: Duration,
43
}
44
45
impl Default for Budget {
46
    fn default() -> Self {
47
        Budget { bytes: 16 << 20, file: 1 << 20, time: Duration::from_secs(2) }
48
    }
49
}
50
51
#[derive(Deserialize)]
52
#[serde(deny_unknown_fields)]
53
struct RulesFile {
54
    version: u32,
55
    rule: Vec<RuleSpec>,
56
}
57
58
#[derive(Deserialize)]
59
#[serde(deny_unknown_fields)]
60
struct RuleSpec {
61
    id: String,
62
    description: String,
63
    regex: String,
64
    #[serde(default)]
65
    keywords: Vec<String>,
66
    #[serde(default)]
67
    requires: Option<String>,
68
}
69
70
/// One compiled rule.
71
pub struct Rule {
72
    pub id: String,
73
    pub description: String,
74
    regex: Regex,
75
    /// Lower-cased; empty runs the regex on every text.
76
    keywords: Vec<String>,
77
    requires: Option<Regex>,
78
}
79
80
/// The compiled rule set.
81
pub struct Rules {
82
    pub version: u32,
83
    pub rules: Vec<Rule>,
84
}
85
86
/// The compiled-in rules. A rule that does not compile is a build defect,
87
/// caught by this module's tests.
88
pub fn rules() -> &'static Rules {
89
    static RULESET: OnceLock<Rules> = OnceLock::new();
90
    RULESET.get_or_init(|| compile(RULES).expect("push_protection.toml"))
91
}
92
93
fn compile(text: &str) -> Result<Rules, String> {
94
    let file: RulesFile = toml::from_str(text).map_err(|e| e.to_string())?;
95
    let mut rules = Vec::new();
96
    let mut seen = HashSet::new();
97
    for r in file.rule {
98
        if !seen.insert(r.id.clone()) {
99
            return Err(format!("rule {} is listed twice", r.id));
100
        }
101
        let regex = Regex::new(&r.regex).map_err(|e| format!("rule {}: {e}", r.id))?;
102
        let requires = match r.requires {
103
            Some(q) => Some(Regex::new(&q).map_err(|e| format!("rule {} requires: {e}", r.id))?),
104
            None => None,
105
        };
106
        rules.push(Rule {
107
            id: r.id,
108
            description: r.description,
109
            regex,
110
            keywords: r.keywords.iter().map(|k| k.to_ascii_lowercase()).collect(),
111
            requires,
112
        });
113
    }
114
    Ok(Rules { version: file.version, rules })
115
}
116
117
/// Where a finding's text came from in the push.
118
#[derive(Clone, Debug, PartialEq, Eq)]
119
pub enum Origin {
120
    /// Lines an insert adds; `atom` is the finding's line's atom index in
121
    /// its change (spec §7.4), counted per file.
122
    Insert { atom: u32 },
123
    /// A new blob replacing a file's content.
124
    Blob,
125
}
126
127
/// A possible secret a push adds.
128
#[derive(Clone, Debug)]
129
pub struct Finding {
130
    pub rule: String,
131
    pub description: String,
132
    /// The change that adds it: its index in the bundle and its hash.
133
    pub change: usize,
134
    pub change_hash: Option<ChangeHash>,
135
    pub node: NodeId,
136
    pub origin: Origin,
137
    /// The file's path, once [`locate`] found it.
138
    pub path: String,
139
    /// 1-based: in the file once [`locate`] worked it out, else in the
140
    /// inserted text or blob.
141
    pub line: usize,
142
    /// The first 16 hex digits of BLAKE3(rule id, NUL, secret).
143
    pub fingerprint: String,
144
    /// The finding's line, the secret masked.
145
    pub excerpt: String,
146
}
147
148
/// What a scan found and how much it read.
149
#[derive(Clone, Debug, Default)]
150
pub struct Scan {
151
    pub findings: Vec<Finding>,
152
    /// Bytes scanned.
153
    pub scanned: u64,
154
    /// Texts and blobs skipped as over the file budget or binary.
155
    pub skipped: usize,
156
    /// Bytes left unscanned when the budget ran out, and why.
157
    pub cut_short: Option<(u64, &'static str)>,
158
}
159
160
impl Scan {
161
    /// One line for the push log when the scan stopped early.
162
    pub fn note(&self) -> Option<String> {
163
        let (left, why) = self.cut_short?;
164
        Some(format!(
165
            "push protection scanned {} and stopped at its {why} budget: {} more were not scanned",
166
            human(self.scanned),
167
            human(left)
168
        ))
169
    }
170
}
171
172
fn human(n: u64) -> String {
173
    match n {
174
        n if n >= 1 << 20 => format!("{:.1} MiB", n as f64 / (1u64 << 20) as f64),
175
        n if n >= 1 << 10 => format!("{:.1} KiB", n as f64 / 1024.0),
176
        n => format!("{n} bytes"),
177
    }
178
}
179
180
/// The fingerprint of `secret` under `rule`.
181
pub fn fingerprint(rule: &str, secret: &[u8]) -> String {
182
    let mut h = blake3::Hasher::new();
183
    h.update(rule.as_bytes());
184
    h.update(&[0]);
185
    h.update(secret);
186
    h.finalize().to_hex()[..16].to_string()
187
}
188
189
/// A finding in one text: its rule, 1-based line, fingerprint and excerpt.
190
#[derive(Clone, Debug)]
191
pub struct Hit {
192
    pub rule: usize,
193
    pub line: usize,
194
    pub fingerprint: String,
195
    pub excerpt: String,
196
}
197
198
/// Every rule over one text. Matches overlapping an earlier rule's are
199
/// dropped, so one secret is reported once, by its most specific rule.
200
pub fn scan_text(rules: &Rules, text: &[u8]) -> Vec<Hit> {
201
    let mut hits = Vec::new();
202
    let mut taken: Vec<(usize, usize)> = Vec::new();
203
    let mut lower: Option<Vec<u8>> = None;
204
    for (i, rule) in rules.rules.iter().enumerate() {
205
        if !rule.keywords.is_empty() {
206
            let lower = lower.get_or_insert_with(|| text.to_ascii_lowercase());
207
            if !rule.keywords.iter().any(|k| memchr::memmem::find(lower, k.as_bytes()).is_some()) {
208
                continue;
209
            }
210
        }
211
        let mut required: Option<bool> = None;
212
        for caps in rule.regex.captures_iter(text) {
213
            let m = caps.name("secret").or_else(|| caps.get(0)).expect("a match");
214
            let (start, end) = (m.start(), m.end());
215
            if start == end || taken.iter().any(|(s, e)| start < *e && *s < end) {
216
                continue;
217
            }
218
            if let Some(q) = &rule.requires {
219
                if !*required.get_or_insert_with(|| q.is_match(text)) {
220
                    break;
221
                }
222
            }
223
            taken.push((start, end));
224
            hits.push(Hit {
225
                rule: i,
226
                line: memchr::memchr_iter(b'\n', &text[..start]).count() + 1,
227
                fingerprint: fingerprint(&rule.id, m.as_bytes()),
228
                excerpt: excerpt(text, start, end),
229
            });
230
        }
231
    }
232
    hits
233
}
234
235
/// The line holding `text[start..end]` with the secret masked: its first
236
/// four characters kept, the rest starred, at most 160 characters around.
237
fn excerpt(text: &[u8], start: usize, end: usize) -> String {
238
    let line_start = memchr::memrchr(b'\n', &text[..start]).map(|i| i + 1).unwrap_or(0);
239
    let line_end = memchr::memchr(b'\n', &text[start..]).map(|i| start + i).unwrap_or(text.len());
240
    let secret_end = end.min(line_end);
241
    let kept = (secret_end - start).min(4);
242
    let before_from = start.saturating_sub(60).max(line_start);
243
    let after_to = (secret_end + 40).min(line_end);
244
    let mut out = String::new();
245
    if before_from > line_start {
246
        out.push('…');
247
    }
248
    out.push_str(&String::from_utf8_lossy(&text[before_from..start + kept]));
249
    out.push_str(&"*".repeat((secret_end - start - kept).clamp(4, 16)));
250
    let mut after = String::from_utf8_lossy(&text[secret_end..after_to]).into_owned();
251
    // The same secret again on the line is masked too.
252
    let secret = String::from_utf8_lossy(&text[start..secret_end]).into_owned();
253
    if secret.len() > 4 {
254
        after = after.replace(&secret, "****");
255
    }
256
    out.push_str(&after);
257
    if after_to < line_end {
258
        out.push('…');
259
    }
260
    out.trim_end_matches(['\r', '\n']).trim().to_string()
261
}
262
263
/// Scan what `bundle` adds: each insert's lines and each new blob a change
264
/// puts in place of a file's content, in the push's order, within
265
/// `budget`. Past the size or time budget the rest is only counted.
266
pub fn scan(bundle: &Bundle, budget: Budget) -> Scan {
267
    let rules = rules();
268
    let started = Instant::now();
269
    let mut out = Scan::default();
270
    // The bundle's blobs and chunks by reference, made when a change first
271
    // replaces a file's content.
272
    let mut objects: Option<HashMap<ObjectRef, usize>> = None;
273
    let mut stop: Option<&'static str> = None;
274
    let mut left: u64 = 0;
275
    for (ci, (change, _)) in bundle.changes.iter().enumerate() {
276
        let mut counters: HashMap<NodeId, u32> = HashMap::new();
277
        for e in &change.edits {
278
            let (node, origin, size) = match e {
279
                Edit::Insert { node, lines, .. } => {
280
                    let idx = counters.entry(*node).or_default();
281
                    let first = *idx;
282
                    *idx += lines.len() as u32;
283
                    let n: usize = lines.iter().map(<[u8]>::len).sum();
284
                    (*node, Origin::Insert { atom: first }, n as u64)
285
                }
286
                Edit::Replace { node, new: Some(r), .. } => {
287
                    let objects = objects.get_or_insert_with(|| {
288
                        bundle
289
                            .objects
290
                            .iter()
291
                            .enumerate()
292
                            .filter(|(_, (ty, _))| matches!(ty, ObjectType::Blob | ObjectType::Chunk))
293
                            .map(|(i, (ty, body))| (oxy_core::hash::hash_object(*ty, body), i))
294
                            .collect()
295
                    });
296
                    // A blob the push does not carry is already on the hub
297
                    // (or lazy): nothing new.
298
                    let Some(blob) = blob_of(bundle, objects, r) else { continue };
299
                    (*node, Origin::Blob, blob.length)
300
                }
301
                _ => continue,
302
            };
303
            if size > budget.file {
304
                out.skipped += 1;
305
                continue;
306
            }
307
            if stop.is_none() {
308
                if out.scanned + size > budget.bytes {
309
                    stop = Some("size");
310
                } else if started.elapsed() > budget.time {
311
                    stop = Some("time");
312
                }
313
            }
314
            if stop.is_some() {
315
                left += size;
316
                continue;
317
            }
318
            let blob_text;
319
            let text: &[u8] = match (e, &origin) {
320
                (Edit::Insert { lines, .. }, _) => &lines.bytes()[..size as usize],
321
                (Edit::Replace { new: Some(r), .. }, Origin::Blob) => {
322
                    let objects = objects.as_ref().expect("made above");
323
                    match blob_content(bundle, objects, r) {
324
                        Some(t) if memchr::memchr(0, &t[..t.len().min(8000)]).is_none() => {
325
                            blob_text = t;
326
                            &blob_text
327
                        }
328
                        // Binary, as Git and oxy judge it (a NUL early on),
329
                        // or incomplete.
330
                        _ => {
331
                            out.skipped += 1;
332
                            continue;
333
                        }
334
                    }
335
                }
336
                _ => continue,
337
            };
338
            for hit in scan_text(rules, text) {
339
                let rule = &rules.rules[hit.rule];
340
                let origin = match origin {
341
                    Origin::Insert { atom } => Origin::Insert { atom: atom + hit.line as u32 - 1 },
342
                    Origin::Blob => Origin::Blob,
343
                };
344
                out.findings.push(Finding {
345
                    rule: rule.id.clone(),
346
                    description: rule.description.clone(),
347
                    change: ci,
348
                    change_hash: bundle.hashes.get(ci).copied(),
349
                    node,
350
                    origin,
351
                    path: String::new(),
352
                    line: hit.line,
353
                    fingerprint: hit.fingerprint,
354
                    excerpt: hit.excerpt,
355
                });
356
            }
357
            out.scanned += size;
358
        }
359
    }
360
    if let Some(why) = stop {
361
        out.cut_short = Some((left, why));
362
    }
363
    out
364
}
365
366
fn object<'b>(bundle: &'b Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef, want: ObjectType) -> Option<&'b [u8]> {
367
    match objects.get(r).map(|i| &bundle.objects[*i]) {
368
        Some((ty, body)) if *ty == want => Some(body),
369
        _ => None,
370
    }
371
}
372
373
fn blob_of(bundle: &Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef) -> Option<oxy_core::objects::Blob> {
374
    oxy_core::objects::Blob::decode(object(bundle, objects, r, ObjectType::Blob)?).ok()
375
}
376
377
/// A blob's content from the bundle's chunks; `None` when one is missing.
378
fn blob_content(bundle: &Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef) -> Option<Vec<u8>> {
379
    let blob = blob_of(bundle, objects, r)?;
380
    let mut content = Vec::with_capacity(blob.length as usize);
381
    for c in &blob.chunks {
382
        content.extend_from_slice(object(bundle, objects, c, ObjectType::Chunk)?);
383
    }
384
    Some(content)
385
}
386
387
/// Fill in each finding's path, and its line in the file as the change that
388
/// adds it leaves the file, from `repo` with the push's changes applied in
389
/// memory. Done only when there are findings: it loads the state the push
390
/// builds on. A finding it cannot place keeps its line in the inserted text
391
/// and is named by the push's own tree operations, or by its node.
392
pub fn locate(repo: &Repo, bundle: &Bundle, findings: &mut [Finding]) {
393
    let mut names: HashMap<NodeId, (NodeId, String)> = HashMap::new();
394
    for (c, _) in &bundle.changes {
395
        for op in &c.tree_ops {
396
            match op {
397
                TreeOp::AddNode { node, parent, name, .. } | TreeOp::Move { node, parent, name, .. } => {
398
                    names.insert(*node, (*parent, name.clone()));
399
                }
400
                _ => {}
401
            }
402
        }
403
    }
404
    let placed = locate_in_state(repo, bundle, findings).unwrap_or_default();
405
    for (i, f) in findings.iter_mut().enumerate() {
406
        if let Some((path, line)) = placed.get(&i) {
407
            f.path = path.clone();
408
            if let Some(line) = line {
409
                f.line = *line;
410
            }
411
            continue;
412
        }
413
        let mut parts = Vec::new();
414
        let mut cur = f.node;
415
        while let Some((parent, name)) = names.get(&cur) {
416
            parts.push(name.clone());
417
            cur = *parent;
418
            if parts.len() > 256 {
419
                break;
420
            }
421
        }
422
        parts.reverse();
423
        f.path = if parts.is_empty() { format!("(file {})", &f.node.to_hex()[..12]) } else { parts.join("/") };
424
    }
425
}
426
427
/// Each finding's (path, line) by index, as the state after its change has
428
/// it; `None` when that state cannot be built.
429
fn locate_in_state(repo: &Repo, bundle: &Bundle, findings: &[Finding]) -> Option<HashMap<usize, (String, Option<usize>)>> {
430
    if bundle.hashes.len() != bundle.changes.len() {
431
        return None;
432
    }
433
    let own: HashSet<ChangeHash> = bundle.hashes.iter().copied().collect();
434
    let mut outside: Vec<ChangeHash> =
435
        bundle.changes.iter().flat_map(|(c, _)| c.deps.iter().copied()).filter(|d| !own.contains(d)).collect();
436
    outside.sort();
437
    outside.dedup();
438
    let mut state = repo.load_state(&outside).ok()?;
439
    let mut out = HashMap::new();
440
    for (ci, (change, _)) in bundle.changes.iter().enumerate() {
441
        state.add_hashed(bundle.hashes[ci], change.clone()).ok()?;
442
        let here: Vec<usize> = (0..findings.len()).filter(|i| findings[*i].change == ci).collect();
443
        if here.is_empty() {
444
            continue;
445
        }
446
        let view = state.view();
447
        let by_node = view.by_node();
448
        let mut lines: HashMap<NodeId, Vec<(ChangeHash, u32)>> = HashMap::new();
449
        for i in here {
450
            let f = &findings[i];
451
            let Some(entry) = by_node.get(&f.node) else { continue };
452
            let line = match f.origin {
453
                Origin::Insert { atom } => {
454
                    let live = lines.entry(f.node).or_insert_with(|| state.live_lines(&f.node).into_iter().map(|(k, _)| k).collect());
455
                    live.iter().position(|k| *k == (bundle.hashes[ci], atom)).map(|p| p + 1)
456
                }
457
                Origin::Blob => None,
458
            };
459
            out.insert(i, (entry.path.clone(), line));
460
        }
461
    }
462
    Some(out)
463
}
464
465
/// Findings listed in a refusal; the rest are counted.
466
const LISTED: usize = 20;
467
468
/// The refusal's text: each finding as `path:line: description
469
/// (fingerprint F)` with its masked excerpt, then what to do. `via` names
470
/// the agent whose token pushed, if one did; `settings` is where a
471
/// maintainer allows a finding.
472
/// `settings` is the repository's settings page, or empty for a drop.
473
pub fn refusal(findings: &[Finding], settings: &str, via: &str) -> String {
474
    use std::fmt::Write;
475
    let n = findings.len();
476
    let mut out = format!(
477
        "push protection refused this push: it adds {} that look{} like {}, and nothing of it was stored (rules v{}).\n\n",
478
        if n == 1 { "a value".to_string() } else { format!("{n} values") },
479
        if n == 1 { "s" } else { "" },
480
        if n == 1 { "a secret" } else { "secrets" },
481
        rules().version
482
    );
483
    for f in findings.iter().take(LISTED) {
484
        let _ = writeln!(out, "  {}:{}: {} (fingerprint {})", f.path, f.line, f.description, f.fingerprint);
485
        if !f.excerpt.is_empty() {
486
            let _ = writeln!(out, "      {}", f.excerpt);
487
        }
488
    }
489
    if n > LISTED {
490
        let _ = writeln!(out, "  … and {} more", n - LISTED);
491
    }
492
    out.push_str("\nWhat to do:\n");
493
    let (it, is) = if n == 1 { ("it", "it is") } else { ("each", "one is") };
494
    let _ = writeln!(
495
        out,
496
        "  - Take {it} out of the change that adds it: edit the file, then `oxy amend` (or `oxy abandon` a change that only adds it), and push again. Read credentials from the environment or a secret store, not from the repository."
497
    );
498
    let _ = writeln!(out, "  - If {is} a real credential, revoke or rotate it as well: it has been on your disk and may be elsewhere.");
499
    if settings.is_empty() {
500
        // A drop (#279) has no settings: nothing there can be allowed.
501
        let _ = writeln!(out, "  - A drop cannot allow a finding: if {is} not a secret, change the value so it no longer looks like one.");
502
    } else {
503
        let _ = writeln!(
504
            out,
505
            "  - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}/push-protection; then push again."
506
        );
507
    }
508
    if !via.is_empty() && settings.is_empty() {
509
        let _ = write!(
510
            out,
511
            "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for."
512
        );
513
    } else if !via.is_empty() {
514
        let _ = write!(
515
            out,
516
            "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}/push-protection."
517
        );
518
    }
519
    out.trim_end().to_string()
520
}
521
522
#[cfg(test)]
523
mod tests {
524
    use super::*;
525
526
    #[test]
527
    fn the_rules_compile() {
528
        let r = rules();
529
        assert!(r.version >= 1);
530
        assert!(r.rules.len() >= 15);
531
    }
532
533
    #[test]
534
    fn the_rules_file_is_not_itself_a_finding() {
535
        assert!(scan_text(rules(), RULES.as_bytes()).is_empty());
536
    }
537
538
    #[test]
539
    fn the_hub_s_own_push_protection_sources_pass_it() {
540
        for (name, text) in [
541
            ("push_protection.rs", include_str!("push_protection.rs")),
542
            ("tests/push_protection.rs", include_str!("../tests/push_protection.rs")),
543
            ("docs/spec.md", include_str!("../../../docs/spec.md")),
544
            // AWS's documented example keys, put together at run time (#277).
545
            ("backups.rs", include_str!("backups.rs")),
546
            ("history_scan.rs", include_str!("history_scan.rs")),
547
            ("tests/history_scan.rs", include_str!("../tests/history_scan.rs")),
548
        ] {
549
            assert!(scan_text(rules(), text.as_bytes()).is_empty(), "{name}");
550
        }
551
    }
552
553
    #[test]
554
    fn a_secret_is_found_masked_and_fingerprinted() {
555
        let key = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE");
556
        let text = format!("[default]\naws_access_key_id = {key}\n");
557
        let hits = scan_text(rules(), text.as_bytes());
558
        assert_eq!(hits.len(), 1, "{hits:?}");
559
        assert_eq!(rules().rules[hits[0].rule].id, "aws-access-key-id");
560
        assert_eq!(hits[0].line, 2);
561
        assert_eq!(hits[0].fingerprint, fingerprint("aws-access-key-id", key.as_bytes()));
562
        assert_eq!(hits[0].fingerprint.len(), 16);
563
        assert!(!hits[0].excerpt.contains(&key) && hits[0].excerpt.starts_with("aws_access_key_id = AKIA*"), "{}", hits[0].excerpt);
564
    }
565
566
    #[test]
567
    fn an_aws_secret_needs_its_key_id_nearby() {
568
        let secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY");
569
        let alone = format!("aws_secret_access_key = {secret}\n");
570
        assert!(scan_text(rules(), alone.as_bytes()).is_empty());
571
        let paired = format!("aws_access_key_id = {}{}\n{alone}", "AKIA", "IOSFODNN7EXAMPLE");
572
        let ids: Vec<&str> = scan_text(rules(), paired.as_bytes()).iter().map(|h| rules().rules[h.rule].id.as_str()).collect();
573
        assert_eq!(ids, ["aws-access-key-id", "aws-secret-access-key"]);
574
    }
575
576
    #[test]
577
    fn a_service_account_key_is_reported_once() {
578
        let pem = format!("-----BEGIN {}-----\\n{}\\n-----END {}-----\\n", "PRIVATE KEY", "A".repeat(64), "PRIVATE KEY");
579
        let json = format!("{{\n  \"type\": \"service_account\",\n  \"private_key\": \"{pem}\"\n}}\n");
580
        let hits = scan_text(rules(), json.as_bytes());
581
        assert_eq!(hits.len(), 1, "{hits:?}");
582
        assert_eq!(rules().rules[hits[0].rule].id, "gcp-service-account");
583
        assert_eq!(hits[0].line, 3);
584
    }
585
586
    #[test]
587
    fn a_pem_header_alone_is_not_a_key() {
588
        let doc = format!("A key starts with -----BEGIN {}-----.\n", "RSA PRIVATE KEY");
589
        assert!(scan_text(rules(), doc.as_bytes()).is_empty());
590
    }
591
}