studio/sandbox: use _find_sensitive_paths for binding-bias instead of substring

Refines the round-5 ``_looks_sensitive`` heuristic that biases
``_record_string_binding`` toward the dangerous value in a chained
reassignment. The substring hint set conflated ``/etc/shadow`` with
``/etc/hosts`` -- both contain ``/etc/`` -- so a payload like
``p = '/etc/hosts'; p = '/etc/shadow'; open(p)`` had ``cur`` already
flagged sensitive, the guard refused to update, and the resolved
value stayed at ``/etc/hosts`` (allow-listed). The chained shadow
binding then slipped through.

Now ``_looks_sensitive`` delegates to ``_find_sensitive_paths``, the
authoritative bash / file gate matcher, so the distinction is exact:
``/etc/hosts`` is allow-listed and ``/etc/shadow`` is sensitive.
``_record_string_binding`` also adopts a clean three-way rule mirroring
Python's last-wins semantics for sensitive values:

  * New sensitive value: always wins (covers the chained shadow case).
  * New benign value, current sensitive: keep current (static gate
    cannot prove the new value executes; err on blocking).
  * Both benign: latest seen wins.

Test suite still 528 passing.
This commit is contained in:
danielhanchen 2026-05-24 14:39:11 +00:00
commit 35d7b72832

View file

@ -1364,46 +1364,33 @@ def _check_signal_escape_patterns(code: str):
if alias.name in _PATHLIB_PATH_CLASSES_PREPASS:
path_class_aliases_prepass.add(alias.asname or alias.name)
# Cheap hint set used to bias ``string_bindings`` toward the most
# sensitive value of a name when multiple literals are assigned.
# Substring match against the full set of credential / process-state
# root tokens (kept intentionally loose; the real per-spec match
# still runs downstream in the file-read / shutil / bash gates).
_SENSITIVE_HINTS = (
"/etc/",
"/proc/",
"/var/spool/",
"/root/",
".ssh/",
".aws/",
".gnupg",
".kube",
".docker",
".config/gcloud",
".pypirc",
".npmrc",
".netrc",
".cargo/credentials",
".password-store",
"credentials",
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
"shadow",
"sudoers",
)
def _looks_sensitive(value: str) -> bool:
"""True if *value* matches any host-credential / process-state
path that the bash / file gates already flag. Uses the
authoritative ``_find_sensitive_paths`` so the bias distinguishes
``/etc/shadow`` (sensitive) from ``/etc/hosts`` (allow-listed) --
a substring hint set conflates them and admits a chained-
reassignment bypass ``p='/etc/hosts'; p='/etc/shadow'``."""
if not value:
return False
low = value.lower()
return any(hint in low for hint in _SENSITIVE_HINTS)
return bool(_find_sensitive_paths(value))
def _record_string_binding(name: str, value: str) -> None:
"""Append ``value`` to ``string_bindings_all[name]`` and update
``string_bindings[name]`` to favour a sensitive-shaped value
when one exists. Resists the second-assignment bypass."""
``string_bindings[name]`` so the gate sees the most sensitive
value the variable could carry at runtime. The selection rule
mirrors Python's last-wins semantics for sensitive values:
* If the new value is sensitive, it always wins (even if the
current is also sensitive) -- a later sensitive assignment
is at least as concerning as an earlier one, and the chain
``p='/etc/hosts'; p='/etc/shadow'`` must surface the shadow.
* If the new value is benign and the current sensitive, keep
the sensitive value (Python would last-wins to benign, but
statically we cannot prove the new value executes and we
err on the side of blocking the path the attacker reached
for).
* If both are benign, latest seen wins."""
bucket = string_bindings_all.setdefault(name, [])
if value not in bucket:
bucket.append(value)
@ -1411,8 +1398,12 @@ def _check_signal_escape_patterns(code: str):
if cur is None:
string_bindings[name] = value
return
if _looks_sensitive(value) and not _looks_sensitive(cur):
if _looks_sensitive(value):
string_bindings[name] = value
return
if _looks_sensitive(cur):
return
string_bindings[name] = value
def _extract_string_literal(node, _depth = 0):
"""Strict literal-string extraction: no name binding lookup,