Skip to content

Commit db4e303

Browse files
committed
fix(safety): scan all tokens for privilege escalation; loop env-prefix in allowlist; localized redaction
Critical: - _rules.ProcessRule._check_bash: privilege-escalation detection only checked tokens[0], so `FOO=bar sudo rm -rf /` and `echo x | sudo tee /etc/passwd` bypassed the CRITICAL privilege rule because the line-leading token was `FOO=bar` / `echo`, not `sudo`. The network-command side already scanned all tokens; privilege now does the same by collecting basenames of ALL tokens that hit _PRIVILEGE_CMDS. This closes a real CRITICAL-detection bypass. Warning: - _rules.ProcessRule._check_bash: strict_command_allowlist only stripped ONE leading KEY=VAL env-assignment token, so `FOO=bar A=b sudo ls` landed on `A=b` (has `=`) and stopped, missing `sudo`. Now loops through ALL consecutive leading KEY=VAL tokens (excluding subshell `(...)`). Same root cause as the privilege bypass above. Suggestion: - _scanner._redact_evidence: the `len > 80 AND 32+ char run` branch called redact(keep=8) on the WHOLE line, which truncated evidence to 8 chars whenever a long identifier/URL-path happened to be present. This destroyed diagnostic context (e.g. requests.get('https://api. github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') became `requests`). Now applies re.sub(r"[A-Za-z0-9_\-]{32,}", "***", ...) so only the matched long token is replaced; the surrounding diagnostic context survives. Removed the now-unused `redact` import. Tests: - test_process_bash_sudo_with_env_prefix_is_caught: `FOO=bar sudo rm -rf /` must trigger privilege CRITICAL. - test_process_bash_sudo_after_pipe_is_caught: `echo x | sudo tee /etc/passwd` must trigger privilege CRITICAL. - test_process_bash_sudo_with_multiple_env_prefix_is_caught: `FOO=bar A=b sudo ls` must trigger privilege CRITICAL. - test_strict_command_allowlist_multiple_env_prefix_is_fail_closed: `FOO=bar A=b ls` (whitelisted) passes; `FOO=bar A=b rm` (not whitelisted) is flagged. - test_redact_evidence_preserves_diagnostic_context: long-URL evidence keeps `requests.get` + domain; only the 32-char token is ***.
1 parent 0761054 commit db4e303

4 files changed

Lines changed: 103 additions & 11 deletions

File tree

tests/tool_safety/test_rules.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,3 +325,57 @@ def test_resource_sleep_at_threshold_is_allowed_bash():
325325
inp = ScanInput(script="sleep 300\n", language="bash")
326326
findings = rule.check(inp, policy)
327327
assert not any("Long sleep" in f.metadata.get("message", "") for f in findings)
328+
329+
330+
def test_process_bash_sudo_with_env_prefix_is_caught():
331+
"""Regression for CongkeChen review: `FOO=bar sudo rm -rf /` used to bypass
332+
the privilege rule because cmd=tokens[0]=`FOO=bar` was not in
333+
_PRIVILEGE_CMDS. Now we scan ALL tokens for privilege-cmd basenames."""
334+
rule = ProcessRule()
335+
inp = ScanInput(script="FOO=bar sudo rm -rf /\n", language="bash")
336+
findings = rule.check(inp, _policy())
337+
priv = [f for f in findings if "Privilege escalation" in f.metadata.get("message", "")]
338+
assert priv, "FOO=bar sudo ... must trigger privilege CRITICAL"
339+
assert priv[0].risk_level == RiskLevel.CRITICAL
340+
341+
342+
def test_process_bash_sudo_after_pipe_is_caught():
343+
"""Regression for CongkeChen review: `echo x | sudo tee /etc/passwd` used
344+
to bypass privilege because cmd=tokens[0]=`echo`. Now all tokens are
345+
scanned."""
346+
rule = ProcessRule()
347+
inp = ScanInput(script="echo x | sudo tee /etc/passwd\n", language="bash")
348+
findings = rule.check(inp, _policy())
349+
priv = [f for f in findings if "Privilege escalation" in f.metadata.get("message", "")]
350+
assert priv, "echo x | sudo ... must trigger privilege CRITICAL"
351+
352+
353+
def test_process_bash_sudo_with_multiple_env_prefix_is_caught():
354+
"""Regression for CongkeChen review: `FOO=bar A=b sudo ls` has TWO leading
355+
env-assignment tokens; the old single-step skip landed on `A=b` and missed
356+
`sudo`. Now strict allowlist loops through all KEY=VAL prefixes."""
357+
rule = ProcessRule()
358+
inp = ScanInput(script="FOO=bar A=b sudo ls\n", language="bash")
359+
findings = rule.check(inp, _policy())
360+
priv = [f for f in findings if "Privilege escalation" in f.metadata.get("message", "")]
361+
assert priv, "FOO=bar A=b sudo ... must trigger privilege CRITICAL"
362+
363+
364+
def test_strict_command_allowlist_multiple_env_prefix_is_fail_closed():
365+
"""Regression for CongkeChen review: strict allowlist with multiple
366+
leading env-assignment tokens must skip ALL of them and check the real
367+
command, not stop at the first KEY=VAL token."""
368+
from trpc_agent_sdk.safety._rules import ProcessRule
369+
rule = ProcessRule()
370+
policy = PolicyConfig(strict_command_allowlist=True, allowed_commands=["ls"])
371+
# `FOO=bar A=b ls` should be ALLOWED (ls is whitelisted).
372+
inp = ScanInput(script="FOO=bar A=b ls /tmp\n", language="bash")
373+
findings = rule.check(inp, policy)
374+
allow_list_findings = [f for f in findings if "allow-list" in f.metadata.get("message", "")]
375+
assert not allow_list_findings, "ls is in allowed_commands; multi-env-prefix must still resolve to ls"
376+
377+
# `FOO=bar A=b rm` should be FLAGGED (rm not in allowed_commands).
378+
inp2 = ScanInput(script="FOO=bar A=b rm /tmp\n", language="bash")
379+
findings2 = rule.check(inp2, policy)
380+
allow_list_findings2 = [f for f in findings2 if "allow-list" in f.metadata.get("message", "")]
381+
assert allow_list_findings2, "rm is not in allowed_commands; multi-env-prefix must still flag rm"

tests/tool_safety/test_scanner.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,19 @@ def test_register_custom_rule_is_picked_up_by_new_scanner():
208208
assert "TEST_CUSTOM_001" in rule_ids
209209
finally:
210210
unregister_custom_rule("TEST_CUSTOM_001")
211+
212+
213+
def test_redact_evidence_preserves_diagnostic_context():
214+
"""Regression for CongkeChen review: _redact_evidence used to truncate the
215+
whole line to 8 chars when len>80 AND a 32+ char token was present, which
216+
destroyed diagnostic context for evidence with long identifiers/URL paths.
217+
Now only the matched 32+ token is replaced with ***, preserving the rest."""
218+
from trpc_agent_sdk.safety._scanner import _redact_evidence
219+
# Long URL with a 32-char path segment. The segment itself should be
220+
# redacted to ***, but `requests.get('https://api.github.com/` and the
221+
# closing `')` must survive so the operator can see what was flagged.
222+
evidence = "requests.get('https://api.github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')"
223+
redacted = _redact_evidence(evidence)
224+
assert "requests.get" in redacted, f"diagnostic context lost: {redacted!r}"
225+
assert "api.github.com" in redacted, f"domain lost: {redacted!r}"
226+
assert "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" not in redacted, f"long token not redacted: {redacted!r}"

trpc_agent_sdk/safety/_rules.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -852,17 +852,33 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
852852
tokens = line.split()
853853
if not tokens:
854854
continue
855-
cmd = tokens[0].split("/")[-1]
855+
856+
# Scan ALL tokens for privilege-escalation commands, not just the
857+
# first. `FOO=bar sudo rm -rf /` and `echo x | sudo tee /etc/passwd`
858+
# would otherwise bypass the CRITICAL privilege rule because the
859+
# line-leading token is `FOO=bar` / `echo`, not `sudo`. The network
860+
# side already scans all tokens; privilege must too.
861+
privilege_hits = [t.split("/")[-1].split("\\")[-1] for t in tokens
862+
if t.split("/")[-1].split("\\")[-1] in _PRIVILEGE_CMDS]
856863

857864
if policy.strict_command_allowlist:
858865
# Fail-closed: when strict mode is on but allowed_commands is
859866
# empty, every non-builtin command is unknown and must be
860867
# flagged HIGH. The previous `and policy.allowed_commands`
861868
# guard turned strict mode into a no-op for empty allow-lists,
862869
# letting rm/chmod slip through.
863-
check_cmd = cmd
864-
if "=" in check_cmd and len(tokens) > 1:
865-
check_cmd = tokens[1].split("/")[-1].split("\\")[-1]
870+
#
871+
# Skip ALL leading KEY=VAL env-assignment tokens, not just one.
872+
# `A=b sudo ls` was caught, but `FOO=bar A=b sudo ls` slipped
873+
# through because tokens[1]=="A=b" (has =) and the loop did not
874+
# advance. Loop until the first non-assignment token.
875+
check_idx = 0
876+
while check_idx < len(tokens) - 1:
877+
tok = tokens[check_idx]
878+
if "=" not in tok or tok.startswith("("):
879+
break
880+
check_idx += 1
881+
check_cmd = tokens[check_idx].split("/")[-1].split("\\")[-1] if tokens else ""
866882
shell_builtins = {
867883
"if",
868884
"then",
@@ -884,7 +900,7 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
884900
"true",
885901
"false",
886902
}
887-
if check_cmd not in shell_builtins and not policy.is_command_allowed(check_cmd):
903+
if check_cmd and check_cmd not in shell_builtins and not policy.is_command_allowed(check_cmd):
888904
findings.append(
889905
self._finding(
890906
line,
@@ -894,7 +910,7 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
894910
message=f"Command not in allow-list: {check_cmd}",
895911
))
896912

897-
if cmd in _PRIVILEGE_CMDS:
913+
for cmd in privilege_hits:
898914
findings.append(
899915
self._finding(
900916
line,

trpc_agent_sdk/safety/_scanner.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from ._policy import PolicyConfig
1717
from ._rules import SafetyRule
1818
from ._rules import default_rules
19-
from ._rules import redact
2019
from ._types import Decision
2120
from ._types import RiskLevel
2221
from ._types import SafetyFinding
@@ -209,7 +208,14 @@ def _run_rules(
209208

210209

211210
def _redact_evidence(text: str) -> str:
212-
"""Best-effort redaction of obvious secret tokens in evidence snippets."""
211+
"""Best-effort redaction of obvious secret tokens in evidence snippets.
212+
213+
Only redacts the matched secret substring, not the whole evidence line.
214+
The previous `len > 80 and 32+ run` branch truncated the entire line to
215+
8 chars, which destroyed diagnostic context for evidence that merely
216+
contained a long identifier/URL path (e.g. requests.get('https://api.
217+
github.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')).
218+
"""
213219
redacted = evidence_snippet(text)
214220
redacted = re.sub(r"(?i)bearer\s+[A-Za-z0-9_\-\.]{20,}", "bearer ***", redacted)
215221
redacted = re.sub(r"AKIA[0-9A-Z]{16}", "AKIA***", redacted)
@@ -219,7 +225,7 @@ def _redact_evidence(text: str) -> str:
219225
redacted,
220226
flags=re.IGNORECASE,
221227
)
222-
# Also apply generic redact for long tokens
223-
if len(redacted) > 80 and re.search(r"[A-Za-z0-9_\-]{32,}", redacted):
224-
redacted = redact(redacted, keep=8)
228+
# Localized replacement: only the 32+ char token itself becomes ***,
229+
# not the whole line. Keeps the surrounding diagnostic context intact.
230+
redacted = re.sub(r"[A-Za-z0-9_\-]{32,}", "***", redacted)
225231
return redacted

0 commit comments

Comments
 (0)