0ee5c3fb Andy McCall 17 days ago ↶
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
32
const RULES : & str = include_str! ( " push_protection.toml" ) ;
33
34
35
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
69
pub struct Rule {
70
pub id : String,
71
pub description : String,
72
regex : Regex,
73
74
keywords : Vec< String> ,
75
requires : Option< Regex> ,
76
}
77
78
79
pub struct Rules {
80
pub version : u32 ,
81
pub rules : Vec< Rule> ,
82
}
83
84
85
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
116
# [ derive ( Clone, Debug, PartialEq, Eq ) ]
117
pub enum Origin {
118
119
120
Insert { atom: u32 } ,
121
122
Blob,
123
}
124
125
126
# [ derive ( Clone, Debug ) ]
127
pub struct Finding {
128
pub rule : String,
129
pub description : String,
130
131
pub change : usize ,
132
pub change_hash : Option< ChangeHash> ,
133
pub node : NodeId,
134
pub origin : Origin,
135
136
pub path : String,
137
138
139
pub line : usize ,
140
141
pub fingerprint : String,
142
143
pub excerpt : String,
144
}
145
146
147
# [ derive ( Clone, Debug, Default ) ]
148
pub struct Scan {
149
pub findings : Vec< Finding> ,
150
151
pub scanned : u64 ,
152
153
pub skipped : usize ,
154
155
pub cut_short : Option< ( u64 , & 'static str ) > ,
156
}
157
158
impl Scan {
159
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 / ( 1 u64 < < 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
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
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
197
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
234
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
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
262
263
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
269
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
295
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
327
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
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
386
387
388
389
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
426
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
464
const LISTED : usize = 20 ;
465
466
467
468
469
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 ( " \n What 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
" \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."
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]\n aws_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
}