skill-vet-cli 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.
- skill_vet.py +1673 -0
- skill_vet_cli-1.0.0.dist-info/METADATA +345 -0
- skill_vet_cli-1.0.0.dist-info/RECORD +7 -0
- skill_vet_cli-1.0.0.dist-info/WHEEL +5 -0
- skill_vet_cli-1.0.0.dist-info/entry_points.txt +2 -0
- skill_vet_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- skill_vet_cli-1.0.0.dist-info/top_level.txt +1 -0
skill_vet.py
ADDED
|
@@ -0,0 +1,1673 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""skill-vet — a static, read-only pre-install audit of an
|
|
3
|
+
agent skill pack: does its SKILL.md parse, do the files it
|
|
4
|
+
references exist, and do its bundled scripts steal, spy, or
|
|
5
|
+
send in secret — found before you install it, not after.
|
|
6
|
+
|
|
7
|
+
Pure Python standard library. READ-ONLY and local only: it
|
|
8
|
+
reads the skill folder you point it at, edits nothing,
|
|
9
|
+
writes nothing, and NEVER executes anything in the pack —
|
|
10
|
+
every file is analysed as static text. The test suite
|
|
11
|
+
proves the claim: a fixture script that would write a
|
|
12
|
+
marker file if it ran never runs, and every fixture tree
|
|
13
|
+
comes out byte-identical after an audit.
|
|
14
|
+
|
|
15
|
+
A skill pack runs with your agent's permissions the
|
|
16
|
+
moment it is installed. Per-file linters check the
|
|
17
|
+
SKILL.md's formatting; nobody vets the whole directory —
|
|
18
|
+
the frontmatter, the referenced files, and above all the
|
|
19
|
+
scripts. The marketplace numbers say why this matters
|
|
20
|
+
(Snyk's ToxicSkills audit, Feb 2026: 1,467 of 3,984
|
|
21
|
+
marketplace skills carried malicious payloads; VirusTotal
|
|
22
|
+
analysed 35,878 skills and found 6,637 malicious, most of
|
|
23
|
+
them hiding the payload in plain sentences or concealment
|
|
24
|
+
mechanics rather than obvious malware).
|
|
25
|
+
|
|
26
|
+
THE DANGER MODEL (the owner's standing rule, 2026-10-30,
|
|
27
|
+
applied exactly): a pack is DANGEROUS only if it
|
|
28
|
+
|
|
29
|
+
1. STEALS — reads credential stores, browser profiles,
|
|
30
|
+
SSH keys or environment secrets AND sends or copies
|
|
31
|
+
the results out;
|
|
32
|
+
2. SPIES — captures screen, keyboard, microphone,
|
|
33
|
+
camera or browser history AND persists or sends the
|
|
34
|
+
capture, without disclosing the capture in its docs;
|
|
35
|
+
3. SENDS IN SECRET — makes network calls whose payload
|
|
36
|
+
or destination is concealed: an encoded payload, the
|
|
37
|
+
contents of local files sent to an undisclosed
|
|
38
|
+
endpoint, or any send at all from a pack whose docs
|
|
39
|
+
claim it works offline.
|
|
40
|
+
|
|
41
|
+
A pattern hit alone is NEVER a danger verdict. Each test
|
|
42
|
+
needs its combination of evidence in the same file, and
|
|
43
|
+
the finding cites both halves (file + line for each).
|
|
44
|
+
Anything short of a combination is a warning with the
|
|
45
|
+
evidence stated: an undisclosed endpoint on its own is
|
|
46
|
+
UNDISCLOSED_NETWORK, a credential read with no egress is
|
|
47
|
+
SECRET_ACCESS, a capture API with no persistence or send
|
|
48
|
+
is CAPTURE_CAPABILITY. Scanner scores are context, never
|
|
49
|
+
a block by themselves; BLOCKED is reserved for a proven
|
|
50
|
+
three-test combination.
|
|
51
|
+
|
|
52
|
+
THE RULES (v1.0.0):
|
|
53
|
+
|
|
54
|
+
Structure (the pack as a document):
|
|
55
|
+
MISSING_SKILL_MD (ERROR) — no SKILL.md at the pack
|
|
56
|
+
root. SKILL_MD_CASE (WARN) — present, but under a
|
|
57
|
+
differently-cased name some loaders will miss.
|
|
58
|
+
FRONTMATTER_MISSING (ERROR) — SKILL.md has no ---
|
|
59
|
+
frontmatter block. FRONTMATTER_PARSE (ERROR) — a
|
|
60
|
+
frontmatter line the subset parser cannot read (the
|
|
61
|
+
line is cited). REQUIRED_FIELD (ERROR) — `name` or
|
|
62
|
+
`description` missing or empty.
|
|
63
|
+
NAME_DIR_MISMATCH (WARN) — normalised frontmatter name
|
|
64
|
+
differs from the folder name. NAME_FORMAT (WARN) —
|
|
65
|
+
the name is not a lowercase kebab-case slug.
|
|
66
|
+
BROAD_TOOL_GRANT (WARN) — an allowed-tools entry that
|
|
67
|
+
grants a whole tool (`*`, `Bash(*)`, a bare `Bash`, …).
|
|
68
|
+
BROKEN_REFERENCE (ERROR) — a local file referenced
|
|
69
|
+
from SKILL.md (link or backticked path) or from a
|
|
70
|
+
script (path literal) does not exist.
|
|
71
|
+
REFERENCE_OUTSIDE_PACK (WARN) — a reference that
|
|
72
|
+
resolves outside the pack folder.
|
|
73
|
+
LARGE_FILE (WARN) — a file over 1 MiB in a pack that
|
|
74
|
+
ships as text. BUNDLED_BINARY (WARN) — a binary file:
|
|
75
|
+
it cannot be statically vetted at all.
|
|
76
|
+
SYMLINK_OUT (INFO) — a symlink whose target leaves the
|
|
77
|
+
pack; its target is not scanned.
|
|
78
|
+
|
|
79
|
+
The three danger tests (ERROR severity, danger class;
|
|
80
|
+
any one of them makes the verdict BLOCKED):
|
|
81
|
+
DANGER_STEALING — secret-source read + (network send
|
|
82
|
+
or copy/move out of the pack) in the same file.
|
|
83
|
+
DANGER_SPYING — capture (screen / keyboard / audio /
|
|
84
|
+
camera / history) + (send or persistence) in the
|
|
85
|
+
same file, with the capture kind undisclosed in
|
|
86
|
+
SKILL.md / README.md.
|
|
87
|
+
DANGER_HIDDEN_SENDING — network send + concealment in
|
|
88
|
+
the same file: an encoded payload sent to an
|
|
89
|
+
undisclosed (or non-literal) endpoint, local file
|
|
90
|
+
contents read from outside the pack and sent to an
|
|
91
|
+
undisclosed endpoint, a send combined with writes
|
|
92
|
+
outside the pack to an undisclosed endpoint, or any
|
|
93
|
+
send at all while the docs claim the pack works
|
|
94
|
+
offline / local-only. (An encoded payload to a
|
|
95
|
+
DISCLOSED endpoint is ENCODED_PAYLOAD, a warning:
|
|
96
|
+
the destination is honest, the payload opaque.)
|
|
97
|
+
|
|
98
|
+
Short-of-danger security warnings (WARN):
|
|
99
|
+
SECRET_ACCESS — reads credential stores / env secrets,
|
|
100
|
+
no egress found in that file. UNDISCLOSED_NETWORK —
|
|
101
|
+
a network call whose endpoint appears nowhere in
|
|
102
|
+
SKILL.md / README.md, with no concealment combo.
|
|
103
|
+
DYNAMIC_ENDPOINT — a network call with no literal URL
|
|
104
|
+
to check against the docs. ENCODED_PAYLOAD — an
|
|
105
|
+
encoded payload sent to a DISCLOSED endpoint (the
|
|
106
|
+
destination is honest; what is sent is opaque).
|
|
107
|
+
DISCLOSED_CAPTURE — capture + persistence/send, but
|
|
108
|
+
the capture kind IS disclosed in the docs.
|
|
109
|
+
CAPTURE_CAPABILITY — a capture API present with no
|
|
110
|
+
persistence or send found.
|
|
111
|
+
|
|
112
|
+
Hygiene (WARN):
|
|
113
|
+
HYGIENE_SHELL_TRUE — subprocess with shell=True.
|
|
114
|
+
HYGIENE_DYNAMIC_EXEC — eval / exec / os.system /
|
|
115
|
+
os.popen / __import__ on computed values.
|
|
116
|
+
HYGIENE_DOWNLOAD_EXEC — downloaded content executed:
|
|
117
|
+
`curl … | sh`, or a download and an exec in one file.
|
|
118
|
+
HYGIENE_OBFUSCATION — a long base64 blob, a long hex
|
|
119
|
+
blob, or a run of \\x escapes in a text file.
|
|
120
|
+
CONCEALED_UNICODE — invisible characters (zero-width,
|
|
121
|
+
bidi controls, Unicode tag characters, soft hyphens)
|
|
122
|
+
in a text file, cited by name.
|
|
123
|
+
CONCEALED_BLANK_RUN — 50+ consecutive blank lines in a
|
|
124
|
+
markdown file with content after them.
|
|
125
|
+
HIDDEN_COMMENT — an HTML comment of 120+ characters in
|
|
126
|
+
a markdown file.
|
|
127
|
+
WRITES_OUTSIDE_PACK — a script writes to a path
|
|
128
|
+
outside the pack (absolute, home, /tmp, …).
|
|
129
|
+
INSTALL_HOOK — a file that runs at install time
|
|
130
|
+
(setup.py, a .pth file, an install script, a
|
|
131
|
+
package.json install hook): review it first.
|
|
132
|
+
|
|
133
|
+
VERDICT (printed on every report):
|
|
134
|
+
BLOCKED — at least one DANGER_* finding (a proven
|
|
135
|
+
three-test combination). Exit 1.
|
|
136
|
+
REVIEW — no danger finding, but at least one ERROR or
|
|
137
|
+
WARN. Exit 0 (exit 1 with --strict).
|
|
138
|
+
CLEAN — no ERROR or WARN findings (INFO may remain).
|
|
139
|
+
Exit 0.
|
|
140
|
+
|
|
141
|
+
WHAT IT DOES NOT CLAIM (stated plainly): static pattern
|
|
142
|
+
analysis reads evidence, it does not prove safety. A
|
|
143
|
+
CLEAN verdict means no pattern fired — a well-written
|
|
144
|
+
plain-sight social-engineering skill, a payload assembled
|
|
145
|
+
at runtime from innocuous pieces, or a binary's behaviour
|
|
146
|
+
can all pass. Vet is the first pass, not the verdict on
|
|
147
|
+
a pack's soul. Input errors (missing folder, not a
|
|
148
|
+
folder, bad arguments) exit 2 as a clean {"error": ...}
|
|
149
|
+
object in --json mode — never a traceback.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
import argparse
|
|
153
|
+
import json
|
|
154
|
+
import os
|
|
155
|
+
import re
|
|
156
|
+
import sys
|
|
157
|
+
|
|
158
|
+
TOOL = "skill-vet"
|
|
159
|
+
VERSION = "1.0.0"
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Constants
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
LARGE_FILE_BYTES = 1_048_576 # 1 MiB
|
|
166
|
+
SCAN_CAP_BYTES = 2_097_152 # text scanning cap per file
|
|
167
|
+
BASE64_BLOB_MIN = 120 # chars in one token
|
|
168
|
+
HEX_BLOB_MIN = 120
|
|
169
|
+
ESCAPE_RUN_MIN = 8 # consecutive \xNN escapes
|
|
170
|
+
BLANK_RUN_MIN = 50 # consecutive blank lines
|
|
171
|
+
HIDDEN_COMMENT_MIN = 120 # chars inside <!-- ... -->
|
|
172
|
+
|
|
173
|
+
SKIP_DIRS = {".git", "__pycache__"}
|
|
174
|
+
|
|
175
|
+
SCRIPT_EXTS = {
|
|
176
|
+
".py", ".sh", ".bash", ".zsh", ".ksh", ".js", ".mjs",
|
|
177
|
+
".cjs", ".ts", ".rb", ".pl", ".php", ".ps1", ".bat",
|
|
178
|
+
".cmd",
|
|
179
|
+
}
|
|
180
|
+
MARKDOWN_EXTS = {".md", ".markdown"}
|
|
181
|
+
BINARY_EXTS = {
|
|
182
|
+
".exe", ".dll", ".so", ".dylib", ".bin", ".whl", ".jar",
|
|
183
|
+
".class", ".o", ".a", ".node", ".wasm", ".com", ".msi",
|
|
184
|
+
".dmg", ".pkg", ".deb", ".rpm", ".elf", ".pyc",
|
|
185
|
+
}
|
|
186
|
+
REF_EXTS = {
|
|
187
|
+
".md", ".py", ".sh", ".json", ".txt", ".csv", ".yaml",
|
|
188
|
+
".yml", ".toml", ".js", ".ts",
|
|
189
|
+
}
|
|
190
|
+
REF_PREFIX_DIRS = (
|
|
191
|
+
"scripts/", "references/", "assets/", "templates/",
|
|
192
|
+
"data/", "files/", "docs/",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
VERDICT_RULE = (
|
|
196
|
+
"BLOCKED = at least one danger finding (a proven "
|
|
197
|
+
"stealing / spying / hidden-sending combination); "
|
|
198
|
+
"REVIEW = no danger finding, but at least one ERROR "
|
|
199
|
+
"or WARN; CLEAN = no ERROR or WARN findings (INFO "
|
|
200
|
+
"may remain). Exit 1 only for BLOCKED (or for REVIEW "
|
|
201
|
+
"with --strict); exit 2 is an input error."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
LIMITS_LINE = (
|
|
205
|
+
"Static pattern analysis only: it reads evidence, it "
|
|
206
|
+
"cannot prove a pack safe. A CLEAN verdict means no "
|
|
207
|
+
"pattern fired — plain-sight social engineering, a "
|
|
208
|
+
"payload assembled at runtime, and bundled binaries "
|
|
209
|
+
"can all pass a static vet."
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ToolError(Exception):
|
|
214
|
+
"""An input error: clean message, exit 2."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Findings
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
_SEVERITY_RANK = {"ERROR": 0, "WARN": 1, "INFO": 2}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def finding(rule, severity, file, line, message, fix,
|
|
225
|
+
danger=False):
|
|
226
|
+
return {
|
|
227
|
+
"rule": rule,
|
|
228
|
+
"severity": severity,
|
|
229
|
+
"danger": danger,
|
|
230
|
+
"file": file,
|
|
231
|
+
"line": line,
|
|
232
|
+
"message": message,
|
|
233
|
+
"fix": fix,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def sort_findings(findings):
|
|
238
|
+
def key(f):
|
|
239
|
+
return (
|
|
240
|
+
0 if f["danger"] else 1,
|
|
241
|
+
_SEVERITY_RANK[f["severity"]],
|
|
242
|
+
f["file"] or "",
|
|
243
|
+
f["line"] or 0,
|
|
244
|
+
f["rule"],
|
|
245
|
+
)
|
|
246
|
+
return sorted(findings, key=key)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Frontmatter (a deliberately small YAML subset)
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Supported: `key: value` scalars (quotes stripped),
|
|
253
|
+
# `key: [a, b]` inline lists, `key:` followed by `- item`
|
|
254
|
+
# block lists, and indented continuation lines folded into
|
|
255
|
+
# the previous scalar. Anything else is a parse error with
|
|
256
|
+
# the line cited — a frontmatter the subset cannot read
|
|
257
|
+
# is reported, never guessed at.
|
|
258
|
+
|
|
259
|
+
_KEY_LINE = re.compile(r"^([A-Za-z0-9_-]+):\s*(.*)$")
|
|
260
|
+
_LIST_ITEM = re.compile(r"^\s+-\s+(.*)$")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _strip_quotes(value):
|
|
264
|
+
value = value.strip()
|
|
265
|
+
if len(value) >= 2 and value[0] == value[-1] and \
|
|
266
|
+
value[0] in ("'", '"'):
|
|
267
|
+
return value[1:-1]
|
|
268
|
+
return value
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse_frontmatter(text):
|
|
272
|
+
"""Return (fields, body, error, error_line).
|
|
273
|
+
|
|
274
|
+
fields maps key -> str | list[str]. error is None on
|
|
275
|
+
success; on a missing block error is "missing".
|
|
276
|
+
Line numbers are 1-based, counting the opening ---.
|
|
277
|
+
"""
|
|
278
|
+
lines = text.splitlines()
|
|
279
|
+
if not lines or lines[0].strip() != "---":
|
|
280
|
+
return None, text, "missing", None
|
|
281
|
+
close = None
|
|
282
|
+
for i in range(1, min(len(lines), 400)):
|
|
283
|
+
if lines[i].strip() in ("---", "..."):
|
|
284
|
+
close = i
|
|
285
|
+
break
|
|
286
|
+
if close is None:
|
|
287
|
+
return None, text, "no closing --- for the " \
|
|
288
|
+
"frontmatter block", 1
|
|
289
|
+
fields = {}
|
|
290
|
+
current = None
|
|
291
|
+
for i in range(1, close):
|
|
292
|
+
raw = lines[i]
|
|
293
|
+
lineno = i + 1
|
|
294
|
+
if not raw.strip() or raw.strip().startswith("#"):
|
|
295
|
+
continue
|
|
296
|
+
item = _LIST_ITEM.match(raw)
|
|
297
|
+
if item and current is not None:
|
|
298
|
+
value = _strip_quotes(item.group(1))
|
|
299
|
+
if not isinstance(fields.get(current), list):
|
|
300
|
+
fields[current] = []
|
|
301
|
+
fields[current].append(value)
|
|
302
|
+
continue
|
|
303
|
+
if raw[0] in (" ", "\t") and current is not None and \
|
|
304
|
+
isinstance(fields.get(current), str):
|
|
305
|
+
fields[current] = (
|
|
306
|
+
fields[current] + " " + raw.strip()).strip()
|
|
307
|
+
continue
|
|
308
|
+
m = _KEY_LINE.match(raw)
|
|
309
|
+
if not m:
|
|
310
|
+
return None, text, "frontmatter line %d is not " \
|
|
311
|
+
"a key: value line, a list item, or a " \
|
|
312
|
+
"continuation: %r" % (lineno, raw.strip()), \
|
|
313
|
+
lineno
|
|
314
|
+
key, value = m.group(1), m.group(2).strip()
|
|
315
|
+
current = key
|
|
316
|
+
if value == "":
|
|
317
|
+
fields[key] = ""
|
|
318
|
+
elif value.startswith("[") and value.endswith("]"):
|
|
319
|
+
inner = value[1:-1].strip()
|
|
320
|
+
fields[key] = [
|
|
321
|
+
_strip_quotes(part)
|
|
322
|
+
for part in inner.split(",") if part.strip()
|
|
323
|
+
] if inner else []
|
|
324
|
+
else:
|
|
325
|
+
fields[key] = _strip_quotes(value)
|
|
326
|
+
body = "\n".join(lines[close + 1:])
|
|
327
|
+
return fields, body, None, None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def field_text(fields, key):
|
|
331
|
+
"""A frontmatter field as one string (lists join)."""
|
|
332
|
+
value = fields.get(key)
|
|
333
|
+
if value is None:
|
|
334
|
+
return None
|
|
335
|
+
if isinstance(value, list):
|
|
336
|
+
return ", ".join(value)
|
|
337
|
+
return value
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def field_entries(fields, key):
|
|
341
|
+
"""A frontmatter field as a list of entries."""
|
|
342
|
+
value = fields.get(key)
|
|
343
|
+
if value is None:
|
|
344
|
+
return []
|
|
345
|
+
if isinstance(value, list):
|
|
346
|
+
return list(value)
|
|
347
|
+
return [part.strip() for part in value.split(",")
|
|
348
|
+
if part.strip()]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
# Inventory
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
def _is_binary(path, ext, head):
|
|
356
|
+
if ext in BINARY_EXTS:
|
|
357
|
+
return True
|
|
358
|
+
return b"\x00" in head
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def inventory(pack_dir):
|
|
362
|
+
"""Walk the pack. Returns a list of records:
|
|
363
|
+
{path, bytes, kind, binary, scanned, symlink_out}.
|
|
364
|
+
.git and __pycache__ directories are skipped.
|
|
365
|
+
"""
|
|
366
|
+
records = []
|
|
367
|
+
for root, dirs, names in os.walk(pack_dir):
|
|
368
|
+
dirs[:] = sorted(
|
|
369
|
+
d for d in dirs if d not in SKIP_DIRS)
|
|
370
|
+
for name in sorted(names):
|
|
371
|
+
full = os.path.join(root, name)
|
|
372
|
+
rel = os.path.relpath(full, pack_dir)
|
|
373
|
+
rec = {
|
|
374
|
+
"path": rel.replace(os.sep, "/"),
|
|
375
|
+
"bytes": 0,
|
|
376
|
+
"kind": "other",
|
|
377
|
+
"binary": False,
|
|
378
|
+
"scanned": False,
|
|
379
|
+
"symlink_out": False,
|
|
380
|
+
}
|
|
381
|
+
try:
|
|
382
|
+
rec["bytes"] = os.path.getsize(full)
|
|
383
|
+
except OSError:
|
|
384
|
+
pass
|
|
385
|
+
if os.path.islink(full):
|
|
386
|
+
real = os.path.realpath(full)
|
|
387
|
+
if not real.startswith(
|
|
388
|
+
os.path.realpath(pack_dir) + os.sep):
|
|
389
|
+
rec["symlink_out"] = True
|
|
390
|
+
ext = os.path.splitext(name)[1].lower()
|
|
391
|
+
head = b""
|
|
392
|
+
if not rec["symlink_out"]:
|
|
393
|
+
try:
|
|
394
|
+
with open(full, "rb") as fh:
|
|
395
|
+
head = fh.read(8192)
|
|
396
|
+
except OSError:
|
|
397
|
+
head = b""
|
|
398
|
+
rec["binary"] = _is_binary(full, ext, head)
|
|
399
|
+
if ext in SCRIPT_EXTS or head.startswith(b"#!"):
|
|
400
|
+
rec["kind"] = "script"
|
|
401
|
+
elif ext in MARKDOWN_EXTS:
|
|
402
|
+
rec["kind"] = "markdown"
|
|
403
|
+
elif rec["binary"]:
|
|
404
|
+
rec["kind"] = "binary"
|
|
405
|
+
elif ext:
|
|
406
|
+
rec["kind"] = "text"
|
|
407
|
+
records.append(rec)
|
|
408
|
+
records.sort(key=lambda r: r["path"])
|
|
409
|
+
return records
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def read_text(pack_dir, rel):
|
|
413
|
+
"""Read one pack file as text (utf-8, lossy). Returns
|
|
414
|
+
None when unreadable. Capped at SCAN_CAP_BYTES."""
|
|
415
|
+
full = os.path.join(pack_dir, rel.replace("/", os.sep))
|
|
416
|
+
try:
|
|
417
|
+
with open(full, "rb") as fh:
|
|
418
|
+
data = fh.read(SCAN_CAP_BYTES)
|
|
419
|
+
except OSError:
|
|
420
|
+
return None
|
|
421
|
+
return data.decode("utf-8", errors="replace")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
# Pattern tables (static text — data, never executed)
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
# Each entry: (label, compiled regex). Lines are matched
|
|
428
|
+
# one at a time; a "hit" records (line_number, label).
|
|
429
|
+
|
|
430
|
+
SECRET_PATTERNS = [
|
|
431
|
+
("an SSH key or .ssh store path",
|
|
432
|
+
re.compile(r"\.ssh[/\\]|id_(rsa|dsa|ecdsa|ed25519)\b")),
|
|
433
|
+
("an AWS credentials/config path",
|
|
434
|
+
re.compile(r"\.aws[/\\]|\.aws\b")),
|
|
435
|
+
("a GnuPG store path", re.compile(r"\.gnupg\b")),
|
|
436
|
+
("a .netrc credentials file", re.compile(r"\.netrc\b|_netrc\b")),
|
|
437
|
+
("a stored-credentials file",
|
|
438
|
+
re.compile(r"\.git-credentials\b|\.docker[/\\]config\.json"
|
|
439
|
+
r"|\.kube[/\\]config\b")),
|
|
440
|
+
("a browser credential store",
|
|
441
|
+
re.compile(r"Login Data|Local State|logins\.json|key4\.db"
|
|
442
|
+
r"|places\.sqlite|Cookies\.sqlite")),
|
|
443
|
+
("a browser profile directory",
|
|
444
|
+
re.compile(r"User Data[/\\]|[/\\]Mozilla[/\\]|[/\\]Firefox[/\\]"
|
|
445
|
+
r"|profiles\.ini")),
|
|
446
|
+
("a .env secrets file",
|
|
447
|
+
re.compile(r"[\"'][^\"']*\.env[\"']|(?:cat|open|read)\b"
|
|
448
|
+
r"[^\n]*\.env\b")),
|
|
449
|
+
]
|
|
450
|
+
|
|
451
|
+
_ENV_ACCESS = re.compile(
|
|
452
|
+
r"os\.environ|os\.getenv|environ\.get|process\.env|ENV\["
|
|
453
|
+
r"|getenv\s*\(")
|
|
454
|
+
_ENV_SECRET_NAME = re.compile(
|
|
455
|
+
r"SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY"
|
|
456
|
+
r"|CREDENTIAL|AWS_|GITHUB_|GH_TOKEN|OPENAI_|ANTHROPIC_"
|
|
457
|
+
r"|STRIPE_")
|
|
458
|
+
_ENV_WHOLE = re.compile(
|
|
459
|
+
r"dict\(\s*os\.environ|list\(\s*os\.environ"
|
|
460
|
+
r"|json\.dumps\([^)]*os\.environ|\{\*\*os\.environ"
|
|
461
|
+
r"|os\.environ\.items\(\)|os\.environ\.copy\(\)")
|
|
462
|
+
|
|
463
|
+
SEND_PATTERNS = [
|
|
464
|
+
("an HTTP client call",
|
|
465
|
+
re.compile(r"requests\.(get|post|put|patch|delete|request"
|
|
466
|
+
r"|head)\s*\(|httpx\.(get|post|put|patch|delete"
|
|
467
|
+
r"|request|stream)\s*\(")),
|
|
468
|
+
("a urllib request",
|
|
469
|
+
re.compile(r"urlopen\s*\(|urllib\.request")),
|
|
470
|
+
("a raw HTTP connection",
|
|
471
|
+
re.compile(r"http\.client\.|HTTPSConnection|HTTPConnection")),
|
|
472
|
+
("a socket connection",
|
|
473
|
+
re.compile(r"socket\.socket\s*\(|create_connection\s*\(")),
|
|
474
|
+
("a browser-style fetch",
|
|
475
|
+
re.compile(r"\bfetch\s*\(|axios\.(get|post|put|delete"
|
|
476
|
+
r"|request)|XMLHttpRequest")),
|
|
477
|
+
("a shell network tool",
|
|
478
|
+
re.compile(r"\bcurl\b|\bwget\b|\bnc\s+-|netcat\b"
|
|
479
|
+
r"|Invoke-WebRequest|Invoke-RestMethod")),
|
|
480
|
+
("a mail/FTP/telnet client",
|
|
481
|
+
re.compile(r"smtplib\.|ftplib\.|telnetlib\.|websocket")),
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
_URL_RE = re.compile(r"(?:https?|ftp|wss?)://([A-Za-z0-9.\-]+)")
|
|
485
|
+
|
|
486
|
+
CAPTURE_PATTERNS = {
|
|
487
|
+
"screen": [
|
|
488
|
+
("a screenshot call",
|
|
489
|
+
re.compile(r"pyautogui\.screenshot|ImageGrab\.grab"
|
|
490
|
+
r"|\bmss\.mss|pyscreenshot|\bscreencapture\b"
|
|
491
|
+
r"|\bscrot\b")),
|
|
492
|
+
],
|
|
493
|
+
"keyboard": [
|
|
494
|
+
("a keyboard hook/listener",
|
|
495
|
+
re.compile(r"pynput|GetAsyncKeyState|pyxhook"
|
|
496
|
+
r"|keyboard\.(on_press|record|hook)"
|
|
497
|
+
r"|\bkeylogger?\b")),
|
|
498
|
+
],
|
|
499
|
+
"audio": [
|
|
500
|
+
("a microphone/audio capture",
|
|
501
|
+
re.compile(r"pyaudio|sounddevice|\barecord\b"
|
|
502
|
+
r"|Microphone\s*\(")),
|
|
503
|
+
],
|
|
504
|
+
"camera": [
|
|
505
|
+
("a camera capture",
|
|
506
|
+
re.compile(r"cv2\.VideoCapture|VideoCapture\s*\(")),
|
|
507
|
+
],
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
CAPTURE_DISCLOSURE_WORDS = {
|
|
511
|
+
"screen": ("screenshot", "screen capture", "screen recording",
|
|
512
|
+
"captures the screen", "screen grab"),
|
|
513
|
+
"keyboard": ("keystroke", "keyboard", "key logging",
|
|
514
|
+
"keylogging"),
|
|
515
|
+
"audio": ("microphone", "audio recording", "records audio",
|
|
516
|
+
"voice recording"),
|
|
517
|
+
"camera": ("webcam", "camera", "video capture"),
|
|
518
|
+
"history": ("browser history", "browsing history", "history"),
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
_SQLITE_RE = re.compile(r"sqlite3|sqlite\b|\bSELECT\b")
|
|
522
|
+
|
|
523
|
+
PERSIST_PATTERNS = [
|
|
524
|
+
("a file save/write",
|
|
525
|
+
re.compile(r"\.save\s*\(|open\s*\([^)]*[\"'][wa][\"']"
|
|
526
|
+
r"|\.write\s*\(")),
|
|
527
|
+
("an endless loop",
|
|
528
|
+
re.compile(r"while\s+True\s*:|while\s*\(\s*true\s*\)"
|
|
529
|
+
r"|for\s*\(\s*;\s*;")),
|
|
530
|
+
("an autostart/persistence mechanism",
|
|
531
|
+
re.compile(r"crontab|LaunchAgent|schtasks|autostart"
|
|
532
|
+
r"|systemd")),
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
ENCODE_PATTERNS = [
|
|
536
|
+
("a base64/hex/compression encoder",
|
|
537
|
+
re.compile(r"b64encode|b64decode|hexlify|unhexlify"
|
|
538
|
+
r"|codecs\.encode|zlib\.compress|gzip\.compress"
|
|
539
|
+
r"|bz2\.compress|lzma\.compress")),
|
|
540
|
+
]
|
|
541
|
+
|
|
542
|
+
COPY_OUT_PATTERNS = [
|
|
543
|
+
("a copy/move out",
|
|
544
|
+
re.compile(r"shutil\.(copy|copy2|copyfile|move)\b"
|
|
545
|
+
r"|os\.rename\s*\(|\bcp\s+|\bmv\s+|\bscp\b"
|
|
546
|
+
r"|\brsync\b")),
|
|
547
|
+
]
|
|
548
|
+
|
|
549
|
+
OUTSIDE_TOKENS = (
|
|
550
|
+
"~/", "$HOME", "%USERPROFILE%", "/Users/", "/home/",
|
|
551
|
+
"/etc/", "/root/", "/tmp/", "/var/", "C:\\Users",
|
|
552
|
+
"C:/Users",
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
_READ_HINT = re.compile(
|
|
556
|
+
r"\bopen\s*\(|\bread\b|\bcat\s|Path\s*\(|expanduser"
|
|
557
|
+
r"|Path\.home")
|
|
558
|
+
_WRITE_HINT = re.compile(
|
|
559
|
+
r"open\s*\([^)]*[\"'][wa][\"']|\.write\s*\(|\.save\s*\("
|
|
560
|
+
r"|>\s*[\"']?(?:/|~|\$)")
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _is_comment_line(line, rel):
|
|
564
|
+
"""A full-line comment, for the hygiene checks: a
|
|
565
|
+
comment MENTIONING shell=True or eval is documentation,
|
|
566
|
+
not code. (Danger ingredients deliberately still scan
|
|
567
|
+
comment lines — capability evidence is judged
|
|
568
|
+
conservatively.)"""
|
|
569
|
+
stripped = line.lstrip()
|
|
570
|
+
if stripped.startswith("#"):
|
|
571
|
+
return True
|
|
572
|
+
ext = os.path.splitext(rel)[1].lower()
|
|
573
|
+
if ext in (".js", ".mjs", ".cjs", ".ts", ".php"):
|
|
574
|
+
return stripped.startswith("//")
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _hits(lines, patterns):
|
|
579
|
+
"""All (line, label) hits for a pattern list."""
|
|
580
|
+
out = []
|
|
581
|
+
for lineno, line in enumerate(lines, 1):
|
|
582
|
+
for label, rx in patterns:
|
|
583
|
+
if rx.search(line):
|
|
584
|
+
out.append((lineno, label))
|
|
585
|
+
break
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _first(hits):
|
|
590
|
+
return hits[0] if hits else None
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class FileScan:
|
|
594
|
+
"""The ingredient evidence found in one script file."""
|
|
595
|
+
|
|
596
|
+
def __init__(self, rel, lines):
|
|
597
|
+
self.rel = rel
|
|
598
|
+
self.lines = lines
|
|
599
|
+
self.secret = _hits(lines, SECRET_PATTERNS)
|
|
600
|
+
self.env_secret = [] # (line, label)
|
|
601
|
+
self.whole_env = [] # (line, label)
|
|
602
|
+
for lineno, line in enumerate(lines, 1):
|
|
603
|
+
if _ENV_ACCESS.search(line) and \
|
|
604
|
+
_ENV_SECRET_NAME.search(line):
|
|
605
|
+
self.env_secret.append(
|
|
606
|
+
(lineno, "an environment-secret read"))
|
|
607
|
+
if _ENV_WHOLE.search(line):
|
|
608
|
+
self.whole_env.append(
|
|
609
|
+
(lineno, "a whole-environment read"))
|
|
610
|
+
self.send = _hits(lines, SEND_PATTERNS)
|
|
611
|
+
self.hosts = [] # (host, line)
|
|
612
|
+
for lineno, line in enumerate(lines, 1):
|
|
613
|
+
for m in _URL_RE.finditer(line):
|
|
614
|
+
self.hosts.append((m.group(1), lineno))
|
|
615
|
+
self.captures = {} # kind -> [(line, label)]
|
|
616
|
+
for kind, pats in CAPTURE_PATTERNS.items():
|
|
617
|
+
hits = _hits(lines, pats)
|
|
618
|
+
if hits:
|
|
619
|
+
self.captures[kind] = hits
|
|
620
|
+
# browser-history scraping: a history store read
|
|
621
|
+
# plus a database read in the same file
|
|
622
|
+
hist_lines = [
|
|
623
|
+
(n, "a browser history store")
|
|
624
|
+
for n, line in enumerate(lines, 1)
|
|
625
|
+
if re.search(r"places\.sqlite|\bHistory\b", line)
|
|
626
|
+
]
|
|
627
|
+
if hist_lines and any(
|
|
628
|
+
_SQLITE_RE.search(l) for l in lines):
|
|
629
|
+
self.captures["history"] = hist_lines
|
|
630
|
+
self.persist = _hits(lines, PERSIST_PATTERNS)
|
|
631
|
+
self.encode = _hits(lines, ENCODE_PATTERNS)
|
|
632
|
+
self.copy_out = []
|
|
633
|
+
for lineno, line in enumerate(lines, 1):
|
|
634
|
+
if any(rx.search(line)
|
|
635
|
+
for _, rx in COPY_OUT_PATTERNS) and \
|
|
636
|
+
_outside_token(line):
|
|
637
|
+
self.copy_out.append(
|
|
638
|
+
(lineno, "a copy/move to an outside path"))
|
|
639
|
+
self.read_outside = [] # (line, label)
|
|
640
|
+
self.write_outside = [] # (line, label)
|
|
641
|
+
for lineno, line in enumerate(lines, 1):
|
|
642
|
+
if _outside_token(line):
|
|
643
|
+
if _READ_HINT.search(line):
|
|
644
|
+
self.read_outside.append(
|
|
645
|
+
(lineno, "a read of a path outside "
|
|
646
|
+
"the pack"))
|
|
647
|
+
if _WRITE_HINT.search(line):
|
|
648
|
+
self.write_outside.append(
|
|
649
|
+
(lineno, "a write to a path outside "
|
|
650
|
+
"the pack"))
|
|
651
|
+
|
|
652
|
+
# -- send-segment analysis --------------------------------
|
|
653
|
+
def send_segments(self):
|
|
654
|
+
"""Approximate call segments for each send hit:
|
|
655
|
+
the hit line plus following lines until the parens
|
|
656
|
+
balance. Used to see what actually flows out."""
|
|
657
|
+
segments = []
|
|
658
|
+
for lineno, _label in self.send:
|
|
659
|
+
chunk = []
|
|
660
|
+
depth = 0
|
|
661
|
+
started = False
|
|
662
|
+
for j in range(lineno - 1, min(len(self.lines),
|
|
663
|
+
lineno + 14)):
|
|
664
|
+
text = self.lines[j]
|
|
665
|
+
chunk.append(text)
|
|
666
|
+
depth += text.count("(") - text.count(")")
|
|
667
|
+
if "(" in text:
|
|
668
|
+
started = True
|
|
669
|
+
if started and depth <= 0:
|
|
670
|
+
break
|
|
671
|
+
segments.append((lineno, "\n".join(chunk)))
|
|
672
|
+
return segments
|
|
673
|
+
|
|
674
|
+
def encoded_payload_sent(self):
|
|
675
|
+
"""(encode_line, send_line) when an encoded value
|
|
676
|
+
reaches a send: the encoder call inside the send
|
|
677
|
+
segment, a variable assigned from an encoder used
|
|
678
|
+
in the segment, or os.environ inside the segment
|
|
679
|
+
(whole-env exfil is judged under STEALING, not
|
|
680
|
+
here)."""
|
|
681
|
+
if not self.send or not self.encode:
|
|
682
|
+
return None
|
|
683
|
+
enc_vars = set()
|
|
684
|
+
assign = re.compile(
|
|
685
|
+
r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*.*"
|
|
686
|
+
r"(b64encode|hexlify|codecs\.encode|zlib\.compress"
|
|
687
|
+
r"|gzip\.compress|bz2\.compress|lzma\.compress)")
|
|
688
|
+
for line in self.lines:
|
|
689
|
+
m = assign.match(line)
|
|
690
|
+
if m:
|
|
691
|
+
enc_vars.add(m.group(1))
|
|
692
|
+
enc_line = self.encode[0][0]
|
|
693
|
+
for send_line, segment in self.send_segments():
|
|
694
|
+
if any(rx.search(segment)
|
|
695
|
+
for _, rx in ENCODE_PATTERNS):
|
|
696
|
+
return (enc_line, send_line)
|
|
697
|
+
for var in enc_vars:
|
|
698
|
+
if re.search(r"\b%s\b" % re.escape(var),
|
|
699
|
+
segment):
|
|
700
|
+
return (enc_line, send_line)
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _outside_token(line):
|
|
705
|
+
return any(tok in line for tok in OUTSIDE_TOKENS)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ------------------------------------------------------------------
|
|
709
|
+
# Disclosure (what the pack's own docs admit to)
|
|
710
|
+
# ------------------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
def doc_hosts(doc_text):
|
|
713
|
+
return {m.group(1).lower()
|
|
714
|
+
for m in _URL_RE.finditer(doc_text)}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def host_disclosed(host, hosts):
|
|
718
|
+
host = host.lower()
|
|
719
|
+
return any(host == h or host.endswith("." + h)
|
|
720
|
+
for h in hosts)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
_OFFLINE_CLAIM = re.compile(
|
|
724
|
+
r"(?i)\boffline\b|no network|never (sends|phones|connects"
|
|
725
|
+
r"|shares)|local[- ]only|works locally|does not (send"
|
|
726
|
+
r"|connect|phone|share)|nothing (is )?(sent|leaves|shared)")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def capture_disclosed(kind, doc_text_lower):
|
|
730
|
+
return any(word in doc_text_lower
|
|
731
|
+
for word in CAPTURE_DISCLOSURE_WORDS[kind])
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ------------------------------------------------------------------
|
|
735
|
+
# The danger tests + short-of-danger warnings (per script)
|
|
736
|
+
# ------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
def scan_script(scan, docs_text, docs_lower, hosts_in_docs,
|
|
739
|
+
offline_claim):
|
|
740
|
+
out = []
|
|
741
|
+
rel = scan.rel
|
|
742
|
+
|
|
743
|
+
def cite(hit):
|
|
744
|
+
return hit[0] if hit else None
|
|
745
|
+
|
|
746
|
+
secret_hits = scan.secret + scan.env_secret + scan.whole_env
|
|
747
|
+
send_hit = _first(scan.send)
|
|
748
|
+
encoded = scan.encoded_payload_sent()
|
|
749
|
+
undisclosed_hosts = [
|
|
750
|
+
(host, line) for host, line in scan.hosts
|
|
751
|
+
if not host_disclosed(host, hosts_in_docs)
|
|
752
|
+
]
|
|
753
|
+
|
|
754
|
+
# ---- Test 1: STEALING --------------------------------
|
|
755
|
+
egress = send_hit or _first(scan.copy_out)
|
|
756
|
+
stealing_fired = False
|
|
757
|
+
if secret_hits and egress:
|
|
758
|
+
stealing_fired = True
|
|
759
|
+
s_line, s_label = secret_hits[0]
|
|
760
|
+
e_line, e_label = egress
|
|
761
|
+
out.append(finding(
|
|
762
|
+
"DANGER_STEALING", "ERROR", rel, s_line,
|
|
763
|
+
"STEALING combination: this file reads secrets "
|
|
764
|
+
"(%s, line %d) and has an egress path (%s, line "
|
|
765
|
+
"%d). Credential material plus a way out is the "
|
|
766
|
+
"stealing test — the pack can take what it reads "
|
|
767
|
+
"off the machine." % (s_label, s_line, e_label,
|
|
768
|
+
e_line),
|
|
769
|
+
"do not install; if you maintain this pack, "
|
|
770
|
+
"remove the credential read or the egress — a "
|
|
771
|
+
"legitimate pack needs at most one of them",
|
|
772
|
+
danger=True))
|
|
773
|
+
elif secret_hits:
|
|
774
|
+
s_line, s_label = secret_hits[0]
|
|
775
|
+
out.append(finding(
|
|
776
|
+
"SECRET_ACCESS", "WARN", rel, s_line,
|
|
777
|
+
"this file reads secrets (%s, line %d) but no "
|
|
778
|
+
"send or copy-out was found in the same file — "
|
|
779
|
+
"short of the stealing combination, and still "
|
|
780
|
+
"worth asking why a skill needs it"
|
|
781
|
+
% (s_label, s_line),
|
|
782
|
+
"confirm the pack genuinely needs this access "
|
|
783
|
+
"before installing"))
|
|
784
|
+
|
|
785
|
+
# ---- Test 2: SPYING ----------------------------------
|
|
786
|
+
for kind, hits in sorted(scan.captures.items()):
|
|
787
|
+
c_line, c_label = hits[0]
|
|
788
|
+
persist_hit = _first(scan.persist)
|
|
789
|
+
if send_hit or persist_hit:
|
|
790
|
+
if capture_disclosed(kind, docs_lower):
|
|
791
|
+
out.append(finding(
|
|
792
|
+
"DISCLOSED_CAPTURE", "WARN", rel, c_line,
|
|
793
|
+
"this file captures %s (%s, line %d) and "
|
|
794
|
+
"persists or sends it — and the pack's "
|
|
795
|
+
"docs DO disclose %s capture, so this is "
|
|
796
|
+
"not the spying test; it is a capability "
|
|
797
|
+
"to accept knowingly"
|
|
798
|
+
% (kind, c_label, c_line, kind),
|
|
799
|
+
"install only if you want this capture "
|
|
800
|
+
"happening"))
|
|
801
|
+
else:
|
|
802
|
+
via = send_hit or persist_hit
|
|
803
|
+
out.append(finding(
|
|
804
|
+
"DANGER_SPYING", "ERROR", rel, c_line,
|
|
805
|
+
"SPYING combination: this file captures "
|
|
806
|
+
"%s (%s, line %d), persists or sends it "
|
|
807
|
+
"(%s, line %d), and the pack's docs "
|
|
808
|
+
"nowhere disclose %s capture — capture "
|
|
809
|
+
"plus a way to keep or move it, in "
|
|
810
|
+
"secret, is the spying test."
|
|
811
|
+
% (kind, c_label, c_line, via[1], via[0],
|
|
812
|
+
kind),
|
|
813
|
+
"do not install; undisclosed capture is "
|
|
814
|
+
"never a feature",
|
|
815
|
+
danger=True))
|
|
816
|
+
else:
|
|
817
|
+
out.append(finding(
|
|
818
|
+
"CAPTURE_CAPABILITY", "WARN", rel, c_line,
|
|
819
|
+
"this file can capture %s (%s, line %d) but "
|
|
820
|
+
"no persistence or send was found in the "
|
|
821
|
+
"same file — capability alone is short of "
|
|
822
|
+
"the spying combination"
|
|
823
|
+
% (kind, c_label, c_line),
|
|
824
|
+
"ask why the pack ships a capture API it "
|
|
825
|
+
"does not appear to use"))
|
|
826
|
+
|
|
827
|
+
# ---- Test 3: HIDDEN SENDING ---------------------------
|
|
828
|
+
# A send already judged as the egress half of STEALING
|
|
829
|
+
# in this file is not counted a second time here.
|
|
830
|
+
hidden_reason = None
|
|
831
|
+
if send_hit and not stealing_fired:
|
|
832
|
+
if offline_claim:
|
|
833
|
+
hidden_reason = (
|
|
834
|
+
"the pack's docs claim it works offline / "
|
|
835
|
+
"local-only, yet this file makes a network "
|
|
836
|
+
"call (%s, line %d) — a send the docs deny "
|
|
837
|
+
"exists" % (send_hit[1], send_hit[0]))
|
|
838
|
+
elif encoded and (undisclosed_hosts or not scan.hosts):
|
|
839
|
+
hidden_reason = (
|
|
840
|
+
"an encoded payload reaches the network "
|
|
841
|
+
"call (encoder at line %d, send at line %d) "
|
|
842
|
+
"to an endpoint the docs never disclose — "
|
|
843
|
+
"neither what is sent nor where it goes "
|
|
844
|
+
"can be read off the pack"
|
|
845
|
+
% encoded)
|
|
846
|
+
elif scan.read_outside and undisclosed_hosts:
|
|
847
|
+
hidden_reason = (
|
|
848
|
+
"this file reads local files from outside "
|
|
849
|
+
"the pack (line %d) and sends to an endpoint "
|
|
850
|
+
"the docs never mention (%s, line %d) — "
|
|
851
|
+
"local contents leaving to an unnamed place"
|
|
852
|
+
% (scan.read_outside[0][0],
|
|
853
|
+
undisclosed_hosts[0][0],
|
|
854
|
+
undisclosed_hosts[0][1]))
|
|
855
|
+
elif scan.write_outside and undisclosed_hosts:
|
|
856
|
+
hidden_reason = (
|
|
857
|
+
"this file writes outside the pack (line "
|
|
858
|
+
"%d) and sends to an endpoint the docs never "
|
|
859
|
+
"mention (%s, line %d)"
|
|
860
|
+
% (scan.write_outside[0][0],
|
|
861
|
+
undisclosed_hosts[0][0],
|
|
862
|
+
undisclosed_hosts[0][1]))
|
|
863
|
+
if hidden_reason:
|
|
864
|
+
out.append(finding(
|
|
865
|
+
"DANGER_HIDDEN_SENDING", "ERROR", rel,
|
|
866
|
+
send_hit[0],
|
|
867
|
+
"HIDDEN-SENDING combination: " + hidden_reason
|
|
868
|
+
+ ". Concealment of what is sent, or to where, "
|
|
869
|
+
"is the hidden-sending test.",
|
|
870
|
+
"do not install until the send is disclosed "
|
|
871
|
+
"in the docs and the payload is readable in "
|
|
872
|
+
"the code",
|
|
873
|
+
danger=True))
|
|
874
|
+
elif send_hit:
|
|
875
|
+
# short-of-danger network warnings (suppressed when
|
|
876
|
+
# a danger finding in this file already covers the
|
|
877
|
+
# send)
|
|
878
|
+
covered = any(f["danger"] for f in out)
|
|
879
|
+
if encoded and not covered:
|
|
880
|
+
out.append(finding(
|
|
881
|
+
"ENCODED_PAYLOAD", "WARN", rel, encoded[1],
|
|
882
|
+
"an encoded payload reaches a network call "
|
|
883
|
+
"(encoder at line %d, send at line %d) to a "
|
|
884
|
+
"disclosed endpoint — the destination is "
|
|
885
|
+
"honest, what is sent is opaque"
|
|
886
|
+
% encoded,
|
|
887
|
+
"ask the pack's author what the encoded "
|
|
888
|
+
"payload contains"))
|
|
889
|
+
elif undisclosed_hosts and not covered:
|
|
890
|
+
host, h_line = undisclosed_hosts[0]
|
|
891
|
+
out.append(finding(
|
|
892
|
+
"UNDISCLOSED_NETWORK", "WARN", rel, h_line,
|
|
893
|
+
"this file calls %s (line %d), an endpoint "
|
|
894
|
+
"that appears nowhere in the pack's SKILL.md "
|
|
895
|
+
"/ README.md — undisclosed network use, "
|
|
896
|
+
"short of the hidden-sending combination "
|
|
897
|
+
"(no encoded payload, no local-file read, "
|
|
898
|
+
"no offline claim)" % (host, h_line),
|
|
899
|
+
"the pack should name every endpoint it "
|
|
900
|
+
"calls in its docs"))
|
|
901
|
+
elif not scan.hosts and not covered:
|
|
902
|
+
out.append(finding(
|
|
903
|
+
"DYNAMIC_ENDPOINT", "WARN", rel, send_hit[0],
|
|
904
|
+
"this file makes a network call (%s, line "
|
|
905
|
+
"%d) with no literal URL in the code — the "
|
|
906
|
+
"endpoint cannot be checked against the docs"
|
|
907
|
+
% (send_hit[1], send_hit[0]),
|
|
908
|
+
"read where the URL is built before "
|
|
909
|
+
"installing"))
|
|
910
|
+
|
|
911
|
+
# ---- Hygiene that lives in scripts --------------------
|
|
912
|
+
# (full-line comments are skipped here: a comment that
|
|
913
|
+
# mentions shell=True is documentation, not code)
|
|
914
|
+
code_lines = [(n, line) for n, line in
|
|
915
|
+
enumerate(scan.lines, 1)
|
|
916
|
+
if not _is_comment_line(line, rel)]
|
|
917
|
+
for lineno, line in code_lines:
|
|
918
|
+
if "shell=True" in line or "shell: true" in line:
|
|
919
|
+
out.append(finding(
|
|
920
|
+
"HYGIENE_SHELL_TRUE", "WARN", rel, lineno,
|
|
921
|
+
"subprocess runs with shell=True — command "
|
|
922
|
+
"strings become shell input, so any value "
|
|
923
|
+
"that reaches the command is an injection "
|
|
924
|
+
"path",
|
|
925
|
+
"use an argument list with shell=False"))
|
|
926
|
+
break
|
|
927
|
+
dyn = None
|
|
928
|
+
for lineno, line in code_lines:
|
|
929
|
+
if re.search(r"\beval\s*\(|\bexec\s*\(|os\.system\s*\("
|
|
930
|
+
r"|os\.popen\s*\(|__import__\s*\(", line):
|
|
931
|
+
dyn = lineno
|
|
932
|
+
break
|
|
933
|
+
if dyn is not None:
|
|
934
|
+
out.append(finding(
|
|
935
|
+
"HYGIENE_DYNAMIC_EXEC", "WARN", rel, dyn,
|
|
936
|
+
"dynamic execution (eval / exec / os.system / "
|
|
937
|
+
"os.popen / __import__) at line %d — code whose "
|
|
938
|
+
"behaviour cannot be fully read statically"
|
|
939
|
+
% dyn,
|
|
940
|
+
"review exactly what strings can reach it"))
|
|
941
|
+
pipe = None
|
|
942
|
+
for lineno, line in code_lines:
|
|
943
|
+
if re.search(r"(curl|wget)[^|\n]*\|\s*(sh|bash|zsh)\b",
|
|
944
|
+
line) or re.search(
|
|
945
|
+
r"(sh|bash)\s+<\(\s*(curl|wget)", line):
|
|
946
|
+
pipe = lineno
|
|
947
|
+
break
|
|
948
|
+
if pipe is not None or (scan.send and dyn is not None):
|
|
949
|
+
where = pipe if pipe is not None else dyn
|
|
950
|
+
out.append(finding(
|
|
951
|
+
"HYGIENE_DOWNLOAD_EXEC", "WARN", rel, where,
|
|
952
|
+
"downloaded content is executed (a download "
|
|
953
|
+
"piped to a shell, or a download and a dynamic "
|
|
954
|
+
"exec in the same file) — the pack's real "
|
|
955
|
+
"behaviour would live on a server, not in this "
|
|
956
|
+
"code",
|
|
957
|
+
"never install a pack that fetches its own "
|
|
958
|
+
"logic at run time"))
|
|
959
|
+
if scan.write_outside and not any(
|
|
960
|
+
f["rule"] == "DANGER_HIDDEN_SENDING" for f in out):
|
|
961
|
+
w_line, w_label = scan.write_outside[0]
|
|
962
|
+
out.append(finding(
|
|
963
|
+
"WRITES_OUTSIDE_PACK", "WARN", rel, w_line,
|
|
964
|
+
"%s (line %d) — a skill should keep its writes "
|
|
965
|
+
"inside its own folder or the user's explicit "
|
|
966
|
+
"workspace" % (w_label, w_line),
|
|
967
|
+
"confirm the destination is one you accept"))
|
|
968
|
+
return out
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
# ------------------------------------------------------------------
|
|
972
|
+
# Hygiene over text files (concealment + obfuscation)
|
|
973
|
+
# ------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
_INVISIBLE = {
|
|
976
|
+
"\u200b": "ZERO WIDTH SPACE",
|
|
977
|
+
"\u200c": "ZERO WIDTH NON-JOINER",
|
|
978
|
+
"\u200d": "ZERO WIDTH JOINER",
|
|
979
|
+
"\u2060": "WORD JOINER",
|
|
980
|
+
"\ufeff": "ZERO WIDTH NO-BREAK SPACE",
|
|
981
|
+
"\u00ad": "SOFT HYPHEN",
|
|
982
|
+
"\u180e": "MONGOLIAN VOWEL SEPARATOR",
|
|
983
|
+
}
|
|
984
|
+
for _cp in list(range(0x202A, 0x202F)) + list(range(0x2066, 0x206A)):
|
|
985
|
+
_INVISIBLE[chr(_cp)] = "BIDI CONTROL U+%04X" % _cp
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _invisible_hits(text):
|
|
989
|
+
"""(count, first_line, [names]) for invisible chars.
|
|
990
|
+
A leading BOM is a file signature, not concealment,
|
|
991
|
+
and is ignored. Unicode tag characters (U+E0000–
|
|
992
|
+
U+E007F) are counted by range."""
|
|
993
|
+
total = 0
|
|
994
|
+
first_line = None
|
|
995
|
+
names = []
|
|
996
|
+
body = text[1:] if text.startswith("\ufeff") else text
|
|
997
|
+
for lineno, line in enumerate(body.splitlines(), 1):
|
|
998
|
+
for ch in line:
|
|
999
|
+
name = _INVISIBLE.get(ch)
|
|
1000
|
+
if name is None and 0xE0000 <= ord(ch) <= 0xE007F:
|
|
1001
|
+
name = "UNICODE TAG CHARACTER U+%05X" % ord(ch)
|
|
1002
|
+
if name is not None:
|
|
1003
|
+
total += 1
|
|
1004
|
+
if first_line is None:
|
|
1005
|
+
first_line = lineno
|
|
1006
|
+
if name not in names and len(names) < 3:
|
|
1007
|
+
names.append(name)
|
|
1008
|
+
return total, first_line, names
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
_B64_TOKEN = re.compile(r"[A-Za-z0-9+/=]{%d,}" % BASE64_BLOB_MIN)
|
|
1012
|
+
_HEX_TOKEN = re.compile(r"[0-9a-fA-F]{%d,}" % HEX_BLOB_MIN)
|
|
1013
|
+
_ESCAPE_RUN = re.compile(r"(?:\\x[0-9a-fA-F]{2}){%d,}"
|
|
1014
|
+
% ESCAPE_RUN_MIN)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def scan_text_hygiene(rel, text, kind):
|
|
1018
|
+
out = []
|
|
1019
|
+
lines = text.splitlines()
|
|
1020
|
+
# obfuscation blobs (one finding per kind per file)
|
|
1021
|
+
found_kinds = set()
|
|
1022
|
+
for lineno, line in enumerate(lines, 1):
|
|
1023
|
+
for m in _ESCAPE_RUN.finditer(line):
|
|
1024
|
+
if "escape-run" not in found_kinds:
|
|
1025
|
+
found_kinds.add("escape-run")
|
|
1026
|
+
out.append(finding(
|
|
1027
|
+
"HYGIENE_OBFUSCATION", "WARN", rel, lineno,
|
|
1028
|
+
"a run of %d+ \\xNN escapes at line %d — "
|
|
1029
|
+
"code or text stored as escapes cannot be "
|
|
1030
|
+
"read at a glance, which is the point of "
|
|
1031
|
+
"storing it that way" % (ESCAPE_RUN_MIN,
|
|
1032
|
+
lineno),
|
|
1033
|
+
"decode it in the open or remove it"))
|
|
1034
|
+
for m in _B64_TOKEN.finditer(line):
|
|
1035
|
+
token = m.group(0)
|
|
1036
|
+
if all(c in "0123456789abcdefABCDEF"
|
|
1037
|
+
for c in token):
|
|
1038
|
+
kind_name = "hex-blob"
|
|
1039
|
+
else:
|
|
1040
|
+
kind_name = "base64-blob"
|
|
1041
|
+
if kind_name in found_kinds:
|
|
1042
|
+
continue
|
|
1043
|
+
if kind_name == "base64-blob" and not (
|
|
1044
|
+
any(c.isupper() for c in token)
|
|
1045
|
+
and any(c.islower() for c in token)
|
|
1046
|
+
and any(c.isdigit() or c in "+/"
|
|
1047
|
+
for c in token)):
|
|
1048
|
+
continue
|
|
1049
|
+
found_kinds.add(kind_name)
|
|
1050
|
+
out.append(finding(
|
|
1051
|
+
"HYGIENE_OBFUSCATION", "WARN", rel, lineno,
|
|
1052
|
+
"a %s of %d+ characters at line %d — content "
|
|
1053
|
+
"encoded as one opaque blob cannot be "
|
|
1054
|
+
"reviewed as written"
|
|
1055
|
+
% (kind_name.replace("-", " "),
|
|
1056
|
+
HEX_BLOB_MIN if kind_name == "hex-blob"
|
|
1057
|
+
else BASE64_BLOB_MIN, lineno),
|
|
1058
|
+
"keep pack content readable; encoded blobs "
|
|
1059
|
+
"belong in the open or not at all"))
|
|
1060
|
+
# invisible characters
|
|
1061
|
+
count, first_line, names = _invisible_hits(text)
|
|
1062
|
+
if count:
|
|
1063
|
+
out.append(finding(
|
|
1064
|
+
"CONCEALED_UNICODE", "WARN", rel, first_line,
|
|
1065
|
+
"%d invisible character(s) in this file (first "
|
|
1066
|
+
"at line %d): %s — invisible text can carry "
|
|
1067
|
+
"instructions a reader never sees"
|
|
1068
|
+
% (count, first_line, ", ".join(names)),
|
|
1069
|
+
"strip invisible characters from pack files"))
|
|
1070
|
+
if kind == "markdown":
|
|
1071
|
+
# long blank-line runs with content after them
|
|
1072
|
+
run = 0
|
|
1073
|
+
run_start = None
|
|
1074
|
+
for lineno, line in enumerate(lines, 1):
|
|
1075
|
+
if not line.strip():
|
|
1076
|
+
if run == 0:
|
|
1077
|
+
run_start = lineno
|
|
1078
|
+
run += 1
|
|
1079
|
+
else:
|
|
1080
|
+
if run >= BLANK_RUN_MIN:
|
|
1081
|
+
out.append(finding(
|
|
1082
|
+
"CONCEALED_BLANK_RUN", "WARN", rel,
|
|
1083
|
+
run_start,
|
|
1084
|
+
"%d consecutive blank lines starting "
|
|
1085
|
+
"at line %d, with content after them — "
|
|
1086
|
+
"a known place to hide instructions "
|
|
1087
|
+
"past the point a reviewer stops "
|
|
1088
|
+
"scrolling" % (run, run_start),
|
|
1089
|
+
"collapse the gap; keep the whole "
|
|
1090
|
+
"file in review range"))
|
|
1091
|
+
run = 0
|
|
1092
|
+
# long HTML comments
|
|
1093
|
+
for m in re.finditer(r"<!--(.*?)-->", text,
|
|
1094
|
+
re.DOTALL):
|
|
1095
|
+
if len(m.group(1)) >= HIDDEN_COMMENT_MIN:
|
|
1096
|
+
line_no = text[:m.start()].count("\n") + 1
|
|
1097
|
+
out.append(finding(
|
|
1098
|
+
"HIDDEN_COMMENT", "WARN", rel, line_no,
|
|
1099
|
+
"an HTML comment of %d characters at "
|
|
1100
|
+
"line %d — comments do not render, so "
|
|
1101
|
+
"long ones are a place to put text a "
|
|
1102
|
+
"reader of the rendered page never sees"
|
|
1103
|
+
% (len(m.group(1)), line_no),
|
|
1104
|
+
"move anything that matters into the "
|
|
1105
|
+
"visible text"))
|
|
1106
|
+
return out
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
# ------------------------------------------------------------------
|
|
1110
|
+
# Install hooks
|
|
1111
|
+
# ------------------------------------------------------------------
|
|
1112
|
+
|
|
1113
|
+
_INSTALL_NAMES = {"setup.py", "install.py", "install.sh",
|
|
1114
|
+
"install.bash", "setup.sh"}
|
|
1115
|
+
_PKG_HOOK = re.compile(r'"(preinstall|install|postinstall)"'
|
|
1116
|
+
r'\s*:')
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def scan_install_hooks(pack_dir, records):
|
|
1120
|
+
out = []
|
|
1121
|
+
for rec in records:
|
|
1122
|
+
base = rec["path"].rsplit("/", 1)[-1]
|
|
1123
|
+
ext = os.path.splitext(base)[1].lower()
|
|
1124
|
+
reason = None
|
|
1125
|
+
if base in _INSTALL_NAMES:
|
|
1126
|
+
reason = "an install/setup script"
|
|
1127
|
+
elif ext == ".pth":
|
|
1128
|
+
reason = "a .pth file (its lines are executed " \
|
|
1129
|
+
"by the interpreter at startup)"
|
|
1130
|
+
elif base == "package.json" and not rec["binary"]:
|
|
1131
|
+
text = read_text(pack_dir, rec["path"]) or ""
|
|
1132
|
+
if _PKG_HOOK.search(text):
|
|
1133
|
+
reason = "a package.json with an install " \
|
|
1134
|
+
"hook (preinstall/install/" \
|
|
1135
|
+
"postinstall)"
|
|
1136
|
+
if reason:
|
|
1137
|
+
out.append(finding(
|
|
1138
|
+
"INSTALL_HOOK", "WARN", rec["path"], None,
|
|
1139
|
+
"this file runs at install time — %s. "
|
|
1140
|
+
"Install-time code executes before any "
|
|
1141
|
+
"review of the pack's normal flow"
|
|
1142
|
+
% reason,
|
|
1143
|
+
"read this file first, before installing"))
|
|
1144
|
+
return out
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
# ------------------------------------------------------------------
|
|
1148
|
+
# References (SKILL.md links + script path literals)
|
|
1149
|
+
# ------------------------------------------------------------------
|
|
1150
|
+
|
|
1151
|
+
_MD_LINK = re.compile(
|
|
1152
|
+
r"!?\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+\"[^\"]*\")?\s*\)")
|
|
1153
|
+
_BACKTICK = re.compile(r"`([^`\n]+)`")
|
|
1154
|
+
_SCRIPT_LITERAL = re.compile(r"[\"']([^\"'\n]{2,200})[\"']")
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _clean_target(raw):
|
|
1158
|
+
target = raw.strip()
|
|
1159
|
+
if not target or "://" in target:
|
|
1160
|
+
return None
|
|
1161
|
+
if target.startswith(("#", "mailto:", "/", "~", "$",
|
|
1162
|
+
"<")):
|
|
1163
|
+
return None
|
|
1164
|
+
target = target.split("#", 1)[0].split("?", 1)[0]
|
|
1165
|
+
if not target:
|
|
1166
|
+
return None
|
|
1167
|
+
return target
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _looks_like_path(target, from_backtick):
|
|
1171
|
+
if " " in target:
|
|
1172
|
+
return False
|
|
1173
|
+
ext = os.path.splitext(target)[1].lower()
|
|
1174
|
+
if "/" in target:
|
|
1175
|
+
return True
|
|
1176
|
+
if from_backtick:
|
|
1177
|
+
return ext in REF_EXTS
|
|
1178
|
+
return ext in REF_EXTS
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _resolve(pack_dir, base_dir, target):
|
|
1182
|
+
"""Returns "ok" | "missing" | "outside". Tries the
|
|
1183
|
+
base dir, then the pack root."""
|
|
1184
|
+
root = os.path.realpath(pack_dir)
|
|
1185
|
+
for base in (base_dir, pack_dir):
|
|
1186
|
+
cand = os.path.realpath(os.path.join(base, target))
|
|
1187
|
+
if cand == root or cand.startswith(root + os.sep):
|
|
1188
|
+
if os.path.exists(cand):
|
|
1189
|
+
return "ok"
|
|
1190
|
+
else:
|
|
1191
|
+
return "outside"
|
|
1192
|
+
return "missing"
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def scan_references(pack_dir, source_rel, text, is_script):
|
|
1196
|
+
out = []
|
|
1197
|
+
seen = set()
|
|
1198
|
+
targets = [] # (target, line)
|
|
1199
|
+
if is_script:
|
|
1200
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
1201
|
+
for m in _SCRIPT_LITERAL.finditer(line):
|
|
1202
|
+
raw = m.group(1)
|
|
1203
|
+
if raw.startswith(("./", "../")) or \
|
|
1204
|
+
raw.startswith(REF_PREFIX_DIRS) or \
|
|
1205
|
+
("/" in raw and not raw.startswith(
|
|
1206
|
+
("/", "~", "$")) and
|
|
1207
|
+
os.path.splitext(raw)[1].lower()
|
|
1208
|
+
in REF_EXTS):
|
|
1209
|
+
targets.append((raw, lineno))
|
|
1210
|
+
else:
|
|
1211
|
+
for lineno, line in enumerate(text.splitlines(), 1):
|
|
1212
|
+
for m in _MD_LINK.finditer(line):
|
|
1213
|
+
targets.append((m.group(1), lineno))
|
|
1214
|
+
for m in _BACKTICK.finditer(line):
|
|
1215
|
+
if _looks_like_path(m.group(1), True):
|
|
1216
|
+
targets.append((m.group(1), lineno))
|
|
1217
|
+
base_dir = os.path.join(
|
|
1218
|
+
pack_dir, os.path.dirname(source_rel.replace(
|
|
1219
|
+
"/", os.sep)))
|
|
1220
|
+
for raw, lineno in targets:
|
|
1221
|
+
target = _clean_target(raw)
|
|
1222
|
+
if target is None or (target, lineno) in seen:
|
|
1223
|
+
continue
|
|
1224
|
+
if not _looks_like_path(target, False):
|
|
1225
|
+
continue
|
|
1226
|
+
seen.add((target, lineno))
|
|
1227
|
+
status = _resolve(pack_dir, base_dir, target)
|
|
1228
|
+
if status == "missing":
|
|
1229
|
+
out.append(finding(
|
|
1230
|
+
"BROKEN_REFERENCE", "ERROR", source_rel,
|
|
1231
|
+
lineno,
|
|
1232
|
+
"referenced local file %r does not exist "
|
|
1233
|
+
"in the pack — the pack points at something "
|
|
1234
|
+
"that is not there" % target,
|
|
1235
|
+
"add the file, or fix the reference"))
|
|
1236
|
+
elif status == "outside":
|
|
1237
|
+
out.append(finding(
|
|
1238
|
+
"REFERENCE_OUTSIDE_PACK", "WARN",
|
|
1239
|
+
source_rel, lineno,
|
|
1240
|
+
"reference %r resolves outside the pack "
|
|
1241
|
+
"folder — the pack depends on, or points "
|
|
1242
|
+
"into, the wider filesystem" % target,
|
|
1243
|
+
"keep pack references inside the pack, or "
|
|
1244
|
+
"document the external dependency"))
|
|
1245
|
+
return out
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
# ------------------------------------------------------------------
|
|
1249
|
+
# Structure checks
|
|
1250
|
+
# ------------------------------------------------------------------
|
|
1251
|
+
|
|
1252
|
+
_NAME_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
1253
|
+
_TOOL_STAR = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*\(\*\)$")
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _norm_name(name):
|
|
1257
|
+
return name.strip().lower()
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def scan_structure(pack_dir, records, skill_rel, fields,
|
|
1261
|
+
parse_error, parse_line):
|
|
1262
|
+
out = []
|
|
1263
|
+
dirname = os.path.basename(os.path.realpath(pack_dir))
|
|
1264
|
+
if skill_rel is None:
|
|
1265
|
+
out.append(finding(
|
|
1266
|
+
"MISSING_SKILL_MD", "ERROR", None, None,
|
|
1267
|
+
"no SKILL.md at the pack root — whatever this "
|
|
1268
|
+
"folder is, a skill loader cannot load it as a "
|
|
1269
|
+
"skill",
|
|
1270
|
+
"a skill pack ships a SKILL.md at its root"))
|
|
1271
|
+
return out, None
|
|
1272
|
+
if os.path.basename(skill_rel) != "SKILL.md":
|
|
1273
|
+
out.append(finding(
|
|
1274
|
+
"SKILL_MD_CASE", "WARN", skill_rel, None,
|
|
1275
|
+
"the skill file is named %r, not SKILL.md — "
|
|
1276
|
+
"some loaders look for the exact name and will "
|
|
1277
|
+
"miss this pack" % os.path.basename(skill_rel),
|
|
1278
|
+
"rename it to SKILL.md"))
|
|
1279
|
+
if parse_error == "missing":
|
|
1280
|
+
out.append(finding(
|
|
1281
|
+
"FRONTMATTER_MISSING", "ERROR", skill_rel, 1,
|
|
1282
|
+
"SKILL.md has no --- frontmatter block — the "
|
|
1283
|
+
"name and description a loader registers the "
|
|
1284
|
+
"skill under are absent",
|
|
1285
|
+
"add a frontmatter block with name and "
|
|
1286
|
+
"description"))
|
|
1287
|
+
return out, None
|
|
1288
|
+
if parse_error:
|
|
1289
|
+
out.append(finding(
|
|
1290
|
+
"FRONTMATTER_PARSE", "ERROR", skill_rel,
|
|
1291
|
+
parse_line,
|
|
1292
|
+
"the frontmatter does not parse: %s — a loader "
|
|
1293
|
+
"reading this block will fail or guess"
|
|
1294
|
+
% parse_error,
|
|
1295
|
+
"fix the frontmatter line"))
|
|
1296
|
+
return out, fields
|
|
1297
|
+
name = field_text(fields, "name")
|
|
1298
|
+
description = field_text(fields, "description")
|
|
1299
|
+
if name is None or not name.strip():
|
|
1300
|
+
out.append(finding(
|
|
1301
|
+
"REQUIRED_FIELD", "ERROR", skill_rel, None,
|
|
1302
|
+
"frontmatter field 'name' is missing or empty "
|
|
1303
|
+
"— a skill is registered under its name",
|
|
1304
|
+
"add a name to the frontmatter"))
|
|
1305
|
+
if description is None or not description.strip():
|
|
1306
|
+
out.append(finding(
|
|
1307
|
+
"REQUIRED_FIELD", "ERROR", skill_rel, None,
|
|
1308
|
+
"frontmatter field 'description' is missing or "
|
|
1309
|
+
"empty — the description is how a loader "
|
|
1310
|
+
"decides when the skill applies",
|
|
1311
|
+
"add a description to the frontmatter"))
|
|
1312
|
+
if name and name.strip():
|
|
1313
|
+
if _norm_name(name) != _norm_name(dirname):
|
|
1314
|
+
out.append(finding(
|
|
1315
|
+
"NAME_DIR_MISMATCH", "WARN", skill_rel,
|
|
1316
|
+
None,
|
|
1317
|
+
"frontmatter name %r does not match the "
|
|
1318
|
+
"pack folder name %r — loaders and humans "
|
|
1319
|
+
"will call this pack two different things"
|
|
1320
|
+
% (name.strip(), dirname),
|
|
1321
|
+
"make the name and the folder agree"))
|
|
1322
|
+
if not _NAME_SLUG.match(name.strip()):
|
|
1323
|
+
out.append(finding(
|
|
1324
|
+
"NAME_FORMAT", "WARN", skill_rel, None,
|
|
1325
|
+
"skill name %r is not a lowercase "
|
|
1326
|
+
"kebab-case slug — some loaders reject or "
|
|
1327
|
+
"mangle names outside that shape"
|
|
1328
|
+
% name.strip(),
|
|
1329
|
+
"rename to lowercase words joined by "
|
|
1330
|
+
"hyphens"))
|
|
1331
|
+
for entry in field_entries(fields, "allowed-tools"):
|
|
1332
|
+
if entry == "*" or _TOOL_STAR.match(entry) or \
|
|
1333
|
+
entry == "Bash":
|
|
1334
|
+
out.append(finding(
|
|
1335
|
+
"BROAD_TOOL_GRANT", "WARN", skill_rel,
|
|
1336
|
+
None,
|
|
1337
|
+
"allowed-tools grants %r — the whole tool, "
|
|
1338
|
+
"unscoped. A grant that wide should be a "
|
|
1339
|
+
"deliberate, documented choice"
|
|
1340
|
+
% entry,
|
|
1341
|
+
"scope the grant (e.g. Bash(npm run *)), "
|
|
1342
|
+
"or document why the whole tool is needed"))
|
|
1343
|
+
return out, fields
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
# ------------------------------------------------------------------
|
|
1347
|
+
# The audit driver
|
|
1348
|
+
# ------------------------------------------------------------------
|
|
1349
|
+
|
|
1350
|
+
def vet_pack(pack_dir):
|
|
1351
|
+
records = inventory(pack_dir)
|
|
1352
|
+
by_path = {r["path"]: r for r in records}
|
|
1353
|
+
|
|
1354
|
+
skill_rel = None
|
|
1355
|
+
for rec in records:
|
|
1356
|
+
base = rec["path"].rsplit("/", 1)[-1]
|
|
1357
|
+
if "/" not in rec["path"] and \
|
|
1358
|
+
base.lower() == "skill.md":
|
|
1359
|
+
if base == "SKILL.md":
|
|
1360
|
+
skill_rel = rec["path"]
|
|
1361
|
+
break
|
|
1362
|
+
skill_rel = skill_rel or rec["path"]
|
|
1363
|
+
|
|
1364
|
+
skill_text = None
|
|
1365
|
+
fields = None
|
|
1366
|
+
parse_error = None
|
|
1367
|
+
parse_line = None
|
|
1368
|
+
if skill_rel is not None:
|
|
1369
|
+
skill_text = read_text(pack_dir, skill_rel)
|
|
1370
|
+
if skill_text is None:
|
|
1371
|
+
skill_text = ""
|
|
1372
|
+
fields, _body, parse_error, parse_line = \
|
|
1373
|
+
parse_frontmatter(skill_text)
|
|
1374
|
+
|
|
1375
|
+
readme_text = ""
|
|
1376
|
+
for rec in records:
|
|
1377
|
+
base = rec["path"].rsplit("/", 1)[-1]
|
|
1378
|
+
if "/" not in rec["path"] and \
|
|
1379
|
+
base.lower() == "readme.md" and \
|
|
1380
|
+
not rec["binary"] and not rec["symlink_out"]:
|
|
1381
|
+
readme_text = read_text(pack_dir,
|
|
1382
|
+
rec["path"]) or ""
|
|
1383
|
+
rec["scanned"] = True
|
|
1384
|
+
break
|
|
1385
|
+
|
|
1386
|
+
docs_text = (skill_text or "") + "\n" + readme_text
|
|
1387
|
+
docs_lower = docs_text.lower()
|
|
1388
|
+
hosts_in_docs = doc_hosts(docs_text)
|
|
1389
|
+
offline_claim = bool(_OFFLINE_CLAIM.search(docs_text))
|
|
1390
|
+
|
|
1391
|
+
findings = []
|
|
1392
|
+
structure, _ = scan_structure(
|
|
1393
|
+
pack_dir, records, skill_rel, fields, parse_error,
|
|
1394
|
+
parse_line)
|
|
1395
|
+
findings.extend(structure)
|
|
1396
|
+
|
|
1397
|
+
for rec in records:
|
|
1398
|
+
if rec["bytes"] > LARGE_FILE_BYTES:
|
|
1399
|
+
findings.append(finding(
|
|
1400
|
+
"LARGE_FILE", "WARN", rec["path"], None,
|
|
1401
|
+
"this file is %d bytes — over the 1 MiB "
|
|
1402
|
+
"line for a pack that ships as reviewable "
|
|
1403
|
+
"text; bulk content hides bulk behaviour"
|
|
1404
|
+
% rec["bytes"],
|
|
1405
|
+
"split it, shrink it, or be ready to "
|
|
1406
|
+
"explain the bulk"))
|
|
1407
|
+
if rec["binary"]:
|
|
1408
|
+
findings.append(finding(
|
|
1409
|
+
"BUNDLED_BINARY", "WARN", rec["path"], None,
|
|
1410
|
+
"a binary file (%d bytes) — it cannot be "
|
|
1411
|
+
"statically vetted at all; its behaviour "
|
|
1412
|
+
"is whatever it does when run"
|
|
1413
|
+
% rec["bytes"],
|
|
1414
|
+
"install only if you trust the source "
|
|
1415
|
+
"enough to run unauditable code"))
|
|
1416
|
+
if rec["symlink_out"]:
|
|
1417
|
+
findings.append(finding(
|
|
1418
|
+
"SYMLINK_OUT", "INFO", rec["path"], None,
|
|
1419
|
+
"a symlink whose target leaves the pack — "
|
|
1420
|
+
"its target was not scanned; what it "
|
|
1421
|
+
"points at is outside this audit",
|
|
1422
|
+
"check the target before installing"))
|
|
1423
|
+
|
|
1424
|
+
findings.extend(scan_install_hooks(pack_dir, records))
|
|
1425
|
+
|
|
1426
|
+
for rec in records:
|
|
1427
|
+
if rec["binary"] or rec["symlink_out"]:
|
|
1428
|
+
continue
|
|
1429
|
+
if rec["kind"] not in ("script", "markdown", "text"):
|
|
1430
|
+
continue
|
|
1431
|
+
text = read_text(pack_dir, rec["path"])
|
|
1432
|
+
if text is None:
|
|
1433
|
+
continue
|
|
1434
|
+
rec["scanned"] = True
|
|
1435
|
+
findings.extend(
|
|
1436
|
+
scan_text_hygiene(rec["path"], text,
|
|
1437
|
+
rec["kind"]))
|
|
1438
|
+
if rec["kind"] == "script":
|
|
1439
|
+
lines = text.splitlines()
|
|
1440
|
+
scan = FileScan(rec["path"], lines)
|
|
1441
|
+
findings.extend(scan_script(
|
|
1442
|
+
scan, docs_text, docs_lower, hosts_in_docs,
|
|
1443
|
+
offline_claim))
|
|
1444
|
+
findings.extend(scan_references(
|
|
1445
|
+
pack_dir, rec["path"], text, True))
|
|
1446
|
+
if skill_rel is not None and \
|
|
1447
|
+
rec["path"] == skill_rel:
|
|
1448
|
+
findings.extend(scan_references(
|
|
1449
|
+
pack_dir, rec["path"], text, False))
|
|
1450
|
+
|
|
1451
|
+
findings = sort_findings(findings)
|
|
1452
|
+
summary = {
|
|
1453
|
+
"findings": len(findings),
|
|
1454
|
+
"errors": sum(1 for f in findings
|
|
1455
|
+
if f["severity"] == "ERROR"),
|
|
1456
|
+
"warnings": sum(1 for f in findings
|
|
1457
|
+
if f["severity"] == "WARN"),
|
|
1458
|
+
"info": sum(1 for f in findings
|
|
1459
|
+
if f["severity"] == "INFO"),
|
|
1460
|
+
"danger": sum(1 for f in findings if f["danger"]),
|
|
1461
|
+
}
|
|
1462
|
+
if summary["danger"]:
|
|
1463
|
+
verdict = "BLOCKED"
|
|
1464
|
+
elif summary["errors"] or summary["warnings"]:
|
|
1465
|
+
verdict = "REVIEW"
|
|
1466
|
+
else:
|
|
1467
|
+
verdict = "CLEAN"
|
|
1468
|
+
pack_name = field_text(fields, "name") if fields else None
|
|
1469
|
+
return {
|
|
1470
|
+
"tool": TOOL,
|
|
1471
|
+
"version": VERSION,
|
|
1472
|
+
"command": "vet",
|
|
1473
|
+
"pack": {
|
|
1474
|
+
"dir": pack_dir,
|
|
1475
|
+
"dirname": os.path.basename(
|
|
1476
|
+
os.path.realpath(pack_dir)),
|
|
1477
|
+
"name": pack_name,
|
|
1478
|
+
"skill_file": skill_rel,
|
|
1479
|
+
},
|
|
1480
|
+
"files": records,
|
|
1481
|
+
"findings": findings,
|
|
1482
|
+
"summary": summary,
|
|
1483
|
+
"verdict": verdict,
|
|
1484
|
+
"verdict_rule": VERDICT_RULE,
|
|
1485
|
+
"limits": LIMITS_LINE,
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
# ------------------------------------------------------------------
|
|
1490
|
+
# Rendering
|
|
1491
|
+
# ------------------------------------------------------------------
|
|
1492
|
+
|
|
1493
|
+
def _human_size(n):
|
|
1494
|
+
if n < 1024:
|
|
1495
|
+
return "%d B" % n
|
|
1496
|
+
if n < 1024 * 1024:
|
|
1497
|
+
return "%.1f KiB" % (n / 1024)
|
|
1498
|
+
return "%.1f MiB" % (n / (1024 * 1024))
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _render_finding(f):
|
|
1502
|
+
head = "[%s] %s" % (f["rule"], f["file"] or "(pack)")
|
|
1503
|
+
if f["line"]:
|
|
1504
|
+
head += " · line %d" % f["line"]
|
|
1505
|
+
return "%s\n %s\n fix: %s" % (head, f["message"],
|
|
1506
|
+
f["fix"])
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def render_text(result):
|
|
1510
|
+
pack = result["pack"]
|
|
1511
|
+
files = result["files"]
|
|
1512
|
+
total = sum(r["bytes"] for r in files)
|
|
1513
|
+
lines = []
|
|
1514
|
+
lines.append(
|
|
1515
|
+
"%s v%s — vet · pack %s · %d files · %s total"
|
|
1516
|
+
% (TOOL, VERSION, pack["dirname"], len(files),
|
|
1517
|
+
_human_size(total)))
|
|
1518
|
+
if pack["name"]:
|
|
1519
|
+
lines.append("Frontmatter name: %s" % pack["name"])
|
|
1520
|
+
lines.append(
|
|
1521
|
+
"Read-only: it edits nothing, writes nothing, and "
|
|
1522
|
+
"never executes anything in the pack — every file "
|
|
1523
|
+
"is analysed as static text.")
|
|
1524
|
+
lines.append("Verdict rule: " + VERDICT_RULE)
|
|
1525
|
+
lines.append("Limits: " + LIMITS_LINE)
|
|
1526
|
+
lines.append("")
|
|
1527
|
+
lines.append("INVENTORY (%d files):" % len(files))
|
|
1528
|
+
for rec in files:
|
|
1529
|
+
lines.append(" %s — %s · %s"
|
|
1530
|
+
% (rec["path"], rec["kind"],
|
|
1531
|
+
_human_size(rec["bytes"])))
|
|
1532
|
+
findings = result["findings"]
|
|
1533
|
+
groups = [
|
|
1534
|
+
("DANGER — proven stealing / spying / "
|
|
1535
|
+
"hidden-sending combinations",
|
|
1536
|
+
[f for f in findings if f["danger"]]),
|
|
1537
|
+
("ERRORS — structure and references that do not "
|
|
1538
|
+
"work as shipped",
|
|
1539
|
+
[f for f in findings if f["severity"] == "ERROR"
|
|
1540
|
+
and not f["danger"]]),
|
|
1541
|
+
("WARNINGS — short of danger, worth a human look",
|
|
1542
|
+
[f for f in findings if f["severity"] == "WARN"]),
|
|
1543
|
+
("INFO — worth knowing",
|
|
1544
|
+
[f for f in findings if f["severity"] == "INFO"]),
|
|
1545
|
+
]
|
|
1546
|
+
for title, group in groups:
|
|
1547
|
+
if not group:
|
|
1548
|
+
continue
|
|
1549
|
+
lines.append("")
|
|
1550
|
+
lines.append("%s (%d):" % (title, len(group)))
|
|
1551
|
+
for f in group:
|
|
1552
|
+
lines.append(_render_finding(f))
|
|
1553
|
+
s = result["summary"]
|
|
1554
|
+
lines.append("")
|
|
1555
|
+
if not findings:
|
|
1556
|
+
lines.append(
|
|
1557
|
+
"No findings — no structure errors, no danger "
|
|
1558
|
+
"combination, no hygiene warning fired. "
|
|
1559
|
+
"(Absence of findings is not proof of safety.)")
|
|
1560
|
+
lines.append(
|
|
1561
|
+
"Summary: %d findings · %d errors · %d warnings "
|
|
1562
|
+
"· %d info · %d danger"
|
|
1563
|
+
% (s["findings"], s["errors"], s["warnings"],
|
|
1564
|
+
s["info"], s["danger"]))
|
|
1565
|
+
verdict = result["verdict"]
|
|
1566
|
+
if verdict == "BLOCKED":
|
|
1567
|
+
lines.append(
|
|
1568
|
+
"Verdict: BLOCKED — %d proven danger "
|
|
1569
|
+
"combination(s) (stealing / spying / hidden "
|
|
1570
|
+
"sending). Do not install." % s["danger"])
|
|
1571
|
+
elif verdict == "REVIEW":
|
|
1572
|
+
lines.append(
|
|
1573
|
+
"Verdict: REVIEW — no danger combination "
|
|
1574
|
+
"found, but %d error(s) and %d warning(s) "
|
|
1575
|
+
"need a human look before installing."
|
|
1576
|
+
% (s["errors"], s["warnings"]))
|
|
1577
|
+
else:
|
|
1578
|
+
lines.append(
|
|
1579
|
+
"Verdict: CLEAN — no errors, no warnings, no "
|
|
1580
|
+
"danger combinations found by the static "
|
|
1581
|
+
"rules.")
|
|
1582
|
+
return "\n".join(lines)
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
# ------------------------------------------------------------------
|
|
1586
|
+
# CLI
|
|
1587
|
+
# ------------------------------------------------------------------
|
|
1588
|
+
|
|
1589
|
+
class _JsonAwareParser(argparse.ArgumentParser):
|
|
1590
|
+
"""ArgumentParser that reports usage errors as JSON in
|
|
1591
|
+
--json mode ({"error": ...} on stdout, same exit code
|
|
1592
|
+
2), matching the house error contract."""
|
|
1593
|
+
|
|
1594
|
+
json_mode = False
|
|
1595
|
+
|
|
1596
|
+
def error(self, message):
|
|
1597
|
+
if self.json_mode:
|
|
1598
|
+
print(json.dumps({"error": message}))
|
|
1599
|
+
raise SystemExit(2)
|
|
1600
|
+
super().error(message)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def cmd_vet(args):
|
|
1604
|
+
path = args.path
|
|
1605
|
+
if not os.path.exists(path):
|
|
1606
|
+
raise ToolError(
|
|
1607
|
+
"no such folder: %s" % path)
|
|
1608
|
+
if not os.path.isdir(path):
|
|
1609
|
+
raise ToolError(
|
|
1610
|
+
"not a folder: %s — point vet at the skill "
|
|
1611
|
+
"pack's root folder" % path)
|
|
1612
|
+
result = vet_pack(path)
|
|
1613
|
+
if args.json:
|
|
1614
|
+
print(json.dumps(result, indent=1))
|
|
1615
|
+
else:
|
|
1616
|
+
print(render_text(result))
|
|
1617
|
+
if result["verdict"] == "BLOCKED":
|
|
1618
|
+
return 1
|
|
1619
|
+
if result["verdict"] == "REVIEW" and args.strict:
|
|
1620
|
+
return 1
|
|
1621
|
+
return 0
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def main(argv=None):
|
|
1625
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
1626
|
+
_JsonAwareParser.json_mode = "--json" in argv
|
|
1627
|
+
ap = _JsonAwareParser(
|
|
1628
|
+
prog=TOOL,
|
|
1629
|
+
description="Statically vet an agent skill pack "
|
|
1630
|
+
"before installing it: frontmatter and "
|
|
1631
|
+
"structure, broken references, bundled "
|
|
1632
|
+
"binaries, and the three danger tests — "
|
|
1633
|
+
"stealing, spying, hidden sending — "
|
|
1634
|
+
"each proven only by combined evidence "
|
|
1635
|
+
"in the same file. Read-only, executes "
|
|
1636
|
+
"nothing (stdlib only, local only).")
|
|
1637
|
+
sub = ap.add_subparsers(dest="command", required=True,
|
|
1638
|
+
parser_class=_JsonAwareParser)
|
|
1639
|
+
|
|
1640
|
+
p_vet = sub.add_parser(
|
|
1641
|
+
"vet", help="vet one skill pack folder: exit 1 "
|
|
1642
|
+
"when the verdict is BLOCKED, 0 for "
|
|
1643
|
+
"CLEAN or REVIEW (1 for REVIEW with "
|
|
1644
|
+
"--strict), 2 for input errors")
|
|
1645
|
+
p_vet.add_argument(
|
|
1646
|
+
"path",
|
|
1647
|
+
help="the skill pack's root folder (the one "
|
|
1648
|
+
"holding SKILL.md)")
|
|
1649
|
+
p_vet.add_argument("--json", action="store_true",
|
|
1650
|
+
help="print one JSON object instead "
|
|
1651
|
+
"of text")
|
|
1652
|
+
p_vet.add_argument(
|
|
1653
|
+
"--strict", action="store_true",
|
|
1654
|
+
help="also exit 1 when the verdict is REVIEW")
|
|
1655
|
+
p_vet.set_defaults(func=cmd_vet)
|
|
1656
|
+
|
|
1657
|
+
a = ap.parse_args(argv)
|
|
1658
|
+
|
|
1659
|
+
def fail(msg):
|
|
1660
|
+
if getattr(a, "json", False):
|
|
1661
|
+
print(json.dumps({"error": msg}))
|
|
1662
|
+
else:
|
|
1663
|
+
print("ERROR: %s" % msg)
|
|
1664
|
+
return 2
|
|
1665
|
+
|
|
1666
|
+
try:
|
|
1667
|
+
return a.func(a)
|
|
1668
|
+
except ToolError as exc:
|
|
1669
|
+
return fail(str(exc))
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
if __name__ == "__main__":
|
|
1673
|
+
sys.exit(main())
|