crumplelab 0.2.4 → 0.3.0
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.
- package/.claude-plugin/plugin.json +12 -3
- package/.mcp.json +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +47 -8
- package/bin/crumple.mjs +37 -0
- package/gemini-extension.json +15 -0
- package/package.json +6 -3
- package/server.json +28 -0
- package/src/clients.mjs +176 -0
- package/src/mcp.mjs +17 -4
- package/src/scan.mjs +57 -9
- package/src/site.mjs +142 -0
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crumple",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Find out whether your AI coding agent can be hijacked, and harden it. Local scan, no key, nothing leaves the machine.",
|
|
5
|
-
"author": {
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Priberry",
|
|
7
|
+
"email": "crumple@priberry.com",
|
|
8
|
+
"url": "https://priberry.com"
|
|
9
|
+
},
|
|
6
10
|
"homepage": "https://crumple.priberry.com",
|
|
7
11
|
"repository": "https://github.com/priberry-data/crumplelab",
|
|
8
12
|
"license": "Apache-2.0",
|
|
9
|
-
"keywords": [
|
|
13
|
+
"keywords": [
|
|
14
|
+
"security",
|
|
15
|
+
"prompt-injection",
|
|
16
|
+
"mcp",
|
|
17
|
+
"agents"
|
|
18
|
+
]
|
|
10
19
|
}
|
package/.mcp.json
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- `crumple site <url>`: checks a deployed app (Lovable, Bolt, v0, Replit, Emergent or hand-built) for secret keys
|
|
6
|
+
in its JavaScript, a Supabase `service_role` key in the browser, public source maps and a missing CSP. With
|
|
7
|
+
`--mine` it also counts which Supabase tables anyone can read, using `HEAD` requests that return no rows.
|
|
8
|
+
- `crumple setup`: adds Crumple's MCP server to every AI coding app found (Claude Code, Claude Desktop, Cursor,
|
|
9
|
+
VS Code, Windsurf, Codex, Gemini CLI, Kiro, opencode, Amazon Q, LM Studio, Cline, Roo Code, Kilo Code), pinned
|
|
10
|
+
to the exact version, with backups and `--undo`.
|
|
11
|
+
- `scan` reads more setups: Claude Desktop, VS Code's user `mcp.json`, Kiro, Roo Code, Cline and Kilo Code
|
|
12
|
+
settings, Zed, opencode, Amazon Q and Continue, config files with comments, and the rules folders
|
|
13
|
+
`.windsurf/rules`, `.kiro/steering`, `.roo/rules`, `.clinerules/`, `.github/instructions` and more.
|
|
14
|
+
- `crumple_site` in the MCP server, and a Gemini CLI extension.
|
|
15
|
+
- The Claude Code plugin now runs its own bundled code for the MCP server instead of an unpinned `npx crumplelab`,
|
|
16
|
+
which Crumple's own scan rightly flagged.
|
|
17
|
+
- `--version`, and a clear message for options Crumple doesn't know.
|
|
18
|
+
|
|
3
19
|
## 0.2.4
|
|
4
20
|
|
|
5
21
|
- **Fix:** `crumple scan` flagged every tracked file as "a .env file is committed to git" in any repo without a
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Crumple: crash-test your AI coding agent
|
|
4
4
|
|
|
5
|
-
> **Version 0.
|
|
5
|
+
> **Version 0.3.** Needs Node 20+ and Docker (`scan`, `site` and `setup` need neither). So far Crumple's scores are
|
|
6
6
|
> verified against its built-in test agents; results for Claude Code, Codex and Gemini CLI will be published
|
|
7
7
|
> as they're run. Website: [crumple.priberry.com](https://crumple.priberry.com) · contact: crumple@priberry.com
|
|
8
8
|
|
|
@@ -13,12 +13,17 @@ Your AI agent can read your `.env`, run shell commands and touch your databases.
|
|
|
13
13
|
npx crumplelab scan # 5 seconds, no API key, nothing leaves your machine
|
|
14
14
|
npx crumplelab attack # the real crash test, in a throwaway container
|
|
15
15
|
npx crumplelab fix # harden it, then attack again to prove it
|
|
16
|
+
npx crumplelab site myapp.lovable.app # built your app with an AI app builder? check what it leaks
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
`scan` audits your agent setup the way an attacker would read it: skip-permissions modes, pre-approved
|
|
19
20
|
commands that can run anything, MCP servers on `@latest` or with keys in plain text, invisible characters in
|
|
20
21
|
`CLAUDE.md` and `.cursorrules`, repo hooks and auto-run tasks, base-URL redirects, committed `.env` files.
|
|
21
|
-
It
|
|
22
|
+
It reads the setups of Claude Code, Claude Desktop, Codex, Gemini CLI, Cursor, Windsurf, VS Code (project
|
|
23
|
+
and user `mcp.json`), Kiro, Cline, Roo Code, Kilo Code, Zed, opencode, Amazon Q and Continue, and the rules
|
|
24
|
+
files and folders they all read (`CLAUDE.md`, `AGENTS.md`, `.cursor/rules`, `.windsurf/rules`, `.kiro/steering`,
|
|
25
|
+
`.roo/rules`, `.clinerules`, `.github/instructions` and more). It prints a Safe Setup score, writes SARIF for
|
|
26
|
+
GitHub code scanning, and fails CI with `--fail-on high`.
|
|
22
27
|
|
|
23
28
|
`attack` builds eighteen booby-trapped projects inside a Docker container, runs **your own** agent setup
|
|
24
29
|
against each one headless, and times how long it takes to fall in. Every trap is drawn from a real CVE,
|
|
@@ -67,6 +72,29 @@ Score: 0/18 traps survived
|
|
|
67
72
|
|
|
68
73
|
You get a card to share, a badge for your README, and a fix for every failed trap.
|
|
69
74
|
|
|
75
|
+
## Check a deployed app
|
|
76
|
+
|
|
77
|
+
Built your app with Lovable, Bolt, v0, Replit, Emergent or any other AI app builder? The mistakes they ship
|
|
78
|
+
most often are visible to anyone who opens the site.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npx crumplelab site myapp.lovable.app # what every visitor can see
|
|
82
|
+
npx crumplelab site myapp.lovable.app --mine # also: which Supabase tables anyone can read
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`site` reads the page and the JavaScript it serves, the way a curious stranger would, and looks for:
|
|
86
|
+
|
|
87
|
+
- secret keys in code every visitor downloads (Stripe, OpenAI, Anthropic, AWS, GitHub, Slack, Resend,
|
|
88
|
+
SendGrid, Twilio, Groq, Gemini)
|
|
89
|
+
- a Supabase `service_role` key in the browser, which skips every security rule
|
|
90
|
+
- public source maps, which hand out your whole source code
|
|
91
|
+
- no Content-Security-Policy
|
|
92
|
+
|
|
93
|
+
With `--mine`, for an app you own, it takes the public Supabase anon key the app already ships and asks the
|
|
94
|
+
database which tables anyone can read. It sends `HEAD` requests with `count=exact`, so Postgres returns a row
|
|
95
|
+
count and never a row. Tables of people (users, profiles, orders, messages) are marked critical, and each
|
|
96
|
+
finding says which switch to flip in Supabase.
|
|
97
|
+
|
|
70
98
|
## Fix it
|
|
71
99
|
|
|
72
100
|
```bash
|
|
@@ -134,13 +162,24 @@ invisible text in `CLAUDE.md`, a changed MCP tool) fail, and findings appear in
|
|
|
134
162
|
|
|
135
163
|
## In your agent
|
|
136
164
|
|
|
165
|
+
One command adds Crumple to every AI coding app it finds: Claude Code, Claude Desktop, Cursor, VS Code,
|
|
166
|
+
Windsurf, Codex, Gemini CLI, Kiro, opencode, Amazon Q, LM Studio, and Cline, Roo Code and Kilo Code. The
|
|
167
|
+
entry is pinned to the exact version, each file is backed up first, and `--undo` removes it.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npx crumplelab setup
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Or one app at a time:
|
|
174
|
+
|
|
137
175
|
- **Claude Code plugin:** `/plugin marketplace add priberry-data/crumplelab`, then `/plugin install crumple@priberry`
|
|
138
176
|
(or in one step: `/plugin install crumple --marketplace priberry-data/crumplelab`). Adds the skill and the MCP server;
|
|
139
177
|
ask "is my agent setup safe?"
|
|
178
|
+
- **Gemini CLI extension:** `gemini extensions install https://github.com/priberry-data/crumplelab`
|
|
140
179
|
- **Skill (Claude Code or Codex):** copy `skills/crumple` into `~/.claude/skills/` or `~/.codex/skills/`.
|
|
141
|
-
- **MCP
|
|
142
|
-
(the setup scan, local and instant)
|
|
143
|
-
command you run on purpose.
|
|
180
|
+
- **Any MCP client:** `{"command": "npx", "args": ["-y", "crumplelab@0.3.0", "mcp"]}`. Tools: `crumple_scan`
|
|
181
|
+
(the setup scan, local and instant), `crumple_site` (what a deployed app shows every visitor) and
|
|
182
|
+
`crumple_traps`. The full crash test spends API credit, so it stays a command you run on purpose.
|
|
144
183
|
|
|
145
184
|
## Private trap packs
|
|
146
185
|
|
|
@@ -181,8 +220,8 @@ npx crumplelab attack --agent mock-careful
|
|
|
181
220
|
|
|
182
221
|
## Status
|
|
183
222
|
|
|
184
|
-
v0.
|
|
185
|
-
GitHub Action. Not yet run against live models: the traps and checks are verified with scripted agents.
|
|
223
|
+
v0.3: Claude Code, Codex CLI and Gemini CLI; 18 traps plus packs; `scan` across 15 agents and editors, `site`
|
|
224
|
+
for deployed apps, `setup`, `fix`, `lock`, the guard and a GitHub Action. Not yet run against live models: the traps and checks are verified with scripted agents.
|
|
186
225
|
Next: first live runs across vendors, Copilot CLI and Cursor adapters, and a public cross-vendor leaderboard.
|
|
187
226
|
|
|
188
|
-
Apache-2.0 · Built by [Priberry](https://priberry.com)
|
|
227
|
+
Apache-2.0 · Built by [Priberry](https://priberry.com), who also make [Tare](https://tare.priberry.com).
|
package/bin/crumple.mjs
CHANGED
|
@@ -50,6 +50,8 @@ try {
|
|
|
50
50
|
pack: { type: "string", multiple: true, default: [] },
|
|
51
51
|
help: { type: "boolean", short: "h" },
|
|
52
52
|
version: { type: "boolean", short: "v" },
|
|
53
|
+
mine: { type: "boolean" },
|
|
54
|
+
undo: { type: "boolean" },
|
|
53
55
|
},
|
|
54
56
|
});
|
|
55
57
|
} catch (e) {
|
|
@@ -70,6 +72,7 @@ const crashed = (e) => {
|
|
|
70
72
|
process.on("uncaughtException", crashed);
|
|
71
73
|
process.on("unhandledRejection", crashed);
|
|
72
74
|
|
|
75
|
+
const SEVERITY_OF = (s) => ({ critical: 40, high: 15, medium: 5, low: 1 })[s] ?? 0;
|
|
73
76
|
const c = { red: (s) => `\x1b[31m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m` };
|
|
74
77
|
|
|
75
78
|
// The guard speaks JSON to the agent over stdout, so it (and any machine-readable output) never gets the banner.
|
|
@@ -126,6 +129,38 @@ if (positionals[0] === "mcp") {
|
|
|
126
129
|
}
|
|
127
130
|
const failOn = opt["fail-on"];
|
|
128
131
|
if (failOn && result.findings.some((f) => SEVERITY[f.severity] >= SEVERITY[failOn])) process.exitCode = 1;
|
|
132
|
+
} else if (positionals[0] === "setup") {
|
|
133
|
+
const { setup, renderSetup } = await import("../src/clients.mjs");
|
|
134
|
+
const rows = setup({ name: "crumple", pkg: "crumplelab", version: VERSION, dryRun: opt["dry-run"], undo: opt.undo, backupDir: path.join(os.homedir(), ".crumplelab", "backups") });
|
|
135
|
+
console.log(renderSetup(rows, { name: "Crumple", undo: opt.undo, dryRun: opt["dry-run"], color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR) }));
|
|
136
|
+
} else if (positionals[0] === "site") {
|
|
137
|
+
if (!positionals[1]) {
|
|
138
|
+
console.error("Usage: npx crumplelab site <url> [--mine] checks a deployed app (Lovable, Bolt, v0, Replit, any site)");
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
const { siteScan } = await import("../src/site.mjs");
|
|
142
|
+
const run = () => siteScan(positionals[1], { mine: opt.mine });
|
|
143
|
+
let result;
|
|
144
|
+
try {
|
|
145
|
+
result = opt.json ? await run() : await spin(`looking at ${positionals[1]} the way a stranger would`, run);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error(c.red(`crumple: ${e.message}`));
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
if (opt.json) console.log(JSON.stringify(result, null, 2));
|
|
151
|
+
else {
|
|
152
|
+
const col = { critical: c.red, high: c.red, medium: c.yellow, low: c.dim };
|
|
153
|
+
console.log(`\n${c.bold("Crumple site check")} · ${result.url} · ${result.scripts} scripts read${result.supabase ? " · Supabase app" : ""}\n`);
|
|
154
|
+
if (!result.findings.length) console.log(c.green("Nothing found.\n"));
|
|
155
|
+
for (const f of result.findings) {
|
|
156
|
+
console.log(`${col[f.severity](f.severity.toUpperCase().padEnd(8))} ${c.bold(f.title)} ${c.dim(`(${f.where})`)}`);
|
|
157
|
+
console.log(` ${f.why}`);
|
|
158
|
+
console.log(` ${c.green("Fix:")} ${f.fix}\n`);
|
|
159
|
+
}
|
|
160
|
+
const color = result.score >= 75 ? c.green : result.score >= 55 ? c.yellow : c.red;
|
|
161
|
+
console.log(`Site score: ${color(`${result.score}/100 (${result.grade})`)} · read only what the site serves to every visitor${opt.mine ? "; database checked with counts only, no rows" : ""}`);
|
|
162
|
+
}
|
|
163
|
+
if (opt["fail-on"] && result.findings.some((f) => SEVERITY_OF(f.severity) >= SEVERITY_OF(opt["fail-on"]))) process.exitCode = 1;
|
|
129
164
|
} else if (positionals[0] === "lock") {
|
|
130
165
|
const { snapshot, diff, LOCK_PATH } = await import("../src/lock.mjs");
|
|
131
166
|
const file = LOCK_PATH(process.cwd());
|
|
@@ -169,6 +204,8 @@ if (positionals[0] === "mcp") {
|
|
|
169
204
|
if (opt.help || positionals[0] !== "attack") {
|
|
170
205
|
console.log(`Usage: crumple attack [options]
|
|
171
206
|
crumple scan [--sarif f] [--fail-on high] instant local audit of your agent setup (no key, no Docker)
|
|
207
|
+
crumple setup [--dry-run] [--undo] add Crumple to every AI coding app here (Cursor, VS Code, Windsurf, Claude, Codex, Gemini CLI, Cline, Kiro…)
|
|
208
|
+
crumple site <url> [--mine] check a deployed app (Lovable, Bolt, v0, Replit, any site) for leaked keys and open data
|
|
172
209
|
crumple fix [--sandbox] [--managed] [--dry-run] harden this project's agent setup, then attack again
|
|
173
210
|
crumple lock [--check] pin your MCP servers' tools; report any that change
|
|
174
211
|
crumple list [--pack dir] every trap, its source and the standards it maps to
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "crumple",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Audit AI coding agent setups and deployed apps for leaked keys, open data and risky config.",
|
|
5
|
+
"mcpServers": {
|
|
6
|
+
"crumple": {
|
|
7
|
+
"command": "node",
|
|
8
|
+
"args": [
|
|
9
|
+
"${extensionPath}/bin/crumple.mjs",
|
|
10
|
+
"mcp"
|
|
11
|
+
],
|
|
12
|
+
"cwd": "${extensionPath}"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crumplelab",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Crash-test your AI coding agent setup against prompt injection, secret leaks, malicious MCP tools and destructive actions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"LICENSE",
|
|
21
21
|
"skills",
|
|
22
22
|
".claude-plugin",
|
|
23
|
-
".mcp.json"
|
|
23
|
+
".mcp.json",
|
|
24
|
+
"server.json",
|
|
25
|
+
"gemini-extension.json"
|
|
24
26
|
],
|
|
25
27
|
"engines": {
|
|
26
28
|
"node": ">=20"
|
|
@@ -49,5 +51,6 @@
|
|
|
49
51
|
"repository": {
|
|
50
52
|
"type": "git",
|
|
51
53
|
"url": "git+https://github.com/priberry-data/crumplelab.git"
|
|
52
|
-
}
|
|
54
|
+
},
|
|
55
|
+
"mcpName": "io.github.priberry-data/crumplelab"
|
|
53
56
|
}
|
package/server.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.priberry-data/crumplelab",
|
|
4
|
+
"title": "Crumple",
|
|
5
|
+
"description": "Audit AI coding agent setups and deployed apps for leaked keys, open data and risky config.",
|
|
6
|
+
"websiteUrl": "https://crumple.priberry.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/priberry-data/crumplelab",
|
|
9
|
+
"source": "github"
|
|
10
|
+
},
|
|
11
|
+
"version": "0.3.0",
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "npm",
|
|
15
|
+
"identifier": "crumplelab",
|
|
16
|
+
"version": "0.3.0",
|
|
17
|
+
"transport": {
|
|
18
|
+
"type": "stdio"
|
|
19
|
+
},
|
|
20
|
+
"packageArguments": [
|
|
21
|
+
{
|
|
22
|
+
"type": "positional",
|
|
23
|
+
"value": "mcp"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
package/src/clients.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `setup`: add this tool's MCP server to every AI coding app found on the machine, in each app's own
|
|
2
|
+
// config format, pinned to the exact version (an unpinned `npx` server runs whatever is published
|
|
3
|
+
// next). Each file is backed up first, nothing else in it changes, and `--undo` removes only our entry.
|
|
4
|
+
// A file that isn't plain JSON (comments, say) is never rewritten; we print what to add instead.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const appSupport = (home) =>
|
|
11
|
+
process.platform === "darwin" ? path.join(home, "Library", "Application Support") : process.platform === "win32" ? process.env.APPDATA || path.join(home, "AppData", "Roaming") : path.join(home, ".config");
|
|
12
|
+
|
|
13
|
+
// Every app we know how to configure: where it keeps MCP servers, and in which shape.
|
|
14
|
+
export function clients(home = os.homedir()) {
|
|
15
|
+
const app = appSupport(home);
|
|
16
|
+
const list = [
|
|
17
|
+
{ name: "Claude Desktop", file: path.join(app, "Claude", "claude_desktop_config.json"), shape: "mcpServers", when: path.join(app, "Claude") },
|
|
18
|
+
{ name: "Cursor", file: path.join(home, ".cursor", "mcp.json"), shape: "mcpServers", when: path.join(home, ".cursor") },
|
|
19
|
+
{ name: "Windsurf", file: path.join(home, ".codeium", "windsurf", "mcp_config.json"), shape: "mcpServers", when: path.join(home, ".codeium", "windsurf") },
|
|
20
|
+
{ name: "VS Code", file: path.join(app, "Code", "User", "mcp.json"), shape: "vscode", when: path.join(app, "Code", "User") },
|
|
21
|
+
{ name: "VS Code Insiders", file: path.join(app, "Code - Insiders", "User", "mcp.json"), shape: "vscode", when: path.join(app, "Code - Insiders", "User") },
|
|
22
|
+
{ name: "Gemini CLI", file: path.join(home, ".gemini", "settings.json"), shape: "mcpServers", when: path.join(home, ".gemini") },
|
|
23
|
+
{ name: "Codex", file: path.join(process.env.CODEX_HOME && home === os.homedir() ? process.env.CODEX_HOME : path.join(home, ".codex"), "config.toml"), shape: "toml", when: path.join(home, ".codex") },
|
|
24
|
+
{ name: "Kiro", file: path.join(home, ".kiro", "settings", "mcp.json"), shape: "mcpServers", when: path.join(home, ".kiro") },
|
|
25
|
+
{ name: "opencode", file: path.join(home, ".config", "opencode", "opencode.json"), shape: "opencode", when: path.join(home, ".config", "opencode") },
|
|
26
|
+
{ name: "Amazon Q", file: path.join(home, ".aws", "amazonq", "mcp.json"), shape: "mcpServers", when: path.join(home, ".aws", "amazonq") },
|
|
27
|
+
{ name: "LM Studio", file: path.join(home, ".lmstudio", "mcp.json"), shape: "mcpServers", when: path.join(home, ".lmstudio") },
|
|
28
|
+
];
|
|
29
|
+
// Cline, Roo Code and Kilo Code keep their own list inside each editor they're installed in.
|
|
30
|
+
for (const ed of ["Code", "Cursor", "Windsurf", "VSCodium", "Trae", "Kiro"])
|
|
31
|
+
for (const [ext, nm, f] of [
|
|
32
|
+
["saoudrizwan.claude-dev", "Cline", "cline_mcp_settings.json"],
|
|
33
|
+
["rooveterinaryinc.roo-cline", "Roo Code", "mcp_settings.json"],
|
|
34
|
+
["kilocode.kilo-code", "Kilo Code", "mcp_settings.json"],
|
|
35
|
+
]) {
|
|
36
|
+
const dir = path.join(app, ed, "User", "globalStorage", ext);
|
|
37
|
+
list.push({ name: ed === "Code" ? nm : `${nm} in ${ed}`, file: path.join(dir, "settings", f), shape: "mcpServers", when: dir });
|
|
38
|
+
}
|
|
39
|
+
return list;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function entry(shape, command, args) {
|
|
43
|
+
if (shape === "vscode") return { type: "stdio", command, args };
|
|
44
|
+
if (shape === "opencode") return { type: "local", command: [command, ...args], enabled: true };
|
|
45
|
+
return { command, args };
|
|
46
|
+
}
|
|
47
|
+
const key = (shape) => (shape === "vscode" ? "servers" : shape === "opencode" ? "mcp" : "mcpServers");
|
|
48
|
+
|
|
49
|
+
function backup(file, dir) {
|
|
50
|
+
if (!fs.existsSync(file)) return;
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
fs.copyFileSync(file, path.join(dir, `${Date.now()}-${file.replace(/[^\w.-]+/g, "_").slice(-120)}`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeJson(file, obj) {
|
|
56
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
|
|
59
|
+
fs.renameSync(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tomlBlock(name, command, args) {
|
|
63
|
+
return `\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((a) => JSON.stringify(a)).join(", ")}]\n`;
|
|
64
|
+
}
|
|
65
|
+
const TOML_RE = (name) => new RegExp(`\\n?\\[mcp_servers\\.${name}\\]\\n(?:(?!\\[)[^\\n]*\\n?)*`, "g");
|
|
66
|
+
|
|
67
|
+
// Returns one line per app: added, updated, already there, removed, skipped (with why), or not installed.
|
|
68
|
+
export function setup({ name, pkg, version, home = os.homedir(), dryRun = false, undo = false, backupDir, claudeCli = true }) {
|
|
69
|
+
const command = "npx";
|
|
70
|
+
const args = ["-y", `${pkg}@${version}`, "mcp"];
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const c of clients(home)) {
|
|
73
|
+
if (!fs.existsSync(c.when)) continue;
|
|
74
|
+
const rec = (status, note) => out.push({ client: c.name, file: c.file, status, note });
|
|
75
|
+
try {
|
|
76
|
+
if (c.shape === "toml") {
|
|
77
|
+
const t = fs.existsSync(c.file) ? fs.readFileSync(c.file, "utf8") : "";
|
|
78
|
+
const has = TOML_RE(name).test(t);
|
|
79
|
+
if (undo) {
|
|
80
|
+
if (!has) continue;
|
|
81
|
+
if (!dryRun) (backup(c.file, backupDir), fs.writeFileSync(c.file, t.replace(TOML_RE(name), "\n")));
|
|
82
|
+
rec("removed");
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const block = tomlBlock(name, command, args);
|
|
86
|
+
if (t.includes(block.trim())) {
|
|
87
|
+
rec("already there");
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!dryRun) {
|
|
91
|
+
backup(c.file, backupDir);
|
|
92
|
+
fs.mkdirSync(path.dirname(c.file), { recursive: true });
|
|
93
|
+
fs.writeFileSync(c.file, (has ? t.replace(TOML_RE(name), "\n") : t).replace(/\n*$/, "\n") + block);
|
|
94
|
+
}
|
|
95
|
+
rec(has ? "updated" : "added");
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
let cfg = {};
|
|
99
|
+
if (fs.existsSync(c.file)) {
|
|
100
|
+
const text = fs.readFileSync(c.file, "utf8");
|
|
101
|
+
if (text.trim()) {
|
|
102
|
+
try {
|
|
103
|
+
cfg = JSON.parse(text);
|
|
104
|
+
} catch {
|
|
105
|
+
rec("skipped", `has comments or isn't plain JSON; add under "${key(c.shape)}": ${JSON.stringify({ [name]: entry(c.shape, command, args) })}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const k = key(c.shape);
|
|
111
|
+
const cur = cfg[k]?.[name];
|
|
112
|
+
if (undo) {
|
|
113
|
+
if (!cur) continue;
|
|
114
|
+
delete cfg[k][name];
|
|
115
|
+
if (!dryRun) (backup(c.file, backupDir), writeJson(c.file, cfg));
|
|
116
|
+
rec("removed");
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const want = entry(c.shape, command, args);
|
|
120
|
+
if (cur && JSON.stringify(cur) === JSON.stringify({ ...cur, ...want })) {
|
|
121
|
+
rec("already there");
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
cfg[k] = { ...(cfg[k] || {}), [name]: { ...(cur || {}), ...want } };
|
|
125
|
+
if (!dryRun) (backup(c.file, backupDir), writeJson(c.file, cfg));
|
|
126
|
+
rec(cur ? "updated" : "added");
|
|
127
|
+
} catch (e) {
|
|
128
|
+
rec("skipped", e.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Claude Code keeps MCP servers in a large file it rewrites often, so go through its own CLI.
|
|
132
|
+
if (claudeCli && fs.existsSync(path.join(home, ".claude"))) {
|
|
133
|
+
// Run from the home folder so a project's own .mcp.json isn't mistaken for the user's entry.
|
|
134
|
+
const run = (a) => execFileSync("claude", a, { cwd: home, stdio: ["ignore", "pipe", "pipe"], timeout: 20000 }).toString();
|
|
135
|
+
let current = null;
|
|
136
|
+
try {
|
|
137
|
+
const got = run(["mcp", "get", name]); // fails when there is no such server
|
|
138
|
+
if (/Scope:\s*User/i.test(got)) current = got;
|
|
139
|
+
} catch {}
|
|
140
|
+
try {
|
|
141
|
+
const has = current != null;
|
|
142
|
+
if (undo) {
|
|
143
|
+
if (has && !dryRun) run(["mcp", "remove", "-s", "user", name]);
|
|
144
|
+
if (has) out.push({ client: "Claude Code", file: "claude mcp", status: "removed" });
|
|
145
|
+
} else if (has && current.includes(`${pkg}@${version}`)) out.push({ client: "Claude Code", file: "claude mcp", status: "already there" });
|
|
146
|
+
else {
|
|
147
|
+
if (!dryRun) {
|
|
148
|
+
if (has) run(["mcp", "remove", "-s", "user", name]);
|
|
149
|
+
run(["mcp", "add", "-s", "user", name, "--", command, ...args]);
|
|
150
|
+
}
|
|
151
|
+
out.push({ client: "Claude Code", file: "claude mcp", status: has ? "updated" : "added" });
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
if (!undo) out.push({ client: "Claude Code", file: "claude mcp", status: "skipped", note: `run: claude mcp add -s user ${name} -- ${command} ${args.join(" ")}` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function renderSetup(rows, { name, undo, dryRun, color = true }) {
|
|
161
|
+
const c = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
162
|
+
const green = c("32"), dim = c("2"), yellow = c("33");
|
|
163
|
+
const L = [];
|
|
164
|
+
if (!rows.length) {
|
|
165
|
+
L.push(undo ? ` ${name} isn't set up in any app on this machine.` : " No supported AI coding app found on this machine.");
|
|
166
|
+
return L.join("\n");
|
|
167
|
+
}
|
|
168
|
+
for (const r of rows) {
|
|
169
|
+
const mark = r.status === "skipped" ? yellow("!") : r.status === "already there" ? dim("·") : green("✓");
|
|
170
|
+
const status = dryRun && /added|updated|removed/.test(r.status) ? `would be ${r.status}` : r.status;
|
|
171
|
+
L.push(` ${mark} ${r.client.padEnd(22)} ${status}${r.note ? dim(` ${r.note}`) : ""}`);
|
|
172
|
+
}
|
|
173
|
+
if (dryRun) L.push(dim("\n Dry run: nothing was written."));
|
|
174
|
+
else if (!undo && rows.some((r) => /added|updated/.test(r.status))) L.push(dim(`\n Restart those apps to load it. Ask your agent to use ${name}. Undo any time with --undo.`));
|
|
175
|
+
return L.join("\n");
|
|
176
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -13,6 +13,12 @@ const TOOLS = [
|
|
|
13
13
|
inputSchema: { type: "object", properties: { path: { type: "string", description: "Project folder to scan. Defaults to the server's working directory." } }, additionalProperties: false },
|
|
14
14
|
annotations: { readOnlyHint: true },
|
|
15
15
|
},
|
|
16
|
+
{
|
|
17
|
+
name: "crumple_site",
|
|
18
|
+
description: "Check a deployed web app (built with Lovable, Bolt, v0, Replit, Emergent or by hand) for secret keys shipped in its JavaScript, a Supabase service_role key in the browser, and public source maps. Reads only what the site serves to every visitor. The Supabase table check needs the owner, so run `npx crumplelab site <url> --mine` for that.",
|
|
19
|
+
inputSchema: { type: "object", properties: { url: { type: "string", description: "The app's address, for example myapp.lovable.app" } }, required: ["url"], additionalProperties: false },
|
|
20
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
21
|
+
},
|
|
16
22
|
{
|
|
17
23
|
name: "crumple_traps",
|
|
18
24
|
description: "The traps Crumple's crash test runs: each modelled on a real attack (poisoned issues, evil MCP servers, secret exfiltration, destructive commands), with its risk.",
|
|
@@ -21,11 +27,12 @@ const TOOLS = [
|
|
|
21
27
|
},
|
|
22
28
|
];
|
|
23
29
|
|
|
24
|
-
function call(name, args = {}) {
|
|
30
|
+
async function call(name, args = {}) {
|
|
25
31
|
if (name === "crumple_scan") {
|
|
26
32
|
const dir = path.resolve(args.path || process.cwd());
|
|
27
33
|
return { project: dir, ...scan(dir), next: "Run `npx crumplelab fix` to harden, then `npx crumplelab attack` to prove it (needs Docker and an API key)." };
|
|
28
34
|
}
|
|
35
|
+
if (name === "crumple_site") return (await import("./site.mjs")).siteScan(args.url, { mine: false });
|
|
29
36
|
if (name === "crumple_traps") return { traps: SCENARIOS.map((s) => ({ id: s.id, title: s.title, risk: s.risk })) };
|
|
30
37
|
throw new Error(`unknown tool ${name}`);
|
|
31
38
|
}
|
|
@@ -33,7 +40,12 @@ function call(name, args = {}) {
|
|
|
33
40
|
export function serve({ version, input = process.stdin, output = process.stdout } = {}) {
|
|
34
41
|
const send = (msg) => output.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
|
|
35
42
|
const rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
43
|
+
const pending = new Set();
|
|
36
44
|
rl.on("line", (line) => {
|
|
45
|
+
const job = handle(line).finally(() => pending.delete(job));
|
|
46
|
+
pending.add(job);
|
|
47
|
+
});
|
|
48
|
+
async function handle(line) {
|
|
37
49
|
let m;
|
|
38
50
|
try {
|
|
39
51
|
m = JSON.parse(line);
|
|
@@ -47,13 +59,14 @@ export function serve({ version, input = process.stdin, output = process.stdout
|
|
|
47
59
|
if (m.method === "tools/list") return send({ id: m.id, result: { tools: TOOLS } });
|
|
48
60
|
if (m.method === "tools/call") {
|
|
49
61
|
try {
|
|
50
|
-
const r = call(m.params?.name, m.params?.arguments);
|
|
62
|
+
const r = await call(m.params?.name, m.params?.arguments);
|
|
51
63
|
return send({ id: m.id, result: { content: [{ type: "text", text: JSON.stringify(r, null, 2) }], structuredContent: r } });
|
|
52
64
|
} catch (e) {
|
|
53
65
|
return send({ id: m.id, result: { content: [{ type: "text", text: `crumple: ${e.message}` }], isError: true } });
|
|
54
66
|
}
|
|
55
67
|
}
|
|
56
68
|
send({ id: m.id, error: { code: -32601, message: `method not found: ${m.method}` } });
|
|
57
|
-
}
|
|
58
|
-
|
|
69
|
+
}
|
|
70
|
+
// Let calls still running (a site check takes seconds) answer before the server exits.
|
|
71
|
+
return new Promise((resolve) => rl.on("close", () => Promise.allSettled([...pending]).then(resolve)));
|
|
59
72
|
}
|
package/src/scan.mjs
CHANGED
|
@@ -23,10 +23,36 @@ const json = (p) => {
|
|
|
23
23
|
if (t == null) return null;
|
|
24
24
|
try {
|
|
25
25
|
return JSON.parse(t);
|
|
26
|
+
} catch {}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(stripJsonc(t)); // VS Code, Zed and others allow comments and trailing commas
|
|
26
29
|
} catch {
|
|
27
30
|
return null;
|
|
28
31
|
}
|
|
29
32
|
};
|
|
33
|
+
// Drops // and /* */ comments and trailing commas, leaving strings alone.
|
|
34
|
+
function stripJsonc(t) {
|
|
35
|
+
let out = "";
|
|
36
|
+
for (let i = 0; i < t.length; i++) {
|
|
37
|
+
const c = t[i];
|
|
38
|
+
if (c === '"') {
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
while (j < t.length && t[j] !== '"') j += t[j] === "\\" ? 2 : 1;
|
|
41
|
+
out += t.slice(i, j + 1);
|
|
42
|
+
i = j;
|
|
43
|
+
} else if (c === "/" && t[i + 1] === "/") while (i < t.length && t[i] !== "\n") i++;
|
|
44
|
+
else if (c === "/" && t[i + 1] === "*") {
|
|
45
|
+
i = t.indexOf("*/", i + 2);
|
|
46
|
+
if (i < 0) break;
|
|
47
|
+
i++;
|
|
48
|
+
} else out += c;
|
|
49
|
+
}
|
|
50
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
51
|
+
}
|
|
52
|
+
// Where VS Code-based editors and desktop apps keep their per-user settings.
|
|
53
|
+
const appSupport = (home) =>
|
|
54
|
+
process.platform === "darwin" ? path.join(home, "Library", "Application Support") : process.platform === "win32" ? process.env.APPDATA || path.join(home, "AppData", "Roaming") : path.join(home, ".config");
|
|
55
|
+
const EDITORS = ["Code", "Code - Insiders", "Cursor", "Windsurf", "VSCodium", "Trae", "Kiro", "Positron", "Void"];
|
|
30
56
|
const redact = (s) => String(s).replace(new RegExp(SECRET_VALUES.source, "g"), (m) => m.slice(0, 4) + "…[redacted]");
|
|
31
57
|
|
|
32
58
|
export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
@@ -71,19 +97,38 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
|
71
97
|
if (!sandboxed) add("medium", "agent setup", "The agent's commands don't run in a sandbox", "Commands can reach every file you can and any server on the internet. Sandboxes and egress limits are the defenses research shows hold up under adaptive attacks.", "Run `crumple fix --sandbox` to switch on Claude Code's built-in sandbox with a network allowlist.");
|
|
72
98
|
|
|
73
99
|
// --- MCP servers, across agents -------------------------------------------------------------
|
|
74
|
-
const
|
|
75
|
-
|
|
100
|
+
const app = appSupport(home);
|
|
101
|
+
const mcpFiles = [
|
|
102
|
+
P(".mcp.json"), H(".claude.json"), path.join(app, "Claude", "claude_desktop_config.json"),
|
|
103
|
+
P(".cursor/mcp.json"), H(".cursor/mcp.json"), P(".vscode/mcp.json"), H(".codeium/windsurf/mcp_config.json"),
|
|
104
|
+
H(".gemini/settings.json"), P(".gemini/settings.json"), P(".kiro/settings/mcp.json"), H(".kiro/settings/mcp.json"),
|
|
105
|
+
P(".roo/mcp.json"), H(".aws/amazonq/mcp.json"), P(".amazonq/mcp.json"), P("opencode.json"), H(".config/opencode/opencode.json"),
|
|
106
|
+
H(".config/zed/settings.json"), P(".zed/settings.json"), P(".continue/config.json"), H(".continue/config.json"),
|
|
107
|
+
...EDITORS.flatMap((ed) => [
|
|
108
|
+
path.join(app, ed, "User", "mcp.json"),
|
|
109
|
+
path.join(app, ed, "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
|
|
110
|
+
path.join(app, ed, "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
|
|
111
|
+
path.join(app, ed, "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
|
|
112
|
+
]),
|
|
113
|
+
];
|
|
114
|
+
for (const f of [...new Set(mcpFiles)]) {
|
|
76
115
|
const cfg = json(f);
|
|
77
|
-
|
|
116
|
+
// Each tool names the block differently: mcpServers (most), servers (VS Code), mcp (opencode), context_servers (Zed).
|
|
117
|
+
const servers = { ...(cfg?.mcpServers ?? {}), ...(cfg?.servers ?? {}), ...(cfg?.mcp ?? {}), ...(cfg?.context_servers ?? {}) };
|
|
118
|
+
if (Array.isArray(cfg?.experimental?.modelContextProtocolServers))
|
|
119
|
+
cfg.experimental.modelContextProtocolServers.forEach((x, i) => (servers[x.name || `server ${i + 1}`] = x.transport || x));
|
|
78
120
|
for (const proj of Object.values(cfg?.projects ?? {})) Object.assign(servers, proj?.mcpServers ?? {});
|
|
79
121
|
for (const [name, srv] of Object.entries(servers)) {
|
|
122
|
+
if (!srv || typeof srv !== "object") continue;
|
|
80
123
|
const where = `${rel(f)} → ${name}`;
|
|
81
|
-
|
|
124
|
+
// opencode gives the command as an array; Zed nests it as { path, args }.
|
|
125
|
+
const parts = Array.isArray(srv.command) ? srv.command : srv.command?.path ? [srv.command.path, ...(srv.command.args ?? [])] : [srv.command, ...(srv.args ?? [])];
|
|
126
|
+
const cmd = parts.filter(Boolean).join(" ");
|
|
82
127
|
if (/\b(npx|bunx|pnpm dlx|uvx)\b/.test(cmd)) {
|
|
83
|
-
const pkg = (
|
|
128
|
+
const pkg = parts.slice(1).find((a) => !String(a).startsWith("-") && !/^(dlx|x)$/.test(a)) ?? "";
|
|
84
129
|
if (!/@\d/.test(pkg.replace(/^@[^/]+\//, "")) || /@latest$/.test(pkg)) add("high", where, `MCP server runs whatever version is newest (${pkg || cmd})`, "If that package is ever compromised or sold, your agent runs the new code with your keys the next time it starts (MCP rug pull).", `Pin an exact version, for example ${pkg.replace(/@latest$/, "")}@1.2.3, and run \`crumple lock\`.`);
|
|
85
130
|
}
|
|
86
|
-
const blob = JSON.stringify({ env: srv.env, args:
|
|
131
|
+
const blob = JSON.stringify({ env: srv.env ?? srv.environment, args: parts.slice(1), headers: srv.headers, url: srv.url });
|
|
87
132
|
if (SECRET_VALUES.test(blob) || /"[A-Z_]*(TOKEN|SECRET|KEY|PASSWORD)[A-Z_]*":"[^"$]{12,}"/.test(blob)) add("high", where, "MCP server config holds a secret in plain text", "Any agent or extension that reads this file gets the key.", "Reference an environment variable or keychain entry instead.");
|
|
88
133
|
if (srv.url && /^http:\/\//.test(srv.url) && !/localhost|127\.0\.0\.1/.test(srv.url)) add("medium", where, "Remote MCP server over plain HTTP", "Anyone on the network path can read and change the tools your agent trusts.", "Use https.");
|
|
89
134
|
}
|
|
@@ -96,9 +141,12 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
|
96
141
|
|
|
97
142
|
// --- rules files and docs the agent reads -----------------------------------------------------
|
|
98
143
|
const rulesFiles = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", ".clinerules", ".github/copilot-instructions.md", "CONTRIBUTING.md", "README.md"].map((f) => P(f));
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
144
|
+
rulesFiles.push(P(".junie/guidelines.md"), P("CONVENTIONS.md"), P(".rules"));
|
|
145
|
+
// Rules folders: Cursor, Windsurf, Kiro, Roo, Cline (a folder form of .clinerules), Copilot, Amazon Q.
|
|
146
|
+
for (const d of [".cursor/rules", ".windsurf/rules", ".kiro/steering", ".roo/rules", ".clinerules", ".github/instructions", ".amazonq/rules", ".continue/rules"])
|
|
147
|
+
try {
|
|
148
|
+
for (const f of fs.readdirSync(P(d))) rulesFiles.push(P(d, f));
|
|
149
|
+
} catch {}
|
|
102
150
|
rulesFiles.push(H(".claude/CLAUDE.md"), H(".codex/AGENTS.md"), H(".gemini/GEMINI.md"));
|
|
103
151
|
for (const f of rulesFiles) {
|
|
104
152
|
const t = read(f);
|
package/src/site.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// crumple site <url>: checks a deployed app the way a curious stranger would, for the mistakes that
|
|
2
|
+
// AI app builders (Lovable, Bolt, v0, Replit, Emergent and the rest) ship most often:
|
|
3
|
+
// - secret keys inside the JavaScript every visitor downloads
|
|
4
|
+
// - a Supabase database whose tables anyone can read with the public key (with --mine only)
|
|
5
|
+
// - source maps that hand out the whole source code
|
|
6
|
+
// It reads only what the site serves to every visitor. The database check asks Postgres for a row
|
|
7
|
+
// count, never for rows, and runs only when you say the app is yours.
|
|
8
|
+
import { SEVERITY } from "./scan.mjs";
|
|
9
|
+
|
|
10
|
+
const SECRETS = [
|
|
11
|
+
[/sk_live_[0-9A-Za-z]{20,}/, "A live Stripe secret key", "Anyone can charge, refund and read your customers' payments."],
|
|
12
|
+
[/rk_live_[0-9A-Za-z]{20,}/, "A live Stripe restricted key", "Anyone can do whatever this key allows on your Stripe account."],
|
|
13
|
+
[/sk-(proj|svcacct|admin)-[0-9A-Za-z_-]{20,}|sk-[0-9A-Za-z]{48}\b/, "An OpenAI API key", "Anyone can run up your OpenAI bill."],
|
|
14
|
+
[/sk-ant-[0-9A-Za-z_-]{20,}/, "An Anthropic API key", "Anyone can run up your Anthropic bill."],
|
|
15
|
+
[/AKIA[0-9A-Z]{16}/, "An AWS access key", "Depending on its rights: your servers, storage and bill."],
|
|
16
|
+
[/gh[pousr]_[0-9A-Za-z]{30,}|github_pat_[0-9A-Za-z_]{20,}/, "A GitHub token", "Access to your code, and possibly the right to change it."],
|
|
17
|
+
[/xox[bpas]-[0-9A-Za-z-]{10,}/, "A Slack token", "Anyone can read or post in your Slack."],
|
|
18
|
+
[/re_[0-9A-Za-z]{8}_[0-9A-Za-z]{20,}/, "A Resend API key", "Anyone can send email as you."],
|
|
19
|
+
[/SG\.[0-9A-Za-z_-]{20,}\.[0-9A-Za-z_-]{20,}/, "A SendGrid API key", "Anyone can send email as you."],
|
|
20
|
+
[/AC[0-9a-f]{32}[^0-9a-f][\s\S]{0,80}?[0-9a-f]{32}/, "Twilio credentials", "Anyone can send texts and make calls on your account."],
|
|
21
|
+
[/gsk_[0-9A-Za-z]{40,}/, "A Groq API key", "Anyone can run up your Groq bill."],
|
|
22
|
+
[/AIza[0-9A-Za-z_-]{35}(?=[\s\S]{0,400}(generativelanguage|gemini))/, "A Google AI (Gemini) API key", "Anyone can run up your Gemini bill. (Firebase keys are public by design; this one sits next to Gemini calls.)"],
|
|
23
|
+
];
|
|
24
|
+
const JWT = /eyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}/g;
|
|
25
|
+
const SUPABASE_URL = /https:\/\/([a-z0-9]{20})\.supabase\.co/;
|
|
26
|
+
|
|
27
|
+
const decodeJwt = (t) => {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(Buffer.from(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function get(url, { timeout = 15000, headers = {}, method = "GET", max = 8 * 1024 * 1024, fetcher = fetch } = {}) {
|
|
36
|
+
const ctl = new AbortController();
|
|
37
|
+
const t = setTimeout(() => ctl.abort(), timeout);
|
|
38
|
+
try {
|
|
39
|
+
const r = await fetcher(url, { method, headers: { "user-agent": "crumple-site (+https://crumple.priberry.com)", ...headers }, signal: ctl.signal, redirect: "follow" });
|
|
40
|
+
const text = method === "HEAD" ? "" : (await r.text()).slice(0, max);
|
|
41
|
+
return { status: r.status, headers: r.headers, text, url: r.url };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return { status: 0, error: e.name === "AbortError" ? "timed out" : e.message, text: "" };
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(t);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function siteScan(target, { mine = false, fetcher = fetch, maxScripts = 40 } = {}) {
|
|
50
|
+
const fetchOpts = (o = {}) => ({ ...o, fetcher });
|
|
51
|
+
const url = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`);
|
|
52
|
+
const findings = [];
|
|
53
|
+
const add = (severity, where, title, why, fix) => findings.push({ severity, where, title, why, fix });
|
|
54
|
+
|
|
55
|
+
const page = await get(url.href, fetchOpts());
|
|
56
|
+
if (!page.status || page.status >= 400) throw new Error(`could not load ${url.href} (${page.error || page.status})`);
|
|
57
|
+
const origin = new URL(page.url || url.href).origin;
|
|
58
|
+
|
|
59
|
+
// Every script the page loads from its own site, plus the scripts those import.
|
|
60
|
+
const srcs = new Set();
|
|
61
|
+
const collect = (html, base) => {
|
|
62
|
+
for (const m of html.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)) srcs.add(new URL(m[1], base).href);
|
|
63
|
+
for (const m of html.matchAll(/<link[^>]+href=["']([^"']+\.m?js)["']/gi)) srcs.add(new URL(m[1], base).href);
|
|
64
|
+
};
|
|
65
|
+
collect(page.text, origin);
|
|
66
|
+
const bundles = [{ where: "the page itself", text: page.text }];
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
69
|
+
for (const src of [...srcs]) {
|
|
70
|
+
if (seen.has(src) || seen.size >= maxScripts || new URL(src).origin !== origin) continue;
|
|
71
|
+
seen.add(src);
|
|
72
|
+
const r = await get(src, fetchOpts());
|
|
73
|
+
if (r.status !== 200) continue;
|
|
74
|
+
bundles.push({ where: new URL(src).pathname, text: r.text, url: src });
|
|
75
|
+
for (const m of r.text.matchAll(/(?:import\(|from\s*)["'](\.{0,2}\/[^"']+\.m?js)["']/g)) srcs.add(new URL(m[1], src).href);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let supabase = null;
|
|
80
|
+
const keysSeen = new Set();
|
|
81
|
+
for (const b of bundles) {
|
|
82
|
+
for (const [re, title, why] of SECRETS) {
|
|
83
|
+
const m = b.text.match(re);
|
|
84
|
+
if (m && !keysSeen.has(m[0])) {
|
|
85
|
+
keysSeen.add(m[0]);
|
|
86
|
+
add("critical", b.where, `${title} is in code every visitor downloads`, `${why} Found: ${m[0].slice(0, 8)}…`, "Revoke this key now, make a new one, and call that service only from your backend (an edge function or server route), never from the browser.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const t of b.text.match(JWT) || []) {
|
|
90
|
+
const p = decodeJwt(t);
|
|
91
|
+
if (!p) continue;
|
|
92
|
+
if (p.role === "service_role" && !keysSeen.has(t)) {
|
|
93
|
+
keysSeen.add(t);
|
|
94
|
+
add("critical", b.where, "Your Supabase service_role key is in the browser code", "This key skips every security rule: anyone can read, change and delete all of your data and users.", "Rotate it in Supabase (Settings → API) and use it only on a server. The browser should only ever have the anon key.");
|
|
95
|
+
}
|
|
96
|
+
if (p.role === "anon" && p.iss === "supabase") supabase = { ...(supabase || {}), anon: t, ref: p.ref };
|
|
97
|
+
}
|
|
98
|
+
const su = b.text.match(SUPABASE_URL);
|
|
99
|
+
if (su) supabase = { ...(supabase || {}), url: su[0], ref: supabase?.ref || su[1] };
|
|
100
|
+
if (b.url && /sourceMappingURL=([^\s'"]+\.map)/.test(b.text)) {
|
|
101
|
+
const mapUrl = new URL(b.text.match(/sourceMappingURL=([^\s'"]+\.map)/)[1], b.url).href;
|
|
102
|
+
const r = await get(mapUrl, fetchOpts({ method: "HEAD" }));
|
|
103
|
+
if (r.status === 200 && !findings.some((f) => f.title.startsWith("Source maps")))
|
|
104
|
+
add("medium", new URL(mapUrl).pathname, "Source maps are public", "Anyone can read your app's original source code, including comments and any logic you assumed was hidden.", "Turn off source maps in production builds (for Vite: build.sourcemap = false).");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Supabase: with the public anon key, which tables can anyone read?
|
|
109
|
+
let tables = null;
|
|
110
|
+
if (supabase?.url && supabase.anon) {
|
|
111
|
+
if (!mine) {
|
|
112
|
+
add("low", "Supabase", "Database checks skipped", "This app uses Supabase. Crumple can check which tables anyone on the internet can read, but only for an app you own.", `Run \`npx crumplelab site ${url.host} --mine\` if this is your app.`);
|
|
113
|
+
} else {
|
|
114
|
+
const h = { apikey: supabase.anon, authorization: `Bearer ${supabase.anon}` };
|
|
115
|
+
const api = await get(`${supabase.url}/rest/v1/`, fetchOpts({ headers: h }));
|
|
116
|
+
let names = [];
|
|
117
|
+
try {
|
|
118
|
+
names = Object.keys(JSON.parse(api.text).paths || {}).map((p) => p.slice(1)).filter((p) => p && !p.startsWith("rpc/"));
|
|
119
|
+
} catch {}
|
|
120
|
+
tables = [];
|
|
121
|
+
for (const t of names.slice(0, 60)) {
|
|
122
|
+
// A HEAD request with count=exact returns how many rows are visible, and no rows.
|
|
123
|
+
const r = await get(`${supabase.url}/rest/v1/${encodeURIComponent(t)}?select=*`, fetchOpts({ method: "HEAD", headers: { ...h, prefer: "count=exact", range: "0-0" } }));
|
|
124
|
+
const n = Number((r.headers?.get("content-range") || "").split("/")[1]);
|
|
125
|
+
if (r.status < 300 && n > 0) tables.push({ table: t, rows: n });
|
|
126
|
+
}
|
|
127
|
+
for (const { table, rows } of tables) {
|
|
128
|
+
const personal = /user|profile|customer|account|order|payment|message|contact|lead|email|subscri|invoice/i.test(table);
|
|
129
|
+
add(personal ? "critical" : "high", `Supabase table "${table}"`, `Anyone on the internet can read ${rows.toLocaleString("en-US")} row${rows > 1 ? "s" : ""} of "${table}"`, "The anon key is public (it ships in your app), so a table without Row Level Security is readable by everyone. This is how most vibe-coded apps have leaked data.", `In Supabase, open Table Editor → ${table} → turn on RLS, then add a policy so people read only their own rows (for example: auth.uid() = user_id).`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!page.headers?.get("content-security-policy")) add("low", "response headers", "No Content-Security-Policy", "If an attacker gets a script into the page (through a comment, a profile field or a dependency), nothing stops it from sending your users' data out.", "Add a Content-Security-Policy header in your host's settings.");
|
|
135
|
+
|
|
136
|
+
const penalty = findings.reduce((a, f) => a + SEVERITY[f.severity], 0);
|
|
137
|
+
const score = Math.max(0, 100 - penalty);
|
|
138
|
+
const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
|
139
|
+
const order = ["critical", "high", "medium", "low"];
|
|
140
|
+
findings.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
141
|
+
return { url: origin, score, grade, scripts: bundles.length - 1, supabase: supabase ? { project: supabase.ref, checked: mine, readable: tables } : null, findings };
|
|
142
|
+
}
|