keywright 0.0.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.
- keywright/__init__.py +3 -0
- keywright/_transport_worker.py +29 -0
- keywright/classify.py +185 -0
- keywright/cli.py +748 -0
- keywright/config.py +105 -0
- keywright/hazards.py +948 -0
- keywright/hook.py +161 -0
- keywright/patterns.py +153 -0
- keywright/session.py +144 -0
- keywright/setup/__init__.py +2 -0
- keywright/setup/check.py +115 -0
- keywright/setup/claude_code.py +176 -0
- keywright/setup/codex.py +62 -0
- keywright/setup/common.py +146 -0
- keywright/skill/SKILL.md +134 -0
- keywright/transport.py +182 -0
- keywright-0.0.1.dist-info/METADATA +77 -0
- keywright-0.0.1.dist-info/RECORD +23 -0
- keywright-0.0.1.dist-info/WHEEL +4 -0
- keywright-0.0.1.dist-info/entry_points.txt +3 -0
- keywright-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
keywright/hazards.py
ADDED
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
"""Destructive-operation tripwire.
|
|
2
|
+
|
|
3
|
+
Classification says WHAT a command is (`rm`, `docker/compose/down`); this says whether a human
|
|
4
|
+
must look at it fresh. It runs on every SSH operation before policy is consulted, so a standing
|
|
5
|
+
rule or a one-hour grant never lets `rm -rf /home` through unseen.
|
|
6
|
+
|
|
7
|
+
Severities:
|
|
8
|
+
critical refused. Keywright never runs it; the human can do it by hand over real SSH.
|
|
9
|
+
high always a fresh prompt, approved once and exactly, whatever grants or rules exist.
|
|
10
|
+
medium runs under existing access; the prompt, the result and the audit row carry the flag.
|
|
11
|
+
|
|
12
|
+
Two passes over a command line. The structured pass tokenizes it, strips `sudo`/`env`/`timeout`
|
|
13
|
+
wrappers, follows `bash -c`, `ssh host ...`, `xargs` and `find -delete`, and tracks `cd` so
|
|
14
|
+
`cd /home/x && rm -rf .` is seen for what it is. The lexical pass scans the raw text for shapes
|
|
15
|
+
the structured pass cannot reach: `python -c "shutil.rmtree('/')"`, heredocs, quoted scripts.
|
|
16
|
+
|
|
17
|
+
This is a tripwire, not a sandbox. It reads the command line, not what runs. Code executed by a
|
|
18
|
+
test runner or interpreter is invisible to it, which is why Keywright keeps credentials off the
|
|
19
|
+
agent's machine in the first place.
|
|
20
|
+
|
|
21
|
+
Stdlib only, no package imports: the file runs standalone as a Claude Code PreToolUse hook
|
|
22
|
+
(`python3 hazards.py --hook`) on a machine that has no Keywright installed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import fnmatch
|
|
28
|
+
import json
|
|
29
|
+
import posixpath
|
|
30
|
+
import re
|
|
31
|
+
import shlex
|
|
32
|
+
import sys
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
SEVERITIES = ("medium", "high", "critical")
|
|
38
|
+
_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Hazard:
|
|
43
|
+
severity: str
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, str]:
|
|
47
|
+
return {"severity": self.severity, "reason": self.reason}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def worst(hazards: Iterable[Hazard | dict[str, Any]] | None) -> str | None:
|
|
51
|
+
"""The highest severity present, or None."""
|
|
52
|
+
top: str | None = None
|
|
53
|
+
for h in hazards or ():
|
|
54
|
+
s = h.severity if isinstance(h, Hazard) else str(h.get("severity", ""))
|
|
55
|
+
if s in _RANK and (top is None or _RANK[s] > _RANK[top]):
|
|
56
|
+
top = s
|
|
57
|
+
return top
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def blocks(level: str | None) -> bool:
|
|
61
|
+
"""Whether this level overrides standing access (needs a fresh once-only approval or is refused)."""
|
|
62
|
+
return level in ("high", "critical")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def describe(hazards: Iterable[Hazard | dict[str, Any]] | None) -> str:
|
|
66
|
+
parts = []
|
|
67
|
+
for h in hazards or ():
|
|
68
|
+
d = h.as_dict() if isinstance(h, Hazard) else h
|
|
69
|
+
parts.append(f"{d.get('severity')}: {d.get('reason')}")
|
|
70
|
+
return "; ".join(parts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---- operation entry point ----------------------------------------------------------------------
|
|
74
|
+
def scan(operation: dict[str, Any]) -> list[Hazard]:
|
|
75
|
+
"""Hazards for one SSH operation dict as Keywright builds it (run, put, get, capture)."""
|
|
76
|
+
out = _Sink()
|
|
77
|
+
if operation.get("command"):
|
|
78
|
+
for h in scan_command(str(operation["command"])):
|
|
79
|
+
out.add(h.severity, h.reason)
|
|
80
|
+
if operation.get("to_path"):
|
|
81
|
+
for h in scan_path(str(operation["to_path"]), "write"):
|
|
82
|
+
out.add(h.severity, h.reason)
|
|
83
|
+
elif "sha256" in operation and operation.get("path"):
|
|
84
|
+
for h in scan_path(str(operation["path"]), "write"):
|
|
85
|
+
out.add(h.severity, h.reason)
|
|
86
|
+
elif operation.get("path"):
|
|
87
|
+
for h in scan_path(str(operation["path"]), "read"):
|
|
88
|
+
out.add(h.severity, h.reason)
|
|
89
|
+
return out.items
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---- brokered MCP tools -------------------------------------------------------------------------
|
|
93
|
+
# A tool's name is the only thing Keywright can read about what it does; the owner's catalog is the
|
|
94
|
+
# rest. Names say medium at most: only the owner knows whether `reset` means a counter or a device.
|
|
95
|
+
_DESTRUCTIVE_WORDS = frozenset((
|
|
96
|
+
"delete", "remove", "destroy", "purge", "wipe", "erase", "reset", "restart", "reboot", "shutdown",
|
|
97
|
+
"poweroff", "format", "uninstall", "revoke", "drop", "kill", "terminate", "factory",
|
|
98
|
+
))
|
|
99
|
+
_WORD_SPLIT = re.compile(r"[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def scan_mcp(tool: str, paths: Iterable[str], catalog: dict[str, list[str]] | None = None) -> list[Hazard]:
|
|
103
|
+
"""Hazards for one brokered MCP call. `paths` are the call's classified paths relative to the
|
|
104
|
+
resource (`tools/<tool>[/<key>=<value>]`, so qualifiers appear only for configured argument
|
|
105
|
+
keys); `catalog` is the owner's {severity: [entry, ...]} in bundle syntax, an entry matching a
|
|
106
|
+
path when its segments glob-match the path's leading segments."""
|
|
107
|
+
out = _Sink()
|
|
108
|
+
hits = sorted({w.lower() for w in _WORD_SPLIT.split(tool) if w} & _DESTRUCTIVE_WORDS)
|
|
109
|
+
if hits:
|
|
110
|
+
out.add("medium", f"tool name suggests a destructive action ({', '.join(hits)})")
|
|
111
|
+
paths = [p.strip("/") for p in paths]
|
|
112
|
+
for severity, entries in (catalog or {}).items():
|
|
113
|
+
if severity not in _RANK:
|
|
114
|
+
continue
|
|
115
|
+
for entry in entries:
|
|
116
|
+
if any(_entry_matches(entry, p) for p in paths):
|
|
117
|
+
out.add(severity, f"listed as {severity} for this server ({entry})")
|
|
118
|
+
return out.items
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _entry_matches(entry: str, path: str) -> bool:
|
|
122
|
+
e, p = entry.strip("/").split("/"), path.split("/")
|
|
123
|
+
return len(e) <= len(p) and all(fnmatch.fnmatchcase(pp, ee) for ee, pp in zip(e, p))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def as_dicts(hazards: Iterable[Hazard]) -> list[dict[str, str]]:
|
|
127
|
+
return [h.as_dict() for h in hazards]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _Sink:
|
|
131
|
+
def __init__(self, cap: str | None = None):
|
|
132
|
+
self.items: list[Hazard] = []
|
|
133
|
+
self.cap = cap
|
|
134
|
+
|
|
135
|
+
def add(self, severity: str, reason: str) -> None:
|
|
136
|
+
if self.cap and _RANK[severity] > _RANK[self.cap]:
|
|
137
|
+
severity = self.cap
|
|
138
|
+
h = Hazard(severity, reason)
|
|
139
|
+
if h not in self.items:
|
|
140
|
+
self.items.append(h)
|
|
141
|
+
|
|
142
|
+
def add_weak(self, severity: str, reason: str) -> None:
|
|
143
|
+
"""A lexical finding: dropped when the structured pass already reported the same thing,
|
|
144
|
+
possibly with more context ("(inside ssh) ...", "... (from .)")."""
|
|
145
|
+
if any(reason in h.reason for h in self.items):
|
|
146
|
+
return
|
|
147
|
+
self.add(severity, reason)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- paths --------------------------------------------------------------------------------------
|
|
151
|
+
# (globs, reason) per group. fnmatch's `*` crosses `/`, so `*/.ssh/*` matches any depth.
|
|
152
|
+
_KEY_MATERIAL = (["*/.ssh", "*/.ssh/*", "*/.gnupg", "*/.gnupg/*", "/etc/ssh/ssh_host_*"],
|
|
153
|
+
"SSH/GPG key material")
|
|
154
|
+
_ACCOUNTS = (["/etc/passwd", "/etc/shadow", "/etc/group", "/etc/gshadow", "/etc/subuid", "/etc/subgid"],
|
|
155
|
+
"the account database")
|
|
156
|
+
_PRIVILEGE = (["/etc/sudoers", "/etc/sudoers.d/*", "/etc/pam.d/*", "/etc/nsswitch.conf", "/etc/ld.so.preload"],
|
|
157
|
+
"login and privilege configuration")
|
|
158
|
+
_BOOT = (["/etc/fstab", "/etc/crypttab", "/boot/*", "/etc/default/grub", "/etc/grub.d/*"], "boot configuration")
|
|
159
|
+
_SSHD = (["/etc/ssh/sshd_config", "/etc/ssh/sshd_config.d/*"], "the SSH server configuration")
|
|
160
|
+
_STARTUP = (["/etc/profile", "/etc/profile.d/*", "/etc/bash.bashrc", "/etc/zsh/*", "*/.bashrc", "*/.bash_profile",
|
|
161
|
+
"*/.profile", "*/.zshrc", "*/.zprofile", "*/.config/fish/config.fish", "/etc/crontab", "/etc/cron.d/*",
|
|
162
|
+
"/etc/cron.hourly/*", "/etc/cron.daily/*", "/var/spool/cron/*", "/etc/rc.local", "/etc/environment"],
|
|
163
|
+
"code that runs at login or on a schedule")
|
|
164
|
+
_SECRETS_READ = (["/etc/shadow", "/etc/gshadow", "*/.ssh/id_*", "*/.gnupg/*", "/etc/ssh/ssh_host_*_key",
|
|
165
|
+
"*/.aws/credentials", "*/.netrc", "*/.docker/config.json", "*/.kube/config"],
|
|
166
|
+
"private key material or password hashes")
|
|
167
|
+
_PUBLIC_KEY = re.compile(r"\.pub$")
|
|
168
|
+
|
|
169
|
+
_TOP_DIRS = {"bin", "boot", "dev", "etc", "home", "lib", "lib32", "lib64", "libx32", "opt", "proc", "root",
|
|
170
|
+
"sbin", "srv", "sys", "usr", "var", "snap", "nix"}
|
|
171
|
+
_CRITICAL_GLOB_PARENTS = {"etc", "usr", "var", "boot", "bin", "sbin", "lib", "lib32", "lib64", "root", "dev", "proc",
|
|
172
|
+
"sys"}
|
|
173
|
+
_HIGH_GLOB_PARENTS = {"opt", "srv", "tmp", "mnt", "media", "run", "snap", "var/lib", "var/log", "usr/local",
|
|
174
|
+
"var/backups", "var/mail", "var/www"}
|
|
175
|
+
_HOME = re.compile(r"^(~[A-Za-z0-9_.-]*|\$HOME|\$\{HOME\}|%h)(?=/|$)")
|
|
176
|
+
_GLOB = re.compile(r"[*?\[]")
|
|
177
|
+
_DEVICE = re.compile(r"^/dev/(sd|nvme|vd|hd|xvd|md|mapper/|dm-|zvol/|disk/|mmcblk|loop\d|nbd|rbd|drbd|sr\d)")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _norm(raw: str, cwd: str | None) -> str | None:
|
|
181
|
+
"""Normalize a path token. `~`, `$HOME` and friends become `~`. Relative paths resolve against
|
|
182
|
+
`cwd` when a `cd` earlier in the line set it; otherwise None (unknown)."""
|
|
183
|
+
p = raw.strip()
|
|
184
|
+
if not p:
|
|
185
|
+
return None
|
|
186
|
+
p = _HOME.sub("~", p)
|
|
187
|
+
if p.startswith("~"):
|
|
188
|
+
rest = posixpath.normpath(p[1:] or "/")
|
|
189
|
+
return "~" if rest in ("/", ".") else "~" + rest
|
|
190
|
+
if p.startswith("/"):
|
|
191
|
+
return re.sub(r"^/+", "/", posixpath.normpath(p))
|
|
192
|
+
if cwd is None:
|
|
193
|
+
return None
|
|
194
|
+
if cwd.startswith("~"):
|
|
195
|
+
joined = posixpath.normpath(posixpath.join(cwd[1:] or "/", p))
|
|
196
|
+
if joined.startswith("/.."):
|
|
197
|
+
return "/home" # above the home directory: the parent of all homes
|
|
198
|
+
return "~" if joined in ("/", ".") else "~" + joined
|
|
199
|
+
return re.sub(r"^/+", "/", posixpath.normpath(posixpath.join(cwd, p)))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _matches(path: str, globs: list[str]) -> bool:
|
|
203
|
+
return any(fnmatch.fnmatchcase(path, g) for g in globs)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _system_scope(p: str) -> str | None:
|
|
207
|
+
"""'critical' for the filesystem root, any top-level directory, a home directory or their
|
|
208
|
+
wildcard contents; 'high' for the contents of shared directories like /opt or /var/lib."""
|
|
209
|
+
if p in ("/", "/*", "/.*", "/.", "/..", "~", "~/*", "~/.*", "~/."):
|
|
210
|
+
return "critical"
|
|
211
|
+
if p.startswith("~"):
|
|
212
|
+
parts = p[2:].split("/") if len(p) > 2 else []
|
|
213
|
+
return "critical" if len(parts) == 1 and _GLOB.search(parts[0]) else None
|
|
214
|
+
parts = p.strip("/").split("/")
|
|
215
|
+
if len(parts) == 1:
|
|
216
|
+
# /etc, /ho*, /*; a top-level directory the distribution did not create is high
|
|
217
|
+
return "critical" if parts[0] in _TOP_DIRS or _GLOB.search(parts[0]) else "high"
|
|
218
|
+
if parts[0] == "home":
|
|
219
|
+
if len(parts) == 2 or (len(parts) == 3 and _GLOB.search(parts[2])):
|
|
220
|
+
return "critical"
|
|
221
|
+
return None
|
|
222
|
+
if len(parts) == 2 and _GLOB.search(parts[1]):
|
|
223
|
+
if parts[0] in _CRITICAL_GLOB_PARENTS:
|
|
224
|
+
return "critical"
|
|
225
|
+
if parts[0] in _HIGH_GLOB_PARENTS:
|
|
226
|
+
return "high"
|
|
227
|
+
if len(parts) == 2 and "/".join(parts) in _HIGH_GLOB_PARENTS:
|
|
228
|
+
return "high"
|
|
229
|
+
# wiping the contents of a shared directory a few levels down (/var/lib/docker/*, /opt/app/*)
|
|
230
|
+
if 3 <= len(parts) <= 4 and _GLOB.search(parts[-1]) and (
|
|
231
|
+
"/".join(parts[:2]) in _HIGH_GLOB_PARENTS or parts[0] in _HIGH_GLOB_PARENTS - {"tmp", "run"}):
|
|
232
|
+
return "high"
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _perm_scope(p: str) -> str | None:
|
|
237
|
+
"""Recursive chmod/chown: critical on the root or a system directory, high on a home directory
|
|
238
|
+
or shared contents (chown -R on a home is a routine fix that still deserves a fresh look)."""
|
|
239
|
+
scope = _system_scope(p)
|
|
240
|
+
if scope != "critical":
|
|
241
|
+
return scope
|
|
242
|
+
parts = p.strip("/").split("/")
|
|
243
|
+
if p.startswith("~") or parts[0] == "home" and len(parts) > 1 or parts[0] == "root":
|
|
244
|
+
return "high"
|
|
245
|
+
return "critical"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def scan_path(path: str, mode: str) -> list[Hazard]:
|
|
249
|
+
"""Hazards of writing to, reading or deleting one path. mode: write | read | delete."""
|
|
250
|
+
out = _Sink()
|
|
251
|
+
p = _norm(path, None) or path
|
|
252
|
+
if mode == "read":
|
|
253
|
+
if _matches(p, _SECRETS_READ[0]) and not _PUBLIC_KEY.search(p):
|
|
254
|
+
out.add("high", f"reads {_SECRETS_READ[1]} ({p})")
|
|
255
|
+
return out.items
|
|
256
|
+
verb = "deletes" if mode == "delete" else "overwrites"
|
|
257
|
+
for globs, what in (_KEY_MATERIAL, _ACCOUNTS, _PRIVILEGE, _BOOT, _SSHD):
|
|
258
|
+
if _matches(p, globs):
|
|
259
|
+
out.add("critical" if mode == "delete" else "high", f"{verb} {what} ({p})")
|
|
260
|
+
if _matches(p, _STARTUP[0]):
|
|
261
|
+
out.add("high", f"{verb} {_STARTUP[1]} ({p})")
|
|
262
|
+
if mode == "write" and _DEVICE.match(p):
|
|
263
|
+
out.add("high", f"writes raw bytes to block device {p}")
|
|
264
|
+
return out.items
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _delete_target(raw: str, cwd: str | None, recursive: bool, how: str, out: _Sink, weak: bool = False) -> None:
|
|
268
|
+
"""`how` reads like "recursive delete of" or "find -delete under"."""
|
|
269
|
+
add = out.add_weak if weak else out.add
|
|
270
|
+
if not raw or raw in ("+", ";", "{}", "$", "\\;"):
|
|
271
|
+
return
|
|
272
|
+
p = _norm(raw, cwd)
|
|
273
|
+
if p is not None:
|
|
274
|
+
specific = scan_path(p, "delete")
|
|
275
|
+
for h in specific:
|
|
276
|
+
add(h.severity, h.reason)
|
|
277
|
+
if specific:
|
|
278
|
+
return
|
|
279
|
+
if not recursive:
|
|
280
|
+
return
|
|
281
|
+
if p is None:
|
|
282
|
+
if raw in (".", "..", "*", "./", "./*", "../*", ".*", "./.*"):
|
|
283
|
+
add("high", f"{how} the working directory ({raw}), location unknown")
|
|
284
|
+
else:
|
|
285
|
+
add("medium", f"{how} {raw}")
|
|
286
|
+
return
|
|
287
|
+
scope = _system_scope(p)
|
|
288
|
+
if scope:
|
|
289
|
+
add(scope, f"{how} {p}" + (f" (from {raw})" if p != raw else ""))
|
|
290
|
+
else:
|
|
291
|
+
add("medium", f"{how} {p}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---- commands -----------------------------------------------------------------------------------
|
|
295
|
+
_SEPARATORS = {";", "|", "||", "&&", "&", "(", ")", "{", "}", "|&"}
|
|
296
|
+
_PUNCT = ";|&()<>"
|
|
297
|
+
_KEYWORDS = {"{", "}", "(", ")", "!", "if", "then", "else", "elif", "fi", "while", "until", "do", "done", "case",
|
|
298
|
+
"esac", "in", "function", "time"}
|
|
299
|
+
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "fish"}
|
|
300
|
+
_WRAPPERS = {"sudo", "doas", "busybox", "env", "nice", "ionice", "nohup", "time", "command", "builtin", "stdbuf", "timeout",
|
|
301
|
+
"exec", "nocorrect", "noglob"}
|
|
302
|
+
_SUDO_VALUE_FLAGS = {"-u", "-g", "-h", "-p", "-r", "-t", "-U", "-C", "-D", "-R", "-T", "--user", "--group", "--host",
|
|
303
|
+
"--prompt", "--role", "--type", "--other-user", "--close-from", "--chdir", "--chroot"}
|
|
304
|
+
_WRAPPER_VALUE_FLAGS = {"-n", "-c", "-k", "-s", "-u", "-o", "-e", "-i", "-p", "-C", "-S", "--kill-after", "--signal",
|
|
305
|
+
"--adjustment", "--class", "--classdata", "--output", "--error", "--input", "--chdir",
|
|
306
|
+
"--split-string"}
|
|
307
|
+
_REMOTE = {"ssh", "pct", "docker", "podman", "qm", "kubectl", "lxc", "machinectl", "incus", "nsenter", "chroot"}
|
|
308
|
+
_FIND_DELETE = {"-delete"}
|
|
309
|
+
_FIND_EXEC = {"-exec", "-execdir", "-ok", "-okdir"}
|
|
310
|
+
_DELETERS = {"rm", "rmdir", "unlink", "shred", "srm"}
|
|
311
|
+
_FORMATTERS = re.compile(r"^(mkfs(\.\w+)?|mke2fs|mkswap|mkfs_\w+|wipefs|blkdiscard|sgdisk|sfdisk|fdisk|cfdisk|parted|"
|
|
312
|
+
r"gdisk|zerofree|nvme|hdparm|badblocks)$")
|
|
313
|
+
_POWER = {"reboot", "poweroff", "halt", "shutdown", "telinit"}
|
|
314
|
+
_FLUSHERS = {"flushall", "flushdb"}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def scan_command(command: str) -> list[Hazard]:
|
|
318
|
+
out = _Sink()
|
|
319
|
+
_structured(command, out, depth=0)
|
|
320
|
+
_lexical(command, out)
|
|
321
|
+
return out.items
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _tokens(command: str) -> list[str] | None:
|
|
325
|
+
try:
|
|
326
|
+
lex = shlex.shlex(command, posix=True, punctuation_chars=_PUNCT)
|
|
327
|
+
lex.whitespace_split = True
|
|
328
|
+
return list(lex)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _segments(tokens: list[str]) -> list[list[str]]:
|
|
334
|
+
segs: list[list[str]] = [[]]
|
|
335
|
+
for tok in tokens:
|
|
336
|
+
if tok in _SEPARATORS:
|
|
337
|
+
if segs[-1]:
|
|
338
|
+
segs.append([])
|
|
339
|
+
continue
|
|
340
|
+
if tok.startswith(("<", ">")) and set(tok) <= set("<>&|"):
|
|
341
|
+
# a redirection operator; the target that follows is data for the lexical pass
|
|
342
|
+
segs[-1].append(tok)
|
|
343
|
+
continue
|
|
344
|
+
segs[-1].append(tok)
|
|
345
|
+
return [s for s in segs if s]
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _structured(command: str, out: _Sink, depth: int, cwd: str | None = None) -> None:
|
|
349
|
+
if depth > 4:
|
|
350
|
+
return
|
|
351
|
+
tokens = _tokens(command)
|
|
352
|
+
if not tokens:
|
|
353
|
+
return
|
|
354
|
+
for seg in _segments(tokens):
|
|
355
|
+
words = _strip_redirections(seg)
|
|
356
|
+
while words and words[0] in _KEYWORDS:
|
|
357
|
+
words = words[1:]
|
|
358
|
+
words = _unwrap(words)
|
|
359
|
+
if not words:
|
|
360
|
+
continue
|
|
361
|
+
bin_ = words[0].rsplit("/", 1)[-1]
|
|
362
|
+
args = words[1:]
|
|
363
|
+
if bin_ in ("cd", "pushd"):
|
|
364
|
+
cwd = _norm(args[0], cwd) if args and not args[0].startswith("-") else "~"
|
|
365
|
+
continue
|
|
366
|
+
_check(bin_, args, cwd, out, depth)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _strip_redirections(words: list[str]) -> list[str]:
|
|
370
|
+
kept: list[str] = []
|
|
371
|
+
skip = False
|
|
372
|
+
for w in words:
|
|
373
|
+
if skip:
|
|
374
|
+
skip = False
|
|
375
|
+
continue
|
|
376
|
+
if w and set(w) <= set("<>&|") and ("<" in w or ">" in w):
|
|
377
|
+
skip = True # drop the operator and its target (tokenized as separate words)
|
|
378
|
+
continue
|
|
379
|
+
kept.append(w)
|
|
380
|
+
return kept
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _unwrap(words: list[str]) -> list[str]:
|
|
384
|
+
"""Strip privilege and environment wrappers so `sudo -u root rm -rf /` is seen as `rm`."""
|
|
385
|
+
while words:
|
|
386
|
+
head = words[0].rsplit("/", 1)[-1]
|
|
387
|
+
if "=" in words[0] and not words[0].startswith("-"):
|
|
388
|
+
words = words[1:]
|
|
389
|
+
continue
|
|
390
|
+
if head not in _WRAPPERS:
|
|
391
|
+
return words
|
|
392
|
+
rest = words[1:]
|
|
393
|
+
value_flags = _SUDO_VALUE_FLAGS if head in ("sudo", "doas") else _WRAPPER_VALUE_FLAGS
|
|
394
|
+
while rest and rest[0].startswith("-") and rest[0] != "--":
|
|
395
|
+
flag = rest[0]
|
|
396
|
+
rest = rest[1:]
|
|
397
|
+
if flag in value_flags and rest:
|
|
398
|
+
rest = rest[1:]
|
|
399
|
+
if rest and rest[0] == "--":
|
|
400
|
+
rest = rest[1:]
|
|
401
|
+
if head == "timeout" and rest:
|
|
402
|
+
rest = rest[1:] # the duration
|
|
403
|
+
if head == "env":
|
|
404
|
+
while rest and "=" in rest[0]:
|
|
405
|
+
rest = rest[1:]
|
|
406
|
+
words = rest
|
|
407
|
+
return words
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _flags_and_targets(args: list[str]) -> tuple[list[str], list[str]]:
|
|
411
|
+
flags, targets = [], []
|
|
412
|
+
end_of_flags = False
|
|
413
|
+
for a in args:
|
|
414
|
+
if end_of_flags:
|
|
415
|
+
targets.append(a)
|
|
416
|
+
elif a == "--":
|
|
417
|
+
end_of_flags = True
|
|
418
|
+
elif a.startswith("-") and len(a) > 1:
|
|
419
|
+
flags.append(a)
|
|
420
|
+
else:
|
|
421
|
+
targets.append(a)
|
|
422
|
+
return flags, targets
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _short_has(flags: list[str], letters: str, long: tuple[str, ...] = ()) -> bool:
|
|
426
|
+
for f in flags:
|
|
427
|
+
if f in long:
|
|
428
|
+
return True
|
|
429
|
+
if f.startswith("-") and not f.startswith("--") and any(c in letters for c in f[1:]):
|
|
430
|
+
return True
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _check(bin_: str, args: list[str], cwd: str | None, out: _Sink, depth: int) -> None:
|
|
435
|
+
# --- things that run another command line ---
|
|
436
|
+
if bin_ in _SHELLS:
|
|
437
|
+
for i, a in enumerate(args):
|
|
438
|
+
if a in ("-c", "-lc", "-ec", "-xc") and i + 1 < len(args):
|
|
439
|
+
_structured(args[i + 1], out, depth + 1, cwd)
|
|
440
|
+
return
|
|
441
|
+
if a.startswith("-") and "c" in a and not a.startswith("--") and i + 1 < len(args):
|
|
442
|
+
_structured(args[i + 1], out, depth + 1, cwd)
|
|
443
|
+
return
|
|
444
|
+
return
|
|
445
|
+
if bin_ == "su":
|
|
446
|
+
for i, a in enumerate(args):
|
|
447
|
+
if a in ("-c", "--command") and i + 1 < len(args):
|
|
448
|
+
_structured(args[i + 1], out, depth + 1, None)
|
|
449
|
+
return
|
|
450
|
+
if bin_ == "eval":
|
|
451
|
+
_structured(" ".join(args), out, depth + 1, cwd)
|
|
452
|
+
return
|
|
453
|
+
if bin_ == "xargs":
|
|
454
|
+
rest = list(args)
|
|
455
|
+
while rest and rest[0].startswith("-"):
|
|
456
|
+
flag = rest[0]
|
|
457
|
+
rest = rest[1:]
|
|
458
|
+
if flag in ("-n", "-P", "-d", "-I", "-L", "-s", "-a", "-E", "--max-args", "--max-procs", "--delimiter",
|
|
459
|
+
"--replace", "--max-lines", "--max-chars", "--arg-file", "--eof") and rest:
|
|
460
|
+
rest = rest[1:]
|
|
461
|
+
cmd = _unwrap(rest)
|
|
462
|
+
if cmd:
|
|
463
|
+
inner = _Sink()
|
|
464
|
+
_check(cmd[0].rsplit("/", 1)[-1], cmd[1:], None, inner, depth + 1)
|
|
465
|
+
for h in inner.items:
|
|
466
|
+
out.add(h.severity, h.reason)
|
|
467
|
+
head = cmd[0].rsplit("/", 1)[-1]
|
|
468
|
+
if head in _DELETERS:
|
|
469
|
+
recursive = _short_has([a for a in cmd[1:] if a.startswith("-")], "rR", ("--recursive",))
|
|
470
|
+
out.add("high" if recursive else "medium",
|
|
471
|
+
f"xargs {head}{' -r' if recursive else ''}: deletes whatever the input names")
|
|
472
|
+
return
|
|
473
|
+
if bin_ in _REMOTE:
|
|
474
|
+
_remote(bin_, args, out, depth)
|
|
475
|
+
# fall through: the outer command itself may also be hazardous (docker rm, pct destroy)
|
|
476
|
+
# --- deletion ---
|
|
477
|
+
if bin_ in ("rm", "srm"):
|
|
478
|
+
flags, targets = _flags_and_targets(args)
|
|
479
|
+
recursive = _short_has(flags, "rR", ("--recursive",))
|
|
480
|
+
if "--no-preserve-root" in flags:
|
|
481
|
+
out.add("critical", "rm --no-preserve-root")
|
|
482
|
+
for t in targets:
|
|
483
|
+
_delete_target(t, cwd, recursive, "recursive delete of" if recursive else "delete of", out)
|
|
484
|
+
return
|
|
485
|
+
if bin_ in ("unlink", "rmdir"):
|
|
486
|
+
for t in _flags_and_targets(args)[1]:
|
|
487
|
+
_delete_target(t, cwd, False, "delete of", out)
|
|
488
|
+
return
|
|
489
|
+
if bin_ == "shred":
|
|
490
|
+
for t in _flags_and_targets(args)[1]:
|
|
491
|
+
p = _norm(t, cwd) or t
|
|
492
|
+
out.add("high" if _DEVICE.match(p) else "medium", f"shreds {p}")
|
|
493
|
+
_delete_target(t, cwd, False, "shreds", out)
|
|
494
|
+
return
|
|
495
|
+
if bin_ == "find":
|
|
496
|
+
_find(args, cwd, out)
|
|
497
|
+
return
|
|
498
|
+
if bin_ in ("chmod", "chown", "chgrp"):
|
|
499
|
+
flags, targets = _flags_and_targets(args)
|
|
500
|
+
if _short_has(flags, "R", ("--recursive",)):
|
|
501
|
+
for t in targets[1:]:
|
|
502
|
+
p = _norm(t, cwd)
|
|
503
|
+
scope = _perm_scope(p) if p else None
|
|
504
|
+
if scope:
|
|
505
|
+
out.add(scope, f"recursive {bin_} of {p}")
|
|
506
|
+
return
|
|
507
|
+
if bin_ == "mv":
|
|
508
|
+
flags, targets = _flags_and_targets(args)
|
|
509
|
+
for t in targets[:-1]:
|
|
510
|
+
p = _norm(t, cwd)
|
|
511
|
+
scope = _system_scope(p) if p else None
|
|
512
|
+
if scope:
|
|
513
|
+
out.add(scope, f"moves {p} away")
|
|
514
|
+
if p:
|
|
515
|
+
for h in scan_path(p, "delete"):
|
|
516
|
+
out.add(h.severity, h.reason.replace("deletes", "moves"))
|
|
517
|
+
if targets:
|
|
518
|
+
_write_target(targets[-1], cwd, out)
|
|
519
|
+
return
|
|
520
|
+
if bin_ in ("tee", "install", "truncate", "ln"):
|
|
521
|
+
flags, targets = _flags_and_targets(args)
|
|
522
|
+
if bin_ == "tee" or bin_ == "truncate":
|
|
523
|
+
for t in targets:
|
|
524
|
+
_write_target(t, cwd, out)
|
|
525
|
+
elif targets:
|
|
526
|
+
_write_target(targets[-1], cwd, out)
|
|
527
|
+
return
|
|
528
|
+
if bin_ == "dd":
|
|
529
|
+
for a in args:
|
|
530
|
+
if a.startswith("of="):
|
|
531
|
+
_write_target(a[3:], cwd, out)
|
|
532
|
+
elif a.startswith("if="):
|
|
533
|
+
_read_target(a[3:], cwd, out)
|
|
534
|
+
return
|
|
535
|
+
if bin_ in ("cat", "head", "tail", "less", "more", "base64", "xxd", "od", "strings", "grep", "cp", "scp", "rsync",
|
|
536
|
+
"tar", "zip", "gzip", "curl", "wget", "nc", "ncat"):
|
|
537
|
+
for t in _flags_and_targets(args)[1]:
|
|
538
|
+
_read_target(t.split(":", 1)[-1] if bin_ in ("scp", "rsync") and ":" in t else t, cwd, out)
|
|
539
|
+
flags, targets = _flags_and_targets(args)
|
|
540
|
+
if bin_ in ("cp", "scp", "rsync") and targets:
|
|
541
|
+
_write_target(targets[-1], cwd, out)
|
|
542
|
+
if bin_ == "rsync" and any(f.startswith("--delete") for f in flags) and targets:
|
|
543
|
+
p = _norm(targets[-1], cwd)
|
|
544
|
+
scope = _system_scope(p) if p else None
|
|
545
|
+
out.add(scope or "medium", f"rsync --delete into {p or targets[-1]}")
|
|
546
|
+
return
|
|
547
|
+
if _FORMATTERS.match(bin_):
|
|
548
|
+
flags, targets = _flags_and_targets(args)
|
|
549
|
+
listing = any(f in ("-l", "--list", "-p", "--print", "-s", "-i") for f in flags) or "print" in targets
|
|
550
|
+
if bin_ in ("nvme", "hdparm"):
|
|
551
|
+
if any(t in ("format", "sanitize") for t in targets) or "--security-erase" in flags:
|
|
552
|
+
out.add("high", f"{bin_} erases {targets[-1] if targets else 'a device'}")
|
|
553
|
+
return
|
|
554
|
+
if bin_ == "badblocks" and not _short_has(flags, "w"):
|
|
555
|
+
return
|
|
556
|
+
if listing and bin_ in ("fdisk", "sfdisk", "sgdisk", "parted", "gdisk", "cfdisk", "wipefs"):
|
|
557
|
+
return
|
|
558
|
+
# A formatter's device is the last positional, or whichever positional names a device or a
|
|
559
|
+
# variable; label values like `-L ncdata` must not be reported as victims.
|
|
560
|
+
victims = [t for t in targets if _DEVICE.match(_norm(t, cwd) or t) or t.startswith("$")]
|
|
561
|
+
if not victims and targets and bin_.startswith("mk"):
|
|
562
|
+
victims = [targets[-1]]
|
|
563
|
+
for t in victims:
|
|
564
|
+
out.add("high", f"{bin_} destroys the contents of {t}")
|
|
565
|
+
return
|
|
566
|
+
if bin_ == "cryptsetup":
|
|
567
|
+
if any(a in ("luksFormat", "erase", "luksErase", "reencrypt") for a in args):
|
|
568
|
+
out.add("high", f"cryptsetup {next(a for a in args if not a.startswith('-'))} on {args[-1]}")
|
|
569
|
+
return
|
|
570
|
+
# --- virtualization, storage, containers ---
|
|
571
|
+
sub = next((a for a in args if not a.startswith("-")), None)
|
|
572
|
+
sub2 = next((a for a in args[args.index(sub) + 1:] if not a.startswith("-")), None) if sub else None
|
|
573
|
+
if bin_ in ("qm", "pct"):
|
|
574
|
+
if sub == "destroy":
|
|
575
|
+
out.add("high", f"{bin_} destroy {sub2 or ''}: deletes a guest and its disks".strip())
|
|
576
|
+
elif sub in ("delsnapshot", "disk") and (sub != "disk" or sub2 == "unlink"):
|
|
577
|
+
out.add("medium", f"{bin_} {sub} {' '.join(args[1:3])}".strip())
|
|
578
|
+
elif sub in ("stop", "reset") and bin_ == "qm":
|
|
579
|
+
out.add("medium", f"{bin_} {sub} {sub2 or ''}: hard power-off".strip())
|
|
580
|
+
return
|
|
581
|
+
if bin_ == "pvesm" and sub in ("free", "remove"):
|
|
582
|
+
out.add("high", f"pvesm {sub} {sub2 or ''}".strip())
|
|
583
|
+
return
|
|
584
|
+
if bin_ == "vzdump":
|
|
585
|
+
return
|
|
586
|
+
if bin_ == "zpool" and sub in ("destroy", "labelclear"):
|
|
587
|
+
out.add("high", f"zpool {sub} {sub2 or ''}: destroys a whole pool".strip())
|
|
588
|
+
return
|
|
589
|
+
if bin_ == "zfs" and sub in ("destroy",):
|
|
590
|
+
flags, targets = _flags_and_targets(args[1:])
|
|
591
|
+
what = targets[0] if targets else ""
|
|
592
|
+
children = " and every child dataset/snapshot" if _short_has(flags, "rR") else ""
|
|
593
|
+
out.add("high", f"zfs destroy {what}{children}".strip())
|
|
594
|
+
return
|
|
595
|
+
if bin_ in ("lvremove", "vgremove", "pvremove") or (bin_ == "lvm" and sub in ("lvremove", "vgremove", "pvremove")):
|
|
596
|
+
out.add("high", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}".strip())
|
|
597
|
+
return
|
|
598
|
+
if bin_ == "rbd" and sub in ("rm", "remove", "trash") or bin_ == "rados" and sub in ("rmpool", "purge"):
|
|
599
|
+
out.add("high", f"{bin_} {sub} {sub2 or ''}".strip())
|
|
600
|
+
return
|
|
601
|
+
if bin_ == "ceph" and sub in ("osd", "fs", "mon", "mgr") and sub2 in ("purge", "destroy", "rm", "remove", "pool"):
|
|
602
|
+
if sub2 == "pool" and not any(a in ("delete", "rm") for a in args):
|
|
603
|
+
return
|
|
604
|
+
out.add("high", f"ceph {sub} {sub2} {' '.join(args[2:5])}".strip())
|
|
605
|
+
return
|
|
606
|
+
if bin_ == "virsh" and sub == "undefine":
|
|
607
|
+
out.add("high" if any("storage" in a for a in args) else "medium", f"virsh undefine {sub2 or ''}".strip())
|
|
608
|
+
return
|
|
609
|
+
if bin_ in ("docker", "podman", "nerdctl"):
|
|
610
|
+
_docker(bin_, args, out)
|
|
611
|
+
return
|
|
612
|
+
if bin_ == "kubectl" and sub == "delete":
|
|
613
|
+
out.add("high" if any(a in ("--all", "-A", "--all-namespaces") or a.startswith("ns") or a == "namespace"
|
|
614
|
+
for a in args) else "medium", f"kubectl delete {' '.join(args[1:4])}".strip())
|
|
615
|
+
return
|
|
616
|
+
if bin_ == "git":
|
|
617
|
+
_git(args, out)
|
|
618
|
+
return
|
|
619
|
+
# --- databases ---
|
|
620
|
+
if bin_ in ("dropdb", "dropuser"):
|
|
621
|
+
out.add("high", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}".strip())
|
|
622
|
+
return
|
|
623
|
+
if bin_ == "redis-cli" and any(a.lower() in _FLUSHERS for a in args):
|
|
624
|
+
out.add("high", "redis flushes every key")
|
|
625
|
+
return
|
|
626
|
+
# --- host ---
|
|
627
|
+
if bin_ in _POWER:
|
|
628
|
+
if bin_ == "shutdown" and ("-c" in args or "--cancel" in args):
|
|
629
|
+
return
|
|
630
|
+
out.add("high", f"{bin_}: reboots or powers off the host")
|
|
631
|
+
return
|
|
632
|
+
if bin_ == "init" and args and args[0] in ("0", "6"):
|
|
633
|
+
out.add("high", "init 0/6: reboots or powers off the host")
|
|
634
|
+
return
|
|
635
|
+
if bin_ == "systemctl":
|
|
636
|
+
if sub in ("reboot", "poweroff", "halt", "kexec", "emergency", "rescue", "suspend", "hibernate"):
|
|
637
|
+
out.add("high", f"systemctl {sub}: takes the host down")
|
|
638
|
+
elif sub in ("stop", "disable", "mask") and any(re.match(r"^(ssh|sshd|networking|NetworkManager|systemd-networkd|"
|
|
639
|
+
r"tailscaled|pve-cluster|pvedaemon|pveproxy)(\.service)?$", a)
|
|
640
|
+
for a in args[1:]):
|
|
641
|
+
out.add("high", f"systemctl {sub} {' '.join(args[1:3])}: cuts remote access".strip())
|
|
642
|
+
return
|
|
643
|
+
if bin_ in ("kill", "pkill", "killall", "killall5"):
|
|
644
|
+
if bin_ == "killall5" or "-1" in args or any(a in ("-r", "--regexp") for a in args) and "." in args:
|
|
645
|
+
out.add("high", f"{bin_}: kills every process")
|
|
646
|
+
elif bin_ in ("pkill", "killall") and any(re.match(r"^(sshd|ssh|init|systemd|pve\w*)$", a) for a in args):
|
|
647
|
+
out.add("high", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}: cuts remote access")
|
|
648
|
+
return
|
|
649
|
+
if bin_ in ("iptables", "ip6tables", "nft", "ufw", "firewall-cmd"):
|
|
650
|
+
if _short_has([a for a in args if a.startswith("-")], "FX", ("--flush", "--delete-chain")) and bin_.endswith("tables") \
|
|
651
|
+
or bin_ == "nft" and "flush" in args and "ruleset" in args \
|
|
652
|
+
or bin_ == "ufw" and sub in ("reset", "disable") \
|
|
653
|
+
or bin_ == "firewall-cmd" and any(a in ("--panic-on", "--complete-reload") for a in args):
|
|
654
|
+
out.add("high", f"{bin_} {' '.join(args[:2])}: drops the firewall".strip())
|
|
655
|
+
return
|
|
656
|
+
if bin_ in ("ip", "ifdown", "ifconfig", "nmcli"):
|
|
657
|
+
if bin_ == "ifdown" or (bin_ == "ip" and sub in ("link", "addr", "address") and any(
|
|
658
|
+
a in ("down", "flush", "del", "delete") for a in args)) \
|
|
659
|
+
or (bin_ == "ifconfig" and "down" in args) or (bin_ == "nmcli" and any(a in ("down", "delete") for a in args)):
|
|
660
|
+
out.add("high", f"{bin_} {' '.join(args[:4])}: may drop the network".strip())
|
|
661
|
+
return
|
|
662
|
+
if bin_ in ("userdel", "deluser", "groupdel"):
|
|
663
|
+
rm = _short_has([a for a in args if a.startswith("-")], "r", ("--remove", "--remove-home", "--remove-all-files"))
|
|
664
|
+
out.add("high" if rm else "medium", f"{bin_} {' '.join(a for a in args if not a.startswith('-'))}"
|
|
665
|
+
+ (" and their files" if rm else ""))
|
|
666
|
+
return
|
|
667
|
+
if bin_ == "passwd" and args and (args[0] in ("-d", "-l", "--delete", "--lock") or "root" in args):
|
|
668
|
+
out.add("high", f"passwd {' '.join(args)}: changes a login")
|
|
669
|
+
return
|
|
670
|
+
if bin_ == "crontab" and "-r" in args:
|
|
671
|
+
out.add("high", "crontab -r: deletes every scheduled job")
|
|
672
|
+
return
|
|
673
|
+
if bin_ in ("apt", "apt-get", "dnf", "yum", "pacman", "apk", "zypper") and sub in ("remove", "purge", "autoremove",
|
|
674
|
+
"erase", "del", "-R", "-Rs", "-Rns"):
|
|
675
|
+
if any(re.match(r"^(openssh|ssh|sudo|systemd|linux-image|grub|pve-|proxmox|qemu-server|libc6|bash|coreutils)", a)
|
|
676
|
+
for a in args[1:]):
|
|
677
|
+
out.add("high", f"{bin_} {sub} {' '.join(a for a in args[1:] if not a.startswith('-'))}: removes a package "
|
|
678
|
+
"the host needs to stay reachable")
|
|
679
|
+
return
|
|
680
|
+
if bin_ == "history" and "-c" in args:
|
|
681
|
+
out.add("medium", "history -c")
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _read_target(raw: str, cwd: str | None, out: _Sink) -> None:
|
|
686
|
+
p = _norm(raw, cwd)
|
|
687
|
+
if p is None:
|
|
688
|
+
return
|
|
689
|
+
for h in scan_path(p, "read"):
|
|
690
|
+
out.add(h.severity, h.reason)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _write_target(raw: str, cwd: str | None, out: _Sink) -> None:
|
|
694
|
+
p = _norm(raw, cwd)
|
|
695
|
+
if p is None:
|
|
696
|
+
return
|
|
697
|
+
for h in scan_path(p, "write"):
|
|
698
|
+
out.add(h.severity, h.reason)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _find(args: list[str], cwd: str | None, out: _Sink) -> None:
|
|
702
|
+
starts: list[str] = []
|
|
703
|
+
i = 0
|
|
704
|
+
while i < len(args) and args[i] in ("-H", "-L", "-P", "-O0", "-O1", "-O2", "-O3"):
|
|
705
|
+
i += 1
|
|
706
|
+
if i < len(args) and args[i] == "-D":
|
|
707
|
+
i += 2
|
|
708
|
+
while i < len(args) and not args[i].startswith(("-", "(", "!")):
|
|
709
|
+
starts.append(args[i])
|
|
710
|
+
i += 1
|
|
711
|
+
deleting = any(a in _FIND_DELETE for a in args)
|
|
712
|
+
recursive_rm = False
|
|
713
|
+
for j, a in enumerate(args):
|
|
714
|
+
if a in _FIND_EXEC and j + 1 < len(args):
|
|
715
|
+
cmd = _unwrap(args[j + 1:])
|
|
716
|
+
if cmd and cmd[0].rsplit("/", 1)[-1] in _DELETERS:
|
|
717
|
+
deleting = True
|
|
718
|
+
recursive_rm = recursive_rm or _short_has([x for x in cmd[1:] if x.startswith("-")], "rR",
|
|
719
|
+
("--recursive",))
|
|
720
|
+
if not deleting:
|
|
721
|
+
return
|
|
722
|
+
for s in starts or ["."]:
|
|
723
|
+
_delete_target(s, cwd, True, "find -delete under", out)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _remote(bin_: str, args: list[str], out: _Sink, depth: int) -> None:
|
|
727
|
+
"""A command line run somewhere else: inside a container, guest or another host. Reported at
|
|
728
|
+
full severity with a prefix: a wiped guest is still a wiped machine."""
|
|
729
|
+
inner = _Sink()
|
|
730
|
+
if bin_ == "ssh":
|
|
731
|
+
# skip options and the host
|
|
732
|
+
i = 0
|
|
733
|
+
while i < len(args) and args[i].startswith("-"):
|
|
734
|
+
i += 2 if args[i] in ("-i", "-l", "-p", "-o", "-F", "-J", "-L", "-R", "-D", "-W", "-b", "-c", "-E",
|
|
735
|
+
"-I", "-m", "-O", "-Q", "-S", "-w", "-B") else 1
|
|
736
|
+
i += 1
|
|
737
|
+
if i < len(args):
|
|
738
|
+
_structured(" ".join(args[i:]), inner, depth + 1)
|
|
739
|
+
elif bin_ in ("pct", "docker", "podman", "lxc", "incus", "machinectl", "kubectl", "nsenter", "chroot", "qm"):
|
|
740
|
+
if "--" in args:
|
|
741
|
+
_structured(" ".join(args[args.index("--") + 1:]), inner, depth + 1)
|
|
742
|
+
elif bin_ in ("nsenter", "chroot"):
|
|
743
|
+
rest = [a for a in args if not a.startswith("-")]
|
|
744
|
+
if bin_ == "chroot":
|
|
745
|
+
rest = rest[1:]
|
|
746
|
+
if rest:
|
|
747
|
+
_structured(" ".join(rest), inner, depth + 1)
|
|
748
|
+
elif args and args[0] in ("exec", "shell", "run") and bin_ != "qm":
|
|
749
|
+
rest = args[1:]
|
|
750
|
+
while rest and rest[0].startswith("-"):
|
|
751
|
+
rest = rest[2:] if rest[0] in ("-u", "-w", "-e", "--user", "--workdir", "--env", "-n", "--namespace") else rest[1:]
|
|
752
|
+
if len(rest) > 1:
|
|
753
|
+
_structured(" ".join(rest[1:]), inner, depth + 1)
|
|
754
|
+
for h in inner.items:
|
|
755
|
+
out.add(h.severity, f"(inside {bin_}) {h.reason}")
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _docker(bin_: str, args: list[str], out: _Sink) -> None:
|
|
759
|
+
words = [a for a in args if not a.startswith("-")]
|
|
760
|
+
flags = [a for a in args if a.startswith("-")]
|
|
761
|
+
if not words:
|
|
762
|
+
return
|
|
763
|
+
sub = words[0]
|
|
764
|
+
sub2 = words[1] if len(words) > 1 else ""
|
|
765
|
+
if sub == "system" and sub2 == "prune":
|
|
766
|
+
out.add("high", f"{bin_} system prune" + (" including volumes" if "--volumes" in flags else ""))
|
|
767
|
+
elif sub == "volume" and sub2 in ("rm", "remove", "prune"):
|
|
768
|
+
out.add("high", f"{bin_} volume {sub2} {' '.join(words[2:5])}: deletes persistent data".strip())
|
|
769
|
+
elif sub == "compose" and "down" in words and (_short_has(flags, "v", ("--volumes",))):
|
|
770
|
+
out.add("high", f"{bin_} compose down -v: deletes the stack's volumes")
|
|
771
|
+
elif sub in ("rm", "container") and (sub == "rm" or sub2 in ("rm", "prune")):
|
|
772
|
+
forced = _short_has(flags, "f", ("--force",))
|
|
773
|
+
out.add("medium", f"{bin_} {sub} {sub2 if sub == 'container' else ''} {' '.join(flags)}".replace(" ", " ").strip()
|
|
774
|
+
+ (": removes running containers" if forced else ""))
|
|
775
|
+
elif sub in ("rmi", "image") and (sub == "rmi" or sub2 in ("rm", "prune")):
|
|
776
|
+
if _short_has(flags, "a", ("--all",)) or _short_has(flags, "f", ("--force",)):
|
|
777
|
+
out.add("medium", f"{bin_} {sub} {sub2 if sub == 'image' else ''} {' '.join(flags)}".replace(" ", " ").strip())
|
|
778
|
+
elif sub == "network" and sub2 == "prune":
|
|
779
|
+
out.add("medium", f"{bin_} network prune")
|
|
780
|
+
elif sub == "swarm" and sub2 == "leave" and _short_has(flags, "f", ("--force",)):
|
|
781
|
+
out.add("high", f"{bin_} swarm leave --force")
|
|
782
|
+
elif sub == "stack" and sub2 in ("rm", "remove"):
|
|
783
|
+
out.add("medium", f"{bin_} stack rm {' '.join(words[2:4])}".strip())
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _git(args: list[str], out: _Sink) -> None:
|
|
787
|
+
words = [a for a in args if not a.startswith("-")]
|
|
788
|
+
flags = [a for a in args if a.startswith("-")]
|
|
789
|
+
if words and words[0] == "-C":
|
|
790
|
+
words = words[2:]
|
|
791
|
+
if not words:
|
|
792
|
+
return
|
|
793
|
+
sub = words[0]
|
|
794
|
+
if sub == "push":
|
|
795
|
+
if "--force" in flags or _short_has(flags, "f") or any(w.startswith("+") for w in words[1:]):
|
|
796
|
+
out.add("high", "git push --force")
|
|
797
|
+
elif any(f.startswith("--force-with-lease") for f in flags) or "--delete" in flags or _short_has(flags, "d"):
|
|
798
|
+
out.add("medium", "git push " + " ".join(flags))
|
|
799
|
+
elif sub == "reset" and "--hard" in flags:
|
|
800
|
+
out.add("high", "git reset --hard")
|
|
801
|
+
elif sub == "clean":
|
|
802
|
+
if _short_has(flags, "f", ("--force",)) and _short_has(flags, "x"):
|
|
803
|
+
out.add("high", f"git clean {' '.join(flags)}: deletes ignored and untracked files")
|
|
804
|
+
elif _short_has(flags, "f", ("--force",)):
|
|
805
|
+
out.add("medium", "git clean -f")
|
|
806
|
+
elif sub == "branch" and _short_has(flags, "D", ("--delete", "--force")):
|
|
807
|
+
out.add("medium", f"git branch -D {' '.join(words[1:3])}".strip())
|
|
808
|
+
elif sub in ("checkout", "restore") and ("." in words[1:] or "--" in args and args[-1] in (".", "*")):
|
|
809
|
+
out.add("medium", f"git {sub} .: discards working changes")
|
|
810
|
+
elif sub == "stash" and words[1:2] and words[1] in ("drop", "clear"):
|
|
811
|
+
out.add("medium", f"git stash {words[1]}")
|
|
812
|
+
elif sub in ("filter-branch", "filter-repo"):
|
|
813
|
+
out.add("high", f"git {sub}: rewrites history")
|
|
814
|
+
elif sub == "gc" and "--prune=now" in flags:
|
|
815
|
+
out.add("medium", "git gc --prune=now")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
# ---- lexical pass -------------------------------------------------------------------------------
|
|
819
|
+
_RM_RE = re.compile(r"(?<![\w.-])(?:\S*/)?rm\s+((?:-[\w-]+\s+)*)([^;&|<>\n'\"`)]+)")
|
|
820
|
+
_CD_RM_RE = re.compile(r"\bcd\s+(\S+)\s*(?:&&|;)\s*(?:\S*/)?rm\s+((?:-[\w-]+\s+)*)([^;&|<>\n'\"`)]+)")
|
|
821
|
+
_RMTREE_RE = re.compile(r"\b(?:shutil\.)?rmtree\(\s*(?:(['\"])(?P<lit>[^'\"]*)\1|(?P<expr>[^)]*))")
|
|
822
|
+
_RIMRAF_RE = re.compile(r"\b(?:rimraf|rm\.-rf)\s+([^\s;&|'\"`]+)")
|
|
823
|
+
_FSRM_RE = re.compile(r"\.rm(?:Sync)?\(\s*(['\"])([^'\"]*)\1[^)]*recursive")
|
|
824
|
+
_FIND_RE = re.compile(r"\bfind\s+([~./][^\s;&|]*(?:\s+[~./][^\s;&|]*)*)[^;&|\n]*?(?:-delete\b|-exec\w*\s+rm\b)")
|
|
825
|
+
_FORMAT_RE = re.compile(r"(?<![\w./-])(mkfs(?:\.\w+)?|wipefs|blkdiscard)[ \t]+([^\n;|&]*)")
|
|
826
|
+
_DEVICE_WRITE_RE = re.compile(r"(?:\bof=|>\s*)(/dev/(?:sd|nvme|vd|hd|xvd|md|mapper/|dm-|zvol/|mmcblk|nbd|rbd)\S*)")
|
|
827
|
+
_REDIRECT_RE = re.compile(r"(?<!\d)(?<![<>])>>?\s*((?:/|~/|\$HOME/|\$\{HOME\}/)[^\s;&|'\"`)]*)")
|
|
828
|
+
_SQL_RE = re.compile(r"\b(DROP\s+(?:DATABASE|SCHEMA|TABLE|USER|ROLE)\b[^;'\"]{0,60}|TRUNCATE(?:\s+TABLE)?\s+\w[^;'\"]{0,60})",
|
|
829
|
+
re.IGNORECASE)
|
|
830
|
+
_MONGO_RE = re.compile(r"\bdropDatabase\s*\(|\bdrop\s*\(\s*\)")
|
|
831
|
+
_FLUSH_RE = re.compile(r"\b(FLUSHALL|FLUSHDB)\b", re.IGNORECASE)
|
|
832
|
+
_FORK_BOMB_RE = re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")
|
|
833
|
+
_CHMOD_RE = re.compile(r"\b(chmod|chown|chgrp)\s+(?:-\S+\s+)*-R\S*\s+\S+\s+(/(?:\*|\s|$|[a-z]+/?(?:\*|\s|$)))")
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _lexical(command: str, out: _Sink) -> None:
|
|
837
|
+
if "--no-preserve-root" in command:
|
|
838
|
+
out.add("critical", "rm --no-preserve-root")
|
|
839
|
+
if _FORK_BOMB_RE.search(command):
|
|
840
|
+
out.add("critical", "fork bomb")
|
|
841
|
+
for m in _RM_RE.finditer(command):
|
|
842
|
+
flags = m.group(1).split()
|
|
843
|
+
if not _short_has(flags, "rR", ("--recursive",)):
|
|
844
|
+
continue
|
|
845
|
+
for raw in m.group(2).split():
|
|
846
|
+
# relative targets are the structured pass's job; here they are mostly `{}` and `+`
|
|
847
|
+
if raw.startswith("-") or not _HOME.match(raw) and not raw.startswith("/"):
|
|
848
|
+
continue
|
|
849
|
+
_delete_target(raw.rstrip("\\"), None, True, "recursive delete of", out, weak=True)
|
|
850
|
+
for m in _CD_RM_RE.finditer(command):
|
|
851
|
+
flags = m.group(2).split()
|
|
852
|
+
if not _short_has(flags, "rR", ("--recursive",)):
|
|
853
|
+
continue
|
|
854
|
+
for raw in m.group(3).split():
|
|
855
|
+
if not raw.startswith("-"):
|
|
856
|
+
_delete_target(raw, _norm(m.group(1), None), True, "recursive delete of", out, weak=True)
|
|
857
|
+
for m in _RMTREE_RE.finditer(command):
|
|
858
|
+
lit = m.group("lit")
|
|
859
|
+
if lit is not None:
|
|
860
|
+
_delete_target(lit, None, True, "shutil.rmtree of", out, weak=True)
|
|
861
|
+
else:
|
|
862
|
+
out.add_weak("medium", f"shutil.rmtree({(m.group('expr') or '').strip()[:40]})")
|
|
863
|
+
for m in _RIMRAF_RE.finditer(command):
|
|
864
|
+
_delete_target(m.group(1), None, True, "rimraf of", out, weak=True)
|
|
865
|
+
for m in _FSRM_RE.finditer(command):
|
|
866
|
+
_delete_target(m.group(2), None, True, "fs.rm recursive of", out, weak=True)
|
|
867
|
+
for m in _FIND_RE.finditer(command):
|
|
868
|
+
for s in m.group(1).split():
|
|
869
|
+
_delete_target(s, None, True, "find -delete under", out, weak=True)
|
|
870
|
+
for m in _FORMAT_RE.finditer(command):
|
|
871
|
+
words = [w.strip("'\"") for w in m.group(2).split() if not w.startswith("-")]
|
|
872
|
+
victims = [w for w in words if _DEVICE.match(w) or w.startswith("$")] or words[-1:]
|
|
873
|
+
for w in victims:
|
|
874
|
+
out.add_weak("high", f"{m.group(1)} destroys the contents of {w}")
|
|
875
|
+
for m in _DEVICE_WRITE_RE.finditer(command):
|
|
876
|
+
out.add_weak("high", f"writes raw bytes to block device {m.group(1)}")
|
|
877
|
+
for m in _REDIRECT_RE.finditer(command):
|
|
878
|
+
p = _norm(m.group(1), None)
|
|
879
|
+
for h in scan_path(p, "write") if p else ():
|
|
880
|
+
out.add_weak(h.severity, h.reason)
|
|
881
|
+
for m in _SQL_RE.finditer(command):
|
|
882
|
+
out.add_weak("high", f"SQL {' '.join(m.group(1).split())[:60]}")
|
|
883
|
+
if _MONGO_RE.search(command):
|
|
884
|
+
out.add_weak("high", "drops a MongoDB database or collection")
|
|
885
|
+
if _FLUSH_RE.search(command) and "redis" in command.lower():
|
|
886
|
+
out.add_weak("high", "redis flushes every key")
|
|
887
|
+
for m in _CHMOD_RE.finditer(command):
|
|
888
|
+
p = _norm(m.group(2).strip(), None)
|
|
889
|
+
scope = _perm_scope(p) if p else None
|
|
890
|
+
if scope:
|
|
891
|
+
out.add_weak(scope, f"recursive {m.group(1)} of {p}")
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# ---- standalone: CLI and Claude Code hook -------------------------------------------------------
|
|
895
|
+
def hook_response(command: str) -> dict[str, Any] | None:
|
|
896
|
+
"""Claude Code PreToolUse output for a Bash command. None when nothing is flagged.
|
|
897
|
+
critical -> deny, high -> ask (even where an allow rule would have let it through),
|
|
898
|
+
medium -> allowed, with a note the user sees."""
|
|
899
|
+
hz = scan_command(command)
|
|
900
|
+
level = worst(hz)
|
|
901
|
+
if level is None:
|
|
902
|
+
return None
|
|
903
|
+
text = "Keywright hazard check: " + describe(hz)
|
|
904
|
+
if level == "medium":
|
|
905
|
+
return {"systemMessage": text}
|
|
906
|
+
decision = "deny" if level == "critical" else "ask"
|
|
907
|
+
reason = text + (". Refused: if this must happen, the user runs it by hand." if level == "critical"
|
|
908
|
+
else ". Flagged as destructive; an existing allow rule does not cover it.")
|
|
909
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": decision,
|
|
910
|
+
"permissionDecisionReason": reason},
|
|
911
|
+
"systemMessage": text}
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def main(argv: list[str] | None = None) -> int:
|
|
915
|
+
import argparse
|
|
916
|
+
|
|
917
|
+
p = argparse.ArgumentParser(prog="keywright hazards",
|
|
918
|
+
description="Flag destructive shell commands. Exit 0 none, 1 medium, 2 high, 3 critical.")
|
|
919
|
+
p.add_argument("command", nargs="?", help="command line to check (omit to read stdin)")
|
|
920
|
+
p.add_argument("--hook", action="store_true",
|
|
921
|
+
help="Claude Code PreToolUse mode: read the hook JSON on stdin, print a decision")
|
|
922
|
+
p.add_argument("--json", action="store_true", help="print hazards as JSON")
|
|
923
|
+
a = p.parse_args(argv)
|
|
924
|
+
if a.hook:
|
|
925
|
+
try:
|
|
926
|
+
payload = json.load(sys.stdin)
|
|
927
|
+
except ValueError:
|
|
928
|
+
return 0
|
|
929
|
+
if payload.get("tool_name") != "Bash":
|
|
930
|
+
return 0
|
|
931
|
+
command = (payload.get("tool_input") or {}).get("command") or ""
|
|
932
|
+
resp = hook_response(command)
|
|
933
|
+
if resp:
|
|
934
|
+
print(json.dumps(resp))
|
|
935
|
+
return 0
|
|
936
|
+
command = a.command if a.command is not None else sys.stdin.read()
|
|
937
|
+
hz = scan_command(command)
|
|
938
|
+
level = worst(hz)
|
|
939
|
+
if a.json:
|
|
940
|
+
print(json.dumps({"level": level, "hazards": as_dicts(hz)}))
|
|
941
|
+
else:
|
|
942
|
+
for h in hz:
|
|
943
|
+
print(f"{h.severity}: {h.reason}")
|
|
944
|
+
return {None: 0, "medium": 1, "high": 2, "critical": 3}[level]
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
if __name__ == "__main__":
|
|
948
|
+
sys.exit(main())
|