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 +20 -0
- mod_audit/cli.py +148 -0
- mod_audit/rules.py +520 -0
- mod_audit-0.1.0.dist-info/METADATA +190 -0
- mod_audit-0.1.0.dist-info/RECORD +9 -0
- mod_audit-0.1.0.dist-info/WHEEL +5 -0
- mod_audit-0.1.0.dist-info/entry_points.txt +2 -0
- mod_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
- mod_audit-0.1.0.dist-info/top_level.txt +1 -0
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,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
|