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