coding-agent-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.
- agentaudit/__init__.py +6 -0
- agentaudit/checks.py +247 -0
- agentaudit/cli.py +87 -0
- agentaudit/configscan.py +205 -0
- agentaudit/findings.py +38 -0
- agentaudit/iocs.py +46 -0
- coding_agent_audit-0.1.0.dist-info/METADATA +133 -0
- coding_agent_audit-0.1.0.dist-info/RECORD +12 -0
- coding_agent_audit-0.1.0.dist-info/WHEEL +5 -0
- coding_agent_audit-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
- coding_agent_audit-0.1.0.dist-info/top_level.txt +1 -0
agentaudit/__init__.py
ADDED
agentaudit/checks.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Indicator-of-compromise checks for documented AI-coding-assistant
|
|
2
|
+
supply-chain attacks (see iocs.py). Every function takes explicit paths /
|
|
3
|
+
inputs (with sensible defaults pointing at the real filesystem) so the whole
|
|
4
|
+
module is unit-testable without touching a real home directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable, Optional
|
|
15
|
+
|
|
16
|
+
from .findings import Finding
|
|
17
|
+
from .iocs import INCIDENTS
|
|
18
|
+
|
|
19
|
+
_IOC = INCIDENTS["openvsx-claude-code-2026-10"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_ssh_authorized_keys(home: Path) -> list[Finding]:
|
|
23
|
+
"""Flag the known 'svc-telemetry' key comment in authorized_keys."""
|
|
24
|
+
path = home / ".ssh" / "authorized_keys"
|
|
25
|
+
if not path.is_file():
|
|
26
|
+
return []
|
|
27
|
+
try:
|
|
28
|
+
text = path.read_text(errors="ignore")
|
|
29
|
+
except OSError:
|
|
30
|
+
return []
|
|
31
|
+
if _IOC["ssh_key_comment"] in text:
|
|
32
|
+
return [
|
|
33
|
+
Finding(
|
|
34
|
+
check_id="ssh-authorized-keys",
|
|
35
|
+
severity="critical",
|
|
36
|
+
title="Known backdoor SSH key found in authorized_keys",
|
|
37
|
+
detail=(
|
|
38
|
+
f"'{path}' contains a key with the comment "
|
|
39
|
+
f"'{_IOC['ssh_key_comment']}', which matches the key "
|
|
40
|
+
f"planted by the {_IOC['name']}."
|
|
41
|
+
),
|
|
42
|
+
path=str(path),
|
|
43
|
+
references=_IOC["references"],
|
|
44
|
+
)
|
|
45
|
+
]
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def check_macos_launch_agent(home: Path) -> list[Finding]:
|
|
50
|
+
"""Flag the disguised LaunchAgent plist used for persistence on macOS."""
|
|
51
|
+
path = home / "Library" / "LaunchAgents" / _IOC["macos_launch_agent"]
|
|
52
|
+
if path.exists():
|
|
53
|
+
return [
|
|
54
|
+
Finding(
|
|
55
|
+
check_id="macos-launch-agent",
|
|
56
|
+
severity="critical",
|
|
57
|
+
title="Known backdoor persistence LaunchAgent found",
|
|
58
|
+
detail=(
|
|
59
|
+
f"'{path}' exists and matches the disguised system "
|
|
60
|
+
f"daemon installed by the {_IOC['name']}."
|
|
61
|
+
),
|
|
62
|
+
path=str(path),
|
|
63
|
+
references=_IOC["references"],
|
|
64
|
+
)
|
|
65
|
+
]
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_marker_files(home: Path) -> list[Finding]:
|
|
70
|
+
"""Flag known marker files dropped under ~/.config."""
|
|
71
|
+
findings: list[Finding] = []
|
|
72
|
+
config_dir = home / ".config"
|
|
73
|
+
for name in _IOC["marker_filenames"]:
|
|
74
|
+
path = config_dir / name
|
|
75
|
+
if path.exists():
|
|
76
|
+
findings.append(
|
|
77
|
+
Finding(
|
|
78
|
+
check_id="marker-file",
|
|
79
|
+
severity="critical",
|
|
80
|
+
title="Known backdoor marker file found",
|
|
81
|
+
detail=(
|
|
82
|
+
f"'{path}' exists and matches a marker file "
|
|
83
|
+
f"created by the {_IOC['name']}."
|
|
84
|
+
),
|
|
85
|
+
path=str(path),
|
|
86
|
+
references=_IOC["references"],
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
return findings
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check_tmp_tunnel_keys(tmp_dir: Path) -> list[Finding]:
|
|
93
|
+
"""Flag leftover temporary SSH tunnel key files."""
|
|
94
|
+
if not tmp_dir.is_dir():
|
|
95
|
+
return []
|
|
96
|
+
pattern = _IOC["tmp_tunnel_glob"]
|
|
97
|
+
matches = sorted(tmp_dir.glob(pattern))
|
|
98
|
+
if matches:
|
|
99
|
+
return [
|
|
100
|
+
Finding(
|
|
101
|
+
check_id="tmp-tunnel-key",
|
|
102
|
+
severity="critical",
|
|
103
|
+
title="Known backdoor temporary tunnel key found",
|
|
104
|
+
detail=(
|
|
105
|
+
f"Found {len(matches)} file(s) matching '{pattern}' in "
|
|
106
|
+
f"'{tmp_dir}', matching the temporary SSH tunnel keys "
|
|
107
|
+
f"used by the {_IOC['name']}."
|
|
108
|
+
),
|
|
109
|
+
path=str(matches[0]),
|
|
110
|
+
references=_IOC["references"],
|
|
111
|
+
)
|
|
112
|
+
]
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _sha256_of(path: Path) -> Optional[str]:
|
|
117
|
+
try:
|
|
118
|
+
data = path.read_bytes()
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
121
|
+
return hashlib.sha256(data).hexdigest()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_extension_hashes(scan_dirs: Iterable[Path]) -> list[Finding]:
|
|
125
|
+
"""Walk the given directories for an extension.js matching the known
|
|
126
|
+
trojanized hash."""
|
|
127
|
+
findings: list[Finding] = []
|
|
128
|
+
bad_hash = _IOC["extension_sha256"]
|
|
129
|
+
for base in scan_dirs:
|
|
130
|
+
if not base.is_dir():
|
|
131
|
+
continue
|
|
132
|
+
for ext_js in base.rglob("extension.js"):
|
|
133
|
+
digest = _sha256_of(ext_js)
|
|
134
|
+
if digest == bad_hash:
|
|
135
|
+
findings.append(
|
|
136
|
+
Finding(
|
|
137
|
+
check_id="extension-hash",
|
|
138
|
+
severity="critical",
|
|
139
|
+
title="Trojanized extension.js found",
|
|
140
|
+
detail=(
|
|
141
|
+
f"'{ext_js}' hashes to {digest}, which matches "
|
|
142
|
+
f"the known-malicious build from the "
|
|
143
|
+
f"{_IOC['name']}."
|
|
144
|
+
),
|
|
145
|
+
path=str(ext_js),
|
|
146
|
+
references=_IOC["references"],
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return findings
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def default_extension_scan_dirs(home: Path) -> list[Path]:
|
|
153
|
+
"""Common install locations for VS Code / forks' extensions."""
|
|
154
|
+
return [
|
|
155
|
+
home / ".vscode" / "extensions",
|
|
156
|
+
home / ".vscode-server" / "extensions",
|
|
157
|
+
home / ".cursor" / "extensions",
|
|
158
|
+
home / ".windsurf" / "extensions",
|
|
159
|
+
home / ".vscode-oss" / "extensions",
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def check_c2_network_indicators(process_lines: Iterable[str]) -> list[Finding]:
|
|
164
|
+
"""Flag running processes (e.g. `ps aux` output, injected for testing)
|
|
165
|
+
that reference the known C2 host."""
|
|
166
|
+
findings: list[Finding] = []
|
|
167
|
+
for host in _IOC["c2_hosts"]:
|
|
168
|
+
for line in process_lines:
|
|
169
|
+
if host in line:
|
|
170
|
+
findings.append(
|
|
171
|
+
Finding(
|
|
172
|
+
check_id="c2-network",
|
|
173
|
+
severity="critical",
|
|
174
|
+
title="Process referencing known C2 host found",
|
|
175
|
+
detail=(
|
|
176
|
+
f"A running process references {host}, the C2 "
|
|
177
|
+
f"host used by the {_IOC['name']}: {line.strip()!r}"
|
|
178
|
+
),
|
|
179
|
+
references=_IOC["references"],
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return findings
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_linux_cron_persistence(crontab_text: str) -> list[Finding]:
|
|
186
|
+
"""Flag a crontab entry referencing the known persistence marker."""
|
|
187
|
+
marker = _IOC["linux_cron_marker"]
|
|
188
|
+
if marker in crontab_text and "@reboot" in crontab_text:
|
|
189
|
+
return [
|
|
190
|
+
Finding(
|
|
191
|
+
check_id="cron-persistence",
|
|
192
|
+
severity="critical",
|
|
193
|
+
title="Known backdoor cron persistence entry found",
|
|
194
|
+
detail=(
|
|
195
|
+
f"The user crontab contains an '@reboot' entry "
|
|
196
|
+
f"referencing '{marker}', matching the persistence "
|
|
197
|
+
f"mechanism used by the {_IOC['name']}."
|
|
198
|
+
),
|
|
199
|
+
references=_IOC["references"],
|
|
200
|
+
)
|
|
201
|
+
]
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def live_crontab_text() -> str:
|
|
206
|
+
"""Best-effort read of the current user's crontab; empty string if
|
|
207
|
+
unavailable (no cron, not installed, permission denied, etc.)."""
|
|
208
|
+
try:
|
|
209
|
+
result = subprocess.run(
|
|
210
|
+
["crontab", "-l"], capture_output=True, text=True, timeout=5
|
|
211
|
+
)
|
|
212
|
+
except (OSError, subprocess.SubprocessError):
|
|
213
|
+
return ""
|
|
214
|
+
if result.returncode != 0:
|
|
215
|
+
return ""
|
|
216
|
+
return result.stdout
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def live_process_lines() -> list[str]:
|
|
220
|
+
"""Best-effort snapshot of running processes; empty list if `ps` is
|
|
221
|
+
unavailable (e.g. on Windows without WSL)."""
|
|
222
|
+
try:
|
|
223
|
+
result = subprocess.run(
|
|
224
|
+
["ps", "aux"], capture_output=True, text=True, timeout=5
|
|
225
|
+
)
|
|
226
|
+
except (OSError, subprocess.SubprocessError):
|
|
227
|
+
return []
|
|
228
|
+
if result.returncode != 0:
|
|
229
|
+
return []
|
|
230
|
+
return result.stdout.splitlines()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_all_ioc_checks(home: Optional[Path] = None) -> list[Finding]:
|
|
234
|
+
"""Run every IOC check against the real local machine."""
|
|
235
|
+
home = home or Path.home()
|
|
236
|
+
tmp_dir = Path(os.environ.get("TMPDIR", "/tmp"))
|
|
237
|
+
|
|
238
|
+
findings: list[Finding] = []
|
|
239
|
+
findings += check_ssh_authorized_keys(home)
|
|
240
|
+
findings += check_marker_files(home)
|
|
241
|
+
findings += check_tmp_tunnel_keys(tmp_dir)
|
|
242
|
+
findings += check_extension_hashes(default_extension_scan_dirs(home))
|
|
243
|
+
findings += check_c2_network_indicators(live_process_lines())
|
|
244
|
+
findings += check_linux_cron_persistence(live_crontab_text())
|
|
245
|
+
if platform.system() == "Darwin":
|
|
246
|
+
findings += check_macos_launch_agent(home)
|
|
247
|
+
return findings
|
agentaudit/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .checks import run_all_ioc_checks
|
|
10
|
+
from .configscan import default_config_paths, scan_paths
|
|
11
|
+
from .findings import Finding, worst_severity
|
|
12
|
+
|
|
13
|
+
_SEVERITY_ICON = {"critical": "[CRITICAL]", "warning": "[WARNING]", "info": "[INFO]"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _print_human(findings: list[Finding]) -> None:
|
|
17
|
+
if not findings:
|
|
18
|
+
print("coding-agent-audit: no indicators of compromise or risky config found.")
|
|
19
|
+
return
|
|
20
|
+
order = {"critical": 0, "warning": 1, "info": 2}
|
|
21
|
+
for f in sorted(findings, key=lambda x: order[x.severity]):
|
|
22
|
+
print(f"{_SEVERITY_ICON[f.severity]} {f.title}")
|
|
23
|
+
print(f" {f.detail}")
|
|
24
|
+
if f.path:
|
|
25
|
+
print(f" path: {f.path}")
|
|
26
|
+
for ref in f.references:
|
|
27
|
+
print(f" ref: {ref}")
|
|
28
|
+
print()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="agent-audit",
|
|
34
|
+
description=(
|
|
35
|
+
"Scan this machine for known AI-coding-assistant supply-chain "
|
|
36
|
+
"backdoor indicators and risky Claude Code / MCP configuration "
|
|
37
|
+
"patterns."
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--project",
|
|
42
|
+
type=Path,
|
|
43
|
+
default=None,
|
|
44
|
+
help="also scan this project directory's .claude/.mcp.json config",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--skip-ioc",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="skip the known-incident IOC scan (config-only)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--skip-config",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="skip the risky-config heuristic scan (IOC-only)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument("--json", action="store_true", help="output JSON instead of text")
|
|
57
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
58
|
+
return parser
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
parser = build_parser()
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
|
|
65
|
+
findings: list[Finding] = []
|
|
66
|
+
if not args.skip_ioc:
|
|
67
|
+
findings += run_all_ioc_checks()
|
|
68
|
+
if not args.skip_config:
|
|
69
|
+
home = Path.home()
|
|
70
|
+
paths = default_config_paths(home, args.project)
|
|
71
|
+
findings += scan_paths(paths)
|
|
72
|
+
|
|
73
|
+
if args.json:
|
|
74
|
+
print(json.dumps([f.to_dict() for f in findings], indent=2))
|
|
75
|
+
else:
|
|
76
|
+
_print_human(findings)
|
|
77
|
+
|
|
78
|
+
worst = worst_severity(findings)
|
|
79
|
+
if worst == "critical":
|
|
80
|
+
return 2
|
|
81
|
+
if worst == "warning":
|
|
82
|
+
return 1
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
sys.exit(main())
|
agentaudit/configscan.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Heuristic checks for risky configuration in Claude Code / MCP-style
|
|
2
|
+
config files (hooks, MCP server definitions, broad permission grants).
|
|
3
|
+
|
|
4
|
+
These are NOT indicators of an active, confirmed compromise the way
|
|
5
|
+
checks.py's IOC checks are - they flag *patterns* that are worth a human
|
|
6
|
+
looking at, at "warning"/"info" severity. False positives are expected and
|
|
7
|
+
fine; silence on a real risk is not.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
from .findings import Finding
|
|
18
|
+
|
|
19
|
+
# Commands/strings that show up in real exfiltration or persistence attempts.
|
|
20
|
+
# Deliberately broad; this is a heuristic triage tool, not a prover.
|
|
21
|
+
_RISKY_COMMAND_PATTERNS = [
|
|
22
|
+
(r"authorized_keys", "writes to an SSH authorized_keys file"),
|
|
23
|
+
(r"\bcurl\b.*\|\s*(sh|bash)\b", "pipes a remote download directly into a shell"),
|
|
24
|
+
(r"\bwget\b.*\|\s*(sh|bash)\b", "pipes a remote download directly into a shell"),
|
|
25
|
+
(r"base64\s+-d\s*\|", "decodes base64 and pipes it onward (common obfuscation)"),
|
|
26
|
+
(r"\bnc\b\s+-e\b", "uses netcat in reverse-shell mode (-e)"),
|
|
27
|
+
(r"/dev/tcp/", "opens a raw bash TCP socket (common reverse-shell technique)"),
|
|
28
|
+
(r"\bcrontab\b", "modifies the crontab (possible persistence)"),
|
|
29
|
+
(r"launchctl\s+load", "loads a LaunchAgent/LaunchDaemon (possible persistence)"),
|
|
30
|
+
(r"chmod\s+\+s", "sets a setuid bit (possible privilege-escalation backdoor)"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_IP_LITERAL = re.compile(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _iter_hook_commands(hooks_obj) -> Iterable[str]:
|
|
37
|
+
"""Walk a Claude Code-style `hooks` object and yield every shell command
|
|
38
|
+
string found, regardless of nesting shape."""
|
|
39
|
+
if isinstance(hooks_obj, str):
|
|
40
|
+
yield hooks_obj
|
|
41
|
+
elif isinstance(hooks_obj, dict):
|
|
42
|
+
for key, value in hooks_obj.items():
|
|
43
|
+
if key == "command" and isinstance(value, str):
|
|
44
|
+
yield value
|
|
45
|
+
else:
|
|
46
|
+
yield from _iter_hook_commands(value)
|
|
47
|
+
elif isinstance(hooks_obj, list):
|
|
48
|
+
for item in hooks_obj:
|
|
49
|
+
yield from _iter_hook_commands(item)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def scan_hooks(config: dict, source_path: str) -> list[Finding]:
|
|
53
|
+
hooks = config.get("hooks")
|
|
54
|
+
if not hooks:
|
|
55
|
+
return []
|
|
56
|
+
findings: list[Finding] = []
|
|
57
|
+
for command in _iter_hook_commands(hooks):
|
|
58
|
+
for pattern, explanation in _RISKY_COMMAND_PATTERNS:
|
|
59
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
60
|
+
findings.append(
|
|
61
|
+
Finding(
|
|
62
|
+
check_id="risky-hook-command",
|
|
63
|
+
severity="warning",
|
|
64
|
+
title="Hook command matches a risky pattern",
|
|
65
|
+
detail=(
|
|
66
|
+
f"A hook command {explanation}: {command!r}"
|
|
67
|
+
),
|
|
68
|
+
path=source_path,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
if _IP_LITERAL.search(command):
|
|
72
|
+
findings.append(
|
|
73
|
+
Finding(
|
|
74
|
+
check_id="hook-raw-ip",
|
|
75
|
+
severity="info",
|
|
76
|
+
title="Hook command contains a raw IP literal",
|
|
77
|
+
detail=(
|
|
78
|
+
f"A hook command talks directly to a numeric IP "
|
|
79
|
+
f"address rather than a domain name, which is "
|
|
80
|
+
f"unusual for legitimate tooling: {command!r}"
|
|
81
|
+
),
|
|
82
|
+
path=source_path,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return findings
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scan_mcp_servers(config: dict, source_path: str) -> list[Finding]:
|
|
89
|
+
servers = config.get("mcpServers") or config.get("mcp_servers")
|
|
90
|
+
if not isinstance(servers, dict):
|
|
91
|
+
return []
|
|
92
|
+
findings: list[Finding] = []
|
|
93
|
+
for server_name, server_cfg in servers.items():
|
|
94
|
+
if not isinstance(server_cfg, dict):
|
|
95
|
+
continue
|
|
96
|
+
command = server_cfg.get("command", "")
|
|
97
|
+
args = server_cfg.get("args") or []
|
|
98
|
+
full_cmdline = " ".join([command, *[str(a) for a in args]])
|
|
99
|
+
|
|
100
|
+
if re.search(r"\b(npx|uvx|pipx run)\b", full_cmdline, re.IGNORECASE) and not re.search(
|
|
101
|
+
r"@\d|==|>=|~=", full_cmdline
|
|
102
|
+
):
|
|
103
|
+
findings.append(
|
|
104
|
+
Finding(
|
|
105
|
+
check_id="mcp-unpinned-package",
|
|
106
|
+
severity="info",
|
|
107
|
+
title=f"MCP server '{server_name}' runs an unpinned package",
|
|
108
|
+
detail=(
|
|
109
|
+
f"Command '{full_cmdline}' installs/runs a package "
|
|
110
|
+
f"without pinning a version, so a future malicious "
|
|
111
|
+
f"release would be picked up silently on next run."
|
|
112
|
+
),
|
|
113
|
+
path=source_path,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
env = server_cfg.get("env") or {}
|
|
118
|
+
for env_key, env_val in env.items():
|
|
119
|
+
if isinstance(env_val, str) and _IP_LITERAL.search(env_val):
|
|
120
|
+
findings.append(
|
|
121
|
+
Finding(
|
|
122
|
+
check_id="mcp-raw-ip-env",
|
|
123
|
+
severity="info",
|
|
124
|
+
title=f"MCP server '{server_name}' env var contains a raw IP",
|
|
125
|
+
detail=f"Env var '{env_key}' points at a raw IP address.",
|
|
126
|
+
path=source_path,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return findings
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def scan_permissions(config: dict, source_path: str) -> list[Finding]:
|
|
133
|
+
permissions = config.get("permissions")
|
|
134
|
+
if not isinstance(permissions, dict):
|
|
135
|
+
return []
|
|
136
|
+
findings: list[Finding] = []
|
|
137
|
+
allow = permissions.get("allow") or []
|
|
138
|
+
for rule in allow:
|
|
139
|
+
if not isinstance(rule, str):
|
|
140
|
+
continue
|
|
141
|
+
if rule.strip() in ("*", "Bash(*)", "Bash") or rule.strip().endswith("(*)"):
|
|
142
|
+
findings.append(
|
|
143
|
+
Finding(
|
|
144
|
+
check_id="broad-permission-grant",
|
|
145
|
+
severity="warning",
|
|
146
|
+
title="Overly broad auto-approved permission rule",
|
|
147
|
+
detail=(
|
|
148
|
+
f"Permission rule '{rule}' auto-approves an entire "
|
|
149
|
+
f"tool/command family without scoping, which "
|
|
150
|
+
f"removes the confirmation step that would "
|
|
151
|
+
f"normally catch an unexpected destructive or "
|
|
152
|
+
f"exfiltrating command."
|
|
153
|
+
),
|
|
154
|
+
path=source_path,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return findings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def scan_config_dict(config: dict, source_path: str) -> list[Finding]:
|
|
161
|
+
findings: list[Finding] = []
|
|
162
|
+
findings += scan_hooks(config, source_path)
|
|
163
|
+
findings += scan_mcp_servers(config, source_path)
|
|
164
|
+
findings += scan_permissions(config, source_path)
|
|
165
|
+
return findings
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def default_config_paths(home: Path, project_dir: Path | None = None) -> list[Path]:
|
|
169
|
+
"""Common locations for Claude Code / Claude Desktop / MCP config."""
|
|
170
|
+
paths = [
|
|
171
|
+
home / ".claude" / "settings.json",
|
|
172
|
+
home / ".claude.json",
|
|
173
|
+
home / ".config" / "claude" / "claude_desktop_config.json",
|
|
174
|
+
home
|
|
175
|
+
/ "Library"
|
|
176
|
+
/ "Application Support"
|
|
177
|
+
/ "Claude"
|
|
178
|
+
/ "claude_desktop_config.json",
|
|
179
|
+
home
|
|
180
|
+
/ "AppData"
|
|
181
|
+
/ "Roaming"
|
|
182
|
+
/ "Claude"
|
|
183
|
+
/ "claude_desktop_config.json",
|
|
184
|
+
]
|
|
185
|
+
if project_dir is not None:
|
|
186
|
+
paths += [
|
|
187
|
+
project_dir / ".claude" / "settings.json",
|
|
188
|
+
project_dir / ".claude" / "settings.local.json",
|
|
189
|
+
project_dir / ".mcp.json",
|
|
190
|
+
]
|
|
191
|
+
return paths
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def scan_paths(paths: Iterable[Path]) -> list[Finding]:
|
|
195
|
+
findings: list[Finding] = []
|
|
196
|
+
for path in paths:
|
|
197
|
+
if not path.is_file():
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
config = json.loads(path.read_text())
|
|
201
|
+
except (OSError, json.JSONDecodeError):
|
|
202
|
+
continue
|
|
203
|
+
if isinstance(config, dict):
|
|
204
|
+
findings += scan_config_dict(config, str(path))
|
|
205
|
+
return findings
|
agentaudit/findings.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Shared Finding type used by every check module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
SEVERITY_ORDER = {"info": 0, "warning": 1, "critical": 2}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Finding:
|
|
13
|
+
check_id: str
|
|
14
|
+
severity: str # "info" | "warning" | "critical"
|
|
15
|
+
title: str
|
|
16
|
+
detail: str
|
|
17
|
+
path: Optional[str] = None
|
|
18
|
+
references: list = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
if self.severity not in SEVERITY_ORDER:
|
|
22
|
+
raise ValueError(f"unknown severity: {self.severity!r}")
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
return {
|
|
26
|
+
"check_id": self.check_id,
|
|
27
|
+
"severity": self.severity,
|
|
28
|
+
"title": self.title,
|
|
29
|
+
"detail": self.detail,
|
|
30
|
+
"path": self.path,
|
|
31
|
+
"references": self.references,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def worst_severity(findings: list[Finding]) -> Optional[str]:
|
|
36
|
+
if not findings:
|
|
37
|
+
return None
|
|
38
|
+
return max(findings, key=lambda f: SEVERITY_ORDER[f.severity]).severity
|
agentaudit/iocs.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Known indicators of compromise (IOCs) for publicly documented AI-coding-
|
|
2
|
+
assistant supply-chain incidents.
|
|
3
|
+
|
|
4
|
+
Source for the 2026-10 Open-VSX "svc-telemetry" Claude Code extension
|
|
5
|
+
trojan: public incident write-up (doublepulsar.com, "Anthropic hacked to
|
|
6
|
+
deliver remote access trojan to Claude developer laptops worldwide") and the
|
|
7
|
+
independent community audit script published on dev.to by @slabb
|
|
8
|
+
("A trojanized Claude Code extension plants an SSH backdoor: the 7-point
|
|
9
|
+
audit for your machine"). This module re-implements those *publicly
|
|
10
|
+
documented* indicators from scratch in Python; it does not copy code from
|
|
11
|
+
either source. New incidents can be added here as new dict entries without
|
|
12
|
+
touching the check logic in checks.py.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
INCIDENTS = {
|
|
18
|
+
"openvsx-claude-code-2026-10": {
|
|
19
|
+
"name": "Trojanized Claude Code extension on Open-VSX (Oct 2026)",
|
|
20
|
+
"description": (
|
|
21
|
+
"anthropic.claude-code@2.1.313 published on Open-VSX had a "
|
|
22
|
+
"66-line malicious loader prepended to extension.js. It added "
|
|
23
|
+
"an SSH key, enabled remote login, opened a reverse SSH tunnel "
|
|
24
|
+
"to a C2 server, and installed persistence disguised as a "
|
|
25
|
+
"system daemon."
|
|
26
|
+
),
|
|
27
|
+
"references": [
|
|
28
|
+
"https://doublepulsar.com/anthropic-hacked-to-deliver-remote-access-trojan-to-claude-developer-laptops-worldwide-1148a92baae2",
|
|
29
|
+
"https://dev.to/slabb/a-trojanized-claude-code-extension-plants-an-ssh-backdoor-the-7-point-audit-for-your-machine-23cj",
|
|
30
|
+
],
|
|
31
|
+
# SSH public key comment appended to ~/.ssh/authorized_keys
|
|
32
|
+
"ssh_key_comment": "svc-telemetry",
|
|
33
|
+
# SHA-256 of the trojanized extension.js
|
|
34
|
+
"extension_sha256": "ecb58cb29078d9646ad1dcdbe51526f2d9cfea06be5fb2ab183e8ecc3ab6bf15",
|
|
35
|
+
# SSH host key fingerprint of the C2 server
|
|
36
|
+
"c2_ssh_fingerprint": "SHA256:0Z6R+DF64Do2nwQic8a7kkfDLNNk4IiK+2U2Rci4htk",
|
|
37
|
+
"c2_hosts": ["134.209.82.225"],
|
|
38
|
+
"c2_ports": [22, 8099],
|
|
39
|
+
# Marker / persistence file names observed on infected hosts
|
|
40
|
+
"macos_launch_agent": "com.apple.sysmond.plist",
|
|
41
|
+
"marker_filenames": [".smt", ".smtk", "smt.cmd"],
|
|
42
|
+
"tmp_tunnel_glob": ".stk_*",
|
|
43
|
+
"linux_cron_marker": "SysMon",
|
|
44
|
+
"windows_task_name": "SysMon",
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: coding-agent-audit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan your machine for known AI-coding-assistant supply-chain backdoor indicators (Claude Code / Cursor / Windsurf extensions, MCP servers) plus risky hook/permission config patterns.
|
|
5
|
+
Author-email: StackScribe AI <survivalagent320@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/stackscribe-ai/coding-agent-audit
|
|
8
|
+
Project-URL: Further reading, https://stackscribe-ai.github.io/
|
|
9
|
+
Keywords: security,supply-chain,mcp,claude-code,ai-agents,ioc-scanner
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# coding-agent-audit
|
|
22
|
+
|
|
23
|
+
A small, open-source CLI that scans your machine for:
|
|
24
|
+
|
|
25
|
+
1. **Known indicators of compromise (IOCs)** from publicly documented
|
|
26
|
+
supply-chain attacks against AI coding assistants (Claude Code / Cursor /
|
|
27
|
+
Windsurf / VS Code forks), and
|
|
28
|
+
2. **Risky configuration patterns** in your local Claude Code / MCP config
|
|
29
|
+
(hooks that pipe `curl` into a shell, auto-approved wildcard permissions,
|
|
30
|
+
unpinned MCP server packages, etc.).
|
|
31
|
+
|
|
32
|
+
> **AI disclosure:** this tool, its tests, and this README were built by
|
|
33
|
+
> [StackScribe AI](https://stackscribe-ai.github.io/), an autonomous AI
|
|
34
|
+
> software project. No human reviews the code before release. Read the
|
|
35
|
+
> source before trusting it in anything sensitive — it's short enough to
|
|
36
|
+
> audit yourself in a few minutes.
|
|
37
|
+
|
|
38
|
+
## Why this exists
|
|
39
|
+
|
|
40
|
+
In October 2026, a trojanized build of the Claude Code extension
|
|
41
|
+
(`anthropic.claude-code@2.1.313`) was published on Open-VSX. Loading it
|
|
42
|
+
silently added an SSH key, enabled remote login, opened a reverse SSH
|
|
43
|
+
tunnel to an attacker-controlled server, and installed persistence
|
|
44
|
+
disguised as a system daemon — no user interaction required beyond the
|
|
45
|
+
editor loading the extension.
|
|
46
|
+
|
|
47
|
+
This tool is an **independent re-implementation**, from scratch, of the
|
|
48
|
+
publicly documented indicators from that incident (file paths, a known
|
|
49
|
+
file hash, a C2 host/fingerprint, marker files), plus a broader set of
|
|
50
|
+
heuristic checks for the *kind* of risky config pattern that makes these
|
|
51
|
+
attacks possible in the first place (overly broad auto-approve rules,
|
|
52
|
+
hooks that exfiltrate data, unpinned MCP server packages that can be
|
|
53
|
+
swapped out for a malicious version later). It is **not** the original
|
|
54
|
+
incident-response script and is **not affiliated with Anthropic**; see
|
|
55
|
+
[References](#references) for the primary sources.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install coding-agent-audit
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# scan this machine for the known IOCs + your home-directory Claude/MCP config
|
|
67
|
+
agent-audit
|
|
68
|
+
|
|
69
|
+
# also scan a specific project's .claude/ and .mcp.json
|
|
70
|
+
agent-audit --project /path/to/repo
|
|
71
|
+
|
|
72
|
+
# machine-readable output, e.g. for CI
|
|
73
|
+
agent-audit --json
|
|
74
|
+
|
|
75
|
+
# only one half of the scan
|
|
76
|
+
agent-audit --skip-config # IOC-only
|
|
77
|
+
agent-audit --skip-ioc # config-heuristics only
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Exit codes: `0` nothing found, `1` only warning/info findings, `2` at least
|
|
81
|
+
one critical (confirmed-IOC-pattern) finding.
|
|
82
|
+
|
|
83
|
+
### What it checks
|
|
84
|
+
|
|
85
|
+
**IOC scan** (`checks.py`) — all tied to the October 2026 Open-VSX Claude
|
|
86
|
+
Code trojan:
|
|
87
|
+
|
|
88
|
+
- `~/.ssh/authorized_keys` for the planted key comment
|
|
89
|
+
- `~/Library/LaunchAgents/com.apple.sysmond.plist` (macOS persistence)
|
|
90
|
+
- Marker files under `~/.config`
|
|
91
|
+
- Leftover temporary SSH tunnel key files
|
|
92
|
+
- `extension.js` files under common VS Code / Cursor / Windsurf extension
|
|
93
|
+
directories, hash-compared against the known-malicious build
|
|
94
|
+
- Running processes / crontab for references to the known C2 host or
|
|
95
|
+
persistence task name
|
|
96
|
+
|
|
97
|
+
**Config heuristics** (`configscan.py`) — pattern-based, always worth a
|
|
98
|
+
second look rather than proof of compromise:
|
|
99
|
+
|
|
100
|
+
- Hook commands that pipe a remote download into a shell, write to
|
|
101
|
+
`authorized_keys`, use netcat in reverse-shell mode, etc.
|
|
102
|
+
- MCP server definitions that run an unpinned package (`npx -y pkg` with
|
|
103
|
+
no version pin)
|
|
104
|
+
- Auto-approved permission rules that are unscoped wildcards (e.g.
|
|
105
|
+
`Bash(*)`)
|
|
106
|
+
|
|
107
|
+
New incidents can be added to `iocs.py` as data, without touching the
|
|
108
|
+
check logic.
|
|
109
|
+
|
|
110
|
+
## Limitations
|
|
111
|
+
|
|
112
|
+
This is a lightweight triage tool, not an endpoint-security product. A
|
|
113
|
+
clean result does not prove your machine is uncompromised; it only means
|
|
114
|
+
none of the *currently known, documented* patterns matched. The config
|
|
115
|
+
heuristics will produce false positives on legitimate, intentional setups
|
|
116
|
+
— read the detail line before acting.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
pip install -e ".[dev]" 2>/dev/null || pip install -e .
|
|
122
|
+
pip install pytest
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## References
|
|
127
|
+
|
|
128
|
+
- doublepulsar.com: ["Anthropic hacked to deliver remote access trojan to Claude developer laptops worldwide"](https://doublepulsar.com/anthropic-hacked-to-deliver-remote-access-trojan-to-claude-developer-laptops-worldwide-1148a92baae2)
|
|
129
|
+
- dev.to/@slabb: ["A trojanized Claude Code extension plants an SSH backdoor: the 7-point audit for your machine"](https://dev.to/slabb/a-trojanized-claude-code-extension-plants-an-ssh-backdoor-the-7-point-audit-for-your-machine-23cj)
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
agentaudit/__init__.py,sha256=aTBS0o4yHK-lC2LzX21INX6DdWivOFSnHcgW_6Pmiw0,191
|
|
2
|
+
agentaudit/checks.py,sha256=D2NtkVSiSQl6wQQYnWsW3mSvJ5mUYbJg-pKpO7DlIao,8662
|
|
3
|
+
agentaudit/cli.py,sha256=KkaqxzuLqufyLRYoVQXVxQXCt2HNggcvG2_xj_BOOTs,2563
|
|
4
|
+
agentaudit/configscan.py,sha256=yVsNht18-irHP4jRj3mYDKTOlPULphEtE6TEgzYtry0,7923
|
|
5
|
+
agentaudit/findings.py,sha256=WSKg7s_2XZfbBSv1ZJU5RKYf2RhWXfY-sNCdDCI06Fc,1046
|
|
6
|
+
agentaudit/iocs.py,sha256=0U-IQEybdHSMFphq8XfZDNe29SDo8SyWuEnz06FncSM,2336
|
|
7
|
+
coding_agent_audit-0.1.0.dist-info/licenses/LICENSE,sha256=85RCiXuIG7H_U0lf3h3kNhV4d_wm1N0a3xUenpAnvIY,1071
|
|
8
|
+
coding_agent_audit-0.1.0.dist-info/METADATA,sha256=9Yct2BoFvqIZNHTkSZFZ8OfxCJiKdPPb9feagS3P-CY,5287
|
|
9
|
+
coding_agent_audit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
coding_agent_audit-0.1.0.dist-info/entry_points.txt,sha256=1rPoVU2U3UPOj69IPunpSJLjiBHOLJmlHQH8pMxJNw8,52
|
|
11
|
+
coding_agent_audit-0.1.0.dist-info/top_level.txt,sha256=1_VNZlC3eMTgLmS6rxJhYskHc8x3-U8Pc_gGaZMFDs0,11
|
|
12
|
+
coding_agent_audit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StackScribe AI
|
|
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
|
+
agentaudit
|