@@ -4,6 +4,18 @@ use rexos_kernel::security::{LeakMode, SecurityConfig};
44
55const BLOCKED_ERROR : & str = "tool output blocked by leak guard" ;
66
7+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
8+ struct SensitiveEnvValue {
9+ detector : String ,
10+ value : String ,
11+ }
12+
13+ #[ derive( Debug , Clone ) ]
14+ pub ( crate ) struct LeakGuard {
15+ mode : LeakMode ,
16+ sensitive_env_values : Vec < SensitiveEnvValue > ,
17+ }
18+
719#[ derive( Debug , Clone , PartialEq , Eq , serde:: Serialize , serde:: Deserialize , Default ) ]
820pub ( crate ) struct LeakGuardAudit {
921 pub ( crate ) mode : String ,
@@ -34,60 +46,92 @@ struct LeakMatch {
3446 detector : String ,
3547}
3648
37- pub ( crate ) fn inspect_tool_output ( content : String , security : & SecurityConfig ) -> LeakGuardVerdict {
38- if matches ! ( security. leaks . mode , LeakMode :: Off ) {
39- return LeakGuardVerdict :: Allowed {
40- content ,
41- audit : None ,
42- } ;
49+ impl LeakGuard {
50+ pub ( crate ) fn from_security ( security : & SecurityConfig ) -> Self {
51+ Self {
52+ mode : security . leaks . mode . clone ( ) ,
53+ sensitive_env_values : collect_sensitive_env_values ( ) ,
54+ }
4355 }
4456
45- let matches = collect_matches ( & content) ;
46- if matches. is_empty ( ) {
47- return LeakGuardVerdict :: Allowed {
48- content,
49- audit : None ,
50- } ;
51- }
57+ pub ( crate ) fn inspect_tool_output ( & self , content : String ) -> LeakGuardVerdict {
58+ if matches ! ( self . mode, LeakMode :: Off ) {
59+ return LeakGuardVerdict :: Allowed {
60+ content,
61+ audit : None ,
62+ } ;
63+ }
64+
65+ let matches = collect_matches ( & content, & self . sensitive_env_values ) ;
66+ if matches. is_empty ( ) {
67+ return LeakGuardVerdict :: Allowed {
68+ content,
69+ audit : None ,
70+ } ;
71+ }
72+
73+ let detectors = detector_labels ( & matches) ;
74+ let mode = mode_label ( & self . mode ) . to_string ( ) ;
5275
53- let detectors = detector_labels ( & matches) ;
54- let mode = mode_label ( & security. leaks . mode ) . to_string ( ) ;
55-
56- match security. leaks . mode {
57- LeakMode :: Off => LeakGuardVerdict :: Allowed {
58- content,
59- audit : None ,
60- } ,
61- LeakMode :: Warn => LeakGuardVerdict :: Allowed {
62- content,
63- audit : Some ( LeakGuardAudit {
64- mode,
65- detectors,
66- redacted : false ,
67- blocked : false ,
68- } ) ,
69- } ,
70- LeakMode :: Redact => LeakGuardVerdict :: Allowed {
71- content : redact_content ( & content, & matches) ,
72- audit : Some ( LeakGuardAudit {
73- mode,
74- detectors,
75- redacted : true ,
76- blocked : false ,
77- } ) ,
78- } ,
79- LeakMode :: Enforce => LeakGuardVerdict :: Blocked {
80- error : BLOCKED_ERROR . to_string ( ) ,
81- audit : LeakGuardAudit {
82- mode,
83- detectors,
84- redacted : false ,
85- blocked : true ,
76+ match self . mode {
77+ LeakMode :: Off => LeakGuardVerdict :: Allowed {
78+ content,
79+ audit : None ,
80+ } ,
81+ LeakMode :: Warn => LeakGuardVerdict :: Allowed {
82+ content,
83+ audit : Some ( LeakGuardAudit {
84+ mode,
85+ detectors,
86+ redacted : false ,
87+ blocked : false ,
88+ } ) ,
89+ } ,
90+ LeakMode :: Redact => LeakGuardVerdict :: Allowed {
91+ content : redact_content ( & content, & matches) ,
92+ audit : Some ( LeakGuardAudit {
93+ mode,
94+ detectors,
95+ redacted : true ,
96+ blocked : false ,
97+ } ) ,
8698 } ,
87- } ,
99+ LeakMode :: Enforce => LeakGuardVerdict :: Blocked {
100+ error : BLOCKED_ERROR . to_string ( ) ,
101+ audit : LeakGuardAudit {
102+ mode,
103+ detectors,
104+ redacted : false ,
105+ blocked : true ,
106+ } ,
107+ } ,
108+ }
88109 }
89110}
90111
112+ fn collect_sensitive_env_values ( ) -> Vec < SensitiveEnvValue > {
113+ let mut out = Vec :: new ( ) ;
114+ let mut seen_values = BTreeSet :: new ( ) ;
115+
116+ for ( name, value) in std:: env:: vars ( ) {
117+ if !is_sensitive_env_name ( & name) {
118+ continue ;
119+ }
120+ if value. trim ( ) . len ( ) < 8 || value. contains ( char:: is_whitespace) {
121+ continue ;
122+ }
123+ if !seen_values. insert ( value. clone ( ) ) {
124+ continue ;
125+ }
126+ out. push ( SensitiveEnvValue {
127+ detector : format ! ( "env:{name}" ) ,
128+ value,
129+ } ) ;
130+ }
131+
132+ out
133+ }
134+
91135fn detector_labels ( matches : & [ LeakMatch ] ) -> Vec < String > {
92136 let mut out = BTreeSet :: new ( ) ;
93137 for item in matches {
@@ -96,9 +140,9 @@ fn detector_labels(matches: &[LeakMatch]) -> Vec<String> {
96140 out. into_iter ( ) . collect ( )
97141}
98142
99- fn collect_matches ( content : & str ) -> Vec < LeakMatch > {
143+ fn collect_matches ( content : & str , sensitive_env_values : & [ SensitiveEnvValue ] ) -> Vec < LeakMatch > {
100144 let mut out = Vec :: new ( ) ;
101- out. extend ( find_sensitive_env_matches ( content) ) ;
145+ out. extend ( find_sensitive_env_matches ( content, sensitive_env_values ) ) ;
102146 out. extend ( find_prefixed_token_matches ( content, "sk-" , 20 , "token:sk" ) ) ;
103147 out. extend ( find_prefixed_token_matches (
104148 content,
@@ -121,24 +165,14 @@ fn collect_matches(content: &str) -> Vec<LeakMatch> {
121165 merge_matches ( out)
122166}
123167
124- fn find_sensitive_env_matches ( content : & str ) -> Vec < LeakMatch > {
168+ fn find_sensitive_env_matches (
169+ content : & str ,
170+ sensitive_env_values : & [ SensitiveEnvValue ] ,
171+ ) -> Vec < LeakMatch > {
125172 let mut matches = Vec :: new ( ) ;
126- let mut seen_values = BTreeSet :: new ( ) ;
127-
128- for ( name, value) in std:: env:: vars ( ) {
129- if !is_sensitive_env_name ( & name) {
130- continue ;
131- }
132- if value. trim ( ) . len ( ) < 8 || value. contains ( char:: is_whitespace) {
133- continue ;
134- }
135- if !seen_values. insert ( value. clone ( ) ) {
136- continue ;
137- }
138-
139- matches. extend ( find_exact_matches ( content, & value, format ! ( "env:{name}" ) ) ) ;
173+ for item in sensitive_env_values {
174+ matches. extend ( find_exact_matches ( content, & item. value , item. detector . clone ( ) ) ) ;
140175 }
141-
142176 matches
143177}
144178
@@ -259,7 +293,7 @@ fn mode_label(mode: &LeakMode) -> &'static str {
259293
260294#[ cfg( test) ]
261295mod tests {
262- use super :: { inspect_tool_output , LeakGuardVerdict } ;
296+ use super :: { LeakGuard , LeakGuardVerdict } ;
263297 use rexos_kernel:: security:: { LeakMode , SecurityConfig } ;
264298
265299 struct EnvVarGuard {
@@ -285,31 +319,32 @@ mod tests {
285319 }
286320 }
287321
288- fn security ( mode : LeakMode ) -> SecurityConfig {
322+ fn leak_guard ( mode : LeakMode ) -> LeakGuard {
289323 let mut cfg = SecurityConfig :: default ( ) ;
290324 cfg. leaks . mode = mode;
291- cfg
325+ LeakGuard :: from_security ( & cfg)
292326 }
293327
294328 #[ test]
295329 fn warn_mode_reports_detected_env_secret_without_mutating_output ( ) {
296- let _guard = EnvVarGuard :: set ( "LOOPFORGE_TEST_SECRET" , "super-secret-value-12345" ) ;
297- let verdict = inspect_tool_output (
298- "value=super-secret-value-12345" . to_string ( ) ,
299- & security ( LeakMode :: Warn ) ,
330+ let _guard = EnvVarGuard :: set (
331+ "LOOPFORGE_TEST_SECRET_WARN" ,
332+ "super-secret-warn-value-12345" ,
300333 ) ;
334+ let verdict = leak_guard ( LeakMode :: Warn )
335+ . inspect_tool_output ( "value=super-secret-warn-value-12345" . to_string ( ) ) ;
301336
302337 match verdict {
303338 LeakGuardVerdict :: Allowed {
304339 content,
305340 audit : Some ( audit) ,
306341 } => {
307- assert_eq ! ( content, "value=super-secret-value-12345" ) ;
342+ assert_eq ! ( content, "value=super-secret-warn- value-12345" ) ;
308343 assert_eq ! ( audit. mode, "warn" ) ;
309344 assert ! ( audit
310345 . detectors
311346 . iter( )
312- . any( |d| d == "env:LOOPFORGE_TEST_SECRET " ) ) ;
347+ . any( |d| d == "env:LOOPFORGE_TEST_SECRET_WARN " ) ) ;
313348 assert ! ( !audit. redacted) ;
314349 assert ! ( !audit. blocked) ;
315350 }
@@ -319,20 +354,21 @@ mod tests {
319354
320355 #[ test]
321356 fn redact_mode_masks_detected_secret_before_returning_content ( ) {
322- let _guard = EnvVarGuard :: set ( "LOOPFORGE_TEST_SECRET" , "super-secret-value-12345" ) ;
323- let verdict = inspect_tool_output (
324- "prefix super-secret-value-12345 suffix" . to_string ( ) ,
325- & security ( LeakMode :: Redact ) ,
357+ let _guard = EnvVarGuard :: set (
358+ "LOOPFORGE_TEST_SECRET_REDACT" ,
359+ "super-secret-redact-value-12345" ,
326360 ) ;
361+ let verdict = leak_guard ( LeakMode :: Redact )
362+ . inspect_tool_output ( "prefix super-secret-redact-value-12345 suffix" . to_string ( ) ) ;
327363
328364 match verdict {
329365 LeakGuardVerdict :: Allowed {
330366 content,
331367 audit : Some ( audit) ,
332368 } => {
333- assert ! ( !content. contains( "super-secret-value-12345" ) , "{content}" ) ;
369+ assert ! ( !content. contains( "super-secret-redact- value-12345" ) , "{content}" ) ;
334370 assert ! (
335- content. contains( "[redacted:env:LOOPFORGE_TEST_SECRET ]" ) ,
371+ content. contains( "[redacted:env:LOOPFORGE_TEST_SECRET_REDACT ]" ) ,
336372 "{content}"
337373 ) ;
338374 assert_eq ! ( audit. mode, "redact" ) ;
@@ -344,11 +380,12 @@ mod tests {
344380
345381 #[ test]
346382 fn enforce_mode_blocks_detected_secret_with_stable_error ( ) {
347- let _guard = EnvVarGuard :: set ( "LOOPFORGE_TEST_SECRET" , "super-secret-value-12345" ) ;
348- let verdict = inspect_tool_output (
349- "prefix super-secret-value-12345 suffix" . to_string ( ) ,
350- & security ( LeakMode :: Enforce ) ,
383+ let _guard = EnvVarGuard :: set (
384+ "LOOPFORGE_TEST_SECRET_ENFORCE" ,
385+ "super-secret-enforce-value-12345" ,
351386 ) ;
387+ let verdict = leak_guard ( LeakMode :: Enforce )
388+ . inspect_tool_output ( "prefix super-secret-enforce-value-12345 suffix" . to_string ( ) ) ;
352389
353390 match verdict {
354391 LeakGuardVerdict :: Blocked { error, audit } => {
@@ -358,18 +395,36 @@ mod tests {
358395 assert ! ( audit
359396 . detectors
360397 . iter( )
361- . any( |d| d == "env:LOOPFORGE_TEST_SECRET " ) ) ;
398+ . any( |d| d == "env:LOOPFORGE_TEST_SECRET_ENFORCE " ) ) ;
362399 }
363400 other => panic ! ( "expected enforce verdict, got: {other:?}" ) ,
364401 }
365402 }
366403
367404 #[ test]
368- fn redact_mode_masks_common_sk_prefixed_tokens ( ) {
369- let verdict = inspect_tool_output (
370- "token=sk-test-abcdefghijklmnopqrstuvwxyz123456" . to_string ( ) ,
371- & security ( LeakMode :: Redact ) ,
405+ fn leak_guard_snapshots_env_values_at_construction ( ) {
406+ let _guard = EnvVarGuard :: set (
407+ "LOOPFORGE_TEST_SECRET_SNAPSHOT" ,
408+ "super-secret-snapshot-value-12345" ,
372409 ) ;
410+ let leak_guard = leak_guard ( LeakMode :: Redact ) ;
411+ std:: env:: remove_var ( "LOOPFORGE_TEST_SECRET_SNAPSHOT" ) ;
412+
413+ let verdict = leak_guard
414+ . inspect_tool_output ( "prefix super-secret-snapshot-value-12345 suffix" . to_string ( ) ) ;
415+
416+ match verdict {
417+ LeakGuardVerdict :: Allowed { content, .. } => {
418+ assert ! ( content. contains( "[redacted:env:LOOPFORGE_TEST_SECRET_SNAPSHOT]" ) ) ;
419+ }
420+ other => panic ! ( "expected snapshot redact verdict, got: {other:?}" ) ,
421+ }
422+ }
423+
424+ #[ test]
425+ fn redact_mode_masks_common_sk_prefixed_tokens ( ) {
426+ let verdict = leak_guard ( LeakMode :: Redact )
427+ . inspect_tool_output ( "token=sk-test-abcdefghijklmnopqrstuvwxyz123456" . to_string ( ) ) ;
373428
374429 match verdict {
375430 LeakGuardVerdict :: Allowed {
0 commit comments