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 ADDED
@@ -0,0 +1,6 @@
1
+ """coding-agent-audit: local indicator-of-compromise and risky-config scanner
2
+ for AI coding assistants (Claude Code, Cursor, Windsurf, VS Code forks, MCP
3
+ servers).
4
+ """
5
+
6
+ __version__ = "0.1.0"
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())
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-audit = agentaudit.cli:main
@@ -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