failsafe-hook 1.0.0__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.
- failsafe_hook/__init__.py +0 -0
- failsafe_hook/__main__.py +15 -0
- failsafe_hook/_install.py +164 -0
- failsafe_hook/core.py +1712 -0
- failsafe_hook-1.0.0.dist-info/METADATA +313 -0
- failsafe_hook-1.0.0.dist-info/RECORD +10 -0
- failsafe_hook-1.0.0.dist-info/WHEEL +5 -0
- failsafe_hook-1.0.0.dist-info/entry_points.txt +2 -0
- failsafe_hook-1.0.0.dist-info/licenses/LICENSE +21 -0
- failsafe_hook-1.0.0.dist-info/top_level.txt +1 -0
failsafe_hook/core.py
ADDED
|
@@ -0,0 +1,1712 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
FailSafe - Claude Code PreToolUse hook
|
|
4
|
+
-----------------------------------------------------------------------
|
|
5
|
+
The zero-config agent seatbelt for Claude Code. Blocks dangerous agent
|
|
6
|
+
actions even in --dangerously-skip-permissions (bypass) mode, where a
|
|
7
|
+
hook deny is the only safety layer that still fires.
|
|
8
|
+
|
|
9
|
+
Current rules:
|
|
10
|
+
1. Slopsquatting defense - blocks installs of non-existent packages
|
|
11
|
+
(AI hallucinated names attackers pre-register with malware).
|
|
12
|
+
2. Destructive rm - blocks rm -rf on root, home, and system dirs.
|
|
13
|
+
3. One-off runner defense - checks npx/npm exec/pnpm dlx/bunx targets
|
|
14
|
+
before the agent executes registry code.
|
|
15
|
+
4. Manifest install defense - reads package.json/requirements.txt/
|
|
16
|
+
pyproject.toml on bare installs so a hallucinated dep hidden in a
|
|
17
|
+
manifest is caught too.
|
|
18
|
+
5. Curl-pipe-shell guard - blocks/flags remote scripts piped directly
|
|
19
|
+
into a shell or interpreter (curl ... | bash, wget ... | python).
|
|
20
|
+
6. Git disaster guard - blocks/flags force pushes to protected branches,
|
|
21
|
+
hard resets, force cleans, and force branch deletions.
|
|
22
|
+
7. Cloud/infra blast radius - flags terraform destroy, kubectl delete
|
|
23
|
+
namespace, docker system prune -a, aws s3 rm --recursive, etc.
|
|
24
|
+
8. Secrets exfiltration guard - flags sensitive files (.env, ~/.ssh,
|
|
25
|
+
keys) piped or sent to network commands.
|
|
26
|
+
|
|
27
|
+
Design rules:
|
|
28
|
+
- FAIL OPEN. Any unexpected error -> allow. A guard that breaks your
|
|
29
|
+
workflow gets uninstalled.
|
|
30
|
+
- FAST PATH. Non-relevant commands return instantly, no network.
|
|
31
|
+
- CONSERVATIVE. Only near-certain danger hard-blocks. Fuzzy signals
|
|
32
|
+
only escalate to a prompt.
|
|
33
|
+
|
|
34
|
+
Stdlib only -> zero install. Works wherever Python 3.8+ is present.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import shlex
|
|
41
|
+
import sys
|
|
42
|
+
import urllib.request
|
|
43
|
+
import urllib.error
|
|
44
|
+
import urllib.parse
|
|
45
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
46
|
+
from datetime import datetime, timezone
|
|
47
|
+
|
|
48
|
+
REQUEST_TIMEOUT = 3.5
|
|
49
|
+
NEW_PACKAGE_DAYS = 90
|
|
50
|
+
LOW_DOWNLOADS = 100
|
|
51
|
+
MAX_PACKAGES = 25 # beyond this, fail open rather than stall the agent
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _load_config(cwd=None):
|
|
55
|
+
"""Load failsafe.toml (project-level first, then ~/.config/failsafe/). Returns {}."""
|
|
56
|
+
search_cwd = cwd or os.getcwd()
|
|
57
|
+
paths = [
|
|
58
|
+
os.path.join(search_cwd, "failsafe.toml"),
|
|
59
|
+
os.path.expanduser(os.path.join("~", ".config", "failsafe", "config.toml")),
|
|
60
|
+
]
|
|
61
|
+
for p in paths:
|
|
62
|
+
if not os.path.isfile(p):
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
|
66
|
+
text = f.read()
|
|
67
|
+
except Exception:
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
import tomllib
|
|
71
|
+
return tomllib.loads(text).get("failsafe") or {}
|
|
72
|
+
except Exception:
|
|
73
|
+
return {}
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_CONFIG = _load_config()
|
|
78
|
+
|
|
79
|
+
POPULAR_NPM = {
|
|
80
|
+
"react", "react-dom", "react-router-dom", "lodash", "express", "axios",
|
|
81
|
+
"chalk", "commander", "next", "vue", "typescript", "webpack", "eslint",
|
|
82
|
+
"prettier", "jest", "vitest", "dotenv", "moment", "dayjs", "uuid",
|
|
83
|
+
"classnames", "zod", "redux", "tailwindcss", "vite", "rollup", "node-fetch",
|
|
84
|
+
"cross-env", "nodemon", "ts-node", "mongoose", "pg", "mysql2", "cors",
|
|
85
|
+
"body-parser", "bcrypt", "jsonwebtoken", "socket.io",
|
|
86
|
+
}
|
|
87
|
+
POPULAR_PYPI = {
|
|
88
|
+
"requests", "numpy", "pandas", "flask", "django", "fastapi", "pytest",
|
|
89
|
+
"pydantic", "sqlalchemy", "boto3", "scipy", "matplotlib", "pillow",
|
|
90
|
+
"urllib3", "certifi", "click", "rich", "tqdm", "beautifulsoup4",
|
|
91
|
+
"scikit-learn", "tensorflow", "torch", "transformers", "openai",
|
|
92
|
+
"anthropic", "aiohttp", "httpx", "uvicorn", "gunicorn", "celery", "redis",
|
|
93
|
+
"psycopg2", "pymongo", "python-dotenv", "setuptools", "wheel", "black",
|
|
94
|
+
"flake8", "mypy", "isort",
|
|
95
|
+
}
|
|
96
|
+
POPULAR_CARGO = {
|
|
97
|
+
"serde", "serde_json", "tokio", "async-std", "reqwest", "hyper",
|
|
98
|
+
"clap", "log", "env_logger", "anyhow", "thiserror", "rand",
|
|
99
|
+
"chrono", "uuid", "regex", "lazy_static", "once_cell", "rayon",
|
|
100
|
+
"itertools", "futures", "async-trait", "tracing", "axum",
|
|
101
|
+
"actix-web", "diesel", "sqlx", "rusqlite", "redis", "bytes",
|
|
102
|
+
"tower", "syn", "quote", "proc-macro2", "nom", "bindgen",
|
|
103
|
+
"wasm-bindgen", "pyo3", "crossbeam", "dashmap", "parking_lot",
|
|
104
|
+
}
|
|
105
|
+
PUBLIC_GO_DOMAINS = frozenset({
|
|
106
|
+
"github.com", "gitlab.com", "bitbucket.org",
|
|
107
|
+
"golang.org", "google.golang.org", "k8s.io", "sigs.k8s.io", "gopkg.in",
|
|
108
|
+
})
|
|
109
|
+
POPULAR_GEMS = {
|
|
110
|
+
"rails", "rake", "bundler", "rspec", "rspec-core", "minitest",
|
|
111
|
+
"nokogiri", "activerecord", "activesupport", "actionpack", "actionview",
|
|
112
|
+
"devise", "sidekiq", "puma", "unicorn", "sinatra", "jekyll",
|
|
113
|
+
"capybara", "factory_bot", "faker", "rubocop", "fastlane", "cocoapods",
|
|
114
|
+
"httparty", "rest-client", "faraday", "redis", "pg", "sqlite3",
|
|
115
|
+
"mongoid", "whenever", "capistrano", "rack", "thor", "zeitwerk",
|
|
116
|
+
"dry-validation", "sorbet", "aws-sdk", "json", "openssl", "net-http",
|
|
117
|
+
"pry", "byebug", "rexml", "csv", "benchmark", "logger",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
JS_MANAGERS = {
|
|
121
|
+
"npm": {"install", "i", "add"},
|
|
122
|
+
"pnpm": {"add", "install", "i"},
|
|
123
|
+
"yarn": {"add"},
|
|
124
|
+
"bun": {"add", "install", "i"},
|
|
125
|
+
}
|
|
126
|
+
CARGO_VALUE_FLAGS = frozenset({
|
|
127
|
+
"--manifest-path", "--target", "--target-dir", "--features", "-F",
|
|
128
|
+
"--branch", "--tag", "--rev", "--git", "--path", "--registry",
|
|
129
|
+
"--vers", "--version", "--bin", "--example", "--root",
|
|
130
|
+
})
|
|
131
|
+
_CARGO_NON_REGISTRY = frozenset({"--git", "--path"})
|
|
132
|
+
CARGO_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
|
|
133
|
+
GEM_VALUE_FLAGS = frozenset({
|
|
134
|
+
"--version", "-v", "--source", "-s", "--install-dir", "-i",
|
|
135
|
+
"--bindir", "-n", "--document", "--platform", "-P",
|
|
136
|
+
"--proxy", "--build", "--trust-policy",
|
|
137
|
+
})
|
|
138
|
+
GEM_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
139
|
+
LOW_GEM_DOWNLOADS = 1000 # total lifetime downloads threshold
|
|
140
|
+
|
|
141
|
+
# Options whose NEXT token is a value, not a package (prevents false positives
|
|
142
|
+
# like `pip install --platform win_amd64 requests` denying "win_amd64").
|
|
143
|
+
PIP_VALUE_FLAGS = {
|
|
144
|
+
"-r", "--requirement", "-c", "--constraint", "-e", "--editable",
|
|
145
|
+
"-i", "--index-url", "--extra-index-url", "-f", "--find-links",
|
|
146
|
+
"--platform", "--python-version", "--implementation", "--abi",
|
|
147
|
+
"-t", "--target", "--prefix", "--root", "--no-binary", "--only-binary",
|
|
148
|
+
"--progress-bar", "--report", "--hash", "--cache-dir", "--log", "--python",
|
|
149
|
+
}
|
|
150
|
+
UV_VALUE_FLAGS = PIP_VALUE_FLAGS | {"--index", "--default-index", "--index-strategy", "-p"}
|
|
151
|
+
POETRY_VALUE_FLAGS = {"--group", "-G", "--source", "--extras", "-E", "--python", "--platform"}
|
|
152
|
+
NPM_VALUE_FLAGS = {
|
|
153
|
+
"--registry", "--prefix", "-C", "--workspace", "-w", "--save-prefix",
|
|
154
|
+
"--omit", "--include", "--tag", "--access", "--otp", "--loglevel", "--cache",
|
|
155
|
+
}
|
|
156
|
+
JS_RUNNER_PACKAGE_FLAGS = {"--package", "-p"}
|
|
157
|
+
JS_RUNNER_VALUE_FLAGS = NPM_VALUE_FLAGS | {
|
|
158
|
+
"--call", "-c", "--shell", "--script-shell", "--userconfig",
|
|
159
|
+
"--node-arg", "-n",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
SHELLS = {"bash", "sh", "zsh", "dash", "ksh"}
|
|
163
|
+
WRAPPERS = {"env", "sudo", "doas", "command", "time", "nice", "exec", "xargs"}
|
|
164
|
+
ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
165
|
+
OPERATORS = {"&&", "||", ";", "|", "&", "|&"}
|
|
166
|
+
|
|
167
|
+
WRAPPER_VALUE_FLAGS = {
|
|
168
|
+
"sudo": {
|
|
169
|
+
"-u", "--user", "-g", "--group", "-h", "--host", "-p", "--prompt",
|
|
170
|
+
"-C", "--close-from", "-T", "--command-timeout", "-D", "--chdir",
|
|
171
|
+
},
|
|
172
|
+
"doas": {"-u", "--user"},
|
|
173
|
+
"env": {
|
|
174
|
+
"-u", "--unset", "-C", "--chdir", "-S", "--split-string",
|
|
175
|
+
"--block-signal", "--default-signal", "--ignore-signal",
|
|
176
|
+
},
|
|
177
|
+
"time": {"-o", "--output", "-f", "--format"},
|
|
178
|
+
"nice": {"-n", "--adjustment"},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
NPM_NAME_RE = re.compile(r"^(@[a-z0-9\-~][a-z0-9\-._~]*/)?[a-z0-9\-~][a-z0-9\-._~]*$", re.I)
|
|
182
|
+
PY_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------- HTTP
|
|
186
|
+
def http_get_json(url):
|
|
187
|
+
req = urllib.request.Request(url, headers={"User-Agent": "failsafe/0.5"})
|
|
188
|
+
try:
|
|
189
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
|
|
190
|
+
status = getattr(r, "status", 200)
|
|
191
|
+
if 200 <= status < 300:
|
|
192
|
+
return status, json.loads(r.read().decode("utf-8", "replace"))
|
|
193
|
+
return status, None
|
|
194
|
+
except urllib.error.HTTPError as e:
|
|
195
|
+
return e.code, None # 404 lands here
|
|
196
|
+
except Exception:
|
|
197
|
+
return 0, None # network/timeout -> unknown -> fail open
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ----------------------------------------------------- command parsing
|
|
201
|
+
def strip_npm_version(arg):
|
|
202
|
+
if arg.startswith("@"):
|
|
203
|
+
i = arg.find("@", 1)
|
|
204
|
+
return arg if i == -1 else arg[:i]
|
|
205
|
+
i = arg.find("@")
|
|
206
|
+
return arg if i == -1 else arg[:i]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def strip_py_version(arg):
|
|
210
|
+
return re.split(r"[=<>!~ ;\[]", arg)[0]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_js_local_or_url(a):
|
|
214
|
+
if a.startswith("@"):
|
|
215
|
+
return False # scoped package, not a path
|
|
216
|
+
return ("/" in a or "\\" in a or "://" in a or a.startswith("git+")
|
|
217
|
+
or a.startswith(".") or a.endswith(".tgz") or a.endswith(".tar.gz"))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _consume_wrapper_options(tokens, i, wrapper):
|
|
221
|
+
"""Skip common wrapper flags so sudo -n rm ... still exposes rm."""
|
|
222
|
+
value_flags = WRAPPER_VALUE_FLAGS.get(wrapper, set())
|
|
223
|
+
while i < len(tokens):
|
|
224
|
+
t = tokens[i]
|
|
225
|
+
if t == "--":
|
|
226
|
+
return i + 1
|
|
227
|
+
if ENV_ASSIGN_RE.match(t):
|
|
228
|
+
i += 1
|
|
229
|
+
continue
|
|
230
|
+
if not t.startswith("-") or t == "-":
|
|
231
|
+
return i
|
|
232
|
+
|
|
233
|
+
takes_value = t in value_flags
|
|
234
|
+
has_inline_value = any(t.startswith(flag + "=") for flag in value_flags)
|
|
235
|
+
has_short_inline_value = len(t) > 2 and t[:2] in value_flags
|
|
236
|
+
|
|
237
|
+
i += 1
|
|
238
|
+
if takes_value and not has_inline_value and not has_short_inline_value and i < len(tokens):
|
|
239
|
+
i += 1
|
|
240
|
+
return i
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def strip_prefix(tokens):
|
|
244
|
+
"""Drop leading env assignments and wrappers: env FOO=bar sudo npm ..."""
|
|
245
|
+
i = 0
|
|
246
|
+
while i < len(tokens):
|
|
247
|
+
if ENV_ASSIGN_RE.match(tokens[i]):
|
|
248
|
+
i += 1
|
|
249
|
+
continue
|
|
250
|
+
if tokens[i] in WRAPPERS:
|
|
251
|
+
wrapper = tokens[i]
|
|
252
|
+
i += 1
|
|
253
|
+
i = _consume_wrapper_options(tokens, i, wrapper)
|
|
254
|
+
continue
|
|
255
|
+
break
|
|
256
|
+
return tokens[i:]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def split_segments(toks):
|
|
260
|
+
"""Split a token list into segments on shell control operators."""
|
|
261
|
+
segments, cur = [], []
|
|
262
|
+
for t in toks:
|
|
263
|
+
if t in OPERATORS:
|
|
264
|
+
if cur:
|
|
265
|
+
segments.append(cur)
|
|
266
|
+
cur = []
|
|
267
|
+
else:
|
|
268
|
+
cur.append(t)
|
|
269
|
+
if cur:
|
|
270
|
+
segments.append(cur)
|
|
271
|
+
return segments
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def add_js_target(arg, targets):
|
|
275
|
+
if not arg or arg.startswith("-") or arg in (".", ".."):
|
|
276
|
+
return False
|
|
277
|
+
if is_js_local_or_url(arg):
|
|
278
|
+
return False
|
|
279
|
+
name = strip_npm_version(arg)
|
|
280
|
+
if name and not name.startswith("-") and NPM_NAME_RE.match(name):
|
|
281
|
+
targets.append(("npm", name))
|
|
282
|
+
return True
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def collect_js(args, targets, value_flags):
|
|
287
|
+
skip_next = False
|
|
288
|
+
for a in args:
|
|
289
|
+
if skip_next:
|
|
290
|
+
skip_next = False
|
|
291
|
+
continue
|
|
292
|
+
if a in value_flags:
|
|
293
|
+
skip_next = True
|
|
294
|
+
continue
|
|
295
|
+
if a.startswith("-") or a in (".", ".."):
|
|
296
|
+
continue
|
|
297
|
+
add_js_target(a, targets)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _flag_value(token, flags):
|
|
301
|
+
for flag in flags:
|
|
302
|
+
prefix = flag + "="
|
|
303
|
+
if token.startswith(prefix):
|
|
304
|
+
return token[len(prefix):]
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def collect_js_runner(args, targets):
|
|
309
|
+
"""Collect packages executed by npx/npm exec/pnpm dlx/bunx style runners."""
|
|
310
|
+
skip_next = False
|
|
311
|
+
explicit_package = False
|
|
312
|
+
after_double_dash = False
|
|
313
|
+
|
|
314
|
+
for i, a in enumerate(args):
|
|
315
|
+
if skip_next:
|
|
316
|
+
skip_next = False
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
if not after_double_dash:
|
|
320
|
+
if a == "--":
|
|
321
|
+
after_double_dash = True
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
value = _flag_value(a, JS_RUNNER_PACKAGE_FLAGS)
|
|
325
|
+
if value is not None:
|
|
326
|
+
if add_js_target(value, targets):
|
|
327
|
+
explicit_package = True
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
if a in JS_RUNNER_PACKAGE_FLAGS:
|
|
331
|
+
if i + 1 < len(args) and add_js_target(args[i + 1], targets):
|
|
332
|
+
explicit_package = True
|
|
333
|
+
skip_next = True
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
if a in JS_RUNNER_VALUE_FLAGS:
|
|
337
|
+
skip_next = True
|
|
338
|
+
continue
|
|
339
|
+
if _flag_value(a, JS_RUNNER_VALUE_FLAGS) is not None:
|
|
340
|
+
continue
|
|
341
|
+
if a.startswith("-"):
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
if explicit_package:
|
|
345
|
+
return
|
|
346
|
+
add_js_target(a, targets)
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def collect_py(args, targets, value_flags):
|
|
351
|
+
skip_next = False
|
|
352
|
+
for a in args:
|
|
353
|
+
if skip_next:
|
|
354
|
+
skip_next = False
|
|
355
|
+
continue
|
|
356
|
+
if a in value_flags:
|
|
357
|
+
skip_next = True # next token is a value, not a package
|
|
358
|
+
continue
|
|
359
|
+
if a.startswith("-") or a in (".", ".."):
|
|
360
|
+
continue
|
|
361
|
+
if "/" in a or "\\" in a or "://" in a:
|
|
362
|
+
continue
|
|
363
|
+
if "<" in a or ">" in a:
|
|
364
|
+
continue # shell redirect token (e.g. 2>&1 split by shlex)
|
|
365
|
+
if re.search(r"\.(txt|cfg|toml|ini|whl|zip)$", a, re.I) or a.endswith(".tar.gz"):
|
|
366
|
+
continue
|
|
367
|
+
name = strip_py_version(a)
|
|
368
|
+
if name and not name.startswith("-") and PY_NAME_RE.match(name):
|
|
369
|
+
targets.append(("pypi", name))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def collect_cargo(args, targets):
|
|
373
|
+
for a in args:
|
|
374
|
+
if any(a == f or a.startswith(f + "=") for f in _CARGO_NON_REGISTRY):
|
|
375
|
+
return # --git or --path: not a registry install
|
|
376
|
+
skip_next = False
|
|
377
|
+
for a in args:
|
|
378
|
+
if skip_next:
|
|
379
|
+
skip_next = False
|
|
380
|
+
continue
|
|
381
|
+
if a in CARGO_VALUE_FLAGS:
|
|
382
|
+
skip_next = True
|
|
383
|
+
continue
|
|
384
|
+
if any(a.startswith(f + "=") for f in CARGO_VALUE_FLAGS):
|
|
385
|
+
continue
|
|
386
|
+
if a.startswith("-"):
|
|
387
|
+
continue
|
|
388
|
+
name = a.split("@")[0]
|
|
389
|
+
if name and CARGO_NAME_RE.match(name):
|
|
390
|
+
targets.append(("cargo", name))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def collect_go(args, targets):
|
|
394
|
+
for a in args:
|
|
395
|
+
if a.startswith("-"):
|
|
396
|
+
continue
|
|
397
|
+
mod = a.split("@")[0]
|
|
398
|
+
parts = mod.split("/")
|
|
399
|
+
if not parts or parts[0].lower() not in PUBLIC_GO_DOMAINS:
|
|
400
|
+
continue # private/unknown domain -> skip (avoid false positives)
|
|
401
|
+
targets.append(("go", mod))
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def collect_gems(args, targets):
|
|
405
|
+
skip_next = False
|
|
406
|
+
for a in args:
|
|
407
|
+
if skip_next:
|
|
408
|
+
skip_next = False
|
|
409
|
+
continue
|
|
410
|
+
if a in GEM_VALUE_FLAGS:
|
|
411
|
+
skip_next = True
|
|
412
|
+
continue
|
|
413
|
+
if any(a.startswith(f + "=") for f in GEM_VALUE_FLAGS):
|
|
414
|
+
continue
|
|
415
|
+
if a.startswith("-"):
|
|
416
|
+
continue
|
|
417
|
+
if "/" in a or a.endswith(".gem"):
|
|
418
|
+
continue # local file path
|
|
419
|
+
if GEM_NAME_RE.match(a):
|
|
420
|
+
targets.append(("rubygems", a))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def tokenize(command):
|
|
424
|
+
try:
|
|
425
|
+
lex = shlex.shlex(command, posix=True, punctuation_chars="|&;")
|
|
426
|
+
lex.whitespace_split = True
|
|
427
|
+
return list(lex)
|
|
428
|
+
except ValueError:
|
|
429
|
+
return command.split()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def parse_install_targets(command, _depth=0):
|
|
433
|
+
toks = tokenize(command)
|
|
434
|
+
targets = []
|
|
435
|
+
for tokens in split_segments(toks):
|
|
436
|
+
tokens = strip_prefix(tokens)
|
|
437
|
+
if len(tokens) < 2:
|
|
438
|
+
continue
|
|
439
|
+
mgr, rest = tokens[0], tokens[1:]
|
|
440
|
+
|
|
441
|
+
# Recurse into shell wrappers: bash -c "npm install x"
|
|
442
|
+
if mgr in SHELLS and _depth < 2:
|
|
443
|
+
for t in rest:
|
|
444
|
+
if " " in t: # a quoted inner command
|
|
445
|
+
targets.extend(parse_install_targets(t, _depth + 1))
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
if mgr in ("python", "python3") and len(rest) >= 2 and rest[0] == "-m" and rest[1] == "pip":
|
|
449
|
+
mgr, rest = "pip", rest[2:]
|
|
450
|
+
|
|
451
|
+
if mgr == "uv":
|
|
452
|
+
if len(rest) >= 2 and rest[0] == "pip" and rest[1] == "install":
|
|
453
|
+
collect_py(rest[2:], targets, UV_VALUE_FLAGS)
|
|
454
|
+
elif rest and rest[0] == "add":
|
|
455
|
+
collect_py(rest[1:], targets, UV_VALUE_FLAGS)
|
|
456
|
+
continue
|
|
457
|
+
if mgr == "poetry":
|
|
458
|
+
if rest and rest[0] == "add":
|
|
459
|
+
collect_py(rest[1:], targets, POETRY_VALUE_FLAGS)
|
|
460
|
+
continue
|
|
461
|
+
if mgr in ("pip", "pip3"):
|
|
462
|
+
if rest and rest[0] == "install":
|
|
463
|
+
collect_py(rest[1:], targets, PIP_VALUE_FLAGS)
|
|
464
|
+
continue
|
|
465
|
+
if mgr == "npx":
|
|
466
|
+
collect_js_runner(rest, targets)
|
|
467
|
+
continue
|
|
468
|
+
if mgr == "npm":
|
|
469
|
+
if rest and rest[0] in ("exec", "x"):
|
|
470
|
+
collect_js_runner(rest[1:], targets)
|
|
471
|
+
continue
|
|
472
|
+
if mgr == "pnpm" and rest and rest[0] == "dlx":
|
|
473
|
+
collect_js_runner(rest[1:], targets)
|
|
474
|
+
continue
|
|
475
|
+
if mgr == "bunx":
|
|
476
|
+
collect_js_runner(rest, targets)
|
|
477
|
+
continue
|
|
478
|
+
if mgr == "bun" and rest and rest[0] == "x":
|
|
479
|
+
collect_js_runner(rest[1:], targets)
|
|
480
|
+
continue
|
|
481
|
+
if mgr == "yarn" and rest and rest[0] == "dlx":
|
|
482
|
+
collect_js_runner(rest[1:], targets)
|
|
483
|
+
continue
|
|
484
|
+
if mgr == "cargo":
|
|
485
|
+
if rest and rest[0] in ("add", "install"):
|
|
486
|
+
collect_cargo(rest[1:], targets)
|
|
487
|
+
continue
|
|
488
|
+
|
|
489
|
+
if mgr == "go":
|
|
490
|
+
if rest and rest[0] in ("get", "install"):
|
|
491
|
+
collect_go(rest[1:], targets)
|
|
492
|
+
continue
|
|
493
|
+
|
|
494
|
+
if mgr == "gem":
|
|
495
|
+
if rest and rest[0] == "install":
|
|
496
|
+
collect_gems(rest[1:], targets)
|
|
497
|
+
continue
|
|
498
|
+
|
|
499
|
+
if mgr == "bundle":
|
|
500
|
+
if rest and rest[0] == "add":
|
|
501
|
+
collect_gems(rest[1:], targets)
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
if mgr in JS_MANAGERS:
|
|
505
|
+
if rest and rest[0] in JS_MANAGERS[mgr]:
|
|
506
|
+
collect_js(rest[1:], targets, NPM_VALUE_FLAGS)
|
|
507
|
+
continue
|
|
508
|
+
return targets
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ----------------------------------------- manifest install check
|
|
512
|
+
#
|
|
513
|
+
# Threat model: an agent invents a package name, writes it into a manifest
|
|
514
|
+
# (requirements.txt, package.json, pyproject.toml), then runs a *bare* install
|
|
515
|
+
# such as `npm install` or `pip install -r requirements.txt`. The direct-arg
|
|
516
|
+
# parser above never sees the package because it lives in a file. Here we read
|
|
517
|
+
# the relevant source manifest and feed its declared packages through the same
|
|
518
|
+
# registry checks.
|
|
519
|
+
#
|
|
520
|
+
# Source manifests only -- generated lockfiles (package-lock.json,
|
|
521
|
+
# pnpm-lock.yaml, yarn.lock) are not parsed: an agent is unlikely to hand-edit
|
|
522
|
+
# them, and YAML has no stdlib parser. pyproject.toml needs tomllib (Python
|
|
523
|
+
# 3.11+); on older Python we fail open rather than guess at TOML.
|
|
524
|
+
|
|
525
|
+
MAX_MANIFEST_BYTES = 512 * 1024 # don't slurp a giant generated file
|
|
526
|
+
|
|
527
|
+
# npm bare-install verbs (no positional package => install from package.json)
|
|
528
|
+
JS_INSTALL_VERBS = {
|
|
529
|
+
"npm": {"install", "i"},
|
|
530
|
+
"pnpm": {"install", "i"},
|
|
531
|
+
"yarn": {"install"},
|
|
532
|
+
"bun": {"install", "i"},
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
# package.json spec values that point at something other than a registry name
|
|
536
|
+
_JS_SKIP_SPEC_PREFIXES = (
|
|
537
|
+
"file:", "link:", "portal:", "workspace:", "git+", "git:",
|
|
538
|
+
"github:", "gitlab:", "bitbucket:", "http:", "https:",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _read_text(path):
|
|
543
|
+
try:
|
|
544
|
+
if os.path.getsize(path) > MAX_MANIFEST_BYTES:
|
|
545
|
+
return None
|
|
546
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
547
|
+
return f.read()
|
|
548
|
+
except Exception:
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _abs(path, cwd):
|
|
553
|
+
return path if os.path.isabs(path) else os.path.join(cwd, path)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _parse_package_json(text):
|
|
557
|
+
targets = []
|
|
558
|
+
try:
|
|
559
|
+
data = json.loads(text)
|
|
560
|
+
except Exception:
|
|
561
|
+
return targets
|
|
562
|
+
if not isinstance(data, dict):
|
|
563
|
+
return targets
|
|
564
|
+
for key in ("dependencies", "devDependencies",
|
|
565
|
+
"optionalDependencies", "peerDependencies"):
|
|
566
|
+
section = data.get(key)
|
|
567
|
+
if not isinstance(section, dict):
|
|
568
|
+
continue
|
|
569
|
+
for name, spec in section.items():
|
|
570
|
+
if not isinstance(name, str):
|
|
571
|
+
continue
|
|
572
|
+
if isinstance(spec, str):
|
|
573
|
+
sl = spec.lower()
|
|
574
|
+
if sl.startswith(_JS_SKIP_SPEC_PREFIXES):
|
|
575
|
+
continue # local path, git, url, or workspace alias
|
|
576
|
+
# npm alias: "alias": "npm:real-pkg@version" -- check real pkg
|
|
577
|
+
if sl.startswith("npm:"):
|
|
578
|
+
real = strip_npm_version(spec[4:])
|
|
579
|
+
if real and NPM_NAME_RE.match(real):
|
|
580
|
+
targets.append(("npm", real))
|
|
581
|
+
continue
|
|
582
|
+
if NPM_NAME_RE.match(name):
|
|
583
|
+
targets.append(("npm", name))
|
|
584
|
+
return targets
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _parse_requirements(text):
|
|
588
|
+
targets = []
|
|
589
|
+
for raw in text.splitlines():
|
|
590
|
+
line = raw.strip()
|
|
591
|
+
if not line or line.startswith("#"):
|
|
592
|
+
continue
|
|
593
|
+
if " #" in line: # strip trailing inline comment
|
|
594
|
+
line = line.split(" #", 1)[0].strip()
|
|
595
|
+
if line.startswith("-"):
|
|
596
|
+
continue # -r/-c includes, -e editable, --hash, other options
|
|
597
|
+
if "://" in line or line.startswith("git+") or " @ " in line:
|
|
598
|
+
continue # url / direct reference
|
|
599
|
+
if "/" in line or "\\" in line:
|
|
600
|
+
continue # local path
|
|
601
|
+
name = strip_py_version(line).strip()
|
|
602
|
+
if name and PY_NAME_RE.match(name):
|
|
603
|
+
targets.append(("pypi", name))
|
|
604
|
+
return targets
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _pep508_name(dep):
|
|
608
|
+
if not isinstance(dep, str):
|
|
609
|
+
return None
|
|
610
|
+
name = strip_py_version(dep).strip()
|
|
611
|
+
return name if name and PY_NAME_RE.match(name) else None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _parse_pyproject(text):
|
|
615
|
+
targets = []
|
|
616
|
+
try:
|
|
617
|
+
import tomllib
|
|
618
|
+
except ImportError:
|
|
619
|
+
return targets # Python < 3.11: fail open
|
|
620
|
+
try:
|
|
621
|
+
data = tomllib.loads(text)
|
|
622
|
+
except Exception:
|
|
623
|
+
return targets
|
|
624
|
+
if not isinstance(data, dict):
|
|
625
|
+
return targets
|
|
626
|
+
|
|
627
|
+
# PEP 621: [project] dependencies + optional-dependencies
|
|
628
|
+
project = data.get("project") or {}
|
|
629
|
+
if isinstance(project, dict):
|
|
630
|
+
for dep in project.get("dependencies") or []:
|
|
631
|
+
name = _pep508_name(dep)
|
|
632
|
+
if name:
|
|
633
|
+
targets.append(("pypi", name))
|
|
634
|
+
opt = project.get("optional-dependencies") or {}
|
|
635
|
+
if isinstance(opt, dict):
|
|
636
|
+
for group in opt.values():
|
|
637
|
+
for dep in group or []:
|
|
638
|
+
name = _pep508_name(dep)
|
|
639
|
+
if name:
|
|
640
|
+
targets.append(("pypi", name))
|
|
641
|
+
|
|
642
|
+
# Poetry: [tool.poetry.dependencies] (table: name = version)
|
|
643
|
+
poetry = ((data.get("tool") or {}).get("poetry") or {})
|
|
644
|
+
if isinstance(poetry, dict):
|
|
645
|
+
tables = [poetry.get("dependencies"), poetry.get("dev-dependencies")]
|
|
646
|
+
groups = poetry.get("group") or {}
|
|
647
|
+
if isinstance(groups, dict):
|
|
648
|
+
for g in groups.values():
|
|
649
|
+
if isinstance(g, dict):
|
|
650
|
+
tables.append(g.get("dependencies"))
|
|
651
|
+
for table in tables:
|
|
652
|
+
if not isinstance(table, dict):
|
|
653
|
+
continue
|
|
654
|
+
for name in table:
|
|
655
|
+
if not isinstance(name, str) or name.lower() == "python":
|
|
656
|
+
continue
|
|
657
|
+
if PY_NAME_RE.match(name):
|
|
658
|
+
targets.append(("pypi", name))
|
|
659
|
+
return targets
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _parse_package_lock(text):
|
|
663
|
+
"""Parse npm package-lock.json v2/v3 -> list of (npm, name) targets."""
|
|
664
|
+
targets = []
|
|
665
|
+
try:
|
|
666
|
+
data = json.loads(text)
|
|
667
|
+
except Exception:
|
|
668
|
+
return targets
|
|
669
|
+
packages = data.get("packages")
|
|
670
|
+
if not isinstance(packages, dict):
|
|
671
|
+
return targets
|
|
672
|
+
for key in packages:
|
|
673
|
+
if not key.startswith("node_modules/"):
|
|
674
|
+
continue
|
|
675
|
+
inner = key[len("node_modules/"):]
|
|
676
|
+
if "/node_modules/" in inner:
|
|
677
|
+
continue # nested dep
|
|
678
|
+
if inner.startswith("@"):
|
|
679
|
+
if inner.count("/") != 1:
|
|
680
|
+
continue # not @scope/name
|
|
681
|
+
elif "/" in inner:
|
|
682
|
+
continue # nested path
|
|
683
|
+
if NPM_NAME_RE.match(inner):
|
|
684
|
+
targets.append(("npm", inner))
|
|
685
|
+
return targets
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _parse_yarn_lock(text):
|
|
689
|
+
"""Parse yarn.lock v1 -> list of (npm, name) targets."""
|
|
690
|
+
targets, seen = [], set()
|
|
691
|
+
for line in text.splitlines():
|
|
692
|
+
if line.startswith(" ") or line.startswith("\t") or not line.strip():
|
|
693
|
+
continue
|
|
694
|
+
if line.startswith("#"):
|
|
695
|
+
continue
|
|
696
|
+
# Entry header: 'express@^4.0.0:' or '"@scope/pkg@^1.0.0, @scope/pkg@^2.0.0":'
|
|
697
|
+
line = line.rstrip(":").strip().strip('"')
|
|
698
|
+
for entry in line.split(", "):
|
|
699
|
+
entry = entry.strip().strip('"')
|
|
700
|
+
if not entry or "@" not in entry:
|
|
701
|
+
continue
|
|
702
|
+
if entry.startswith("@"):
|
|
703
|
+
at = entry.find("@", 1)
|
|
704
|
+
name = entry[:at] if at > 0 else entry
|
|
705
|
+
else:
|
|
706
|
+
name = entry.split("@")[0]
|
|
707
|
+
name = name.strip()
|
|
708
|
+
if name and name not in seen and NPM_NAME_RE.match(name):
|
|
709
|
+
seen.add(name)
|
|
710
|
+
targets.append(("npm", name))
|
|
711
|
+
return targets
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _parse_poetry_lock(text):
|
|
715
|
+
"""Parse poetry.lock (TOML) -> list of (pypi, name) targets."""
|
|
716
|
+
targets = []
|
|
717
|
+
try:
|
|
718
|
+
import tomllib
|
|
719
|
+
except ImportError:
|
|
720
|
+
return targets
|
|
721
|
+
try:
|
|
722
|
+
data = tomllib.loads(text)
|
|
723
|
+
except Exception:
|
|
724
|
+
return targets
|
|
725
|
+
for pkg in data.get("package") or []:
|
|
726
|
+
name = pkg.get("name")
|
|
727
|
+
if name and PY_NAME_RE.match(name):
|
|
728
|
+
targets.append(("pypi", name))
|
|
729
|
+
return targets
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _npm_prefix_dir(args, cwd):
|
|
733
|
+
"""Honor --prefix DIR / -C DIR so we read the right package.json."""
|
|
734
|
+
for i, a in enumerate(args):
|
|
735
|
+
if a in ("--prefix", "-C", "--cwd") and i + 1 < len(args):
|
|
736
|
+
return _abs(args[i + 1], cwd)
|
|
737
|
+
for flag in ("--prefix=", "-C=", "--cwd="):
|
|
738
|
+
if a.startswith(flag):
|
|
739
|
+
return _abs(a[len(flag):], cwd)
|
|
740
|
+
return cwd
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _requirement_files(args, cwd):
|
|
744
|
+
files, i = [], 0
|
|
745
|
+
while i < len(args):
|
|
746
|
+
a = args[i]
|
|
747
|
+
if a in ("-r", "--requirement"):
|
|
748
|
+
if i + 1 < len(args):
|
|
749
|
+
files.append(_abs(args[i + 1], cwd))
|
|
750
|
+
i += 2
|
|
751
|
+
continue
|
|
752
|
+
elif a.startswith("--requirement="):
|
|
753
|
+
files.append(_abs(a.split("=", 1)[1], cwd))
|
|
754
|
+
elif a.startswith("-r") and len(a) > 2:
|
|
755
|
+
files.append(_abs(a[2:], cwd))
|
|
756
|
+
i += 1
|
|
757
|
+
return files
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _manifest_file(path, parser):
|
|
761
|
+
text = _read_text(path)
|
|
762
|
+
return parser(text) if text else []
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def parse_manifest_targets(command, cwd, _depth=0):
|
|
766
|
+
"""Collect packages declared in a manifest that a bare install would pull."""
|
|
767
|
+
if not cwd:
|
|
768
|
+
cwd = os.getcwd()
|
|
769
|
+
toks = tokenize(command)
|
|
770
|
+
out = []
|
|
771
|
+
for tokens in split_segments(toks):
|
|
772
|
+
tokens = strip_prefix(tokens)
|
|
773
|
+
if not tokens:
|
|
774
|
+
continue
|
|
775
|
+
mgr, rest = tokens[0], tokens[1:]
|
|
776
|
+
|
|
777
|
+
if mgr in SHELLS and _depth < 2:
|
|
778
|
+
for t in rest:
|
|
779
|
+
if " " in t:
|
|
780
|
+
out.extend(parse_manifest_targets(t, cwd, _depth + 1))
|
|
781
|
+
continue
|
|
782
|
+
|
|
783
|
+
if mgr in ("python", "python3") and len(rest) >= 2 and rest[0] == "-m" and rest[1] == "pip":
|
|
784
|
+
mgr, rest = "pip", rest[2:]
|
|
785
|
+
|
|
786
|
+
# npm ci reads package-lock.json, not package.json
|
|
787
|
+
if mgr == "npm" and rest and rest[0] == "ci":
|
|
788
|
+
pkg_dir = _npm_prefix_dir(rest[1:], cwd)
|
|
789
|
+
out += _manifest_file(os.path.join(pkg_dir, "package-lock.json"), _parse_package_lock)
|
|
790
|
+
continue
|
|
791
|
+
|
|
792
|
+
# JS bare install -> read package.json (only when no direct package arg)
|
|
793
|
+
if mgr in JS_MANAGERS:
|
|
794
|
+
is_install = (not rest and mgr == "yarn") or (rest and rest[0] in JS_INSTALL_VERBS[mgr])
|
|
795
|
+
if is_install:
|
|
796
|
+
direct = []
|
|
797
|
+
collect_js(rest[1:] if rest else [], direct, NPM_VALUE_FLAGS)
|
|
798
|
+
if not direct: # direct args are handled by parse_install_targets
|
|
799
|
+
pkg_dir = _npm_prefix_dir(rest[1:] if rest else [], cwd)
|
|
800
|
+
out += _manifest_file(os.path.join(pkg_dir, "package.json"), _parse_package_json)
|
|
801
|
+
if mgr == "yarn":
|
|
802
|
+
out += _manifest_file(os.path.join(pkg_dir, "yarn.lock"), _parse_yarn_lock)
|
|
803
|
+
continue
|
|
804
|
+
|
|
805
|
+
if mgr in ("pip", "pip3") and rest and rest[0] == "install":
|
|
806
|
+
for fp in _requirement_files(rest[1:], cwd):
|
|
807
|
+
out += _manifest_file(fp, _parse_requirements)
|
|
808
|
+
continue
|
|
809
|
+
|
|
810
|
+
if mgr == "uv":
|
|
811
|
+
if rest and rest[0] == "sync":
|
|
812
|
+
out += _manifest_file(os.path.join(cwd, "pyproject.toml"), _parse_pyproject)
|
|
813
|
+
elif len(rest) >= 2 and rest[0] == "pip" and rest[1] == "install":
|
|
814
|
+
for fp in _requirement_files(rest[2:], cwd):
|
|
815
|
+
out += _manifest_file(fp, _parse_requirements)
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
if mgr == "poetry" and rest and rest[0] == "install":
|
|
819
|
+
lock = _manifest_file(os.path.join(cwd, "poetry.lock"), _parse_poetry_lock)
|
|
820
|
+
out += lock if lock else _manifest_file(os.path.join(cwd, "pyproject.toml"), _parse_pyproject)
|
|
821
|
+
continue
|
|
822
|
+
|
|
823
|
+
return out
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ---------------------------------------- destructive command check
|
|
827
|
+
|
|
828
|
+
# Matches any rm flag combination that includes -r or -R
|
|
829
|
+
_RM_RECURSIVE_RE = re.compile(r"^-[a-zA-Z]*[rR][a-zA-Z]*$")
|
|
830
|
+
|
|
831
|
+
# Paths that are dangerous at the top level (rm -rf on these = catastrophe)
|
|
832
|
+
_SYSTEM_ROOTS = frozenset({
|
|
833
|
+
"/usr", "/etc", "/bin", "/sbin", "/lib", "/lib64", "/lib32",
|
|
834
|
+
"/boot", "/sys", "/proc", "/dev", "/opt", "/root", "/var", "/snap",
|
|
835
|
+
"/run", "/srv",
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
# Home-dir aliases that mean "delete my entire home"
|
|
839
|
+
_HOME_ALIASES = frozenset({"~", "$HOME", "${HOME}", "$USERPROFILE", "${USERPROFILE}"})
|
|
840
|
+
|
|
841
|
+
_ROOT_GLOB_SUFFIXES = ("/*", "/**", "/{*,.*}")
|
|
842
|
+
|
|
843
|
+
# Git Bash maps Windows drives to /c/, /d/, etc. (single lowercase letter).
|
|
844
|
+
_WIN_DRIVE_RE = re.compile(r"^/([a-z])(/.*)?$", re.I)
|
|
845
|
+
_WIN_SYSTEM_SUBDIRS = frozenset({
|
|
846
|
+
"windows", "program files", "program files (x86)", "programdata",
|
|
847
|
+
})
|
|
848
|
+
# Windows env vars for system dirs (Git Bash expands these as $VAR)
|
|
849
|
+
_WIN_ENV_SYSTEM = frozenset({
|
|
850
|
+
"$systemroot", "${systemroot}", "$windir", "${windir}",
|
|
851
|
+
"$programfiles", "${programfiles}",
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _path_is_dangerous(path):
|
|
856
|
+
"""Return a short description of why this path is dangerous, or None."""
|
|
857
|
+
# Trailing slash does not change what you delete; keep "/" itself intact
|
|
858
|
+
p = path.rstrip("/") or "/"
|
|
859
|
+
|
|
860
|
+
if p == "/":
|
|
861
|
+
return "the filesystem root (/)"
|
|
862
|
+
|
|
863
|
+
if p in _HOME_ALIASES:
|
|
864
|
+
return "your entire home directory (%s)" % p
|
|
865
|
+
|
|
866
|
+
# ~/ with nothing meaningful after it (e.g. "~/" or "~/.")
|
|
867
|
+
if re.match(r"^~/?\.?$", p):
|
|
868
|
+
return "your entire home directory (~)"
|
|
869
|
+
|
|
870
|
+
# Glob at root or home root: /* or ~/* or $HOME/*
|
|
871
|
+
if p in {"/*", "~/*", "$HOME/*", "${HOME}/*"}:
|
|
872
|
+
return "everything under %s" % p.rstrip("*")
|
|
873
|
+
if p in {"~/{*,.*}", "$HOME/{*,.*}", "${HOME}/{*,.*}"}:
|
|
874
|
+
return "everything in your home directory (%s)" % p
|
|
875
|
+
|
|
876
|
+
for suffix in _ROOT_GLOB_SUFFIXES:
|
|
877
|
+
if p.endswith(suffix):
|
|
878
|
+
base = p[:-len(suffix)]
|
|
879
|
+
if base in _SYSTEM_ROOTS:
|
|
880
|
+
return "everything in a critical system directory (%s)" % p
|
|
881
|
+
|
|
882
|
+
# Top-level system directories
|
|
883
|
+
if p in _SYSTEM_ROOTS:
|
|
884
|
+
return "a critical system directory (%s)" % p
|
|
885
|
+
|
|
886
|
+
# /home or /home/<user> (one level: wipes a whole user account)
|
|
887
|
+
if p == "/home":
|
|
888
|
+
return "all user home directories (/home)"
|
|
889
|
+
if re.match(r"^/home/[^/]+$", p):
|
|
890
|
+
return "a user's entire home directory (%s)" % p
|
|
891
|
+
|
|
892
|
+
# /home/<user>/* - glob that empties a home dir
|
|
893
|
+
if re.match(r"^/home/[^/]+/\*$", p):
|
|
894
|
+
return "everything in a home directory (%s)" % p
|
|
895
|
+
if re.match(r"^/home/[^/]+/\{\*,\.\*\}$", p):
|
|
896
|
+
return "everything in a home directory (%s)" % p
|
|
897
|
+
|
|
898
|
+
# Repository metadata - wipes entire git history
|
|
899
|
+
if p == ".git":
|
|
900
|
+
return "the repository's git history (.git)"
|
|
901
|
+
|
|
902
|
+
# Windows env var system dirs (Git Bash: $WINDIR, $SYSTEMROOT, $PROGRAMFILES)
|
|
903
|
+
if p.lower() in _WIN_ENV_SYSTEM:
|
|
904
|
+
return "a critical Windows system directory (%s)" % p
|
|
905
|
+
|
|
906
|
+
# Git Bash Windows drive paths: /c = C:\, /c/Windows = C:\Windows, etc.
|
|
907
|
+
m = _WIN_DRIVE_RE.match(p)
|
|
908
|
+
if m:
|
|
909
|
+
drive = m.group(1).upper()
|
|
910
|
+
rest = (m.group(2) or "").strip("/")
|
|
911
|
+
if not rest:
|
|
912
|
+
return "the Windows %s:\\ drive root" % drive
|
|
913
|
+
parts = [x for x in rest.split("/") if x]
|
|
914
|
+
top = parts[0].lower()
|
|
915
|
+
if top == "users":
|
|
916
|
+
if len(parts) == 1:
|
|
917
|
+
return "all Windows user home directories (%s:\\Users)" % drive
|
|
918
|
+
sub = parts[1]
|
|
919
|
+
if sub == "*":
|
|
920
|
+
return "all Windows user home directories (%s:\\Users\\*)" % drive
|
|
921
|
+
if len(parts) == 2:
|
|
922
|
+
return "a Windows user's entire home directory (%s:\\Users\\%s)" % (drive, sub)
|
|
923
|
+
if top in _WIN_SYSTEM_SUBDIRS:
|
|
924
|
+
return "a critical Windows system directory (%s:\\%s)" % (drive, parts[0])
|
|
925
|
+
|
|
926
|
+
return None
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def check_destructive_rm(command, _depth=0):
|
|
930
|
+
"""Return (decision, reason) if command contains a dangerous rm, else None."""
|
|
931
|
+
toks = tokenize(command)
|
|
932
|
+
|
|
933
|
+
for tokens in split_segments(toks):
|
|
934
|
+
tokens = strip_prefix(tokens)
|
|
935
|
+
if not tokens:
|
|
936
|
+
continue
|
|
937
|
+
|
|
938
|
+
cmd = tokens[0]
|
|
939
|
+
|
|
940
|
+
# Recurse into bash -c "rm -rf ..."
|
|
941
|
+
if cmd in SHELLS and _depth < 2:
|
|
942
|
+
for t in tokens[1:]:
|
|
943
|
+
if " " in t:
|
|
944
|
+
result = check_destructive_rm(t, _depth + 1)
|
|
945
|
+
if result:
|
|
946
|
+
return result
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
if cmd != "rm":
|
|
950
|
+
continue
|
|
951
|
+
|
|
952
|
+
# Parse flags and paths from rm arguments
|
|
953
|
+
has_recursive = False
|
|
954
|
+
paths = []
|
|
955
|
+
end_of_flags = False
|
|
956
|
+
|
|
957
|
+
for t in tokens[1:]:
|
|
958
|
+
if end_of_flags:
|
|
959
|
+
paths.append(t)
|
|
960
|
+
continue
|
|
961
|
+
if t == "--":
|
|
962
|
+
end_of_flags = True
|
|
963
|
+
continue
|
|
964
|
+
if t.startswith("-") and len(t) > 1:
|
|
965
|
+
if _RM_RECURSIVE_RE.match(t):
|
|
966
|
+
has_recursive = True
|
|
967
|
+
else:
|
|
968
|
+
paths.append(t)
|
|
969
|
+
|
|
970
|
+
if not has_recursive:
|
|
971
|
+
continue
|
|
972
|
+
|
|
973
|
+
for path in paths:
|
|
974
|
+
why = _path_is_dangerous(path)
|
|
975
|
+
if why:
|
|
976
|
+
return ("deny",
|
|
977
|
+
"FailSafe blocked a destructive command:\n\n"
|
|
978
|
+
" rm -rf %s\n\n"
|
|
979
|
+
"This would permanently delete %s. "
|
|
980
|
+
"If you are certain this is correct, run it yourself outside the agent." % (path, why))
|
|
981
|
+
|
|
982
|
+
return None
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
# ------------------------------------------ curl-pipe-shell guard
|
|
986
|
+
|
|
987
|
+
_FETCHERS = frozenset({"curl", "wget", "fetch", "aria2c", "http", "lwp-download"})
|
|
988
|
+
_PIPE_EXECUTORS = frozenset({
|
|
989
|
+
"bash", "sh", "zsh", "dash", "ksh",
|
|
990
|
+
"python", "python3", "ruby", "perl", "node", "nodejs",
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _segments_with_ops(toks):
|
|
995
|
+
"""Like split_segments but preserves the connecting operator per segment."""
|
|
996
|
+
result, cur, op = [], [], None
|
|
997
|
+
for t in toks:
|
|
998
|
+
if t in OPERATORS:
|
|
999
|
+
if cur:
|
|
1000
|
+
result.append((op, cur))
|
|
1001
|
+
cur, op = [], t
|
|
1002
|
+
else:
|
|
1003
|
+
cur.append(t)
|
|
1004
|
+
if cur:
|
|
1005
|
+
result.append((op, cur))
|
|
1006
|
+
return result
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _fetcher_url(args):
|
|
1010
|
+
for a in args:
|
|
1011
|
+
if a.startswith("http://") or a.startswith("https://"):
|
|
1012
|
+
return a
|
|
1013
|
+
return None
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def check_curl_pipe_shell(command, _depth=0):
|
|
1017
|
+
"""Return (decision, reason) if command pipes a remote fetch into a shell."""
|
|
1018
|
+
toks = tokenize(command)
|
|
1019
|
+
segs = _segments_with_ops(toks)
|
|
1020
|
+
|
|
1021
|
+
for i, (_, seg) in enumerate(segs):
|
|
1022
|
+
seg_s = strip_prefix(seg)
|
|
1023
|
+
if not seg_s or seg_s[0] not in _FETCHERS:
|
|
1024
|
+
continue
|
|
1025
|
+
if i + 1 >= len(segs):
|
|
1026
|
+
continue
|
|
1027
|
+
next_op, next_seg = segs[i + 1]
|
|
1028
|
+
if next_op != "|":
|
|
1029
|
+
continue
|
|
1030
|
+
next_s = strip_prefix(next_seg)
|
|
1031
|
+
if not next_s or next_s[0] not in _PIPE_EXECUTORS:
|
|
1032
|
+
continue
|
|
1033
|
+
|
|
1034
|
+
fetcher, executor = seg_s[0], next_s[0]
|
|
1035
|
+
url = _fetcher_url(seg_s[1:])
|
|
1036
|
+
|
|
1037
|
+
if url and url.startswith("http://"):
|
|
1038
|
+
return ("deny",
|
|
1039
|
+
"FailSafe blocked a remote script over plain HTTP:\n\n"
|
|
1040
|
+
" %s ... | %s\n\n"
|
|
1041
|
+
"Fetching over unencrypted HTTP lets a network attacker intercept "
|
|
1042
|
+
"and replace the script before it runs. "
|
|
1043
|
+
"If you need this script, switch to HTTPS and inspect it first." % (fetcher, executor))
|
|
1044
|
+
|
|
1045
|
+
return ("ask",
|
|
1046
|
+
"FailSafe flagged a remote script execution:\n\n"
|
|
1047
|
+
" %s ... | %s\n\n"
|
|
1048
|
+
"This downloads and immediately executes remote code without inspection. "
|
|
1049
|
+
"Review the script source before running it." % (fetcher, executor))
|
|
1050
|
+
|
|
1051
|
+
if _depth < 2:
|
|
1052
|
+
for _, seg in segs:
|
|
1053
|
+
seg_s = strip_prefix(seg)
|
|
1054
|
+
if seg_s and seg_s[0] in SHELLS:
|
|
1055
|
+
for t in seg_s[1:]:
|
|
1056
|
+
if " " in t:
|
|
1057
|
+
result = check_curl_pipe_shell(t, _depth + 1)
|
|
1058
|
+
if result:
|
|
1059
|
+
return result
|
|
1060
|
+
return None
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
# ---------------------------------------------- git disaster guard
|
|
1064
|
+
|
|
1065
|
+
_GIT_PROTECTED_BRANCHES = frozenset({
|
|
1066
|
+
"main", "master", "production", "release", "prod", "staging",
|
|
1067
|
+
})
|
|
1068
|
+
_GIT_FORCE_FLAGS = frozenset({"--force", "-f", "--force-with-lease", "--force-if-includes"})
|
|
1069
|
+
_GIT_SKIP_VALUE_FLAGS = frozenset({
|
|
1070
|
+
"--receive-pack", "--exec", "--repo", "-o", "--push-option",
|
|
1071
|
+
"--recurse-submodules", "--signed",
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _git_push_branch(subargs):
|
|
1076
|
+
"""Return the destination branch from git push args, or None."""
|
|
1077
|
+
skip_next = False
|
|
1078
|
+
non_flag = []
|
|
1079
|
+
for a in subargs:
|
|
1080
|
+
if skip_next:
|
|
1081
|
+
skip_next = False
|
|
1082
|
+
continue
|
|
1083
|
+
if a in _GIT_SKIP_VALUE_FLAGS:
|
|
1084
|
+
skip_next = True
|
|
1085
|
+
continue
|
|
1086
|
+
if a.startswith("-"):
|
|
1087
|
+
continue
|
|
1088
|
+
non_flag.append(a)
|
|
1089
|
+
# non_flag: [remote, refspec, ...] refspec may be src:dst or branch
|
|
1090
|
+
for token in non_flag[1:]:
|
|
1091
|
+
dst = token.split(":")[-1].lstrip("+")
|
|
1092
|
+
if dst.startswith("refs/heads/"):
|
|
1093
|
+
dst = dst[len("refs/heads/"):]
|
|
1094
|
+
if dst:
|
|
1095
|
+
return dst
|
|
1096
|
+
return None
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def check_git_disaster(command, _depth=0):
|
|
1100
|
+
"""Return (decision, reason) for dangerous git operations, or None."""
|
|
1101
|
+
toks = tokenize(command)
|
|
1102
|
+
for tokens in split_segments(toks):
|
|
1103
|
+
tokens = strip_prefix(tokens)
|
|
1104
|
+
if not tokens:
|
|
1105
|
+
continue
|
|
1106
|
+
cmd = tokens[0]
|
|
1107
|
+
if cmd in SHELLS and _depth < 2:
|
|
1108
|
+
for t in tokens[1:]:
|
|
1109
|
+
if " " in t:
|
|
1110
|
+
result = check_git_disaster(t, _depth + 1)
|
|
1111
|
+
if result:
|
|
1112
|
+
return result
|
|
1113
|
+
continue
|
|
1114
|
+
if cmd != "git":
|
|
1115
|
+
continue
|
|
1116
|
+
|
|
1117
|
+
rest = tokens[1:]
|
|
1118
|
+
# Skip global git options like -C <dir>
|
|
1119
|
+
i = 0
|
|
1120
|
+
while i < len(rest):
|
|
1121
|
+
if rest[i] in ("-C", "--git-dir", "--work-tree", "--namespace") and i + 1 < len(rest):
|
|
1122
|
+
i += 2
|
|
1123
|
+
elif rest[i].startswith("-"):
|
|
1124
|
+
i += 1
|
|
1125
|
+
else:
|
|
1126
|
+
break
|
|
1127
|
+
if i >= len(rest):
|
|
1128
|
+
continue
|
|
1129
|
+
subcmd, subargs = rest[i], rest[i + 1:]
|
|
1130
|
+
|
|
1131
|
+
if subcmd == "reset" and ("--hard" in subargs or "--merge" in subargs):
|
|
1132
|
+
return ("ask",
|
|
1133
|
+
"FailSafe flagged a potentially destructive git operation:\n\n"
|
|
1134
|
+
" git reset %s\n\n"
|
|
1135
|
+
"This discards all uncommitted changes and cannot be undone. "
|
|
1136
|
+
"Confirm this is intentional." % " ".join(subargs))
|
|
1137
|
+
|
|
1138
|
+
if subcmd == "clean":
|
|
1139
|
+
flags_combined = "".join(
|
|
1140
|
+
a[1:] for a in subargs if re.match(r"^-[a-zA-Z]+$", a)
|
|
1141
|
+
)
|
|
1142
|
+
if "f" in flags_combined and "n" not in flags_combined:
|
|
1143
|
+
return ("ask",
|
|
1144
|
+
"FailSafe flagged a potentially destructive git operation:\n\n"
|
|
1145
|
+
" git clean %s\n\n"
|
|
1146
|
+
"This permanently deletes untracked files and cannot be undone. "
|
|
1147
|
+
"Confirm this is intentional." % " ".join(subargs))
|
|
1148
|
+
|
|
1149
|
+
if subcmd == "push":
|
|
1150
|
+
force_flags = [a for a in subargs if a in _GIT_FORCE_FLAGS
|
|
1151
|
+
or a.startswith("--force-with-lease=")]
|
|
1152
|
+
plus_refspecs = [a for a in subargs
|
|
1153
|
+
if not a.startswith("-") and a.startswith("+")]
|
|
1154
|
+
if force_flags or plus_refspecs:
|
|
1155
|
+
cfg_branches = _CONFIG.get("protected_branches")
|
|
1156
|
+
protected = (
|
|
1157
|
+
frozenset(b.lower() for b in cfg_branches if isinstance(b, str))
|
|
1158
|
+
if isinstance(cfg_branches, list)
|
|
1159
|
+
else _GIT_PROTECTED_BRANCHES
|
|
1160
|
+
)
|
|
1161
|
+
branch = _git_push_branch(subargs)
|
|
1162
|
+
if branch and branch.lower() in protected:
|
|
1163
|
+
return ("deny",
|
|
1164
|
+
"FailSafe blocked a force push to a protected branch:\n\n"
|
|
1165
|
+
" git push %s\n\n"
|
|
1166
|
+
"Force pushing to '%s' permanently overwrites remote history "
|
|
1167
|
+
"and affects all collaborators. "
|
|
1168
|
+
"If you are certain, run this yourself outside the agent." % (
|
|
1169
|
+
" ".join(subargs), branch))
|
|
1170
|
+
return ("ask",
|
|
1171
|
+
"FailSafe flagged a force push:\n\n"
|
|
1172
|
+
" git push %s\n\n"
|
|
1173
|
+
"Force pushing rewrites remote history. "
|
|
1174
|
+
"Confirm the target branch and that this is intentional." % " ".join(subargs))
|
|
1175
|
+
|
|
1176
|
+
if subcmd == "branch":
|
|
1177
|
+
flags = [a for a in subargs if a.startswith("-")]
|
|
1178
|
+
branches = [a for a in subargs if not a.startswith("-")]
|
|
1179
|
+
flag_chars = "".join(a[1:] for a in flags if re.match(r"^-[a-zA-Z]+$", a))
|
|
1180
|
+
long_flags = set(flags)
|
|
1181
|
+
is_force_delete = (
|
|
1182
|
+
"D" in flag_chars
|
|
1183
|
+
or ("--delete" in long_flags and ("--force" in long_flags or "f" in flag_chars))
|
|
1184
|
+
or ("-d" in flags and ("--force" in long_flags or "f" in flag_chars))
|
|
1185
|
+
)
|
|
1186
|
+
if is_force_delete and branches:
|
|
1187
|
+
return ("ask",
|
|
1188
|
+
"FailSafe flagged a force branch deletion:\n\n"
|
|
1189
|
+
" git branch -D %s\n\n"
|
|
1190
|
+
"This deletes the branch even if it has unmerged commits. "
|
|
1191
|
+
"Confirm this is intentional." % " ".join(branches))
|
|
1192
|
+
|
|
1193
|
+
return None
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
# ------------------------------------------ cloud / infra blast-radius guard
|
|
1197
|
+
|
|
1198
|
+
_CLOUD_ASK_TMPL = (
|
|
1199
|
+
"FailSafe flagged a potentially destructive infrastructure command:\n\n"
|
|
1200
|
+
" %s\n\n"
|
|
1201
|
+
"%s\n\n"
|
|
1202
|
+
"Confirm this is intentional before proceeding."
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def check_cloud_infra(command, _depth=0):
|
|
1207
|
+
"""Return (decision, reason) for high-blast-radius cloud/infra commands."""
|
|
1208
|
+
toks = tokenize(command)
|
|
1209
|
+
for tokens in split_segments(toks):
|
|
1210
|
+
tokens = strip_prefix(tokens)
|
|
1211
|
+
if not tokens:
|
|
1212
|
+
continue
|
|
1213
|
+
cmd = tokens[0]
|
|
1214
|
+
if cmd in SHELLS and _depth < 2:
|
|
1215
|
+
for t in tokens[1:]:
|
|
1216
|
+
if " " in t:
|
|
1217
|
+
result = check_cloud_infra(t, _depth + 1)
|
|
1218
|
+
if result:
|
|
1219
|
+
return result
|
|
1220
|
+
continue
|
|
1221
|
+
rest = tokens[1:]
|
|
1222
|
+
|
|
1223
|
+
if cmd == "terraform" and rest and rest[0] == "destroy":
|
|
1224
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1225
|
+
"terraform destroy",
|
|
1226
|
+
"This tears down all infrastructure managed by this Terraform workspace."))
|
|
1227
|
+
|
|
1228
|
+
if cmd == "kubectl" and rest and rest[0] == "delete":
|
|
1229
|
+
args = rest[1:]
|
|
1230
|
+
frag = "kubectl delete " + " ".join(args)
|
|
1231
|
+
if any(a in ("namespace", "ns") for a in args):
|
|
1232
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1233
|
+
frag, "This deletes an entire Kubernetes namespace and every resource in it."))
|
|
1234
|
+
if "--all" in args or "-A" in args or "--all-namespaces" in args:
|
|
1235
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1236
|
+
frag, "This deletes all matching Kubernetes resources."))
|
|
1237
|
+
|
|
1238
|
+
if cmd == "docker":
|
|
1239
|
+
if (len(rest) >= 2 and rest[0] == "system" and rest[1] == "prune"
|
|
1240
|
+
and ("--all" in rest or "-a" in rest)):
|
|
1241
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1242
|
+
"docker system prune -a",
|
|
1243
|
+
"This removes all unused images, containers, networks, and build cache."))
|
|
1244
|
+
if len(rest) >= 2 and rest[0] == "volume" and rest[1] in ("rm", "remove"):
|
|
1245
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1246
|
+
"docker volume rm " + " ".join(rest[2:]),
|
|
1247
|
+
"This permanently removes Docker volumes and their stored data."))
|
|
1248
|
+
|
|
1249
|
+
if (cmd == "aws" and len(rest) >= 2 and rest[0] == "s3"
|
|
1250
|
+
and rest[1] in ("rm", "sync", "mv")
|
|
1251
|
+
and ("--recursive" in rest or "--delete" in rest)):
|
|
1252
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1253
|
+
"aws s3 " + " ".join(rest[1:]),
|
|
1254
|
+
"This recursively deletes or overwrites S3 objects and cannot be undone."))
|
|
1255
|
+
|
|
1256
|
+
if cmd == "gcloud" and len(rest) >= 2 and rest[0] == "projects" and rest[1] == "delete":
|
|
1257
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1258
|
+
"gcloud projects delete " + " ".join(rest[2:]),
|
|
1259
|
+
"This schedules a GCP project for deletion, removing all its resources."))
|
|
1260
|
+
|
|
1261
|
+
if cmd == "az" and len(rest) >= 2 and rest[0] == "group" and rest[1] == "delete":
|
|
1262
|
+
return ("ask", _CLOUD_ASK_TMPL % (
|
|
1263
|
+
"az group delete " + " ".join(rest[2:]),
|
|
1264
|
+
"This deletes an Azure resource group and all resources inside it."))
|
|
1265
|
+
|
|
1266
|
+
return None
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# ------------------------------------------ secrets exfiltration guard
|
|
1270
|
+
|
|
1271
|
+
_SENSITIVE_RE = [
|
|
1272
|
+
re.compile(r"(^|[\\/])\.env(\.[a-zA-Z]+)?$"),
|
|
1273
|
+
re.compile(r"(^|[\\/])\.ssh([\\/]|$)"),
|
|
1274
|
+
re.compile(r"\.(pem|key|p12|pfx|crt|cer|jks)$", re.I),
|
|
1275
|
+
re.compile(r"(^|[\\/])(id_rsa|id_ed25519|id_ecdsa|id_dsa)(\.pub)?$"),
|
|
1276
|
+
re.compile(r"(^|[\\/])(\.netrc|\.npmrc|\.pypirc)$"),
|
|
1277
|
+
re.compile(r"(^|[\\/])\.aws[\\/]credentials$"),
|
|
1278
|
+
]
|
|
1279
|
+
|
|
1280
|
+
_NETWORK_SENDERS = frozenset({
|
|
1281
|
+
"curl", "wget", "scp", "rsync", "nc", "netcat", "ncat", "socat",
|
|
1282
|
+
"ftp", "sftp", "aws", "gcloud", "gsutil",
|
|
1283
|
+
})
|
|
1284
|
+
_FILE_READERS = frozenset({"cat", "head", "tail", "tee"})
|
|
1285
|
+
_CURL_DATA_FLAGS = frozenset({
|
|
1286
|
+
"-d", "--data", "--data-binary", "--data-raw", "--data-ascii",
|
|
1287
|
+
"--data-urlencode", "-F", "--form", "--upload-file", "-T",
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def _is_sensitive(path):
|
|
1292
|
+
for pat in _SENSITIVE_RE:
|
|
1293
|
+
if pat.search(path):
|
|
1294
|
+
return True
|
|
1295
|
+
return False
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def _curl_sensitive_file(args):
|
|
1299
|
+
"""Return sensitive filename if curl is reading one via @file or -T."""
|
|
1300
|
+
skip_next = False
|
|
1301
|
+
for i, a in enumerate(args):
|
|
1302
|
+
if skip_next:
|
|
1303
|
+
skip_next = False
|
|
1304
|
+
continue
|
|
1305
|
+
if a in _CURL_DATA_FLAGS:
|
|
1306
|
+
skip_next = True
|
|
1307
|
+
if i + 1 < len(args):
|
|
1308
|
+
val = args[i + 1]
|
|
1309
|
+
# -F name=@file or -d @file
|
|
1310
|
+
f = val.split("=@", 1)[1] if "=@" in val else val.lstrip("@")
|
|
1311
|
+
if _is_sensitive(f):
|
|
1312
|
+
return f
|
|
1313
|
+
continue
|
|
1314
|
+
if "=" in a:
|
|
1315
|
+
key, _, val = a.partition("=")
|
|
1316
|
+
if key in _CURL_DATA_FLAGS:
|
|
1317
|
+
val = val.lstrip("@")
|
|
1318
|
+
if _is_sensitive(val):
|
|
1319
|
+
return val
|
|
1320
|
+
return None
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def check_secrets_exfil(command, _depth=0):
|
|
1324
|
+
"""Return (decision, reason) if command appears to leak sensitive files."""
|
|
1325
|
+
toks = tokenize(command)
|
|
1326
|
+
segs = _segments_with_ops(toks)
|
|
1327
|
+
|
|
1328
|
+
# Pattern 1: cat/head/tail <sensitive> | <network_cmd>
|
|
1329
|
+
for i, (_, seg) in enumerate(segs):
|
|
1330
|
+
seg_s = strip_prefix(seg)
|
|
1331
|
+
if not seg_s or seg_s[0] not in _FILE_READERS:
|
|
1332
|
+
continue
|
|
1333
|
+
sensitive = [a for a in seg_s[1:] if not a.startswith("-") and _is_sensitive(a)]
|
|
1334
|
+
if not sensitive:
|
|
1335
|
+
continue
|
|
1336
|
+
for j in range(i + 1, len(segs)):
|
|
1337
|
+
jop, jseg = segs[j]
|
|
1338
|
+
if jop != "|":
|
|
1339
|
+
break
|
|
1340
|
+
jseg_s = strip_prefix(jseg)
|
|
1341
|
+
if jseg_s and jseg_s[0] in _NETWORK_SENDERS:
|
|
1342
|
+
return ("ask",
|
|
1343
|
+
"FailSafe flagged a possible secrets leak:\n\n"
|
|
1344
|
+
" %s\n\n"
|
|
1345
|
+
"A sensitive file (%s) is being piped into a network command. "
|
|
1346
|
+
"Confirm this is intentional." % (" ".join(toks), sensitive[0]))
|
|
1347
|
+
|
|
1348
|
+
# Pattern 2: curl/wget -d @<sensitive>
|
|
1349
|
+
for _, seg in segs:
|
|
1350
|
+
seg_s = strip_prefix(seg)
|
|
1351
|
+
if not seg_s or seg_s[0] not in {"curl", "wget", "http"}:
|
|
1352
|
+
continue
|
|
1353
|
+
f = _curl_sensitive_file(seg_s[1:])
|
|
1354
|
+
if f:
|
|
1355
|
+
return ("ask",
|
|
1356
|
+
"FailSafe flagged a possible secrets leak:\n\n"
|
|
1357
|
+
" %s\n\n"
|
|
1358
|
+
"A sensitive file (%s) is being sent to a remote server. "
|
|
1359
|
+
"Confirm this is intentional." % (" ".join(toks), f))
|
|
1360
|
+
|
|
1361
|
+
# Pattern 3a: tar <flags> <sensitive> | <network_cmd>
|
|
1362
|
+
for i, (_, seg) in enumerate(segs):
|
|
1363
|
+
seg_s = strip_prefix(seg)
|
|
1364
|
+
if not seg_s or seg_s[0] != "tar":
|
|
1365
|
+
continue
|
|
1366
|
+
sensitive = [a for a in seg_s[1:]
|
|
1367
|
+
if a != "-" and not a.startswith("-") and _is_sensitive(a)]
|
|
1368
|
+
if not sensitive:
|
|
1369
|
+
continue
|
|
1370
|
+
for j in range(i + 1, len(segs)):
|
|
1371
|
+
jop, jseg = segs[j]
|
|
1372
|
+
if jop != "|":
|
|
1373
|
+
break
|
|
1374
|
+
jseg_s = strip_prefix(jseg)
|
|
1375
|
+
if jseg_s and jseg_s[0] in _NETWORK_SENDERS:
|
|
1376
|
+
return ("ask",
|
|
1377
|
+
"FailSafe flagged a possible secrets leak:\n\n"
|
|
1378
|
+
" %s\n\n"
|
|
1379
|
+
"Sensitive files (%s) are being archived and piped to a network command. "
|
|
1380
|
+
"Confirm this is intentional." % (" ".join(toks), sensitive[0]))
|
|
1381
|
+
|
|
1382
|
+
# Pattern 3: scp/rsync <sensitive> <remote:path>
|
|
1383
|
+
for _, seg in segs:
|
|
1384
|
+
seg_s = strip_prefix(seg)
|
|
1385
|
+
if not seg_s or seg_s[0] not in {"scp", "rsync"}:
|
|
1386
|
+
continue
|
|
1387
|
+
non_flags = [a for a in seg_s[1:] if not a.startswith("-")]
|
|
1388
|
+
if len(non_flags) >= 2:
|
|
1389
|
+
src, dst = non_flags[0], non_flags[-1]
|
|
1390
|
+
if ":" in dst and _is_sensitive(src):
|
|
1391
|
+
return ("ask",
|
|
1392
|
+
"FailSafe flagged a possible secrets leak:\n\n"
|
|
1393
|
+
" %s\n\n"
|
|
1394
|
+
"A sensitive file (%s) is being copied to a remote destination. "
|
|
1395
|
+
"Confirm this is intentional." % (" ".join(toks), src))
|
|
1396
|
+
|
|
1397
|
+
# Pattern 4: aws s3 cp/mv <sensitive> s3://...
|
|
1398
|
+
for _, seg in segs:
|
|
1399
|
+
seg_s = strip_prefix(seg)
|
|
1400
|
+
if not seg_s or seg_s[0] != "aws":
|
|
1401
|
+
continue
|
|
1402
|
+
rest = seg_s[1:]
|
|
1403
|
+
if len(rest) < 2 or rest[0] != "s3" or rest[1] not in ("cp", "mv"):
|
|
1404
|
+
continue
|
|
1405
|
+
non_flags = [a for a in rest[2:] if not a.startswith("-")]
|
|
1406
|
+
if len(non_flags) >= 2:
|
|
1407
|
+
src, dst = non_flags[0], non_flags[-1]
|
|
1408
|
+
if dst.startswith("s3://") and _is_sensitive(src):
|
|
1409
|
+
return ("ask",
|
|
1410
|
+
"FailSafe flagged a possible secrets leak:\n\n"
|
|
1411
|
+
" %s\n\n"
|
|
1412
|
+
"A sensitive file (%s) is being uploaded to S3. "
|
|
1413
|
+
"Confirm this is intentional." % (" ".join(toks), src))
|
|
1414
|
+
|
|
1415
|
+
if _depth < 2:
|
|
1416
|
+
for _, seg in segs:
|
|
1417
|
+
seg_s = strip_prefix(seg)
|
|
1418
|
+
if seg_s and seg_s[0] in SHELLS:
|
|
1419
|
+
for t in seg_s[1:]:
|
|
1420
|
+
if " " in t:
|
|
1421
|
+
result = check_secrets_exfil(t, _depth + 1)
|
|
1422
|
+
if result:
|
|
1423
|
+
return result
|
|
1424
|
+
|
|
1425
|
+
return None
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
# ----------------------------------------------------- registry lookups
|
|
1429
|
+
def parse_dt(s):
|
|
1430
|
+
if not s:
|
|
1431
|
+
return None
|
|
1432
|
+
try:
|
|
1433
|
+
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
1434
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
1435
|
+
except Exception:
|
|
1436
|
+
return None
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def age_days(dt):
|
|
1440
|
+
if not dt:
|
|
1441
|
+
return None
|
|
1442
|
+
return (datetime.now(timezone.utc) - dt).total_seconds() / 86400.0
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def npm_encode(name):
|
|
1446
|
+
return name.replace("/", "%2F") if name.startswith("@") else urllib.parse.quote(name)
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def check_npm(name):
|
|
1450
|
+
status, data = http_get_json("https://registry.npmjs.org/" + npm_encode(name))
|
|
1451
|
+
if status == 404:
|
|
1452
|
+
return {"exists": False}
|
|
1453
|
+
if not data:
|
|
1454
|
+
return {"exists": None}
|
|
1455
|
+
created = parse_dt((data.get("time") or {}).get("created"))
|
|
1456
|
+
downloads = None
|
|
1457
|
+
_, dd = http_get_json("https://api.npmjs.org/downloads/point/last-month/" + npm_encode(name))
|
|
1458
|
+
if dd and isinstance(dd.get("downloads"), int):
|
|
1459
|
+
downloads = dd["downloads"]
|
|
1460
|
+
return {"exists": True, "age_days": age_days(created), "downloads": downloads}
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def check_pypi(name):
|
|
1464
|
+
status, data = http_get_json("https://pypi.org/pypi/" + urllib.parse.quote(name) + "/json")
|
|
1465
|
+
if status == 404:
|
|
1466
|
+
return {"exists": False}
|
|
1467
|
+
if not data:
|
|
1468
|
+
return {"exists": None}
|
|
1469
|
+
earliest = None
|
|
1470
|
+
for files in (data.get("releases") or {}).values():
|
|
1471
|
+
for f in files or []:
|
|
1472
|
+
dt = parse_dt(f.get("upload_time_iso_8601") or f.get("upload_time"))
|
|
1473
|
+
if dt and (earliest is None or dt < earliest):
|
|
1474
|
+
earliest = dt
|
|
1475
|
+
downloads = None
|
|
1476
|
+
_, dd = http_get_json("https://pypistats.org/api/packages/" + urllib.parse.quote(name.lower()) + "/recent")
|
|
1477
|
+
if dd and isinstance((dd.get("data") or {}).get("last_month"), (int, float)):
|
|
1478
|
+
downloads = int(dd["data"]["last_month"])
|
|
1479
|
+
return {"exists": True, "age_days": age_days(earliest), "downloads": downloads}
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
LOW_CARGO_DOWNLOADS = 500 # recent_downloads threshold (90-day window from crates.io)
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def check_cargo(name):
|
|
1486
|
+
req = urllib.request.Request(
|
|
1487
|
+
"https://crates.io/api/v1/crates/" + urllib.parse.quote(name, safe=""),
|
|
1488
|
+
headers={"User-Agent": "failsafe/0.5 (github.com/MoAz06/FailSafe)"},
|
|
1489
|
+
)
|
|
1490
|
+
try:
|
|
1491
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
|
|
1492
|
+
if getattr(r, "status", 200) != 200:
|
|
1493
|
+
return {"exists": None}
|
|
1494
|
+
data = json.loads(r.read().decode("utf-8", "replace"))
|
|
1495
|
+
except urllib.error.HTTPError as e:
|
|
1496
|
+
return {"exists": False} if e.code == 404 else {"exists": None}
|
|
1497
|
+
except Exception:
|
|
1498
|
+
return {"exists": None}
|
|
1499
|
+
krate = data.get("crate") or {}
|
|
1500
|
+
created = parse_dt(krate.get("created_at"))
|
|
1501
|
+
downloads = krate.get("recent_downloads") or krate.get("downloads")
|
|
1502
|
+
return {"exists": True, "age_days": age_days(created), "downloads": downloads}
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def check_go(module):
|
|
1506
|
+
# Go proxy encoding: uppercase letter X -> !x
|
|
1507
|
+
encoded = re.sub(r"[A-Z]", lambda m: "!" + m.group().lower(), module)
|
|
1508
|
+
url = "https://proxy.golang.org/" + encoded + "/@v/list"
|
|
1509
|
+
req = urllib.request.Request(url, headers={"User-Agent": "failsafe/0.5"})
|
|
1510
|
+
try:
|
|
1511
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
|
|
1512
|
+
return {"exists": getattr(r, "status", 200) == 200}
|
|
1513
|
+
except urllib.error.HTTPError as e:
|
|
1514
|
+
return {"exists": False} if e.code in (404, 410, 451) else {"exists": None}
|
|
1515
|
+
except Exception:
|
|
1516
|
+
return {"exists": None}
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def check_rubygems(name):
|
|
1520
|
+
req = urllib.request.Request(
|
|
1521
|
+
"https://rubygems.org/api/v1/gems/" + urllib.parse.quote(name, safe="") + ".json",
|
|
1522
|
+
headers={"User-Agent": "failsafe/0.9"},
|
|
1523
|
+
)
|
|
1524
|
+
try:
|
|
1525
|
+
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
|
|
1526
|
+
if getattr(r, "status", 200) != 200:
|
|
1527
|
+
return {"exists": None}
|
|
1528
|
+
data = json.loads(r.read().decode("utf-8", "replace"))
|
|
1529
|
+
except urllib.error.HTTPError as e:
|
|
1530
|
+
return {"exists": False} if e.code == 404 else {"exists": None}
|
|
1531
|
+
except Exception:
|
|
1532
|
+
return {"exists": None}
|
|
1533
|
+
downloads = data.get("downloads")
|
|
1534
|
+
created_str = data.get("first_release_created_at") or data.get("version_created_at")
|
|
1535
|
+
return {"exists": True, "age_days": age_days(parse_dt(created_str)), "downloads": downloads}
|
|
1536
|
+
|
|
1537
|
+
|
|
1538
|
+
# --------------------------------------------------- look-alike check
|
|
1539
|
+
def damerau_levenshtein(a, b):
|
|
1540
|
+
"""Optimal string alignment: counts an adjacent transposition as 1."""
|
|
1541
|
+
m, n = len(a), len(b)
|
|
1542
|
+
if abs(m - n) > 1:
|
|
1543
|
+
return 2 # we only care about distance <= 1
|
|
1544
|
+
d = [[0] * (n + 1) for _ in range(m + 1)]
|
|
1545
|
+
for i in range(m + 1):
|
|
1546
|
+
d[i][0] = i
|
|
1547
|
+
for j in range(n + 1):
|
|
1548
|
+
d[0][j] = j
|
|
1549
|
+
for i in range(1, m + 1):
|
|
1550
|
+
for j in range(1, n + 1):
|
|
1551
|
+
cost = 0 if a[i - 1] == b[j - 1] else 1
|
|
1552
|
+
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
|
|
1553
|
+
if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
|
|
1554
|
+
d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1)
|
|
1555
|
+
return d[m][n]
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
def nearest_popular(name, popular):
|
|
1559
|
+
if name in popular:
|
|
1560
|
+
return None
|
|
1561
|
+
for p in popular:
|
|
1562
|
+
if damerau_levenshtein(name, p) == 1:
|
|
1563
|
+
return p
|
|
1564
|
+
return None
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
# ------------------------------------------------------------- evaluate
|
|
1568
|
+
def evaluate(target):
|
|
1569
|
+
ecosystem, name = target
|
|
1570
|
+
|
|
1571
|
+
allowed = _CONFIG.get("allowed_packages")
|
|
1572
|
+
if isinstance(allowed, list) and name.lower() in {a.lower() for a in allowed if isinstance(a, str)}:
|
|
1573
|
+
return ("allow", name, "")
|
|
1574
|
+
|
|
1575
|
+
if ecosystem == "npm":
|
|
1576
|
+
info, registry, popular = check_npm(name), "the npm registry", POPULAR_NPM
|
|
1577
|
+
elif ecosystem == "pypi":
|
|
1578
|
+
info, registry, popular = check_pypi(name), "PyPI", POPULAR_PYPI
|
|
1579
|
+
elif ecosystem == "cargo":
|
|
1580
|
+
info, registry, popular = check_cargo(name), "crates.io", POPULAR_CARGO
|
|
1581
|
+
elif ecosystem == "go":
|
|
1582
|
+
info, registry, popular = check_go(name), "the Go module proxy", set()
|
|
1583
|
+
elif ecosystem == "rubygems":
|
|
1584
|
+
info, registry, popular = check_rubygems(name), "RubyGems", POPULAR_GEMS
|
|
1585
|
+
else:
|
|
1586
|
+
return ("allow", name, "")
|
|
1587
|
+
|
|
1588
|
+
if info["exists"] is False:
|
|
1589
|
+
return ("deny", name, "not found on " + registry)
|
|
1590
|
+
if info["exists"] is not True:
|
|
1591
|
+
return ("allow", name, "")
|
|
1592
|
+
|
|
1593
|
+
reasons = []
|
|
1594
|
+
if popular:
|
|
1595
|
+
look = nearest_popular(name.lower(), popular)
|
|
1596
|
+
if look:
|
|
1597
|
+
reasons.append('one character away from the popular package "%s" (possible look-alike)' % look)
|
|
1598
|
+
dl_threshold = {"cargo": LOW_CARGO_DOWNLOADS, "rubygems": LOW_GEM_DOWNLOADS}.get(ecosystem, LOW_DOWNLOADS)
|
|
1599
|
+
if (info.get("age_days") is not None and info["age_days"] < NEW_PACKAGE_DAYS
|
|
1600
|
+
and info.get("downloads") is not None and info["downloads"] < dl_threshold):
|
|
1601
|
+
reasons.append("first published %d days ago with only ~%d downloads"
|
|
1602
|
+
% (round(info["age_days"]), info["downloads"]))
|
|
1603
|
+
if reasons:
|
|
1604
|
+
return ("ask", name, "; ".join(reasons))
|
|
1605
|
+
return ("allow", name, "")
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _strict(result):
|
|
1609
|
+
"""Upgrade 'ask' -> 'deny' when strict mode is enabled in config."""
|
|
1610
|
+
if result and result[0] == "ask" and _CONFIG.get("strict"):
|
|
1611
|
+
return ("deny", result[1])
|
|
1612
|
+
return result
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def emit(decision, reason):
|
|
1616
|
+
sys.stdout.write(json.dumps({
|
|
1617
|
+
"hookSpecificOutput": {
|
|
1618
|
+
"hookEventName": "PreToolUse",
|
|
1619
|
+
"permissionDecision": decision,
|
|
1620
|
+
"permissionDecisionReason": reason,
|
|
1621
|
+
}
|
|
1622
|
+
}))
|
|
1623
|
+
sys.exit(0)
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def main():
|
|
1627
|
+
try:
|
|
1628
|
+
raw = sys.stdin.read()
|
|
1629
|
+
if not raw:
|
|
1630
|
+
return
|
|
1631
|
+
data = json.loads(raw)
|
|
1632
|
+
if data.get("tool_name") != "Bash":
|
|
1633
|
+
return
|
|
1634
|
+
command = (data.get("tool_input") or {}).get("command")
|
|
1635
|
+
if not command or not isinstance(command, str):
|
|
1636
|
+
return
|
|
1637
|
+
|
|
1638
|
+
# Reload config using the project cwd from the payload.
|
|
1639
|
+
# The module-level _CONFIG was loaded at import using the hook's own
|
|
1640
|
+
# working directory, which may differ from the project being worked on.
|
|
1641
|
+
global _CONFIG
|
|
1642
|
+
cwd = data.get("cwd") or os.getcwd()
|
|
1643
|
+
_CONFIG = _load_config(cwd)
|
|
1644
|
+
|
|
1645
|
+
# Rule 2: destructive rm (instant, no network)
|
|
1646
|
+
rm_result = check_destructive_rm(command)
|
|
1647
|
+
if rm_result:
|
|
1648
|
+
decision, reason = rm_result
|
|
1649
|
+
emit(decision, reason)
|
|
1650
|
+
|
|
1651
|
+
# Rule 6: git disaster (instant, no network)
|
|
1652
|
+
git_result = _strict(check_git_disaster(command))
|
|
1653
|
+
if git_result:
|
|
1654
|
+
decision, reason = git_result
|
|
1655
|
+
emit(decision, reason)
|
|
1656
|
+
|
|
1657
|
+
# Rule 7: cloud/infra blast radius (instant, no network)
|
|
1658
|
+
cloud_result = _strict(check_cloud_infra(command))
|
|
1659
|
+
if cloud_result:
|
|
1660
|
+
decision, reason = cloud_result
|
|
1661
|
+
emit(decision, reason)
|
|
1662
|
+
|
|
1663
|
+
# Rule 5: curl/wget piped into shell (instant, no network)
|
|
1664
|
+
pipe_result = _strict(check_curl_pipe_shell(command))
|
|
1665
|
+
if pipe_result:
|
|
1666
|
+
decision, reason = pipe_result
|
|
1667
|
+
emit(decision, reason)
|
|
1668
|
+
|
|
1669
|
+
# Rule 8: secrets exfiltration (instant, no network)
|
|
1670
|
+
exfil_result = _strict(check_secrets_exfil(command))
|
|
1671
|
+
if exfil_result:
|
|
1672
|
+
decision, reason = exfil_result
|
|
1673
|
+
emit(decision, reason)
|
|
1674
|
+
|
|
1675
|
+
# Rules 1 + 3 + 4: slopsquatting / one-off runners / manifest installs
|
|
1676
|
+
targets = parse_install_targets(command)
|
|
1677
|
+
targets += parse_manifest_targets(command, cwd)
|
|
1678
|
+
seen, uniq = set(), []
|
|
1679
|
+
for t in targets:
|
|
1680
|
+
if t not in seen:
|
|
1681
|
+
seen.add(t)
|
|
1682
|
+
uniq.append(t)
|
|
1683
|
+
if not uniq or len(uniq) > MAX_PACKAGES:
|
|
1684
|
+
return # fast path / too many to check quickly -> fail open
|
|
1685
|
+
|
|
1686
|
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
1687
|
+
results = list(ex.map(evaluate, uniq))
|
|
1688
|
+
|
|
1689
|
+
denies = [(n, r) for (lvl, n, r) in results if lvl == "deny"]
|
|
1690
|
+
asks = [(n, r) for (lvl, n, r) in results if lvl == "ask"]
|
|
1691
|
+
|
|
1692
|
+
if denies:
|
|
1693
|
+
listing = "\n".join(" - %s: %s" % (n, r) for n, r in denies)
|
|
1694
|
+
emit("deny",
|
|
1695
|
+
"FailSafe blocked this install:\n%s\n\n"
|
|
1696
|
+
"AI assistants sometimes invent package names that don't exist; attackers "
|
|
1697
|
+
"pre-register those names with malware (\"slopsquatting\"). Verify the correct "
|
|
1698
|
+
"name on the official registry before installing. If you're certain it's "
|
|
1699
|
+
"legitimate, install it yourself outside the agent." % listing)
|
|
1700
|
+
if asks:
|
|
1701
|
+
listing = "\n".join(" - %s: %s" % (n, r) for n, r in asks)
|
|
1702
|
+
pkg_ask = _strict(("ask",
|
|
1703
|
+
"FailSafe flagged a possibly suspicious package:\n%s\n\n"
|
|
1704
|
+
"Review before installing." % listing))
|
|
1705
|
+
emit(*pkg_ask)
|
|
1706
|
+
except Exception:
|
|
1707
|
+
pass # fail open
|
|
1708
|
+
sys.exit(0)
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
if __name__ == "__main__":
|
|
1712
|
+
main()
|