3636 r"(\.env|\.ssh|id_rsa|credentials?|token|secret|password|private[_-]?key)" ,
3737 re .IGNORECASE ,
3838)
39+ SENSITIVE_ENV_REFERENCE_RE = re .compile (
40+ r"\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*(?:\})?)" ,
41+ )
42+
43+ PATH_LITERAL_RE = re .compile (
44+ r"(?<![A-Za-z0-9_])(?:"
45+ r"~?/[^\s'\";|]+|"
46+ r"\.env(?![A-Za-z0-9_])|"
47+ r"[^\s'\";|]+(?:\.pem|\.key|\.token|token\.txt|credentials(?:\.[^\s'\";|]+)?)"
48+ r")" ,
49+ re .IGNORECASE ,
50+ )
3951
4052
4153def sanitize_text (text : str , limit : int = 180 ) -> tuple [str , bool ]:
@@ -148,6 +160,76 @@ def _network_finding(url: str, policy: ToolSafetyPolicy, evidence: str, line: in
148160 )
149161
150162
163+ def _sensitive_env_names (text : str ) -> list [str ]:
164+ names : list [str ] = []
165+ for match in SENSITIVE_ENV_REFERENCE_RE .finditer (text ):
166+ name = match .group (1 ).rstrip ("}" )
167+ if SENSITIVE_NAME_RE .search (name ):
168+ names .append (name )
169+ return names
170+
171+
172+ def _scan_denied_path_candidates (
173+ candidates : list [str ],
174+ policy : ToolSafetyPolicy ,
175+ evidence : str ,
176+ language : str ,
177+ line_no : int ,
178+ ) -> list [RiskFinding ]:
179+ findings : list [RiskFinding ] = []
180+ seen : set [str ] = set ()
181+ for candidate in candidates :
182+ path_candidate = candidate .strip ().strip ("'\" " )
183+ if not path_candidate or path_candidate in seen :
184+ continue
185+ seen .add (path_candidate )
186+ if policy .is_path_denied (path_candidate ):
187+ findings .append (
188+ _finding (
189+ "FILE_SECRET_PATH_ACCESS" ,
190+ "dangerous_file_operation" ,
191+ RiskLevel .CRITICAL ,
192+ Decision .DENY ,
193+ evidence ,
194+ "Remove direct credential file access or explicitly scope the tool to safe workspace files." ,
195+ f"Script references denied path { path_candidate } ." ,
196+ line = line_no ,
197+ metadata = {
198+ "path" : path_candidate ,
199+ "language" : language
200+ },
201+ ))
202+ return findings
203+
204+
205+ def _bash_argument_path_candidates (line : str ) -> list [str ]:
206+ try :
207+ tokens = shlex .split (line , comments = True )
208+ except ValueError :
209+ tokens = line .split ()
210+ if not tokens :
211+ return []
212+
213+ candidates : list [str ] = []
214+ skip_next = False
215+ redirection_tokens = {">" , ">>" , "<" , "2>" , "2>>" , "&>" , "&>>" }
216+ for index , token in enumerate (tokens ):
217+ if skip_next :
218+ skip_next = False
219+ continue
220+ if index == 0 or token in {"|" , "&&" , "||" , ";" }:
221+ continue
222+ if token in redirection_tokens :
223+ skip_next = True
224+ continue
225+ if token .startswith ("-" ) or token .startswith ("$" ) or "://" in token or "=" in token :
226+ continue
227+ cleaned = token .rstrip (").," )
228+ if cleaned :
229+ candidates .append (cleaned )
230+ return candidates
231+
232+
151233class PythonSafetyVisitor (ast .NodeVisitor ):
152234 """AST visitor that collects Python script safety findings."""
153235
@@ -472,23 +554,8 @@ def scan_text_patterns(script: str, policy: ToolSafetyPolicy, language: str) ->
472554 "Private key material appears in script content." ,
473555 line = line_no ,
474556 ))
475- for path_candidate in re .findall (r"(~?/[^\s'\";|]+|\.env|[^\s'\";|]+\.pem|[^\s'\";|]+\.key)" , line ):
476- if policy .is_path_denied (path_candidate ):
477- findings .append (
478- _finding (
479- "FILE_SECRET_PATH_ACCESS" ,
480- "dangerous_file_operation" ,
481- RiskLevel .CRITICAL ,
482- Decision .DENY ,
483- line ,
484- "Remove direct credential file access or explicitly scope the tool to safe workspace files." ,
485- f"Script references denied path { path_candidate } ." ,
486- line = line_no ,
487- metadata = {
488- "path" : path_candidate ,
489- "language" : language
490- },
491- ))
557+ findings .extend (
558+ _scan_denied_path_candidates (PATH_LITERAL_RE .findall (line ), policy , line , language , line_no ))
492559 for url in _extract_urls (line ):
493560 finding = _network_finding (url , policy , line , line_no )
494561 if finding :
@@ -519,6 +586,21 @@ def scan_text_patterns(script: str, policy: ToolSafetyPolicy, language: str) ->
519586 "Script may write or transmit sensitive information." ,
520587 line = line_no ,
521588 ))
589+ if language == "bash" :
590+ sensitive_envs = _sensitive_env_names (line )
591+ if sensitive_envs and re .search (r"\b(echo|printf|cat|curl|wget|tee|logger)\b" , line , re .IGNORECASE ):
592+ findings .append (
593+ _finding (
594+ "SENSITIVE_OUTPUT" ,
595+ "sensitive_information_leak" ,
596+ RiskLevel .HIGH ,
597+ Decision .DENY ,
598+ line ,
599+ "Redact secret values before logging, writing files, or making network requests." ,
600+ "Script appears to output a sensitive environment variable or credential." ,
601+ line = line_no ,
602+ metadata = {"env_keys" : sensitive_envs },
603+ ))
522604 if re .search (r"\bos\.getenv\(['\"][^'\"]*(token|secret|password|api[_-]?key)[^'\"]*['\"]\)" , line ,
523605 re .IGNORECASE ) and re .search (r"\b(requests|curl|post|get|print|logging|logger)\b" , line ,
524606 re .IGNORECASE ):
@@ -533,6 +615,9 @@ def scan_text_patterns(script: str, policy: ToolSafetyPolicy, language: str) ->
533615 "Script appears to read a sensitive environment variable for output or network use." ,
534616 line = line_no ,
535617 ))
618+ if language == "bash" :
619+ findings .extend (
620+ _scan_denied_path_candidates (_bash_argument_path_candidates (line ), policy , line , language , line_no ))
536621 return findings
537622
538623
0 commit comments