OXYGENHUB

Coverage / crates/hub/src/push_protection.rs 90.3%

337 of 373 lines run, 55 of 82 functions · run #504 on main · run missed

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 {
46596×
    fn default() -> Self {
47596×
        Budget { bytes: 16 << 20, file: 1 << 20, time: Duration::from_secs(2) }
48596×
    }
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.
88339×
pub fn rules() -> &'static Rules {
89
    static RULESET: OnceLock<Rules> = OnceLock::new();
90339×
    RULESET.get_or_init(|| compile(RULES).expect("push_protection.toml"))
91339×
}
92
93130×
fn compile(text: &str) -> Result<Rules, String> {
94130×
    let file: RulesFile = toml::from_str(text).map_err(|e| e.to_string())?;
95130×
    let mut rules = Vec::new();
96130×
    let mut seen = HashSet::new();
972080×
    for r in file.rule {
982080×
        if !seen.insert(r.id.clone()) {
990×
            return Err(format!("rule {} is listed twice", r.id));
1002080×
        }
1012080×
        let regex = Regex::new(&r.regex).map_err(|e| format!("rule {}: {e}", r.id))?;
1022080×
        let requires = match r.requires {
103260×
            Some(q) => Some(Regex::new(&q).map_err(|e| format!("rule {} requires: {e}", r.id))?),
1041820×
            None => None,
105
        };
1062080×
        rules.push(Rule {
1072080×
            id: r.id,
1082080×
            description: r.description,
1092080×
            regex,
1103770×
            keywords: r.keywords.iter().map(|k| k.to_ascii_lowercase()).collect(),
1112080×
            requires,
112
        });
113
    }
114130×
    Ok(Rules { version: file.version, rules })
115130×
}
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.
162228×
    pub fn note(&self) -> Option<String> {
163228×
        let (left, why) = self.cut_short?;
1641×
        Some(format!(
1651×
            "push protection scanned {} and stopped at its {why} budget: {} more were not scanned",
1661×
            human(self.scanned),
1671×
            human(left)
1681×
        ))
169228×
    }
170
}
171
1722×
fn human(n: u64) -> String {
1732×
    match n {
1742×
        n if n >= 1 << 20 => format!("{:.1} MiB", n as f64 / (1u64 << 20) as f64),
1752×
        n if n >= 1 << 10 => format!("{:.1} KiB", n as f64 / 1024.0),
1761×
        n => format!("{n} bytes"),
177
    }
1782×
}
179
180
/// The fingerprint of `secret` under `rule`.
18151×
pub fn fingerprint(rule: &str, secret: &[u8]) -> String {
18251×
    let mut h = blake3::Hasher::new();
18351×
    h.update(rule.as_bytes());
18451×
    h.update(&[0]);
18551×
    h.update(secret);
18651×
    h.finalize().to_hex()[..16].to_string()
18751×
}
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.
200380×
pub fn scan_text(rules: &Rules, text: &[u8]) -> Vec<Hit> {
201380×
    let mut hits = Vec::new();
202380×
    let mut taken: Vec<(usize, usize)> = Vec::new();
203380×
    let mut lower: Option<Vec<u8>> = None;
2046080×
    for (i, rule) in rules.rules.iter().enumerate() {
2056080×
        if !rule.keywords.is_empty() {
2066080×
            let lower = lower.get_or_insert_with(|| text.to_ascii_lowercase());
20710921×
            if !rule.keywords.iter().any(|k| memchr::memmem::find(lower, k.as_bytes()).is_some()) {
2085993×
                continue;
20987×
            }
2100×
        }
21187×
        let mut required: Option<bool> = None;
21287×
        for caps in rule.regex.captures_iter(text) {
21336×
            let m = caps.name("secret").or_else(|| caps.get(0)).expect("a match");
21436×
            let (start, end) = (m.start(), m.end());
21536×
            if start == end || taken.iter().any(|(s, e)| start < *e && *s < end) {
2163×
                continue;
21733×
            }
21833×
            if let Some(q) = &rule.requires {
2195×
                if !*required.get_or_insert_with(|| q.is_match(text)) {
2201×
                    break;
2214×
                }
22228×
            }
22332×
            taken.push((start, end));
22432×
            hits.push(Hit {
22532×
                rule: i,
22632×
                line: memchr::memchr_iter(b'\n', &text[..start]).count() + 1,
22732×
                fingerprint: fingerprint(&rule.id, m.as_bytes()),
22832×
                excerpt: excerpt(text, start, end),
22932×
            });
230
        }
231
    }
232380×
    hits
233380×
}
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.
23732×
fn excerpt(text: &[u8], start: usize, end: usize) -> String {
23832×
    let line_start = memchr::memrchr(b'\n', &text[..start]).map(|i| i + 1).unwrap_or(0);
23932×
    let line_end = memchr::memchr(b'\n', &text[start..]).map(|i| start + i).unwrap_or(text.len());
24032×
    let secret_end = end.min(line_end);
24132×
    let kept = (secret_end - start).min(4);
24232×
    let before_from = start.saturating_sub(60).max(line_start);
24332×
    let after_to = (secret_end + 40).min(line_end);
24432×
    let mut out = String::new();
24532×
    if before_from > line_start {
2461×
        out.push('…');
24731×
    }
24832×
    out.push_str(&String::from_utf8_lossy(&text[before_from..start + kept]));
24932×
    out.push_str(&"*".repeat((secret_end - start - kept).clamp(4, 16)));
25032×
    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.
25232×
    let secret = String::from_utf8_lossy(&text[start..secret_end]).into_owned();
25332×
    if secret.len() > 4 {
25432×
        after = after.replace(&secret, "****");
25532×
    }
25632×
    out.push_str(&after);
25732×
    if after_to < line_end {
2580×
        out.push('…');
25932×
    }
26032×
    out.trim_end_matches(['\r', '\n']).trim().to_string()
26132×
}
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.
266270×
pub fn scan(bundle: &Bundle, budget: Budget) -> Scan {
267270×
    let rules = rules();
268270×
    let started = Instant::now();
269270×
    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.
272270×
    let mut objects: Option<HashMap<ObjectRef, usize>> = None;
273270×
    let mut stop: Option<&'static str> = None;
274270×
    let mut left: u64 = 0;
275285×
    for (ci, (change, _)) in bundle.changes.iter().enumerate() {
276285×
        let mut counters: HashMap<NodeId, u32> = HashMap::new();
277396×
        for e in &change.edits {
278369×
            let (node, origin, size) = match e {
279353×
                Edit::Insert { node, lines, .. } => {
280353×
                    let idx = counters.entry(*node).or_default();
281353×
                    let first = *idx;
282353×
                    *idx += lines.len() as u32;
283353×
                    let n: usize = lines.iter().map(<[u8]>::len).sum();
284353×
                    (*node, Origin::Insert { atom: first }, n as u64)
285
                }
28616×
                Edit::Replace { node, new: Some(r), .. } => {
28716×
                    let objects = objects.get_or_insert_with(|| {
28814×
                        bundle
28914×
                            .objects
29014×
                            .iter()
29114×
                            .enumerate()
29242×
                            .filter(|(_, (ty, _))| matches!(ty, ObjectType::Blob | ObjectType::Chunk))
29332×
                            .map(|(i, (ty, body))| (oxy_core::hash::hash_object(*ty, body), i))
29414×
                            .collect()
29514×
                    });
296
                    // A blob the push does not carry is already on the hub
297
                    // (or lazy): nothing new.
29816×
                    let Some(blob) = blob_of(bundle, objects, r) else { continue };
29916×
                    (*node, Origin::Blob, blob.length)
300
                }
30127×
                _ => continue,
302
            };
303369×
            if size > budget.file {
3040×
                out.skipped += 1;
3050×
                continue;
306369×
            }
307369×
            if stop.is_none() {
308369×
                if out.scanned + size > budget.bytes {
3091×
                    stop = Some("size");
310368×
                } else if started.elapsed() > budget.time {
3110×
                    stop = Some("time");
312368×
                }
3130×
            }
314369×
            if stop.is_some() {
3151×
                left += size;
3161×
                continue;
317368×
            }
318
            let blob_text;
319368×
            let text: &[u8] = match (e, &origin) {
320352×
                (Edit::Insert { lines, .. }, _) => &lines.bytes()[..size as usize],
32116×
                (Edit::Replace { new: Some(r), .. }, Origin::Blob) => {
32216×
                    let objects = objects.as_ref().expect("made above");
32316×
                    match blob_content(bundle, objects, r) {
32416×
                        Some(t) if memchr::memchr(0, &t[..t.len().min(8000)]).is_none() => {
3250×
                            blob_text = t;
3260×
                            &blob_text
327
                        }
328
                        // Binary, as Git and oxy judge it (a NUL early on),
329
                        // or incomplete.
330
                        _ => {
33116×
                            out.skipped += 1;
33216×
                            continue;
333
                        }
334
                    }
335
                }
3360×
                _ => continue,
337
            };
338352×
            for hit in scan_text(rules, text) {
33926×
                let rule = &rules.rules[hit.rule];
34026×
                let origin = match origin {
34126×
                    Origin::Insert { atom } => Origin::Insert { atom: atom + hit.line as u32 - 1 },
3420×
                    Origin::Blob => Origin::Blob,
343
                };
34426×
                out.findings.push(Finding {
34526×
                    rule: rule.id.clone(),
34626×
                    description: rule.description.clone(),
34726×
                    change: ci,
34826×
                    change_hash: bundle.hashes.get(ci).copied(),
34926×
                    node,
35026×
                    origin,
35126×
                    path: String::new(),
35226×
                    line: hit.line,
35326×
                    fingerprint: hit.fingerprint,
35426×
                    excerpt: hit.excerpt,
35526×
                });
356
            }
357352×
            out.scanned += size;
358
        }
359
    }
360270×
    if let Some(why) = stop {
3611×
        out.cut_short = Some((left, why));
362269×
    }
363270×
    out
364270×
}
365
36648×
fn object<'b>(bundle: &'b Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef, want: ObjectType) -> Option<&'b [u8]> {
36748×
    match objects.get(r).map(|i| &bundle.objects[*i]) {
36848×
        Some((ty, body)) if *ty == want => Some(body),
3690×
        _ => None,
370
    }
37148×
}
372
37332×
fn blob_of(bundle: &Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef) -> Option<oxy_core::objects::Blob> {
37432×
    oxy_core::objects::Blob::decode(object(bundle, objects, r, ObjectType::Blob)?).ok()
37532×
}
376
377
/// A blob's content from the bundle's chunks; `None` when one is missing.
37816×
fn blob_content(bundle: &Bundle, objects: &HashMap<ObjectRef, usize>, r: &ObjectRef) -> Option<Vec<u8>> {
37916×
    let blob = blob_of(bundle, objects, r)?;
38016×
    let mut content = Vec::with_capacity(blob.length as usize);
38116×
    for c in &blob.chunks {
38216×
        content.extend_from_slice(object(bundle, objects, c, ObjectType::Chunk)?);
383
    }
38416×
    Some(content)
38516×
}
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.
39221×
pub fn locate(repo: &Repo, bundle: &Bundle, findings: &mut [Finding]) {
39321×
    let mut names: HashMap<NodeId, (NodeId, String)> = HashMap::new();
39421×
    for (c, _) in &bundle.changes {
39527×
        for op in &c.tree_ops {
39627×
            match op {
39727×
                TreeOp::AddNode { node, parent, name, .. } | TreeOp::Move { node, parent, name, .. } => {
39827×
                    names.insert(*node, (*parent, name.clone()));
39927×
                }
4000×
                _ => {}
401
            }
402
        }
403
    }
40421×
    let placed = locate_in_state(repo, bundle, findings).unwrap_or_default();
40525×
    for (i, f) in findings.iter_mut().enumerate() {
40625×
        if let Some((path, line)) = placed.get(&i) {
40725×
            f.path = path.clone();
40825×
            if let Some(line) = line {
40925×
                f.line = *line;
41025×
            }
41125×
            continue;
4120×
        }
4130×
        let mut parts = Vec::new();
4140×
        let mut cur = f.node;
4150×
        while let Some((parent, name)) = names.get(&cur) {
4160×
            parts.push(name.clone());
4170×
            cur = *parent;
4180×
            if parts.len() > 256 {
4190×
                break;
4200×
            }
421
        }
4220×
        parts.reverse();
4230×
        f.path = if parts.is_empty() { format!("(file {})", &f.node.to_hex()[..12]) } else { parts.join("/") };
424
    }
42521×
}
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.
42921×
fn locate_in_state(repo: &Repo, bundle: &Bundle, findings: &[Finding]) -> Option<HashMap<usize, (String, Option<usize>)>> {
43021×
    if bundle.hashes.len() != bundle.changes.len() {
4310×
        return None;
43221×
    }
43321×
    let own: HashSet<ChangeHash> = bundle.hashes.iter().copied().collect();
43421×
    let mut outside: Vec<ChangeHash> =
43521×
        bundle.changes.iter().flat_map(|(c, _)| c.deps.iter().copied()).filter(|d| !own.contains(d)).collect();
43621×
    outside.sort();
43721×
    outside.dedup();
43821×
    let mut state = repo.load_state(&outside).ok()?;
43921×
    let mut out = HashMap::new();
44021×
    for (ci, (change, _)) in bundle.changes.iter().enumerate() {
44121×
        state.add_hashed(bundle.hashes[ci], change.clone()).ok()?;
44225×
        let here: Vec<usize> = (0..findings.len()).filter(|i| findings[*i].change == ci).collect();
44321×
        if here.is_empty() {
4440×
            continue;
44521×
        }
44621×
        let view = state.view();
44721×
        let by_node = view.by_node();
44821×
        let mut lines: HashMap<NodeId, Vec<(ChangeHash, u32)>> = HashMap::new();
44925×
        for i in here {
45025×
            let f = &findings[i];
45125×
            let Some(entry) = by_node.get(&f.node) else { continue };
45225×
            let line = match f.origin {
45325×
                Origin::Insert { atom } => {
45425×
                    let live = lines.entry(f.node).or_insert_with(|| state.live_lines(&f.node).into_iter().map(|(k, _)| k).collect());
45562×
                    live.iter().position(|k| *k == (bundle.hashes[ci], atom)).map(|p| p + 1)
456
                }
4570×
                Origin::Blob => None,
458
            };
45925×
            out.insert(i, (entry.path.clone(), line));
460
        }
461
    }
46221×
    Some(out)
46321×
}
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.
47314×
pub fn refusal(findings: &[Finding], settings: &str, via: &str) -> String {
474
    use std::fmt::Write;
47514×
    let n = findings.len();
47614×
    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",
47814×
        if n == 1 { "a value".to_string() } else { format!("{n} values") },
47914×
        if n == 1 { "s" } else { "" },
48014×
        if n == 1 { "a secret" } else { "secrets" },
48114×
        rules().version
482
    );
48318×
    for f in findings.iter().take(LISTED) {
48418×
        let _ = writeln!(out, "  {}:{}: {} (fingerprint {})", f.path, f.line, f.description, f.fingerprint);
48518×
        if !f.excerpt.is_empty() {
48618×
            let _ = writeln!(out, "      {}", f.excerpt);
48718×
        }
488
    }
48914×
    if n > LISTED {
4900×
        let _ = writeln!(out, "  … and {} more", n - LISTED);
49114×
    }
49214×
    out.push_str("\nWhat to do:\n");
49314×
    let (it, is) = if n == 1 { ("it", "it is") } else { ("each", "one is") };
49414×
    let _ = writeln!(
49514×
        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
    );
49814×
    let _ = writeln!(out, "  - If {is} a real credential, revoke or rotate it as well: it has been on your disk and may be elsewhere.");
49914×
    if settings.is_empty() {
5001×
        // A drop (#279) has no settings: nothing there can be allowed.
5011×
        let _ = writeln!(out, "  - A drop cannot allow a finding: if {is} not a secret, change the value so it no longer looks like one.");
50213×
    } else {
50313×
        let _ = writeln!(
50413×
            out,
50513×
            "  - 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."
50613×
        );
50713×
    }
50814×
    if !via.is_empty() && settings.is_empty() {
5090×
        let _ = write!(
5100×
            out,
5110×
            "\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."
5120×
        );
51314×
    } else if !via.is_empty() {
5141×
        let _ = write!(
5151×
            out,
5161×
            "\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."
5171×
        );
51813×
    }
51914×
    out.trim_end().to_string()
52014×
}
521
522
#[cfg(test)]
523
mod tests {
524
    use super::*;
525
526
    #[test]
5271×
    fn the_rules_compile() {
5281×
        let r = rules();
5291×
        assert!(r.version >= 1);
5301×
        assert!(r.rules.len() >= 15);
5311×
    }
532
533
    #[test]
5341×
    fn the_rules_file_is_not_itself_a_finding() {
5351×
        assert!(scan_text(rules(), RULES.as_bytes()).is_empty());
5361×
    }
537
538
    #[test]
5391×
    fn the_hub_s_own_push_protection_sources_pass_it() {
5406×
        for (name, text) in [
5411×
            ("push_protection.rs", include_str!("push_protection.rs")),
5421×
            ("tests/push_protection.rs", include_str!("../tests/push_protection.rs")),
5431×
            ("docs/spec.md", include_str!("../../../docs/spec.md")),
5441×
            // AWS's documented example keys, put together at run time (#277).
5451×
            ("backups.rs", include_str!("backups.rs")),
5461×
            ("history_scan.rs", include_str!("history_scan.rs")),
5471×
            ("tests/history_scan.rs", include_str!("../tests/history_scan.rs")),
5481×
        ] {
5496×
            assert!(scan_text(rules(), text.as_bytes()).is_empty(), "{name}");
550
        }
5511×
    }
552
553
    #[test]
5541×
    fn a_secret_is_found_masked_and_fingerprinted() {
5551×
        let key = format!("{}{}", "AKIA", "IOSFODNN7EXAMPLE");
5561×
        let text = format!("[default]\naws_access_key_id = {key}\n");
5571×
        let hits = scan_text(rules(), text.as_bytes());
5581×
        assert_eq!(hits.len(), 1, "{hits:?}");
5591×
        assert_eq!(rules().rules[hits[0].rule].id, "aws-access-key-id");
5601×
        assert_eq!(hits[0].line, 2);
5611×
        assert_eq!(hits[0].fingerprint, fingerprint("aws-access-key-id", key.as_bytes()));
5621×
        assert_eq!(hits[0].fingerprint.len(), 16);
5631×
        assert!(!hits[0].excerpt.contains(&key) && hits[0].excerpt.starts_with("aws_access_key_id = AKIA*"), "{}", hits[0].excerpt);
5641×
    }
565
566
    #[test]
5671×
    fn an_aws_secret_needs_its_key_id_nearby() {
5681×
        let secret = format!("{}{}", "wJalrXUtnFEMI/K7MDENG/", "bPxRfiCYEXAMPLEKEY");
5691×
        let alone = format!("aws_secret_access_key = {secret}\n");
5701×
        assert!(scan_text(rules(), alone.as_bytes()).is_empty());
5711×
        let paired = format!("aws_access_key_id = {}{}\n{alone}", "AKIA", "IOSFODNN7EXAMPLE");
5722×
        let ids: Vec<&str> = scan_text(rules(), paired.as_bytes()).iter().map(|h| rules().rules[h.rule].id.as_str()).collect();
5731×
        assert_eq!(ids, ["aws-access-key-id", "aws-secret-access-key"]);
5741×
    }
575
576
    #[test]
5771×
    fn a_service_account_key_is_reported_once() {
5781×
        let pem = format!("-----BEGIN {}-----\\n{}\\n-----END {}-----\\n", "PRIVATE KEY", "A".repeat(64), "PRIVATE KEY");
5791×
        let json = format!("{{\n  \"type\": \"service_account\",\n  \"private_key\": \"{pem}\"\n}}\n");
5801×
        let hits = scan_text(rules(), json.as_bytes());
5811×
        assert_eq!(hits.len(), 1, "{hits:?}");
5821×
        assert_eq!(rules().rules[hits[0].rule].id, "gcp-service-account");
5831×
        assert_eq!(hits[0].line, 3);
5841×
    }
585
586
    #[test]
5871×
    fn a_pem_header_alone_is_not_a_key() {
5881×
        let doc = format!("A key starts with -----BEGIN {}-----.\n", "RSA PRIVATE KEY");
5891×
        assert!(scan_text(rules(), doc.as_bytes()).is_empty());
5901×
    }
591
}