@@ -238,10 +238,13 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
238238 "os.unlink" ,
239239 "pathlib.path.unlink" ,
240240 "pathlib.Path.unlink" ,
241- } or lname .endswith (".unlink" ) or lname .endswith (".rmtree" ):
241+ } or lname .endswith (".unlink" ) or lname .endswith (".rmtree" ) or lname . endswith ( ".rmdir" ) :
242242 arg = node .args [0 ] if node .args else None
243- target = get_string_literal (arg ) or path_expr_text (arg ) if arg else "<dynamic>"
244- target = target or "<dynamic>"
243+ if arg is not None :
244+ target = get_string_literal (arg ) or path_expr_text (arg ) or "<dynamic>"
245+ else :
246+ # Path(...).unlink() / Path(...).rmdir() — path is the receiver.
247+ target = _path_target_from_attr (node , aliases , path_vars ) or "<dynamic>"
245248 if "rmtree" in lname or _is_recursive_delete (name , node ):
246249 findings .append (
247250 self ._finding (
@@ -269,17 +272,20 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
269272 var = node .args [0 ].id
270273 if var in path_vars :
271274 target = path_vars [var ]
275+ # Non-constant mode is treated as potentially writable (fail-closed)
276+ # so open("/usr/bin/x", mode_var) cannot bypass system-dir writes.
272277 if _is_write_open (node ):
273278 if _matches_sensitive (target ) or _matches_forbidden (target , policy ) or _matches_system_dir (target ):
274279 findings .append (
275280 self ._finding (
276- f"open({ target !r} , 'w' )" ,
281+ f"open({ target !r} , write )" ,
277282 node .lineno ,
278283 "Do not write to system or credential paths." ,
279284 message = f"Write to sensitive path { target !r} " ,
280285 ))
281286 else :
282- if _matches_sensitive (target ) or _matches_forbidden (target , policy ):
287+ if (_matches_sensitive (target ) or _matches_forbidden (target , policy )
288+ or _matches_system_dir (target )):
283289 findings .append (
284290 self ._finding (
285291 f"{ name } ({ target !r} )" ,
@@ -290,8 +296,8 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
290296
291297 # pathlib Path.read_text / read_bytes with sensitive target
292298 if lname .endswith (".read_text" ) or lname .endswith (".read_bytes" ):
293- target = _path_from_attr_call (node , aliases )
294- if _matches_sensitive (target ) or _matches_forbidden (target , policy ):
299+ target = _path_target_from_attr (node , aliases , path_vars )
300+ if ( _matches_sensitive (target ) or _matches_forbidden (target , policy ) or _matches_system_dir ( target ) ):
295301 findings .append (
296302 self ._finding (
297303 f"{ name } (...)" ,
@@ -300,6 +306,29 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
300306 message = f"Read sensitive file via pathlib { target !r} " ,
301307 ))
302308
309+ # pathlib write / mkdir / touch / rename on sensitive, forbidden, or system
310+ # paths. Missing write_* left Path("/usr/bin/x").write_text(...) as
311+ # ALLOW (fail-open) because only path-construction of sensitive names
312+ # was covered, not system-dir writes.
313+ if (lname .endswith (".write_text" ) or lname .endswith (".write_bytes" ) or lname .endswith (".mkdir" )
314+ or lname .endswith (".touch" ) or lname .endswith (".replace" ) or lname .endswith (".rename" )
315+ or lname .endswith (".chmod" )):
316+ target = _path_target_from_attr (node , aliases , path_vars )
317+ if target and (_matches_sensitive (target ) or _matches_forbidden (target , policy )
318+ or _matches_system_dir (target )):
319+ if lname .endswith (".write_text" ) or lname .endswith (".write_bytes" ):
320+ msg = f"Write to sensitive path via pathlib { target !r} "
321+ rec = "Do not write to system or credential paths."
322+ else :
323+ msg = f"Mutate sensitive path via pathlib { name } ({ target !r} )"
324+ rec = "Do not mutate system or credential paths via pathlib."
325+ findings .append (self ._finding (
326+ f"{ name } (...)" ,
327+ node .lineno ,
328+ rec ,
329+ message = msg ,
330+ ))
331+
303332 # Path(...).joinpath(...).read_text pattern: also inspect path construction
304333 if "pathlib" in lname or lname .endswith ("path" ) or lname .endswith ("joinpath" ) or lname in {
305334 "os.path.join" ,
@@ -391,16 +420,30 @@ def _is_recursive_delete(name: str, node: ast.Call) -> bool:
391420
392421
393422def _is_write_open (node : ast .Call ) -> bool :
423+ """True when open() may write.
424+
425+ Constant modes containing w/a/x/+ are writes. A present but non-constant
426+ mode (Name/Call/…) is treated as write (fail-closed) so
427+ ``open("/usr/bin/x", mode_var)`` cannot bypass system-dir checks.
428+ Missing mode defaults to read-only.
429+ """
394430 mode_val = None
431+ mode_dynamic = False
395432 for kw in node .keywords :
396433 if kw .arg == "mode" :
397434 if isinstance (kw .value , ast .Constant ) and isinstance (kw .value .value , str ):
398435 mode_val = kw .value .value
436+ else :
437+ mode_dynamic = True
399438 break
400- if mode_val is None and len (node .args ) >= 2 :
439+ if mode_val is None and not mode_dynamic and len (node .args ) >= 2 :
401440 arg = node .args [1 ]
402441 if isinstance (arg , ast .Constant ) and isinstance (arg .value , str ):
403442 mode_val = arg .value
443+ else :
444+ mode_dynamic = True
445+ if mode_dynamic :
446+ return True
404447 if not mode_val :
405448 return False
406449 return any (m in mode_val for m in ("w" , "a" , "x" , "+" ))
@@ -424,6 +467,21 @@ def _path_from_attr_call(node: ast.Call, aliases: dict[str, str]) -> str:
424467 return ""
425468
426469
470+ def _path_target_from_attr (
471+ node : ast .Call ,
472+ aliases : dict [str , str ],
473+ path_vars : dict [str , str ],
474+ ) -> str :
475+ """Path string for Path(...).method() / p.method() calls."""
476+ target = _path_from_attr_call (node , aliases )
477+ func = node .func
478+ if isinstance (func , ast .Attribute ) and isinstance (func .value , ast .Name ):
479+ var = func .value .id
480+ if var in path_vars :
481+ target = path_vars [var ] or target
482+ return target or ""
483+
484+
427485def _collect_sensitive_path_vars (
428486 tree : ast .AST ,
429487 aliases : dict [str , str ],
0 commit comments