0ee5c3fb Andy McCall 17 days ago ↶
1
2
3
4
5
6
7
8
9
10
cb7b3cd4 Andy McCall 16 days ago ↶
11
12
13
0ee5c3fb Andy McCall 17 days ago ↶
14
15
16
17
18
19
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
34
const RULES : & str = include_str! ( " push_protection.toml" ) ;
35
36
37
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
71
pub struct Rule {
72
pub id : String,
73
pub description : String,
74
regex : Regex,
75
76
keywords : Vec< String> ,
77
requires : Option< Regex> ,
78
}
79
80
81
pub struct Rules {
82
pub version : u32 ,
83
pub rules : Vec< Rule> ,
84
}
85
86
87
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
118
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
119
pub enum Origin {
120
121
122
Insert { atom: u32 } ,
123
124
Blob,
125
}
126
127
128
# [ derive ( Clone, Debug ) ]
129
pub struct Finding {
130
pub rule : String,
131
pub description : String,
132
133
pub change : usize ,
134
pub change_hash : Option< ChangeHash> ,
135
pub node : NodeId,
136
pub origin : Origin,
137
138
pub path : String,
139
140
141
pub line : usize ,
142
143
pub fingerprint : String,
144
145
pub excerpt : String,
146
}
147
148
149
# [ derive ( Clone, Debug, Default ) ]
150
pub struct Scan {
151
pub findings : Vec< Finding> ,
152
153
pub scanned : u64 ,
154
155
pub skipped : usize ,
156
157
pub cut_short : Option< ( u64 , & 'static str ) > ,
158
}
159
160
impl Scan {
161
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 / ( 1 u64 < < 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
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
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
199
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
236
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
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
264
265
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
271
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
297
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
329
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
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
388
389
390
391
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
428
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
466
const LISTED : usize = 20 ;
467
468
469
470
471
166f6e9b Andy McCall 16 days ago
472
0ee5c3fb Andy McCall 17 days ago ↶
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 ( " \n What 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." ) ;
166f6e9b Andy McCall 16 days ago
499
if settings.is_empty ( ) {
500
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,
d366306d Andy McCall 16 days ago ↶
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."
166f6e9b Andy McCall 16 days ago
506
) ;
507
}
508
if ! via.is_empty ( ) & & settings.is_empty ( ) {
509
let _ = write! (
510
out,
511
" \n This 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 ( ) {
0ee5c3fb Andy McCall 17 days ago ↶
514
let _ = write! (
515
out,
c7067c7b Andy McCall 16 days ago
516
" \n This 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."
0ee5c3fb Andy McCall 17 days ago ↶
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" ) ) ,
cb7b3cd4 Andy McCall 16 days ago ↶
544
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" ) ) ,
0ee5c3fb Andy McCall 17 days ago ↶
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]\n aws_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
}