skillsentinel-cli 0.3.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.
- skillsentinel/__init__.py +3 -0
- skillsentinel/__main__.py +4 -0
- skillsentinel/cli.py +364 -0
- skillsentinel/detonate.py +236 -0
- skillsentinel/rules.py +151 -0
- skillsentinel/scanner.py +768 -0
- skillsentinel/verifier.py +470 -0
- skillsentinel/watch.py +93 -0
- skillsentinel_cli-0.3.1.data/data/share/skillsentinel/rules/rules.json +553 -0
- skillsentinel_cli-0.3.1.dist-info/METADATA +314 -0
- skillsentinel_cli-0.3.1.dist-info/RECORD +15 -0
- skillsentinel_cli-0.3.1.dist-info/WHEEL +5 -0
- skillsentinel_cli-0.3.1.dist-info/entry_points.txt +2 -0
- skillsentinel_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
- skillsentinel_cli-0.3.1.dist-info/top_level.txt +1 -0
skillsentinel/cli.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""SkillSentinel CLI: scan / watch / rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .detonate import DetonationRefusedError, detonate_tree
|
|
15
|
+
from .rules import load_rules, SEVERITY_ORDER
|
|
16
|
+
from .scanner import InputError, scan_path, to_sarif
|
|
17
|
+
from .verifier import VERIFIERS_BY_NAME
|
|
18
|
+
from .watch import (write_snapshot, load_snapshot, diff_snapshots, snapshot_dir,
|
|
19
|
+
rescan_changes)
|
|
20
|
+
|
|
21
|
+
SEV_EXIT: dict[str, int] = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
22
|
+
|
|
23
|
+
# `scan --help` must state the contract a CI pipeline relies on.
|
|
24
|
+
EXIT_CODES_EPILOG = """\
|
|
25
|
+
exit codes:
|
|
26
|
+
0 clean — no confirmed findings
|
|
27
|
+
1 at least one confirmed finding at/above the --severity threshold
|
|
28
|
+
2 input error: missing or unreadable path, invalid or empty --rules, a .skillsentinelignore
|
|
29
|
+
pattern that ignores the whole skill, a scan that saw zero scannable files,
|
|
30
|
+
--timeout expiry, --max-files limit reached
|
|
31
|
+
3 internal error — a bug, please report it"""
|
|
32
|
+
|
|
33
|
+
SEV_COLOR = {
|
|
34
|
+
"critical": "\033[91m", "high": "\033[31m",
|
|
35
|
+
"medium": "\033[33m", "low": "\033[32m", "end": "\033[0m",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _colorized(s: str, sev: str, enabled: bool) -> str:
|
|
40
|
+
if not enabled:
|
|
41
|
+
return s
|
|
42
|
+
return f"{SEV_COLOR[sev]}{s}{SEV_COLOR['end']}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _park_stdout_on_devnull() -> None:
|
|
46
|
+
"""After a stdout write failed, point fd 1 at /dev/null so the interpreter's
|
|
47
|
+
shutdown flush of the leftover buffer cannot re-raise — without this a
|
|
48
|
+
broken pipe turns into an "Exception ignored" dump and rc=120. Call only
|
|
49
|
+
after the real error line is already on stderr."""
|
|
50
|
+
if sys.stdout is None:
|
|
51
|
+
return # no stdout buffer was ever created — nothing to re-point or flush
|
|
52
|
+
try:
|
|
53
|
+
null = os.open(os.devnull, os.O_WRONLY)
|
|
54
|
+
except OSError: # nothing to hide the failure behind: best effort only
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
os.dup2(null, sys.stdout.fileno())
|
|
58
|
+
except (OSError, ValueError):
|
|
59
|
+
pass # already detached/closed — nothing buffered can re-raise then
|
|
60
|
+
finally:
|
|
61
|
+
os.close(null)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _emit_stdout(emit: Callable[[], None], what: str) -> bool:
|
|
65
|
+
"""Run a stdout-writing report block AND flush it here, so a stdout that
|
|
66
|
+
cannot be written (closed pipe, full device, fd 1 closed at exec so
|
|
67
|
+
sys.stdout is None) fails now as one clean error naming the output — not
|
|
68
|
+
at interpreter shutdown. Returns False after printing that line and
|
|
69
|
+
muting fd 1 (see _park_stdout_on_devnull)."""
|
|
70
|
+
if sys.stdout is None:
|
|
71
|
+
# fd 1 was closed before the interpreter started: print() discards
|
|
72
|
+
# silently, so treat the missing stream exactly like an unwritable
|
|
73
|
+
# one — an output error, not an internal one.
|
|
74
|
+
print(f"skillsentinel: error: cannot write {what}: stdout is closed "
|
|
75
|
+
f"(fd 1 was closed at startup)", file=sys.stderr)
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
emit()
|
|
79
|
+
sys.stdout.flush()
|
|
80
|
+
except (BrokenPipeError, OSError) as e:
|
|
81
|
+
print(f"skillsentinel: error: cannot write {what}: {e}",
|
|
82
|
+
file=sys.stderr)
|
|
83
|
+
_park_stdout_on_devnull()
|
|
84
|
+
return False
|
|
85
|
+
return True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _print_findings(result, use_color: bool, suppressed: int = 0,
|
|
89
|
+
show_inconclusive: bool = False) -> None:
|
|
90
|
+
c = result.counts
|
|
91
|
+
total = sum(c.values())
|
|
92
|
+
dropped = len(result.demoted)
|
|
93
|
+
inconclusive = [f for f in result.demoted if f.verdict == "inconclusive"]
|
|
94
|
+
print(f"SkillSentinel v{__version__} — scanned {result.path} "
|
|
95
|
+
f"({result.files_scanned} files, {result.files_skipped} skipped, "
|
|
96
|
+
f"profile: {result.profile})")
|
|
97
|
+
if result.undecodable_files:
|
|
98
|
+
# D3: undecodable files are reported loudly, never silently dropped.
|
|
99
|
+
names = ", ".join(result.undecodable_files[:3])
|
|
100
|
+
more = f" (+{len(result.undecodable_files) - 3} more)" \
|
|
101
|
+
if len(result.undecodable_files) > 3 else ""
|
|
102
|
+
print(f"warning: {len(result.undecodable_files)} file(s) could not be "
|
|
103
|
+
f"decoded as text: {names}{more}", file=sys.stderr)
|
|
104
|
+
if not total:
|
|
105
|
+
note = f", {suppressed} suppressed by inline markers" if suppressed else ""
|
|
106
|
+
if dropped:
|
|
107
|
+
note += f"; {dropped} candidate(s) demoted — see --json for reasons"
|
|
108
|
+
print(f"No exploitable findings. Supply chain looks clean. ✓{note}")
|
|
109
|
+
else:
|
|
110
|
+
print(f"Confirmed findings: {total} "
|
|
111
|
+
f"(critical: {c['critical']}, high: {c['high']}, "
|
|
112
|
+
f"medium: {c['medium']}, low: {c['low']})"
|
|
113
|
+
+ (f" — {suppressed} suppressed by inline markers" if suppressed else "")
|
|
114
|
+
+ (f"; {dropped} candidate(s) demoted" if dropped else ""))
|
|
115
|
+
cur = None
|
|
116
|
+
for f in result.findings:
|
|
117
|
+
if f.severity != cur:
|
|
118
|
+
cur = f.severity
|
|
119
|
+
print(_colorized(f"\n== {cur.upper()} ==", cur, use_color))
|
|
120
|
+
print(_colorized(f"[{f.rule_id}] {f.file}:{f.line}", cur, use_color)
|
|
121
|
+
+ (f" (x{f.count})" if f.count > 1 else "")
|
|
122
|
+
+ f" {f.title}")
|
|
123
|
+
print(f" {f.snippet[:160]}")
|
|
124
|
+
print(f" verdict: {f.verdict} — {f.reason}")
|
|
125
|
+
if show_inconclusive or inconclusive:
|
|
126
|
+
print(f"\n{len(inconclusive)} inconclusive candidate(s):")
|
|
127
|
+
for f in inconclusive:
|
|
128
|
+
print(f" [{f.rule_id}] {f.file}:{f.line} — {f.reason}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_ignore_file(root: Path) -> list[str]:
|
|
132
|
+
p = root if root.is_file() else root / ".skillsentinelignore"
|
|
133
|
+
if not p.exists() or not p.is_file():
|
|
134
|
+
return []
|
|
135
|
+
out = []
|
|
136
|
+
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
137
|
+
line = line.strip()
|
|
138
|
+
if line and not line.startswith("#"):
|
|
139
|
+
out.append(line)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
144
|
+
root = Path(args.path).expanduser()
|
|
145
|
+
# `.skillsentinelignore` is input from the scanned tree, so it is trusted
|
|
146
|
+
# only under --honor-ignores — exactly like inline suppression markers.
|
|
147
|
+
ignore_patterns = load_ignore_file(root) if args.honor_ignores else []
|
|
148
|
+
result = scan_path(args.path, args.rules,
|
|
149
|
+
honor_ignores=args.honor_ignores,
|
|
150
|
+
exclude=list(args.exclude),
|
|
151
|
+
ignore_patterns=ignore_patterns,
|
|
152
|
+
profile=args.profile,
|
|
153
|
+
timeout=args.timeout,
|
|
154
|
+
max_files=args.max_files)
|
|
155
|
+
reports = None
|
|
156
|
+
if args.detonate:
|
|
157
|
+
try:
|
|
158
|
+
reports = detonate_tree(Path(args.path), allow_net=args.allow_net,
|
|
159
|
+
timeout=args.detonate_timeout)
|
|
160
|
+
except DetonationRefusedError as e:
|
|
161
|
+
print(f"detonation refused: {e}", file=sys.stderr)
|
|
162
|
+
return 2
|
|
163
|
+
def _text_report() -> None:
|
|
164
|
+
_print_findings(result, use_color=sys.stdout is not None and sys.stdout.isatty(),
|
|
165
|
+
suppressed=result.suppressed,
|
|
166
|
+
show_inconclusive=args.show_inconclusive)
|
|
167
|
+
if reports is not None:
|
|
168
|
+
print(f"\n-- Detonation ({len(reports)} scripts) --")
|
|
169
|
+
for r in reports:
|
|
170
|
+
if not r.executed:
|
|
171
|
+
print(f"[skip] {r.file}: {'; '.join(r.notes)}")
|
|
172
|
+
continue
|
|
173
|
+
net = ", ".join(r.network_attempts) or "none observed"
|
|
174
|
+
print(f"[ran ] {r.file} exit={r.exit_code} egress: {net}")
|
|
175
|
+
for n in r.notes:
|
|
176
|
+
if "timed out" in n.lower():
|
|
177
|
+
print(f"[warn] {r.file}: {n}")
|
|
178
|
+
if any("network NOT isolated" in n for n in r.notes):
|
|
179
|
+
print("[warn] network NOT isolated (--allow-net): the "
|
|
180
|
+
"sandbox shares the host network — real egress "
|
|
181
|
+
"is possible")
|
|
182
|
+
|
|
183
|
+
if args.json:
|
|
184
|
+
payload = result.to_dict()
|
|
185
|
+
if reports is not None:
|
|
186
|
+
payload["detonations"] = [r.to_dict() for r in reports]
|
|
187
|
+
if not _emit_stdout(lambda: print(json.dumps(payload, indent=2)),
|
|
188
|
+
"the JSON report to stdout"):
|
|
189
|
+
return 2
|
|
190
|
+
elif not _emit_stdout(_text_report, "the report to stdout"):
|
|
191
|
+
return 2
|
|
192
|
+
if args.sarif:
|
|
193
|
+
sarif = to_sarif(result)
|
|
194
|
+
try:
|
|
195
|
+
Path(args.sarif).write_text(json.dumps(sarif, indent=2), encoding="utf-8")
|
|
196
|
+
except OSError as e: # the report target is part of the input contract too
|
|
197
|
+
print(f"skillsentinel: error: cannot write the SARIF report to "
|
|
198
|
+
f"{args.sarif}: {e}", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
if not args.json and not _emit_stdout(
|
|
201
|
+
lambda: print(f"\nSARIF written to {args.sarif}"),
|
|
202
|
+
"the SARIF confirmation to stdout"):
|
|
203
|
+
return 2
|
|
204
|
+
if result.limit_reached is not None:
|
|
205
|
+
n, m = result.limit_reached
|
|
206
|
+
print(f"error: limit reached: {n} of {m} files scanned "
|
|
207
|
+
f"(raise --max-files to see the rest)", file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
threshold = SEVERITY_ORDER[args.severity]
|
|
210
|
+
return 1 if result.max_severity_rank() >= threshold else 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
214
|
+
root = Path(args.path).expanduser().resolve()
|
|
215
|
+
if args.watch_cmd == "init":
|
|
216
|
+
snap = write_snapshot(root, Path(args.snapshot))
|
|
217
|
+
print(f"Snapshot saved: {args.snapshot} "
|
|
218
|
+
f"({len(snap['entries'])} files under {snap['root']})")
|
|
219
|
+
return 0
|
|
220
|
+
if args.watch_cmd == "diff":
|
|
221
|
+
snap_path = Path(args.snapshot)
|
|
222
|
+
if not snap_path.exists():
|
|
223
|
+
print(f"error: snapshot not found: {snap_path} — run `watch init` first",
|
|
224
|
+
file=sys.stderr)
|
|
225
|
+
return 2
|
|
226
|
+
try:
|
|
227
|
+
old = load_snapshot(snap_path)
|
|
228
|
+
except (OSError, ValueError) as e:
|
|
229
|
+
raise InputError(f"cannot read snapshot {snap_path}: {e}") from None
|
|
230
|
+
new = snapshot_dir(root)
|
|
231
|
+
d = diff_snapshots(old, new)
|
|
232
|
+
if d["clean"]:
|
|
233
|
+
print(f"Clean: no drift across {len(new['entries'])} files.")
|
|
234
|
+
return 0
|
|
235
|
+
print("DRIFT DETECTED:")
|
|
236
|
+
for k in ("added", "changed", "removed"):
|
|
237
|
+
for p in d[k]:
|
|
238
|
+
print(f" {k:>7}: {p}")
|
|
239
|
+
if getattr(args, "rescan", False):
|
|
240
|
+
findings = rescan_changes(root, d)
|
|
241
|
+
if findings:
|
|
242
|
+
print(f"\nRescan of changed/added files — "
|
|
243
|
+
f"{len(findings)} confirmed finding(s):")
|
|
244
|
+
for f in findings:
|
|
245
|
+
print(f" [{f.rule_id}] {f.file}:{f.line} {f.title}")
|
|
246
|
+
print(f" {f.snippet[:160]}")
|
|
247
|
+
print(f" verdict: {f.verdict} — {f.reason}")
|
|
248
|
+
else:
|
|
249
|
+
print("\nRescan of changed/added files: no confirmed findings.")
|
|
250
|
+
return 1
|
|
251
|
+
print(json.dumps(snapshot_dir(root), indent=2))
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def cmd_rules(args: argparse.Namespace) -> int:
|
|
256
|
+
try:
|
|
257
|
+
rules = load_rules(args.rules)
|
|
258
|
+
except (OSError, ValueError) as e:
|
|
259
|
+
raise InputError(
|
|
260
|
+
f"cannot load rules from {args.rules or 'the built-in catalog'}: {e}") from None
|
|
261
|
+
if args.json:
|
|
262
|
+
print(json.dumps({
|
|
263
|
+
"count": len(rules),
|
|
264
|
+
"rules": [{
|
|
265
|
+
"id": r.id, "title": r.title, "severity": r.severity,
|
|
266
|
+
"category": r.category, "owasp": r.owasp,
|
|
267
|
+
"description": r.description,
|
|
268
|
+
} for r in rules]}, indent=2))
|
|
269
|
+
else:
|
|
270
|
+
print(f"{len(rules)} rules:")
|
|
271
|
+
for r in sorted(rules, key=lambda x: (x.category, x.id)):
|
|
272
|
+
print(f" {r.id} {r.severity:<8} {r.category:<18} {r.title}")
|
|
273
|
+
return 0
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
277
|
+
p = argparse.ArgumentParser(
|
|
278
|
+
prog="skillsentinel",
|
|
279
|
+
description="Post-install security scanner for AI-agent skills and prompt configs.")
|
|
280
|
+
p.add_argument("--version", action="version", version=f"skillsentinel {__version__}")
|
|
281
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
282
|
+
|
|
283
|
+
s = sub.add_parser("scan", help="scan a skill file or directory",
|
|
284
|
+
epilog=EXIT_CODES_EPILOG,
|
|
285
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
286
|
+
s.add_argument("path", help="file or directory to scan")
|
|
287
|
+
s.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
288
|
+
s.add_argument("--sarif", metavar="OUT.sarif", help="also write SARIF 2.1.0 report")
|
|
289
|
+
s.add_argument("--severity", choices=list(SEVERITY_ORDER), default="low",
|
|
290
|
+
help="exit 1 only for findings at/above this severity (default: low)")
|
|
291
|
+
s.add_argument("--rules", help="path to alternative rules.json")
|
|
292
|
+
s.add_argument("--profile", choices=list(VERIFIERS_BY_NAME), default="balanced",
|
|
293
|
+
help="verification strictness: strict (every candidate confirmed, "
|
|
294
|
+
"demoted is empty) / balanced (default) / loose / paranoid")
|
|
295
|
+
s.add_argument("--honor-ignores", action="store_true",
|
|
296
|
+
help="honor inline `skillsentinel:ignore` markers and "
|
|
297
|
+
"`.skillsentinelignore` patterns (OFF by default: a scanned "
|
|
298
|
+
"file must not be able to silence its own detection)")
|
|
299
|
+
s.add_argument("--exclude", action="append", default=[], metavar="GLOB",
|
|
300
|
+
help="skip files matching this glob (repeatable)")
|
|
301
|
+
s.add_argument("--timeout", type=float, default=None, metavar="SEC",
|
|
302
|
+
help="abort the scan after SEC seconds and exit 2 (0 disables); "
|
|
303
|
+
"protects CI against pathological trees")
|
|
304
|
+
s.add_argument("--max-files", type=int, default=None, metavar="N",
|
|
305
|
+
help="scan at most N files, then exit 2 with a limit report; "
|
|
306
|
+
"bounds huge trees without silently ignoring the rest")
|
|
307
|
+
s.add_argument("--detonate", action="store_true",
|
|
308
|
+
help="run discovered scripts in a disposable bwrap sandbox "
|
|
309
|
+
"with no network access (--unshare-net; requires "
|
|
310
|
+
"bubblewrap, skipped with a notice otherwise)")
|
|
311
|
+
s.add_argument("--allow-net", action="store_true",
|
|
312
|
+
help="with --detonate: lift network isolation — the sandbox "
|
|
313
|
+
"CAN open real connections (each detonation prints a "
|
|
314
|
+
"'network NOT isolated' warning)")
|
|
315
|
+
s.add_argument("--detonate-timeout", type=int, default=10, metavar="SEC",
|
|
316
|
+
help="with --detonate: kill each sandboxed script after SEC "
|
|
317
|
+
"seconds and record a timeout warning (0 disables; "
|
|
318
|
+
"default: 10)")
|
|
319
|
+
s.add_argument("--show-inconclusive", action="store_true",
|
|
320
|
+
help="also list candidates the verifier graded inconclusive "
|
|
321
|
+
"(unknown context) with their file:line and reason")
|
|
322
|
+
s.set_defaults(func=cmd_scan)
|
|
323
|
+
|
|
324
|
+
w = sub.add_parser("watch", help="snapshot installed skills and detect drift")
|
|
325
|
+
wsub = w.add_subparsers(dest="watch_cmd")
|
|
326
|
+
for name, helptext in (("init", "record a hash snapshot"),
|
|
327
|
+
("diff", "compare current tree against the snapshot"),
|
|
328
|
+
("show", "print the current snapshot as JSON")):
|
|
329
|
+
wp = wsub.add_parser(name, help=helptext)
|
|
330
|
+
wp.add_argument("path", help="directory of installed skills")
|
|
331
|
+
wp.add_argument("--snapshot", default=".skillsentinel-snapshot.json",
|
|
332
|
+
help="snapshot file path (default: .skillsentinel-snapshot.json)")
|
|
333
|
+
wsub.choices["diff"].add_argument(
|
|
334
|
+
"--rescan", action="store_true",
|
|
335
|
+
help="also re-scan changed/added files and print their confirmed findings")
|
|
336
|
+
w.set_defaults(func=cmd_watch, watch_cmd="show")
|
|
337
|
+
|
|
338
|
+
r = sub.add_parser("rules", help="list detection rules")
|
|
339
|
+
r.add_argument("--json", action="store_true", help="JSON output")
|
|
340
|
+
r.add_argument("--rules", help="path to alternative rules.json")
|
|
341
|
+
r.set_defaults(func=cmd_rules)
|
|
342
|
+
return p
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def main(argv: list[str] | None = None) -> int:
|
|
346
|
+
args = build_parser().parse_args(argv)
|
|
347
|
+
try:
|
|
348
|
+
return args.func(args)
|
|
349
|
+
except InputError as e:
|
|
350
|
+
print(f"skillsentinel: error: {e}", file=sys.stderr)
|
|
351
|
+
return 2
|
|
352
|
+
except OSError as e:
|
|
353
|
+
# IO trouble reaching the input (vanished path, unreadable file): the
|
|
354
|
+
# scan target's problem, not the scanner's — still exit 2, one line.
|
|
355
|
+
print(f"skillsentinel: error: {e}", file=sys.stderr)
|
|
356
|
+
return 2
|
|
357
|
+
except Exception as e: # noqa: BLE001 — the CLI boundary turns bugs into exit 3
|
|
358
|
+
print(f"skillsentinel: internal error: {type(e).__name__}: {e}",
|
|
359
|
+
file=sys.stderr)
|
|
360
|
+
return 3
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
if __name__ == "__main__":
|
|
364
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""`--detonate`: run skill scripts in a disposable bwrap sandbox, record egress attempts.
|
|
2
|
+
|
|
3
|
+
Network isolation by default: the sandbox is built with --unshare-net, so no process
|
|
4
|
+
inside can open a connection — attempted tool egress (curl/wget/nc/ssh/...) is still
|
|
5
|
+
observed via PATH shims that log the destination. The sandbox also gets its own PID
|
|
6
|
+
namespace (--unshare-pid): killing the bwrap supervisor at a detonation timeout takes
|
|
7
|
+
the whole sandbox tree down with it, so a payload cannot survive as a host orphan.
|
|
8
|
+
The capability preflight runs in every mode: if the host bwrap cannot honor the unshare
|
|
9
|
+
options the mode relies on (--unshare-net and --unshare-pid; only --unshare-pid under
|
|
10
|
+
`allow_net=True`), detonation is refused outright (never a live-egress or
|
|
11
|
+
leaky-process-tree fallback); the caller reports it on stderr and exits 2.
|
|
12
|
+
`allow_net=True` lifts the network isolation explicitly and stamps the report with a
|
|
13
|
+
'network NOT isolated' warning. If bwrap is unavailable, detonation is skipped and
|
|
14
|
+
reported as such.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import tempfile
|
|
22
|
+
from dataclasses import dataclass, field, asdict
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
NETLOG_HELPER = r"""#!/usr/bin/env python3
|
|
26
|
+
import sys, os
|
|
27
|
+
log = os.environ.get("SANDBOX_NETLOG", "/netlog.txt")
|
|
28
|
+
with open(log, "a") as f:
|
|
29
|
+
f.write(sys.argv[0].rsplit("/", 1)[-1] + " " + " ".join(sys.argv[1:]) + "\n")
|
|
30
|
+
sys.exit(7)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
SHIM_TOOLS = ("curl", "wget", "nc", "ncat", "ping", "telnet", "ssh", "scp", "ftp", "dig")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DetonationRefusedError(RuntimeError):
|
|
37
|
+
"""bwrap cannot guarantee sandbox isolation — detonation must not run."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DetonationReport:
|
|
42
|
+
file: str
|
|
43
|
+
executed: bool
|
|
44
|
+
sandbox: str
|
|
45
|
+
exit_code: int | None = None
|
|
46
|
+
stdout_tail: str = ""
|
|
47
|
+
stderr_tail: str = ""
|
|
48
|
+
network_attempts: list[str] = field(default_factory=list)
|
|
49
|
+
notes: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
return asdict(self)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _tails(out: str, err: str, n: int = 500) -> tuple[str, str]:
|
|
56
|
+
return out[-n:], err[-n:]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_scripts(root: Path) -> list[Path]:
|
|
60
|
+
scripts = []
|
|
61
|
+
for p in sorted(root.rglob("*")):
|
|
62
|
+
if not p.is_file() or p.is_symlink():
|
|
63
|
+
continue
|
|
64
|
+
if p.suffix in (".sh", ".py", ".bash") or p.name == "install":
|
|
65
|
+
scripts.append(p)
|
|
66
|
+
return scripts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _base_bind_args() -> list[str]:
|
|
70
|
+
"""Bind the minimal read-only host dirs needed to run python/sh."""
|
|
71
|
+
args = []
|
|
72
|
+
for d in ("/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc/alternatives"):
|
|
73
|
+
if Path(d).exists():
|
|
74
|
+
args += ["--ro-bind", d, d]
|
|
75
|
+
return args
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _bwrap_capability_probe(bwrap: str, flags: list[str]) -> tuple[bool, str]:
|
|
79
|
+
"""Non-destructive preflight: can this bwrap build the sandbox we need?
|
|
80
|
+
|
|
81
|
+
Runs /usr/bin/true once in a throwaway sandbox with `flags` — the unshare
|
|
82
|
+
options the caller's mode relies on: --unshare-net (no egress from the
|
|
83
|
+
sandbox) and --unshare-pid (the payload dies with the bwrap supervisor, so
|
|
84
|
+
a timeout cannot leave it running as a host orphan) — plus the same base
|
|
85
|
+
binds the real detonation uses. Old bwrap builds reject the options
|
|
86
|
+
outright ("unrecognized option", exit 1); any probe failure counts as
|
|
87
|
+
"cannot isolate", so the caller refuses instead of running untrusted code
|
|
88
|
+
with live egress or a leaking process tree. Returns (ok, detail), where
|
|
89
|
+
detail carries the probe's stderr (or launch failure) so the refusal
|
|
90
|
+
message can name the flag that was missing.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
proc = subprocess.run(
|
|
94
|
+
[bwrap, *flags, *_base_bind_args(), "/usr/bin/true"],
|
|
95
|
+
capture_output=True, text=True, timeout=10,
|
|
96
|
+
)
|
|
97
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
98
|
+
return False, f"probe did not complete: {e}"
|
|
99
|
+
return proc.returncode == 0, (proc.stderr or "").strip()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def detonate_file(path: Path, timeout: int | None = 10,
|
|
103
|
+
allow_net: bool = False) -> DetonationReport:
|
|
104
|
+
bwrap = shutil.which("bwrap")
|
|
105
|
+
if bwrap is None:
|
|
106
|
+
return DetonationReport(
|
|
107
|
+
file=str(path), executed=False, sandbox="none",
|
|
108
|
+
notes=["bwrap not available — detonation skipped "
|
|
109
|
+
"(install bubblewrap to enable)"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# preflight in every mode: a detonation timeout must never leave payload
|
|
113
|
+
# processes on the host, so --unshare-pid is probed (and required) even
|
|
114
|
+
# when network isolation is lifted; --unshare-net is probed alongside it
|
|
115
|
+
# only in the mode that actually uses it
|
|
116
|
+
probe_flags = (["--unshare-pid"] if allow_net
|
|
117
|
+
else ["--unshare-net", "--unshare-pid"])
|
|
118
|
+
capable, detail = _bwrap_capability_probe(bwrap, probe_flags)
|
|
119
|
+
if not capable:
|
|
120
|
+
detail_note = f" Probe said: {detail}" if detail else ""
|
|
121
|
+
if allow_net:
|
|
122
|
+
raise DetonationRefusedError(
|
|
123
|
+
f"{bwrap} does not support --unshare-pid — detonation "
|
|
124
|
+
"refused: without its own PID namespace a detonation timeout "
|
|
125
|
+
"can leave payload processes running on the host, and "
|
|
126
|
+
"--allow-net lifts only the network guarantee, not this one. "
|
|
127
|
+
f"Upgrade bubblewrap to a build with --unshare-pid support."
|
|
128
|
+
f"{detail_note}")
|
|
129
|
+
raise DetonationRefusedError(
|
|
130
|
+
f"{bwrap} does not support --unshare-net/--unshare-pid — "
|
|
131
|
+
"detonation refused: without them the sandbox would run untrusted "
|
|
132
|
+
"code with real egress and a detonation timeout could leave "
|
|
133
|
+
"payload processes running on the host. Upgrade bubblewrap to a "
|
|
134
|
+
"build with --unshare-net and --unshare-pid support, or pass "
|
|
135
|
+
f"--allow-net to explicitly accept a networked sandbox."
|
|
136
|
+
f"{detail_note}")
|
|
137
|
+
|
|
138
|
+
with tempfile.TemporaryDirectory(prefix="ssdet_") as tmp:
|
|
139
|
+
tmpdir = Path(tmp)
|
|
140
|
+
netlog = tmpdir / "netlog.txt"
|
|
141
|
+
netlog.touch()
|
|
142
|
+
work = tmpdir / "work"
|
|
143
|
+
work.mkdir()
|
|
144
|
+
home = tmpdir / "home"
|
|
145
|
+
home.mkdir()
|
|
146
|
+
script_copy = work / path.name
|
|
147
|
+
script_copy.write_bytes(path.read_bytes())
|
|
148
|
+
if script_copy.suffix in (".sh", ".bash") or script_copy.name == "install":
|
|
149
|
+
script_copy.chmod(0o755)
|
|
150
|
+
|
|
151
|
+
shims = tmpdir / "shims"
|
|
152
|
+
shims.mkdir()
|
|
153
|
+
for tool in SHIM_TOOLS:
|
|
154
|
+
f = shims / tool
|
|
155
|
+
f.write_text(NETLOG_HELPER)
|
|
156
|
+
f.chmod(0o755)
|
|
157
|
+
|
|
158
|
+
cmd = [
|
|
159
|
+
bwrap,
|
|
160
|
+
*_base_bind_args(),
|
|
161
|
+
"--proc", "/proc",
|
|
162
|
+
"--dev", "/dev",
|
|
163
|
+
"--tmpfs", "/tmp",
|
|
164
|
+
"--ro-bind", str(work), "/work",
|
|
165
|
+
"--ro-bind", str(shims), "/shims",
|
|
166
|
+
"--bind", str(home), "/home/sandbox",
|
|
167
|
+
"--bind", str(netlog), "/netlog.txt",
|
|
168
|
+
"--chdir", "/work",
|
|
169
|
+
]
|
|
170
|
+
if not allow_net:
|
|
171
|
+
cmd.append("--unshare-net")
|
|
172
|
+
# own PID namespace: when the bwrap supervisor is killed at a timeout,
|
|
173
|
+
# the kernel tears down the whole sandbox tree — without it the payload
|
|
174
|
+
# would outlive the monitor as an orphan host process
|
|
175
|
+
cmd += ["--unshare-pid", "--die-with-parent", "--new-session"]
|
|
176
|
+
if script_copy.suffix == ".py":
|
|
177
|
+
cmd += ["python3", f"/work/{path.name}"]
|
|
178
|
+
else:
|
|
179
|
+
cmd += ["/bin/sh", f"/work/{path.name}"]
|
|
180
|
+
|
|
181
|
+
env = {
|
|
182
|
+
"PATH": "/shims:/usr/bin:/bin",
|
|
183
|
+
"HOME": "/home/sandbox",
|
|
184
|
+
"SANDBOX_NETLOG": "/netlog.txt",
|
|
185
|
+
}
|
|
186
|
+
timeout_note = None
|
|
187
|
+
run_kwargs: dict = {"capture_output": True, "text": True, "env": env}
|
|
188
|
+
if timeout is not None and timeout > 0:
|
|
189
|
+
run_kwargs["timeout"] = timeout
|
|
190
|
+
try:
|
|
191
|
+
proc = subprocess.run(cmd, **run_kwargs)
|
|
192
|
+
rc, out, err = proc.returncode, proc.stdout, proc.stderr
|
|
193
|
+
except subprocess.TimeoutExpired as e:
|
|
194
|
+
# the sandbox was killed at the deadline — recorded as a warning,
|
|
195
|
+
# never propagated: one runaway script must not end the run
|
|
196
|
+
rc = None
|
|
197
|
+
out = (e.stdout or "")
|
|
198
|
+
out = out.decode() if isinstance(out, bytes) else out
|
|
199
|
+
err = "detonation timed out"
|
|
200
|
+
timeout_note = (f"detonation timed out after {timeout} s — the "
|
|
201
|
+
"script was killed; scanning continues")
|
|
202
|
+
except OSError as e:
|
|
203
|
+
return DetonationReport(
|
|
204
|
+
file=str(path), executed=False, sandbox="bwrap",
|
|
205
|
+
notes=[f"failed to launch bwrap: {e}"],
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
attempts = [l for l in netlog.read_text().splitlines() if l.strip()]
|
|
210
|
+
except OSError:
|
|
211
|
+
attempts = []
|
|
212
|
+
|
|
213
|
+
o, e2 = _tails(out, err)
|
|
214
|
+
if allow_net:
|
|
215
|
+
net_note = ("network NOT isolated (--allow-net): the sandbox shares "
|
|
216
|
+
"the host network — real egress is possible")
|
|
217
|
+
else:
|
|
218
|
+
net_note = ("network isolated (--unshare-net): no connection can be "
|
|
219
|
+
"opened from inside the sandbox")
|
|
220
|
+
notes = [net_note,
|
|
221
|
+
"attempted tool egress is logged via PATH shims "
|
|
222
|
+
"(curl/wget/nc/ssh/...)"]
|
|
223
|
+
if timeout_note is not None:
|
|
224
|
+
notes.append(timeout_note)
|
|
225
|
+
return DetonationReport(
|
|
226
|
+
file=str(path), executed=True, sandbox="bwrap",
|
|
227
|
+
exit_code=rc, stdout_tail=o, stderr_tail=e2,
|
|
228
|
+
network_attempts=attempts,
|
|
229
|
+
notes=notes,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def detonate_tree(root: Path, allow_net: bool = False,
|
|
234
|
+
timeout: int | None = 10) -> list[DetonationReport]:
|
|
235
|
+
return [detonate_file(p, timeout=timeout, allow_net=allow_net)
|
|
236
|
+
for p in find_scripts(root)]
|