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.
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "crumple",
3
- "version": "0.2.4",
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": { "name": "Priberry", "email": "crumple@priberry.com", "url": "https://priberry.com" },
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": ["security", "prompt-injection", "mcp", "agents"]
13
+ "keywords": [
14
+ "security",
15
+ "prompt-injection",
16
+ "mcp",
17
+ "agents"
18
+ ]
10
19
  }
package/.mcp.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "mcpServers": {
3
- "crumple": { "command": "npx", "args": ["-y", "crumplelab", "mcp"] }
3
+ "crumple": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/bin/crumple.mjs", "mcp"] }
4
4
  }
5
5
  }
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.2.** Needs Node 20+ and Docker (`scan` needs neither). So far Crumple's scores are
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 prints a Safe Setup score, writes SARIF for GitHub code scanning, and fails CI with `--fail-on high`.
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 server (any agent):** `{"command": "npx", "args": ["-y", "crumplelab", "mcp"]}`. Tools: `crumple_scan`
142
- (the setup scan, local and instant) and `crumple_traps`. The full crash test spends API credit, so it stays a
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.2: Claude Code, Codex CLI and Gemini CLI; 18 traps plus packs; `scan`, `fix`, `lock`, the guard and a
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.2.4",
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
+ }
@@ -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
- return new Promise((resolve) => rl.on("close", resolve));
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 mcpFiles = [P(".mcp.json"), H(".claude.json"), P(".cursor/mcp.json"), H(".cursor/mcp.json"), P(".vscode/mcp.json"), H(".codeium/windsurf/mcp_config.json"), H(".gemini/settings.json"), P(".gemini/settings.json")];
75
- for (const f of mcpFiles) {
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
- const servers = { ...(cfg?.mcpServers ?? {}), ...(cfg?.servers ?? {}) };
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
- const cmd = [srv.command, ...(srv.args ?? [])].join(" ");
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 = (srv.args ?? []).find((a) => !String(a).startsWith("-")) ?? "";
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: srv.args, headers: srv.headers, url: srv.url });
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
- try {
100
- for (const f of fs.readdirSync(P(".cursor/rules"))) rulesFiles.push(P(".cursor/rules", f));
101
- } catch {}
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
+ }