mod-audit 0.1.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.
mod_audit/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """mod-audit: static supply-chain auditor for Claude Code Mods.
2
+
3
+ Local, offline, stdlib-only. Scans lifecycle hooks, TypeScript/JavaScript
4
+ source, env/API-key exfiltration patterns, and permissions manifests;
5
+ snapshots a trusted state and diffs installed mods against it to catch
6
+ trojanized updates.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ from .rules import Finding, scan_mod, scan_ts_source, scan_hook_commands, scan_permissions
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "Finding",
16
+ "scan_mod",
17
+ "scan_ts_source",
18
+ "scan_hook_commands",
19
+ "scan_permissions",
20
+ ]
mod_audit/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """mod-audit CLI: scan, snapshot, diff for Claude Code Mods."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import datetime as _dt
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import __version__
11
+ from .rules import (
12
+ SEVERITIES,
13
+ build_snapshot,
14
+ diff_against_snapshot,
15
+ scan_mod,
16
+ severity_at_least,
17
+ )
18
+
19
+
20
+ def _format_text(findings) -> str:
21
+ if not findings:
22
+ return "mod-audit: no findings. Mod looks clean.\n"
23
+ lines = []
24
+ for f in findings:
25
+ loc = f"{f.file}:{f.line}" if f.line else f.file
26
+ lines.append(f"[{f.severity.upper():8}] {f.rule_id} {loc}")
27
+ lines.append(f" {f.message}")
28
+ lines.append(f" Fix: {f.fix}")
29
+ summary = {}
30
+ for f in findings:
31
+ summary[f.severity] = summary.get(f.severity, 0) + 1
32
+ lines.append("")
33
+ lines.append(
34
+ "Findings: %d (%s)"
35
+ % (len(findings), ", ".join(f"{k}={v}" for k, v in sorted(summary.items())))
36
+ )
37
+ return "\n".join(lines) + "\n"
38
+
39
+
40
+ def _emit(findings, fmt: str) -> int:
41
+ if fmt == "json":
42
+ print(json.dumps([f.to_dict() for f in findings], indent=2))
43
+ else:
44
+ sys.stdout.write(_format_text(findings))
45
+ return 0
46
+
47
+
48
+ def cmd_scan(args) -> int:
49
+ root = os.path.abspath(args.path)
50
+ if not os.path.isdir(root):
51
+ print(f"mod-audit: not a directory: {args.path}", file=sys.stderr)
52
+ return 2
53
+ findings = scan_mod(root)
54
+ _emit(findings, args.format)
55
+ if any(severity_at_least(f.severity, args.fail_on) for f in findings):
56
+ return 1
57
+ return 0
58
+
59
+
60
+ def cmd_snapshot(args) -> int:
61
+ root = os.path.abspath(args.path)
62
+ if not os.path.isdir(root):
63
+ print(f"mod-audit: not a directory: {args.path}", file=sys.stderr)
64
+ return 2
65
+ manifest = build_snapshot(root)
66
+ manifest["generated_at"] = _dt.datetime.now(_dt.timezone.utc).isoformat()
67
+ manifest["root"] = root
68
+
69
+ out = args.out or os.path.join(
70
+ os.getcwd(), os.path.basename(root.rstrip(os.sep)) + ".snapshot"
71
+ )
72
+ os.makedirs(out, exist_ok=True)
73
+ path = os.path.join(out, "manifest.json")
74
+ with open(path, "w", encoding="utf-8") as fh:
75
+ json.dump(manifest, fh, indent=2, sort_keys=True)
76
+ print(f"mod-audit: snapshot written to {path}")
77
+ print(f" files: {len(manifest['files'])}, hooks: {len(manifest['hooks'])}")
78
+ print("Keep this snapshot somewhere the mod updater cannot modify.")
79
+ return 0
80
+
81
+
82
+ def cmd_diff(args) -> int:
83
+ root = os.path.abspath(args.installed_dir)
84
+ snap_dir = os.path.abspath(args.against)
85
+ manifest_path = (
86
+ snap_dir
87
+ if snap_dir.endswith(".json")
88
+ else os.path.join(snap_dir, "manifest.json")
89
+ )
90
+ if not os.path.isdir(root):
91
+ print(f"mod-audit: not a directory: {args.installed_dir}", file=sys.stderr)
92
+ return 2
93
+ try:
94
+ with open(manifest_path, "r", encoding="utf-8") as fh:
95
+ snapshot = json.load(fh)
96
+ except (OSError, ValueError) as exc:
97
+ print(f"mod-audit: cannot read snapshot {manifest_path}: {exc}", file=sys.stderr)
98
+ return 2
99
+ findings = diff_against_snapshot(root, snapshot)
100
+ _emit(findings, args.format)
101
+ if any(severity_at_least(f.severity, args.fail_on) for f in findings):
102
+ return 1
103
+ return 0
104
+
105
+
106
+ def build_parser() -> argparse.ArgumentParser:
107
+ p = argparse.ArgumentParser(
108
+ prog="mod-audit",
109
+ description="Static supply-chain auditor for Claude Code Mods (local, offline, stdlib-only).",
110
+ )
111
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
112
+ sub = p.add_subparsers(dest="command", required=True)
113
+
114
+ s = sub.add_parser("scan", help="Audit a mod directory for supply-chain risks.")
115
+ s.add_argument("path", help="Path to the mod directory (e.g. ~/.claude/plugins/foo).")
116
+ s.add_argument("--format", choices=("text", "json"), default="text")
117
+ s.add_argument(
118
+ "--fail-on",
119
+ choices=SEVERITIES,
120
+ default="high",
121
+ help="Exit 1 if any finding meets this severity (default: high).",
122
+ )
123
+ s.set_defaults(func=cmd_scan)
124
+
125
+ s = sub.add_parser("snapshot", help="Save a trusted snapshot of a mod.")
126
+ s.add_argument("path", help="Path to the mod directory.")
127
+ s.add_argument("--out", help="Snapshot directory (default: ./<mod-name>.snapshot).")
128
+ s.set_defaults(func=cmd_snapshot)
129
+
130
+ s = sub.add_parser("diff", help="Diff an installed mod against a trusted snapshot.")
131
+ s.add_argument("installed_dir", help="Path to the installed mod directory.")
132
+ s.add_argument(
133
+ "--against", required=True, help="Snapshot directory or manifest.json from `snapshot`."
134
+ )
135
+ s.add_argument("--format", choices=("text", "json"), default="text")
136
+ s.add_argument("--fail-on", choices=SEVERITIES, default="high")
137
+ s.set_defaults(func=cmd_diff)
138
+ return p
139
+
140
+
141
+ def main(argv=None) -> int:
142
+ parser = build_parser()
143
+ args = parser.parse_args(argv)
144
+ return args.func(args)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ raise SystemExit(main())
mod_audit/rules.py ADDED
@@ -0,0 +1,520 @@
1
+ """Audit rules: hooks, TypeScript source, env exfiltration, permissions.
2
+
3
+ All rules are offline heuristics over static text. No code is executed.
4
+ Findings carry a stable rule id, severity, file:line, explanation, and fix.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ from dataclasses import asdict, dataclass
13
+ from typing import Dict, Iterator, List, Optional, Tuple
14
+
15
+ SEVERITIES = ("low", "medium", "high", "critical")
16
+ _SEV_ORDER = {s: i for i, s in enumerate(SEVERITIES)}
17
+
18
+ # Duplicated from mod_audit.__init__ to avoid a circular import at module load.
19
+ __version__ = "0.1.0"
20
+
21
+ TS_EXTS = (".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx")
22
+ CONFIG_FILES = ("plugin.json", "hooks.json", "package.json")
23
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", "__pycache__"}
24
+
25
+
26
+ @dataclass
27
+ class Finding:
28
+ rule_id: str
29
+ severity: str # low | medium | high | critical
30
+ file: str
31
+ line: int
32
+ message: str
33
+ fix: str
34
+
35
+ def to_dict(self) -> Dict:
36
+ return asdict(self)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # 1. Lifecycle hook scanning
41
+ # ---------------------------------------------------------------------------
42
+
43
+ # Hook command patterns -----------------------------------------------------
44
+ _PIPED_DOWNLOAD = re.compile(
45
+ r"\b(curl|wget)\b[^|;&\n]*\|\s*(?:sudo\s+)?(sh|bash|zsh|dash|fish)\b", re.I
46
+ )
47
+ _B64_DECODE_EXEC = re.compile(
48
+ r"base64\s+(?:-d|--decode)\b|\bb64decode\b|"
49
+ r"\$\(\s*echo\s+[A-Za-z0-9+/=]{16,}\s*\|\s*base64",
50
+ re.I,
51
+ )
52
+ _CURL_DATA_EXFIL = re.compile(
53
+ r"\bcurl\b[^\n;]*?(?:-d\b|--data(?:-binary|-urlencode)?\b)", re.I
54
+ )
55
+ _SUDO = re.compile(r"(?:^|[\s;&|])sudo(?:\s|$)", re.I)
56
+ _REVERSE_SHELL = re.compile(r"\b(nc|netcat|ncat|socat)\b[^|;&\n]*-e\b|/dev/tcp/", re.I)
57
+ _CHMOD_EXEC = re.compile(r"\bchmod\s+\+x\b", re.I)
58
+ _RM_RF = re.compile(r"\brm\s+-[a-z]*r[a-z]*f\b|\brm\s+-rf?\s+[/~]", re.I)
59
+
60
+ # Keys whose string values are shell commands.
61
+ HOOK_CMD_KEYS = {"command", "cmd", "shell", "exec", "run", "script", "entrypoint"}
62
+ # Ancestor keys under which free-form strings are treated as hook commands.
63
+ HOOK_SECTION_KEYS = {"hooks", "lifecycle", "scripts"}
64
+
65
+
66
+ def _walk_hook_commands(node, ancestors: Tuple[str, ...] = ()) -> Iterator[Tuple[str, Tuple[str, ...]]]:
67
+ """Yield (command_string, key_path) for every shell command in a config."""
68
+ if isinstance(node, dict):
69
+ in_hooks = any(a.lower() in HOOK_SECTION_KEYS for a in ancestors)
70
+ for key, value in node.items():
71
+ key_l = str(key).lower()
72
+ if key_l in HOOK_CMD_KEYS and isinstance(value, str) and value.strip():
73
+ yield value, ancestors + (str(key),)
74
+ elif in_hooks and isinstance(value, str) and value.strip():
75
+ # Free-form string directly under a hooks/lifecycle/scripts
76
+ # section (e.g. {"hooks": {"setup": "npm install"}}).
77
+ yield value, ancestors + (str(key),)
78
+ else:
79
+ yield from _walk_hook_commands(value, ancestors + (str(key),))
80
+ elif isinstance(node, list):
81
+ in_hooks = any(a.lower() in HOOK_SECTION_KEYS for a in ancestors)
82
+ for idx, item in enumerate(node):
83
+ if in_hooks and isinstance(item, str) and item.strip():
84
+ yield item, ancestors + (str(idx),)
85
+ else:
86
+ yield from _walk_hook_commands(item, ancestors + (str(idx),))
87
+
88
+
89
+ def _line_of(text: str, needle: str) -> int:
90
+ """Best-effort 1-based line number of needle inside text (0 if unknown)."""
91
+ idx = text.find(needle)
92
+ if idx < 0:
93
+ return 0
94
+ return text.count("\n", 0, idx) + 1
95
+
96
+
97
+ def audit_hook_command(command: str, relfile: str, line: int) -> List[Finding]:
98
+ """Classify one lifecycle hook shell command into findings."""
99
+ out: List[Finding] = []
100
+ cmd = command.strip()
101
+
102
+ def add(rule_id, severity, message, fix):
103
+ out.append(Finding(rule_id, severity, relfile, line, message, fix))
104
+
105
+ if _PIPED_DOWNLOAD.search(cmd):
106
+ add(
107
+ "HOOK-PIPED-DOWNLOAD", "high",
108
+ f"Piped remote download into a shell in lifecycle hook: {cmd[:120]}",
109
+ "Pin and vendor the script instead of piping curl/wget into sh; "
110
+ "verify its checksum before execution.",
111
+ )
112
+ if _B64_DECODE_EXEC.search(cmd):
113
+ add(
114
+ "HOOK-B64-EXEC", "high",
115
+ f"Base64 decode piped into execution in lifecycle hook: {cmd[:120]}",
116
+ "Decode the payload offline, inspect it, and vendor the decoded "
117
+ "script instead of decoding at install time.",
118
+ )
119
+ if _CURL_DATA_EXFIL.search(cmd):
120
+ add(
121
+ "HOOK-EXFIL", "high",
122
+ f"Hook sends data over the network (curl --data): {cmd[:120]}",
123
+ "Remove data exfiltration from lifecycle hooks; move telemetry to "
124
+ "explicit, documented opt-in code.",
125
+ )
126
+ if _REVERSE_SHELL.search(cmd):
127
+ add(
128
+ "HOOK-REVERSE-SHELL", "critical",
129
+ f"Possible reverse shell in lifecycle hook: {cmd[:120]}",
130
+ "Remove immediately and treat the mod as compromised; reinstall "
131
+ "from a trusted source.",
132
+ )
133
+ if _SUDO.search(cmd):
134
+ add(
135
+ "HOOK-SUDO", "high",
136
+ f"Hook requests privilege escalation (sudo): {cmd[:120]}",
137
+ "Lifecycle hooks should never need root; drop sudo and run the "
138
+ "mod's setup steps manually with review.",
139
+ )
140
+ if _RM_RF.search(cmd):
141
+ add(
142
+ "HOOK-RM-RF", "high",
143
+ f"Destructive recursive delete in lifecycle hook: {cmd[:120]}",
144
+ "Scope deletions to the mod's own directory and require explicit "
145
+ "user confirmation.",
146
+ )
147
+ if _CHMOD_EXEC.search(cmd):
148
+ add(
149
+ "HOOK-CHMOD-EXEC", "medium",
150
+ f"Hook marks a file executable (chmod +x): {cmd[:120]}",
151
+ "Ship binaries with the executable bit already set instead of "
152
+ "flipping it at install time.",
153
+ )
154
+ if not out:
155
+ # Any remaining shell hook still runs with the user's privileges.
156
+ add(
157
+ "HOOK-SHELL-EXEC", "medium",
158
+ f"Shell command in lifecycle hook: {cmd[:120]}",
159
+ "Review the command; prefer declarative setup steps over shell "
160
+ "hooks where the platform allows it.",
161
+ )
162
+ return out
163
+
164
+
165
+ def scan_hook_commands(config_text: str, relfile: str) -> List[Finding]:
166
+ """Parse a JSON config and audit every lifecycle hook command in it."""
167
+ findings: List[Finding] = []
168
+ try:
169
+ config = json.loads(config_text)
170
+ except (json.JSONDecodeError, ValueError):
171
+ return findings
172
+ for command, _key_path in _walk_hook_commands(config):
173
+ line = _line_of(config_text, command[:60])
174
+ findings.extend(audit_hook_command(command, relfile, line))
175
+ return findings
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # 2. TypeScript / JavaScript source scanning
180
+ # ---------------------------------------------------------------------------
181
+
182
+ _CHILD_PROC_IMPORT = re.compile(
183
+ r"""require\s*\(\s*['"]child_process['"]\s*\)|from\s+['"]child_process['"]""",
184
+ )
185
+ _EXEC_CALL = re.compile(r"\b(exec|execSync|spawn|spawnSync|execFile|execFileSync)\s*\(")
186
+ _SHELL_TRUE = re.compile(r"\bshell\s*:\s*true\b")
187
+ _CONCAT_ARG = re.compile(r"""['"`]\s*\+|\+\s*['"`]|\$\{""")
188
+ _EVAL = re.compile(r"\beval\s*\(|\bnew\s+Function\s*\(")
189
+ _DYN_REQUIRE = re.compile(r"\brequire\s*\(\s*(?![\'\"`])")
190
+ _DYN_IMPORT = re.compile(r"\bimport\s*\(\s*(?![\'\"`])")
191
+ _ENV_SECRET = re.compile(
192
+ r"process\.env\.[A-Za-z0-9_]*?(?:API_KEY|TOKEN|SECRET|PRIVATE)[A-Za-z0-9_]*", re.I
193
+ )
194
+ _NET_SINK = re.compile(
195
+ r"\b(fetch|axios\.(?:get|post|put|delete|request)|https?\.request|"
196
+ r"XMLHttpRequest|\.post\s*\()\s*\(",
197
+ )
198
+ _WRITE_FILE = re.compile(r"\bwriteFile(?:Sync)?\s*\(")
199
+ _CRON_PERSIST = re.compile(r"crontab|launchd|LaunchAgents|/etc/cron", re.I)
200
+
201
+
202
+ def scan_ts_source(source: str, relfile: str) -> List[Finding]:
203
+ """Heuristic scan of one TypeScript/JavaScript file."""
204
+ findings: List[Finding] = []
205
+ lines = source.splitlines()
206
+
207
+ def add(rule_id, severity, lineno, message, fix):
208
+ findings.append(Finding(rule_id, severity, relfile, lineno, message, fix))
209
+
210
+ uses_child_process = bool(_CHILD_PROC_IMPORT.search(source))
211
+
212
+ for i, line in enumerate(lines, start=1):
213
+ stripped = line.strip()
214
+
215
+ m = _EXEC_CALL.search(stripped)
216
+ if m:
217
+ window = "\n".join(lines[i - 1 : i + 4])
218
+ fn = m.group(1)
219
+ if _SHELL_TRUE.search(window):
220
+ add(
221
+ "TS-SHELL-TRUE", "high", i,
222
+ f"{fn}() invoked with shell:true — full shell parsing of the argument",
223
+ "Use execFile/spawn with an argv array and shell:false; "
224
+ "never pass interpolated strings to a shell.",
225
+ )
226
+ elif _CONCAT_ARG.search(stripped) or "`" in stripped and "${" in stripped:
227
+ add(
228
+ "TS-EXEC-CONCAT", "high", i,
229
+ f"{fn}() called with a concatenated/interpolated command string",
230
+ "Build the command from a fixed argv array; validate or "
231
+ "allow-list any dynamic part.",
232
+ )
233
+ elif uses_child_process or fn in ("exec", "execSync"):
234
+ add(
235
+ "TS-EXEC", "medium", i,
236
+ f"child_process.{fn}() usage — verify the argument is not attacker-controlled",
237
+ "Prefer execFile with argv arrays; audit where the "
238
+ "command string comes from.",
239
+ )
240
+
241
+ if _EVAL.search(stripped):
242
+ add(
243
+ "TS-EVAL", "high", i,
244
+ "eval() / new Function() — dynamic code execution",
245
+ "Replace with static code paths; if a template is needed, use "
246
+ "a sandboxed expression evaluator.",
247
+ )
248
+ if _DYN_REQUIRE.search(stripped) or _DYN_IMPORT.search(stripped):
249
+ add(
250
+ "TS-DYN-IMPORT", "medium", i,
251
+ "Dynamic require()/import() with a non-literal specifier",
252
+ "Pin imports to string literals or an allow-list of module "
253
+ "names; resolve paths against the mod directory.",
254
+ )
255
+ if _CRON_PERSIST.search(stripped):
256
+ add(
257
+ "TS-PERSISTENCE", "high", i,
258
+ "Persistence mechanism referenced (cron/launchd)",
259
+ "A mod has no business installing persistence; remove it.",
260
+ )
261
+ if _WRITE_FILE.search(stripped) and re.search(r"process\.env\.(HOME|PATH)", stripped):
262
+ add(
263
+ "TS-DOTFILE-WRITE", "medium", i,
264
+ "Writes a file derived from $HOME/$PATH — possible dotfile tampering",
265
+ "Confine writes to the mod's own directory.",
266
+ )
267
+
268
+ # Env/API-key exfiltration: secret env var within a few lines of a network sink.
269
+ for i, line in enumerate(lines, start=1):
270
+ if _ENV_SECRET.search(line):
271
+ secret = _ENV_SECRET.search(line).group(0)
272
+ window = "\n".join(lines[max(0, i - 2) : i + 3])
273
+ if _NET_SINK.search(window):
274
+ sink = _NET_SINK.search(window).group(1)
275
+ add(
276
+ "ENV-EXFIL", "high", i,
277
+ f"{secret} appears near a network sink ({sink}) — possible key exfiltration",
278
+ "Keep secrets out of request bodies/URLs; route network "
279
+ "calls through an allow-listed host set.",
280
+ )
281
+
282
+ return findings
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # 3. Permissions manifest review
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def scan_permissions(config: dict, relfile: str) -> List[Finding]:
290
+ """Flag over-broad permissions in a mod manifest."""
291
+ findings: List[Finding] = []
292
+ perms = config.get("permissions")
293
+ if perms is None:
294
+ return findings
295
+
296
+ def add(rule_id, severity, message, fix):
297
+ findings.append(Finding(rule_id, severity, relfile, 0, message, fix))
298
+
299
+ if isinstance(perms, dict):
300
+ shell = perms.get("shell")
301
+ if shell in (True, "always", "allow"):
302
+ add(
303
+ "PERM-SHELL-OVERGRANT", "high",
304
+ "Manifest grants shell execution without per-action confirmation",
305
+ "Narrow to specific allow-listed commands or require explicit "
306
+ "user confirmation per invocation.",
307
+ )
308
+ network = perms.get("network")
309
+ if network in (True, "always", "allow", "*"):
310
+ add(
311
+ "PERM-NETWORK-OVERGRANT", "medium",
312
+ "Manifest grants unrestricted network access",
313
+ "Restrict to the documented host allow-list the mod actually needs.",
314
+ )
315
+ fs = perms.get("filesystem") or perms.get("fs")
316
+ if fs in (True, "write", "read-write", "/") or (
317
+ isinstance(fs, str) and fs.strip().startswith("/")
318
+ ):
319
+ add(
320
+ "PERM-FS-OVERGRANT", "medium",
321
+ "Manifest grants broad filesystem write access",
322
+ "Scope filesystem access to the mod's own directory.",
323
+ )
324
+ tools = perms.get("tools")
325
+ if isinstance(tools, list) and any(
326
+ str(t).lower() in ("bash", "shell", "exec", "*") for t in tools
327
+ ):
328
+ add(
329
+ "PERM-TOOL-SHELL", "high",
330
+ "Manifest grants a shell-capable tool without constraints",
331
+ "Remove the shell tool or bind it to an allow-listed command set.",
332
+ )
333
+ elif isinstance(perms, list):
334
+ lowered = [str(p).lower() for p in perms]
335
+ if any(p in ("shell", "bash", "exec", "*") for p in lowered):
336
+ add(
337
+ "PERM-SHELL-OVERGRANT", "high",
338
+ "Manifest grants shell execution permission",
339
+ "Narrow to specific allow-listed commands or require explicit "
340
+ "user confirmation per invocation.",
341
+ )
342
+ if any(p in ("network", "internet", "*") for p in lowered):
343
+ add(
344
+ "PERM-NETWORK-OVERGRANT", "medium",
345
+ "Manifest grants unrestricted network access",
346
+ "Restrict to the documented host allow-list the mod actually needs.",
347
+ )
348
+ return findings
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # 4. Mod-level orchestration + snapshots
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def _iter_mod_files(root: str) -> Iterator[Tuple[str, str]]:
356
+ """Yield (abspath, relpath) for auditable files, skipping vendored dirs."""
357
+ for dirpath, dirnames, filenames in os.walk(root):
358
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
359
+ for name in filenames:
360
+ rel = os.path.relpath(os.path.join(dirpath, name), root)
361
+ yield os.path.join(dirpath, name), rel
362
+
363
+
364
+ def scan_mod(root: str) -> List[Finding]:
365
+ """Run every rule over a mod directory."""
366
+ findings: List[Finding] = []
367
+ for abspath, rel in _iter_mod_files(root):
368
+ base = os.path.basename(rel)
369
+ if base in CONFIG_FILES:
370
+ try:
371
+ with open(abspath, "r", encoding="utf-8", errors="replace") as fh:
372
+ text = fh.read()
373
+ except OSError:
374
+ continue
375
+ findings.extend(scan_hook_commands(text, rel))
376
+ try:
377
+ config = json.loads(text)
378
+ except (json.JSONDecodeError, ValueError):
379
+ config = {}
380
+ if isinstance(config, dict):
381
+ findings.extend(scan_permissions(config, rel))
382
+ elif rel.lower().endswith(TS_EXTS):
383
+ try:
384
+ with open(abspath, "r", encoding="utf-8", errors="replace") as fh:
385
+ text = fh.read()
386
+ except OSError:
387
+ continue
388
+ findings.extend(scan_ts_source(text, rel))
389
+ return findings
390
+
391
+
392
+ def sha256_file(path: str) -> str:
393
+ h = hashlib.sha256()
394
+ with open(path, "rb") as fh:
395
+ for chunk in iter(lambda: fh.read(65536), b""):
396
+ h.update(chunk)
397
+ return h.hexdigest()
398
+
399
+
400
+ def build_snapshot(root: str) -> Dict:
401
+ """Capture a trusted state: file hashes + hook commands + permissions."""
402
+ files: Dict[str, str] = {}
403
+ hooks: List[Dict] = []
404
+ permissions: Dict[str, object] = {}
405
+ for abspath, rel in _iter_mod_files(root):
406
+ try:
407
+ files[rel] = sha256_file(abspath)
408
+ except OSError:
409
+ continue
410
+ base = os.path.basename(rel)
411
+ if base in CONFIG_FILES:
412
+ try:
413
+ with open(abspath, "r", encoding="utf-8", errors="replace") as fh:
414
+ text = fh.read()
415
+ config = json.loads(text)
416
+ except (OSError, ValueError):
417
+ continue
418
+ for command, _ in _walk_hook_commands(config):
419
+ hooks.append({"file": rel, "command": command})
420
+ if isinstance(config, dict) and config.get("permissions") is not None:
421
+ permissions[rel] = config["permissions"]
422
+ return {
423
+ "tool": "mod-audit",
424
+ "version": __version__,
425
+ "files": files,
426
+ "hooks": hooks,
427
+ "permissions": permissions,
428
+ }
429
+
430
+
431
+ def diff_against_snapshot(root: str, snapshot: Dict) -> List[Finding]:
432
+ """Compare an installed mod against a trusted snapshot.
433
+
434
+ Reports new/removed/changed files, hook changes, and re-runs content
435
+ rules on anything new or changed — the trojanized-update audit.
436
+ """
437
+ findings: List[Finding] = []
438
+ old_files: Dict[str, str] = snapshot.get("files", {})
439
+ new_files: Dict[str, str] = {}
440
+ for abspath, rel in _iter_mod_files(root):
441
+ try:
442
+ new_files[rel] = sha256_file(abspath)
443
+ except OSError:
444
+ continue
445
+
446
+ def add(rule_id, severity, rel, message, fix):
447
+ findings.append(Finding(rule_id, severity, rel, 0, message, fix))
448
+
449
+ for rel in sorted(set(new_files) - set(old_files)):
450
+ add(
451
+ "DIFF-NEW-FILE", "medium", rel,
452
+ f"New file appeared since snapshot: {rel}",
453
+ "Verify the file is expected in this update; re-scan flagged it "
454
+ "below if it contains suspicious patterns.",
455
+ )
456
+ for rel in sorted(set(old_files) - set(new_files)):
457
+ add(
458
+ "DIFF-REMOVED-FILE", "low", rel,
459
+ f"File removed since snapshot: {rel}",
460
+ "Confirm the removal is part of the documented update.",
461
+ )
462
+ changed = sorted(r for r in new_files if r in old_files and new_files[r] != old_files[r])
463
+ for rel in changed:
464
+ add(
465
+ "DIFF-CHANGED-FILE", "medium", rel,
466
+ f"File changed since snapshot: {rel}",
467
+ "Review the change; content rules were re-run on it below.",
468
+ )
469
+
470
+ # Re-run content rules on new + changed files.
471
+ for rel in sorted(set(changed) | (set(new_files) - set(old_files))):
472
+ abspath = os.path.join(root, rel)
473
+ base = os.path.basename(rel)
474
+ try:
475
+ with open(abspath, "r", encoding="utf-8", errors="replace") as fh:
476
+ text = fh.read()
477
+ except OSError:
478
+ continue
479
+ if base in CONFIG_FILES:
480
+ findings.extend(scan_hook_commands(text, rel))
481
+ try:
482
+ config = json.loads(text)
483
+ except (json.JSONDecodeError, ValueError):
484
+ config = {}
485
+ if isinstance(config, dict):
486
+ findings.extend(scan_permissions(config, rel))
487
+ elif rel.lower().endswith(TS_EXTS):
488
+ findings.extend(scan_ts_source(text, rel))
489
+
490
+ # Hook inventory comparison (catches pin-swap style hook swaps).
491
+ old_cmds = {(h.get("file"), h.get("command")) for h in snapshot.get("hooks", [])}
492
+ new_cmds = set()
493
+ for abspath, rel in _iter_mod_files(root):
494
+ if os.path.basename(rel) not in CONFIG_FILES:
495
+ continue
496
+ try:
497
+ with open(abspath, "r", encoding="utf-8", errors="replace") as fh:
498
+ config = json.load(fh)
499
+ except (OSError, ValueError):
500
+ continue
501
+ for command, _ in _walk_hook_commands(config):
502
+ new_cmds.add((rel, command))
503
+ for rel, command in sorted(new_cmds - old_cmds):
504
+ add(
505
+ "DIFF-HOOK-CHANGED", "high", rel,
506
+ f"Hook command added/changed since snapshot: {command[:120]}",
507
+ "Treat unexpected hook changes as a compromise indicator; "
508
+ "reinstall from the trusted source.",
509
+ )
510
+ for rel, command in sorted(old_cmds - new_cmds):
511
+ add(
512
+ "DIFF-HOOK-REMOVED", "low", rel,
513
+ f"Hook command removed since snapshot: {command[:120]}",
514
+ "Confirm the removal is part of the documented update.",
515
+ )
516
+ return findings
517
+
518
+
519
+ def severity_at_least(sev: str, threshold: str) -> bool:
520
+ return _SEV_ORDER.get(sev, 0) >= _SEV_ORDER.get(threshold, 0)
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: mod-audit
3
+ Version: 0.1.0
4
+ Summary: Static supply-chain auditor for Claude Code Mods — local, offline, stdlib-only
5
+ Author: hao li
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hahahahahahahahah6/mod-audit
8
+ Keywords: claude-code,mods,supply-chain,security,static-analysis,audit
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # mod-audit
25
+
26
+ Static supply-chain auditor for **Claude Code Mods** — local, offline, stdlib-only.
27
+
28
+ Claude Code Mods are TypeScript plugin packages that usually live under
29
+ `~/.claude/plugins/` and run **lifecycle hooks with your shell privileges**.
30
+ A single trojanized mod update can pipe `curl | sh` straight into your
31
+ machine. mod-audit scans a mod before you install it, snapshots the trusted
32
+ state, and diffs later updates against that snapshot — so a pin-swap style
33
+ update hijack lights up instead of slipping through.
34
+
35
+ Zero third-party dependencies. Python >= 3.9. No network calls, ever.
36
+
37
+ ## Why this exists
38
+
39
+ The agent supply chain is getting hit, repeatedly, in public:
40
+
41
+ - **AIR SkillJacking** — 925 skills hijacked, reaching an estimated 134k agents.
42
+ - **Plugin4Shell** — pin-swap attacks bypass SHA-pinning on plugin updates,
43
+ swapping trusted code for malicious code between the pin check and install.
44
+ - **Pwn2Own Ireland** — a Codex argument-injection flaw worth $40k showed how
45
+ agent tooling becomes a shell-execution primitive.
46
+ - **SKILLCLOAK** — cloaking techniques that bypass 90%+ of existing scanners.
47
+
48
+ Most defenses are either cloud-based scanners (your mod source leaves your
49
+ machine) or metadata-only reviewers that never look at the TypeScript that
50
+ actually runs. mod-audit does the opposite: it runs on your machine, offline,
51
+ and reads the code.
52
+
53
+ ## How it differs
54
+
55
+ | | mod-audit | ClawSecure Watchtower | rad-security AgentKeeper |
56
+ |---|---|---|---|
57
+ | Where it runs | Local / offline | Cloud scan | Cloud scan |
58
+ | Audits Mod TypeScript source | Yes | Partial | No — plugin/skill metadata only |
59
+ | Lifecycle hook analysis | Yes (shell patterns) | Generic | Metadata-level |
60
+ | Trojanized-update diffing | Yes (`snapshot`/`diff`) | No | No |
61
+ | Dependencies | Zero (stdlib only) | SaaS | SaaS |
62
+
63
+ Positioning: **local + offline + Mod TypeScript code specialist**. It does not
64
+ replace a metadata/policy reviewer — it covers the layer those tools skip:
65
+ the code that actually executes on your box.
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install mod-audit
71
+ ```
72
+
73
+ ## Quick start
74
+
75
+ ```bash
76
+ # 1. Audit a mod before installing it
77
+ mod-audit scan ~/.claude/plugins/some-mod
78
+
79
+ # 2. Snapshot the trusted state right after a clean install
80
+ mod-audit snapshot ~/.claude/plugins/some-mod --out ~/snapshots/some-mod.snapshot
81
+
82
+ # 3. After every update, diff against the snapshot
83
+ mod-audit diff ~/.claude/plugins/some-mod --against ~/snapshots/some-mod.snapshot
84
+ ```
85
+
86
+ JSON output for scripting:
87
+
88
+ ```bash
89
+ mod-audit scan ./my-mod --format json
90
+ ```
91
+
92
+ ## What it checks
93
+
94
+ ### 1. Dangerous lifecycle hooks (`plugin.json` / `hooks.json` / `package.json`)
95
+
96
+ | Rule | Severity | What it catches |
97
+ |---|---|---|
98
+ | `HOOK-PIPED-DOWNLOAD` | high | `curl … \| sh`, `wget … \| bash` in hooks |
99
+ | `HOOK-B64-EXEC` | high | base64 decode piped into execution |
100
+ | `HOOK-EXFIL` | high | `curl --data` exfiltrating data from a hook |
101
+ | `HOOK-REVERSE-SHELL` | critical | `nc -e`, `/dev/tcp/` reverse shells |
102
+ | `HOOK-SUDO` | high | privilege escalation in hooks |
103
+ | `HOOK-RM-RF` | high | destructive recursive deletes |
104
+ | `HOOK-CHMOD-EXEC` | medium | flipping files executable at install time |
105
+ | `HOOK-SHELL-EXEC` | medium | any other shell hook (runs as you) |
106
+
107
+ ### 2. Shell-execution patterns in `.ts`/`.js` source
108
+
109
+ | Rule | Severity | What it catches |
110
+ |---|---|---|
111
+ | `TS-SHELL-TRUE` | high | `exec/spawn` with `shell: true` |
112
+ | `TS-EXEC-CONCAT` | high | concatenated/interpolated command strings |
113
+ | `TS-EXEC` | medium | `child_process` usage to review |
114
+ | `TS-EVAL` | high | `eval()` / `new Function()` |
115
+ | `TS-DYN-IMPORT` | medium | dynamic `require()`/`import()` with non-literal specifiers |
116
+ | `TS-PERSISTENCE` | high | cron/launchd persistence references |
117
+ | `TS-DOTFILE-WRITE` | medium | writes derived from `$HOME`/`$PATH` |
118
+
119
+ ### 3. Env / API-key exfiltration
120
+
121
+ | Rule | Severity | What it catches |
122
+ |---|---|---|
123
+ | `ENV-EXFIL` | high | `process.env.*(API_KEY\|TOKEN\|SECRET\|PRIVATE)` within a few lines of a network sink (`fetch`, `axios`, `http.request`, …) |
124
+
125
+ ### 4. Trojanized-update diff (`snapshot` / `diff`)
126
+
127
+ | Rule | Severity | What it catches |
128
+ |---|---|---|
129
+ | `DIFF-NEW-FILE` | medium | files that appeared since the snapshot |
130
+ | `DIFF-CHANGED-FILE` | medium | files whose hash changed |
131
+ | `DIFF-REMOVED-FILE` | low | files that disappeared |
132
+ | `DIFF-HOOK-CHANGED` | high | hook commands added or swapped since the snapshot |
133
+ | `DIFF-HOOK-REMOVED` | low | hook commands removed |
134
+
135
+ New and changed files are re-scanned with all content rules during `diff`.
136
+
137
+ ### 5. Permissions manifest review
138
+
139
+ | Rule | Severity | What it catches |
140
+ |---|---|---|
141
+ | `PERM-SHELL-OVERGRANT` | high | shell granted without per-action confirmation |
142
+ | `PERM-TOOL-SHELL` | high | shell-capable tool granted without constraints |
143
+ | `PERM-NETWORK-OVERGRANT` | medium | unrestricted network access |
144
+ | `PERM-FS-OVERGRANT` | medium | broad filesystem write access |
145
+
146
+ Every finding includes the rule id, severity, `file:line`, an explanation,
147
+ and a concrete fix.
148
+
149
+ ## CI integration
150
+
151
+ mod-audit is CI-ready: it exits `1` when any finding meets `--fail-on`
152
+ (default `high`), `0` when clean, `2` on usage errors.
153
+
154
+ ```yaml
155
+ # .github/workflows/mod-audit.yml
156
+ name: mod-audit
157
+ on: [push, pull_request]
158
+ jobs:
159
+ audit:
160
+ runs-on: ubuntu-latest
161
+ steps:
162
+ - uses: actions/checkout@v4
163
+ - uses: actions/setup-python@v5
164
+ with:
165
+ python-version: "3.12"
166
+ - run: pip install mod-audit
167
+ - run: mod-audit scan ./my-mod --format json
168
+ ```
169
+
170
+ Gate updates in a scheduled job:
171
+
172
+ ```bash
173
+ mod-audit diff ~/.claude/plugins/my-mod --against ~/snapshots/my-mod.snapshot --fail-on medium
174
+ ```
175
+
176
+ ## Limitations
177
+
178
+ - **Offline heuristics, not a sandbox.** Rules are pattern-based and can miss
179
+ obfuscated code or flag benign code. Treat findings as triage signals.
180
+ - **No execution.** The tool never runs mod code, which is the point — but it
181
+ also means runtime-only behavior (e.g. payloads fetched at runtime) is out
182
+ of scope.
183
+ - **Snapshot trust.** `diff` is only as trustworthy as the snapshot: take it
184
+ from a clean install and store it where the mod updater cannot modify it.
185
+ - **TypeScript via regex, not a parser.** Keeps the tool stdlib-only and fast;
186
+ heavily minified or dynamically generated code may need manual review.
187
+
188
+ ## License
189
+
190
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ mod_audit/__init__.py,sha256=NHvIQ71ntL61KaJg6hPUFzHkkngWhVxG_F6Tj6n5EoY,556
2
+ mod_audit/cli.py,sha256=53LyoS1XmI6vkkw7MYa_gD-KaTDUGpzTIrvGOZSniPQ,5001
3
+ mod_audit/rules.py,sha256=rL6V6nuK8lUMsEWm0Cw9UwNy94Fk1AX64pI53PlVNXA,21212
4
+ mod_audit-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
5
+ mod_audit-0.1.0.dist-info/METADATA,sha256=9ro1Hec3NrNcdOXjwGEDLe4fRHgp1EguCdD8GxGLJj8,7233
6
+ mod_audit-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ mod_audit-0.1.0.dist-info/entry_points.txt,sha256=7GAFxo2Cg17G8wSJ5n9930CiKn3IZWBVR6ZtKXH0D84,49
8
+ mod_audit-0.1.0.dist-info/top_level.txt,sha256=SMi_PMtuX2bj8Tvu_OjkVI5wbannH0qax_J4_ETw1E0,10
9
+ mod_audit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mod-audit = mod_audit.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hao li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ mod_audit