crumplelab 0.2.4 → 0.3.1

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.1",
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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ - `crumple site` is now a grade anyone can finish without reading code. Each finding says why it matters in
6
+ plain words, what to do by hand first (like revoking a leaked key), and gives a fix prompt to paste into the
7
+ app builder. It names the builder when it can tell (Lovable, Bolt, Replit, Emergent, Base44, v0).
8
+ - Re-check: run the same command again and it shows the old grade, the new one, and what got fixed. Only ids,
9
+ titles and scores are kept, in `~/.crumple/sites`; never keys or data.
10
+ - `--prompt N` prints one fix prompt on its own, ready for the clipboard.
11
+ - `--mine` now checks Firebase too: whether the Realtime Database is open (top-level names only, counted and
12
+ dropped) and whether common Firestore collections are readable (one document name, no fields).
13
+ - A table anyone can read is critical when it holds people (users, profiles, orders, messages); otherwise it is
14
+ medium, with a prompt that keeps a public list readable but locks writes.
15
+ - Supabase's newer `sb_publishable_` keys are recognised. A CSP set in a `<meta>` tag counts.
16
+ - `scan` flags a pre-approved code-running MCP tool (`execute`, `run_code`, `execute_sql` and the like). With
17
+ "code mode" servers, one such tool reaches a whole API, so approving it approves everything its token can do.
18
+ - The `crumple_site` MCP tool returns the prompts, and `change` on a second call in the same session.
19
+
20
+ ## 0.3.0
21
+
22
+ - `crumple site <url>`: checks a deployed app (Lovable, Bolt, v0, Replit, Emergent or hand-built) for secret keys
23
+ in its JavaScript, a Supabase `service_role` key in the browser, public source maps and a missing CSP. With
24
+ `--mine` it also counts which Supabase tables anyone can read, using `HEAD` requests that return no rows.
25
+ - `crumple setup`: adds Crumple's MCP server to every AI coding app found (Claude Code, Claude Desktop, Cursor,
26
+ VS Code, Windsurf, Codex, Gemini CLI, Kiro, opencode, Amazon Q, LM Studio, Cline, Roo Code, Kilo Code), pinned
27
+ to the exact version, with backups and `--undo`.
28
+ - `scan` reads more setups: Claude Desktop, VS Code's user `mcp.json`, Kiro, Roo Code, Cline and Kilo Code
29
+ settings, Zed, opencode, Amazon Q and Continue, config files with comments, and the rules folders
30
+ `.windsurf/rules`, `.kiro/steering`, `.roo/rules`, `.clinerules/`, `.github/instructions` and more.
31
+ - `crumple_site` in the MCP server, and a Gemini CLI extension.
32
+ - The Claude Code plugin now runs its own bundled code for the MCP server instead of an unpinned `npx crumplelab`,
33
+ which Crumple's own scan rightly flagged.
34
+ - `--version`, and a clear message for options Crumple doesn't know.
35
+
3
36
  ## 0.2.4
4
37
 
5
38
  - **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,36 @@ 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 or Firebase data anyone can read
83
+ npx crumplelab site myapp.lovable.app --prompt 1 | pbcopy # copy fix prompt 1
84
+ ```
85
+
86
+ You get a grade from A to F and, for each problem, what it means in plain words, what to do by hand first
87
+ (revoking a leaked key is something no AI builder can do for you), and a prompt to paste into Lovable, Bolt,
88
+ Replit or whichever tool built the app. Run the same command again after the fix is live: it shows the old
89
+ grade, the new one, and what got fixed.
90
+
91
+ `site` reads the page and the JavaScript it serves, the way a curious stranger would, and looks for:
92
+
93
+ - secret keys in code every visitor downloads (Stripe, OpenAI, Anthropic, AWS, GitHub, Slack, Resend,
94
+ SendGrid, Twilio, Groq, Gemini)
95
+ - a Supabase `service_role` key in the browser, which skips every security rule
96
+ - public source maps, which hand out your whole source code
97
+ - no Content-Security-Policy
98
+
99
+ With `--mine`, for an app you own, it takes the public Supabase anon key the app already ships and asks the
100
+ database which tables anyone can read. It sends `HEAD` requests with `count=exact`, so Postgres returns a row
101
+ count and never a row. For Firebase it checks whether the Realtime Database is open (top-level names only) and
102
+ whether common Firestore collections are readable (one document name, no fields). Tables of people (users, profiles, orders, messages) are marked critical, and each
103
+ finding says which switch to flip in Supabase.
104
+
70
105
  ## Fix it
71
106
 
72
107
  ```bash
@@ -134,13 +169,24 @@ invisible text in `CLAUDE.md`, a changed MCP tool) fail, and findings appear in
134
169
 
135
170
  ## In your agent
136
171
 
172
+ One command adds Crumple to every AI coding app it finds: Claude Code, Claude Desktop, Cursor, VS Code,
173
+ Windsurf, Codex, Gemini CLI, Kiro, opencode, Amazon Q, LM Studio, and Cline, Roo Code and Kilo Code. The
174
+ entry is pinned to the exact version, each file is backed up first, and `--undo` removes it.
175
+
176
+ ```bash
177
+ npx crumplelab setup
178
+ ```
179
+
180
+ Or one app at a time:
181
+
137
182
  - **Claude Code plugin:** `/plugin marketplace add priberry-data/crumplelab`, then `/plugin install crumple@priberry`
138
183
  (or in one step: `/plugin install crumple --marketplace priberry-data/crumplelab`). Adds the skill and the MCP server;
139
184
  ask "is my agent setup safe?"
185
+ - **Gemini CLI extension:** `gemini extensions install https://github.com/priberry-data/crumplelab`
140
186
  - **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.
187
+ - **Any MCP client:** `{"command": "npx", "args": ["-y", "crumplelab@0.3.1", "mcp"]}`. Tools: `crumple_scan`
188
+ (the setup scan, local and instant), `crumple_site` (what a deployed app shows every visitor) and
189
+ `crumple_traps`. The full crash test spends API credit, so it stays a command you run on purpose.
144
190
 
145
191
  ## Private trap packs
146
192
 
@@ -181,8 +227,8 @@ npx crumplelab attack --agent mock-careful
181
227
 
182
228
  ## Status
183
229
 
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.
230
+ v0.3: Claude Code, Codex CLI and Gemini CLI; 18 traps plus packs; `scan` across 15 agents and editors, `site`
231
+ 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
232
  Next: first live runs across vendors, Copilot CLI and Cursor adapters, and a public cross-vendor leaderboard.
187
233
 
188
- Apache-2.0 · Built by [Priberry](https://priberry.com)
234
+ 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,9 @@ 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
+ prompt: { type: "string" },
55
+ undo: { type: "boolean" },
53
56
  },
54
57
  });
55
58
  } catch (e) {
@@ -70,10 +73,11 @@ const crashed = (e) => {
70
73
  process.on("uncaughtException", crashed);
71
74
  process.on("unhandledRejection", crashed);
72
75
 
76
+ const SEVERITY_OF = (s) => ({ critical: 40, high: 15, medium: 5, low: 1 })[s] ?? 0;
73
77
  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
78
 
75
79
  // The guard speaks JSON to the agent over stdout, so it (and any machine-readable output) never gets the banner.
76
- if (!["guard", "audit", "mcp"].includes(positionals[0]) && !opt.json) process.stdout.write(banner({ version: VERSION, tagline: "the crash test for AI coding agents" }));
80
+ if (!["guard", "audit", "mcp"].includes(positionals[0]) && !opt.json && !opt.prompt) process.stdout.write(banner({ version: VERSION, tagline: "the crash test for AI coding agents" }));
77
81
 
78
82
  if (positionals[0] === "mcp") {
79
83
  // MCP over stdio: the scan and the trap list, for any agent or editor that speaks MCP.
@@ -126,6 +130,39 @@ if (positionals[0] === "mcp") {
126
130
  }
127
131
  const failOn = opt["fail-on"];
128
132
  if (failOn && result.findings.some((f) => SEVERITY[f.severity] >= SEVERITY[failOn])) process.exitCode = 1;
133
+ } else if (positionals[0] === "setup") {
134
+ const { setup, renderSetup } = await import("../src/clients.mjs");
135
+ const rows = setup({ name: "crumple", pkg: "crumplelab", version: VERSION, dryRun: opt["dry-run"], undo: opt.undo, backupDir: path.join(os.homedir(), ".crumplelab", "backups") });
136
+ console.log(renderSetup(rows, { name: "Crumple", undo: opt.undo, dryRun: opt["dry-run"], color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR) }));
137
+ } else if (positionals[0] === "site") {
138
+ if (!positionals[1]) {
139
+ console.error("Usage: npx crumplelab site <url> [--mine] [--prompt N] [--json] checks a deployed app (Lovable, Bolt, v0, Replit, Emergent, any site)");
140
+ process.exit(2);
141
+ }
142
+ const { siteScan, loadLast, saveRun, compare, renderSite } = await import("../src/site.mjs");
143
+ const run = () => siteScan(positionals[1], { mine: opt.mine });
144
+ let result;
145
+ try {
146
+ result = opt.json || opt.prompt ? await run() : await spin(`looking at ${positionals[1]} the way a stranger would`, run);
147
+ } catch (e) {
148
+ console.error(c.red(`crumple: ${e.message}`));
149
+ process.exit(1);
150
+ }
151
+ if (opt.prompt) {
152
+ // Just one fix prompt, plain, so it can go straight to the clipboard.
153
+ const p = result.findings.filter((f) => f.prompt)[Number(opt.prompt) - 1];
154
+ if (!p) {
155
+ console.error(`crumple: there is no fix prompt ${opt.prompt}; this check has ${result.findings.filter((f) => f.prompt).length}.`);
156
+ process.exit(2);
157
+ }
158
+ console.log(p.prompt);
159
+ } else {
160
+ const change = compare(loadLast(result.url, result.mine), result);
161
+ saveRun(result);
162
+ if (opt.json) console.log(JSON.stringify({ ...result, change }, null, 2));
163
+ else console.log(renderSite(result, change, { color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR) }));
164
+ }
165
+ if (opt["fail-on"] && result.findings.some((f) => SEVERITY_OF(f.severity) >= SEVERITY_OF(opt["fail-on"]))) process.exitCode = 1;
129
166
  } else if (positionals[0] === "lock") {
130
167
  const { snapshot, diff, LOCK_PATH } = await import("../src/lock.mjs");
131
168
  const file = LOCK_PATH(process.cwd());
@@ -169,6 +206,8 @@ if (positionals[0] === "mcp") {
169
206
  if (opt.help || positionals[0] !== "attack") {
170
207
  console.log(`Usage: crumple attack [options]
171
208
  crumple scan [--sarif f] [--fail-on high] instant local audit of your agent setup (no key, no Docker)
209
+ crumple setup [--dry-run] [--undo] add Crumple to every AI coding app here (Cursor, VS Code, Windsurf, Claude, Codex, Gemini CLI, Cline, Kiro…)
210
+ crumple site <url> [--mine] [--prompt N] grade a deployed app (Lovable, Bolt, v0, Replit, Emergent, any site): leaked keys, open data, fix prompts
172
211
  crumple fix [--sandbox] [--managed] [--dry-run] harden this project's agent setup, then attack again
173
212
  crumple lock [--check] pin your MCP servers' tools; report any that change
174
213
  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.1",
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.1",
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.1",
12
+ "packages": [
13
+ {
14
+ "registryType": "npm",
15
+ "identifier": "crumplelab",
16
+ "version": "0.3.1",
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: "Grade a deployed web app (built with Lovable, Bolt, v0, Replit, Emergent or by hand) A to F for what a stranger can use against it: secret keys shipped in its JavaScript, a Supabase service_role key in the browser, public source maps, no Content-Security-Policy. Each finding has a plain-English reason, what the owner must do by hand (like revoking a key), and a fix `prompt` to apply in the app's code. Call it again after fixing: `change` lists what got fixed. Reads only what the site serves to every visitor. Database checks (which Supabase tables or Firebase data anyone can read) need the owner, so tell them to run `npx crumplelab site <url> --mine` themselves.",
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,21 @@ const TOOLS = [
21
27
  },
22
28
  ];
23
29
 
24
- function call(name, args = {}) {
30
+ const lastSite = new Map();
31
+
32
+ async function call(name, args = {}) {
25
33
  if (name === "crumple_scan") {
26
34
  const dir = path.resolve(args.path || process.cwd());
27
35
  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
36
  }
37
+ if (name === "crumple_site") {
38
+ const { siteScan, compare } = await import("./site.mjs");
39
+ const result = await siteScan(args.url, { mine: false });
40
+ // Remembered for this session only, so a second call shows what the fixes changed.
41
+ const change = compare(lastSite.get(result.url), result);
42
+ lastSite.set(result.url, { at: new Date().toISOString(), score: result.score, grade: result.grade, findings: result.findings.map(({ id, severity, title }) => ({ id, severity, title })) });
43
+ return { ...result, change };
44
+ }
29
45
  if (name === "crumple_traps") return { traps: SCENARIOS.map((s) => ({ id: s.id, title: s.title, risk: s.risk })) };
30
46
  throw new Error(`unknown tool ${name}`);
31
47
  }
@@ -33,7 +49,12 @@ function call(name, args = {}) {
33
49
  export function serve({ version, input = process.stdin, output = process.stdout } = {}) {
34
50
  const send = (msg) => output.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n");
35
51
  const rl = readline.createInterface({ input, crlfDelay: Infinity });
52
+ const pending = new Set();
36
53
  rl.on("line", (line) => {
54
+ const job = handle(line).finally(() => pending.delete(job));
55
+ pending.add(job);
56
+ });
57
+ async function handle(line) {
37
58
  let m;
38
59
  try {
39
60
  m = JSON.parse(line);
@@ -47,13 +68,14 @@ export function serve({ version, input = process.stdin, output = process.stdout
47
68
  if (m.method === "tools/list") return send({ id: m.id, result: { tools: TOOLS } });
48
69
  if (m.method === "tools/call") {
49
70
  try {
50
- const r = call(m.params?.name, m.params?.arguments);
71
+ const r = await call(m.params?.name, m.params?.arguments);
51
72
  return send({ id: m.id, result: { content: [{ type: "text", text: JSON.stringify(r, null, 2) }], structuredContent: r } });
52
73
  } catch (e) {
53
74
  return send({ id: m.id, result: { content: [{ type: "text", text: `crumple: ${e.message}` }], isError: true } });
54
75
  }
55
76
  }
56
77
  send({ id: m.id, error: { code: -32601, message: `method not found: ${m.method}` } });
57
- });
58
- return new Promise((resolve) => rl.on("close", resolve));
78
+ }
79
+ // Let calls still running (a site check takes seconds) answer before the server exits.
80
+ return new Promise((resolve) => rl.on("close", () => Promise.allSettled([...pending]).then(resolve)));
59
81
  }
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() } = {}) {
@@ -51,6 +77,9 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
51
77
  // An exact rule only ever approves that one command, so only wildcards can carry your data out.
52
78
  else if (/^Bash\((curl|wget|nc|ssh|scp|rsync|http)\b/.test(r) && r.includes("*")) add("high", where, `Network tool pre-approved (${r})`, "This is the exact command attackers use to send your .env out.", `Remove "${r}"; let the agent ask, or allow only fixed URLs.`);
53
79
  else if (/^Bash\((git|find|fd|rg|go|xargs|env|sed|awk|tar|npx|npm|pnpm|yarn|node|python3?|bash|sh)(:\*|\s\*|\*)?\)$/.test(r)) add("high", where, `Broad pre-approval that can run any program (${r})`, "Flags like `git -c core.fsmonitor=`, `find -exec`, `npm run` and `npx` turn a \"safe\" prefix into arbitrary code (Trail of Bits, 2025).", `Narrow "${r}" to exact subcommands (for example "Bash(git status)"), and install the Crumple guard, which checks flags.`);
80
+ // "Code mode" servers (Cloudflare's, and the pattern it started) put a whole API behind one execute tool,
81
+ // so approving that one tool approves every call the server's credentials allow, in one step.
82
+ else if (/^mcp__[\w-]+__(execute|exec|eval|run|run_?code|execute_?code|run_?script|run_?js|run_?python|code|code_?mode|execute_?sql|run_?sql|shell)$/i.test(r)) add("high", where, `A code-running MCP tool is pre-approved (${r})`, "This one tool can run any code against the API behind it, so a single injected instruction can make many changes (delete DNS records, wipe a database) with no prompt. Per-tool approvals stop meaning anything.", `Remove "${r}" from the allow list so each run asks you, and give that server a read-only or narrowly scoped token.`);
54
83
  // A single MCP tool (mcp__server__tool) is a narrow approval; a whole server or open WebFetch is not.
55
84
  else if (/^WebFetch$/.test(r) || (/^mcp__[\w-]+$/.test(r) && r.split("__").length === 2)) add("medium", where, `Pre-approved without limits (${r})`, "Lets content from anywhere drive the agent and receive data without a prompt.", `Scope "${r}" to specific domains or tools.`);
56
85
  }
@@ -71,19 +100,38 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
71
100
  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
101
 
73
102
  // --- 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) {
103
+ const app = appSupport(home);
104
+ const mcpFiles = [
105
+ P(".mcp.json"), H(".claude.json"), path.join(app, "Claude", "claude_desktop_config.json"),
106
+ P(".cursor/mcp.json"), H(".cursor/mcp.json"), P(".vscode/mcp.json"), H(".codeium/windsurf/mcp_config.json"),
107
+ H(".gemini/settings.json"), P(".gemini/settings.json"), P(".kiro/settings/mcp.json"), H(".kiro/settings/mcp.json"),
108
+ P(".roo/mcp.json"), H(".aws/amazonq/mcp.json"), P(".amazonq/mcp.json"), P("opencode.json"), H(".config/opencode/opencode.json"),
109
+ H(".config/zed/settings.json"), P(".zed/settings.json"), P(".continue/config.json"), H(".continue/config.json"),
110
+ ...EDITORS.flatMap((ed) => [
111
+ path.join(app, ed, "User", "mcp.json"),
112
+ path.join(app, ed, "User", "globalStorage", "saoudrizwan.claude-dev", "settings", "cline_mcp_settings.json"),
113
+ path.join(app, ed, "User", "globalStorage", "rooveterinaryinc.roo-cline", "settings", "mcp_settings.json"),
114
+ path.join(app, ed, "User", "globalStorage", "kilocode.kilo-code", "settings", "mcp_settings.json"),
115
+ ]),
116
+ ];
117
+ for (const f of [...new Set(mcpFiles)]) {
76
118
  const cfg = json(f);
77
- const servers = { ...(cfg?.mcpServers ?? {}), ...(cfg?.servers ?? {}) };
119
+ // Each tool names the block differently: mcpServers (most), servers (VS Code), mcp (opencode), context_servers (Zed).
120
+ const servers = { ...(cfg?.mcpServers ?? {}), ...(cfg?.servers ?? {}), ...(cfg?.mcp ?? {}), ...(cfg?.context_servers ?? {}) };
121
+ if (Array.isArray(cfg?.experimental?.modelContextProtocolServers))
122
+ cfg.experimental.modelContextProtocolServers.forEach((x, i) => (servers[x.name || `server ${i + 1}`] = x.transport || x));
78
123
  for (const proj of Object.values(cfg?.projects ?? {})) Object.assign(servers, proj?.mcpServers ?? {});
79
124
  for (const [name, srv] of Object.entries(servers)) {
125
+ if (!srv || typeof srv !== "object") continue;
80
126
  const where = `${rel(f)} → ${name}`;
81
- const cmd = [srv.command, ...(srv.args ?? [])].join(" ");
127
+ // opencode gives the command as an array; Zed nests it as { path, args }.
128
+ const parts = Array.isArray(srv.command) ? srv.command : srv.command?.path ? [srv.command.path, ...(srv.command.args ?? [])] : [srv.command, ...(srv.args ?? [])];
129
+ const cmd = parts.filter(Boolean).join(" ");
82
130
  if (/\b(npx|bunx|pnpm dlx|uvx)\b/.test(cmd)) {
83
- const pkg = (srv.args ?? []).find((a) => !String(a).startsWith("-")) ?? "";
131
+ const pkg = parts.slice(1).find((a) => !String(a).startsWith("-") && !/^(dlx|x)$/.test(a)) ?? "";
84
132
  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
133
  }
86
- const blob = JSON.stringify({ env: srv.env, args: srv.args, headers: srv.headers, url: srv.url });
134
+ const blob = JSON.stringify({ env: srv.env ?? srv.environment, args: parts.slice(1), headers: srv.headers, url: srv.url });
87
135
  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
136
  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
137
  }
@@ -96,9 +144,12 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
96
144
 
97
145
  // --- rules files and docs the agent reads -----------------------------------------------------
98
146
  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 {}
147
+ rulesFiles.push(P(".junie/guidelines.md"), P("CONVENTIONS.md"), P(".rules"));
148
+ // Rules folders: Cursor, Windsurf, Kiro, Roo, Cline (a folder form of .clinerules), Copilot, Amazon Q.
149
+ for (const d of [".cursor/rules", ".windsurf/rules", ".kiro/steering", ".roo/rules", ".clinerules", ".github/instructions", ".amazonq/rules", ".continue/rules"])
150
+ try {
151
+ for (const f of fs.readdirSync(P(d))) rulesFiles.push(P(d, f));
152
+ } catch {}
102
153
  rulesFiles.push(H(".claude/CLAUDE.md"), H(".codex/AGENTS.md"), H(".gemini/GEMINI.md"));
103
154
  for (const f of rulesFiles) {
104
155
  const t = read(f);
package/src/site.mjs ADDED
@@ -0,0 +1,369 @@
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 or Firebase database that anyone can read with the app's public key (with --mine only)
5
+ // - source maps that hand out the whole source code
6
+ // Every finding comes with a fix prompt to paste back into the app builder, and running the check again
7
+ // shows what got fixed. It reads only what the site serves to every visitor. The database checks ask for
8
+ // a row count (Supabase) or a single document name (Firebase), never for data, and run only when you say
9
+ // the app is yours.
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { SEVERITY } from "./scan.mjs";
14
+
15
+ // [pattern, id, what it is, what a stranger can do with it, where to revoke it]
16
+ const SECRETS = [
17
+ [/sk_live_[0-9A-Za-z]{20,}/, "stripe", "A live Stripe secret key", "Anyone can charge, refund and read your customers' payments.", "Stripe → Developers → API keys → Roll key", "STRIPE_SECRET_KEY"],
18
+ [/rk_live_[0-9A-Za-z]{20,}/, "stripe-restricted", "A live Stripe restricted key", "Anyone can do whatever this key allows on your Stripe account.", "Stripe → Developers → API keys → delete the restricted key", "STRIPE_RESTRICTED_KEY"],
19
+ [/sk-(proj|svcacct|admin)-[0-9A-Za-z_-]{20,}|sk-[0-9A-Za-z]{48}\b/, "openai", "An OpenAI API key", "Anyone can run up your OpenAI bill.", "platform.openai.com → API keys → revoke", "OPENAI_API_KEY"],
20
+ [/sk-ant-[0-9A-Za-z_-]{20,}/, "anthropic", "An Anthropic API key", "Anyone can run up your Anthropic bill.", "console.anthropic.com → API keys → disable", "ANTHROPIC_API_KEY"],
21
+ [/AKIA[0-9A-Z]{16}/, "aws", "An AWS access key", "Depending on its rights: your servers, storage and bill.", "AWS console → IAM → Users → Security credentials → deactivate the key", "AWS_SECRET_ACCESS_KEY"],
22
+ [/gh[pousr]_[0-9A-Za-z]{30,}|github_pat_[0-9A-Za-z_]{20,}/, "github", "A GitHub token", "Access to your code, and possibly the right to change it.", "GitHub → Settings → Developer settings → tokens → delete", "GITHUB_TOKEN"],
23
+ [/xox[bpas]-[0-9A-Za-z-]{10,}/, "slack", "A Slack token", "Anyone can read or post in your Slack.", "api.slack.com/apps → your app → reinstall to get a new token", "SLACK_TOKEN"],
24
+ [/re_[0-9A-Za-z]{8}_[0-9A-Za-z]{20,}/, "resend", "A Resend API key", "Anyone can send email as you.", "resend.com → API Keys → delete", "RESEND_API_KEY"],
25
+ [/SG\.[0-9A-Za-z_-]{20,}\.[0-9A-Za-z_-]{20,}/, "sendgrid", "A SendGrid API key", "Anyone can send email as you.", "SendGrid → Settings → API Keys → delete", "SENDGRID_API_KEY"],
26
+ [/AC[0-9a-f]{32}[^0-9a-f][\s\S]{0,80}?[0-9a-f]{32}/, "twilio", "Twilio credentials", "Anyone can send texts and make calls on your account.", "Twilio console → Account → API keys & tokens → rotate the auth token", "TWILIO_AUTH_TOKEN"],
27
+ [/gsk_[0-9A-Za-z]{40,}/, "groq", "A Groq API key", "Anyone can run up your Groq bill.", "console.groq.com → API Keys → delete", "GROQ_API_KEY"],
28
+ [/AIza[0-9A-Za-z_-]{35}(?=[\s\S]{0,400}(generativelanguage|gemini))/, "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.)", "aistudio.google.com → API keys → delete", "GEMINI_API_KEY"],
29
+ ];
30
+ const JWT = /eyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}/g;
31
+ const SUPABASE_URL = /https:\/\/([a-z0-9]{20})\.supabase\.co/;
32
+ const FB_PROJECT = /projectId["']?\s*:\s*["']([a-z0-9-]{4,40})["']/;
33
+ const FB_DB = /["'](https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:firebaseio\.com|firebasedatabase\.app))\/?["']/;
34
+ const FB_KEY = /apiKey["']?\s*:\s*["'](AIza[0-9A-Za-z_-]{35})["']/;
35
+ // Firestore can't list its collections without admin rights, so try the names apps use for people and money.
36
+ const FIRESTORE_GUESSES = ["users", "profiles", "customers", "accounts", "orders", "payments", "messages", "chats", "leads", "contacts", "posts"];
37
+ const PERSONAL = /user|profile|customer|account|order|payment|message|chat|contact|lead|email|subscri|invoice/i;
38
+
39
+ // Which app builder made this, so the fix prompt can say where to paste it.
40
+ export function detectBuilder(host, html = "") {
41
+ const h = host.toLowerCase();
42
+ if (/\.lovable\.app$|\.lovableproject\.com$/.test(h) || /lovable-tagger|content=["']Lovable/i.test(html)) return "Lovable";
43
+ if (/\.bolt\.host$|\.bolt\.new$/.test(h)) return "Bolt";
44
+ if (/\.replit\.app$|\.repl\.co$|\.replit\.dev$/.test(h)) return "Replit";
45
+ if (/\.emergent\.host$|\.emergentagent\.com$|\.preview\.emergentagent\.com$/.test(h)) return "Emergent";
46
+ if (/\.base44\.app$/.test(h)) return "Base44";
47
+ if (/v0\.dev|v0\.app/.test(h) || /generated by v0/i.test(html)) return "v0";
48
+ return null;
49
+ }
50
+
51
+ const decodeJwt = (t) => {
52
+ try {
53
+ return JSON.parse(Buffer.from(t.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
54
+ } catch {
55
+ return null;
56
+ }
57
+ };
58
+
59
+ async function get(url, { timeout = 15000, headers = {}, method = "GET", max = 8 * 1024 * 1024, fetcher = fetch } = {}) {
60
+ const ctl = new AbortController();
61
+ const t = setTimeout(() => ctl.abort(), timeout);
62
+ try {
63
+ const r = await fetcher(url, { method, headers: { "user-agent": "crumple-site (+https://crumple.priberry.com)", ...headers }, signal: ctl.signal, redirect: "follow" });
64
+ const text = method === "HEAD" ? "" : (await r.text()).slice(0, max);
65
+ return { status: r.status, headers: r.headers, text, url: r.url };
66
+ } catch (e) {
67
+ return { status: 0, error: e.name === "AbortError" ? "timed out" : e.message, text: "" };
68
+ } finally {
69
+ clearTimeout(t);
70
+ }
71
+ }
72
+
73
+ // --- fix prompts ---------------------------------------------------------------------------------
74
+ // Written for someone who can't read the code: what to do by hand first (revoking a key is something
75
+ // no AI builder can do for you), then one prompt to paste into the builder's chat.
76
+ const P = {
77
+ secret: (label, revoke, env) => ({
78
+ first: `Revoke the leaked key, because it is already public: ${revoke}. Then make a new one, and give it only to your backend (in Supabase: Edge Functions → Secrets, named ${env}).`,
79
+ prompt: `My app has ${label.replace(/^A |^An /, "a ")} in its frontend code, so every visitor can download it. Remove that key from every frontend file. Move every call that uses it into a backend function (a Supabase Edge Function, or a server route if this app has a server) that reads the key from the secret ${env}, and have the frontend call that function instead. The function must check that the user is signed in before it does anything, and must never send the key back to the browser. When you're done, list every file you changed and confirm the key appears nowhere in the frontend.`,
80
+ }),
81
+ serviceRole: () => ({
82
+ first: "Rotate the key, because it is already public: Supabase → Project Settings → API → generate new keys (or reset the JWT secret), then update the apps and functions that use them.",
83
+ prompt: "My app has the Supabase service_role key in its frontend code. That key skips every security rule. Remove it from every frontend file; the browser must only ever use the anon (publishable) key. Anything that really needs admin access goes into a Supabase Edge Function that reads SUPABASE_SERVICE_ROLE_KEY from its environment and checks the signed-in user before acting. Then make sure Row Level Security is on for every table, with policies so users reach only their own rows. List what you changed.",
84
+ }),
85
+ rls: (table, personal) => ({
86
+ first: null,
87
+ prompt: personal
88
+ ? `In my Supabase database, anyone on the internet can read the table "${table}" using the public anon key. Turn on Row Level Security for "${table}" and add policies so a signed-in user can read and change only their own rows: compare auth.uid() with the column that stores the owner's user id (if there is no such column, add user_id uuid references auth.users default auth.uid(), and fill it for existing rows). If some rows really must be public, add a separate read-only policy for just those rows and tell me which. Do not turn off any existing policy. Show me the SQL you ran.`
89
+ : `In my Supabase database, anyone on the internet can read the table "${table}" using the public anon key. First tell me in one sentence whether this app needs strangers to read "${table}" (a public product list or blog does; anything about specific users doesn't). If it should be public: turn on Row Level Security, add a policy that lets everyone read it, and allow insert, update and delete only for signed-in admins or the row's owner, so strangers can look but never change it. If it shouldn't be public: turn on Row Level Security and let a signed-in user reach only their own rows (auth.uid() = user_id). Do not turn off any existing policy. Show me the SQL you ran.`,
90
+ sql: personal
91
+ ? `alter table public."${table}" enable row level security;\ncreate policy "own rows" on public."${table}" for all using (auth.uid() = user_id) with check (auth.uid() = user_id);`
92
+ : `alter table public."${table}" enable row level security;\ncreate policy "anyone can read" on public."${table}" for select using (true);`,
93
+ }),
94
+ rtdb: () => ({
95
+ first: null,
96
+ prompt: 'Anyone on the internet can read my Firebase Realtime Database. Rewrite the database rules so nothing is readable or writable without signing in, and each user can only reach their own data, for example under /users/$uid with ".read": "auth.uid === $uid" and ".write": "auth.uid === $uid". Keep public only what truly must be public, and tell me which paths those are. Show me the final rules.',
97
+ }),
98
+ firestore: (coll) => ({
99
+ first: null,
100
+ prompt: `Anyone on the internet can read the Firestore collection "${coll}". Rewrite my Firestore security rules so "${coll}" (and every other collection) is closed by default, and a signed-in user can only read and write documents they own, for example match /${coll}/{id} { allow read, write: if request.auth != null && request.auth.uid == resource.data.userId; }. Adjust the owner field to whatever this app uses. Show me the final rules.`,
101
+ }),
102
+ sourceMaps: () => ({
103
+ first: null,
104
+ prompt: "My production build publishes source maps, so anyone can read my app's original source code. Turn them off for production builds only (for Vite: build.sourcemap = false in vite.config), rebuild and redeploy. Don't change anything else.",
105
+ }),
106
+ csp: (origins) => ({
107
+ first: null,
108
+ prompt: `My app has no Content-Security-Policy. Add one as a <meta http-equiv="Content-Security-Policy"> tag in index.html (or as a response header if this host supports headers). Allow scripts, styles and fonts only from my own site and the services this app really uses${origins.length ? `, including ${origins.join(", ")}` : ""}, and allow connect-src for my backend and APIs. Then test every page and fix anything the policy blocks, without loosening it to allow everything.`,
109
+ }),
110
+ };
111
+
112
+ // --- the check -----------------------------------------------------------------------------------
113
+ export async function siteScan(target, { mine = false, fetcher = fetch, maxScripts = 40 } = {}) {
114
+ const fetchOpts = (o = {}) => ({ ...o, fetcher });
115
+ const url = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`);
116
+ const findings = [];
117
+ const add = (f) => findings.push(f);
118
+
119
+ const page = await get(url.href, fetchOpts());
120
+ if (!page.status || page.status >= 400) throw new Error(`could not load ${url.href} (${page.error || page.status})`);
121
+ const origin = new URL(page.url || url.href).origin;
122
+ const builder = detectBuilder(new URL(origin).host, page.text);
123
+
124
+ // Every script the page loads from its own site, plus the scripts those import.
125
+ const srcs = new Set();
126
+ const collect = (html, base) => {
127
+ for (const m of html.matchAll(/<script[^>]+src=["']([^"']+)["']/gi)) srcs.add(new URL(m[1], base).href);
128
+ for (const m of html.matchAll(/<link[^>]+href=["']([^"']+\.m?js)["']/gi)) srcs.add(new URL(m[1], base).href);
129
+ };
130
+ collect(page.text, origin);
131
+ const bundles = [{ where: "the page itself", text: page.text }];
132
+ const seen = new Set();
133
+ for (let pass = 0; pass < 2; pass++) {
134
+ for (const src of [...srcs]) {
135
+ if (seen.has(src) || seen.size >= maxScripts || new URL(src).origin !== origin) continue;
136
+ seen.add(src);
137
+ const r = await get(src, fetchOpts());
138
+ if (r.status !== 200) continue;
139
+ bundles.push({ where: new URL(src).pathname, text: r.text, url: src });
140
+ for (const m of r.text.matchAll(/(?:import\(|from\s*)["'](\.{0,2}\/[^"']+\.m?js)["']/g)) srcs.add(new URL(m[1], src).href);
141
+ }
142
+ }
143
+
144
+ let supabase = null;
145
+ let firebase = null;
146
+ const keysSeen = new Set();
147
+ const thirdParty = new Set();
148
+ for (const b of bundles) {
149
+ for (const m of b.text.matchAll(/https:\/\/([a-z0-9-]+\.)+[a-z]{2,}/gi)) {
150
+ const o = new URL(m[0]).origin;
151
+ if (o !== origin && /supabase\.co|firebase|googleapis|stripe\.com|clerk|posthog|sentry|vercel/.test(o)) thirdParty.add(o.replace(/^https:\/\/[a-z0-9]{20}\.supabase\.co$/, "https://*.supabase.co"));
152
+ }
153
+ for (const [re, id, title, why, revoke, env] of SECRETS) {
154
+ const m = b.text.match(re);
155
+ if (m && !keysSeen.has(m[0])) {
156
+ keysSeen.add(m[0]);
157
+ add({ id: `secret:${id}`, severity: "critical", where: b.where, title: `${title} is in code every visitor downloads`, why: `${why} Found: ${m[0].slice(0, 8)}…`, fix: "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.", ...P.secret(title, revoke, env) });
158
+ }
159
+ }
160
+ for (const t of b.text.match(JWT) || []) {
161
+ const p = decodeJwt(t);
162
+ if (!p) continue;
163
+ if (p.role === "service_role" && !keysSeen.has(t)) {
164
+ keysSeen.add(t);
165
+ add({ id: "supabase:service_role", severity: "critical", where: b.where, title: "Your Supabase service_role key is in the browser code", why: "This key skips every security rule: anyone can read, change and delete all of your data and users.", fix: "Rotate it in Supabase (Settings → API) and use it only on a server. The browser should only ever have the anon key.", ...P.serviceRole() });
166
+ }
167
+ if (p.role === "anon" && p.iss === "supabase") supabase = { ...(supabase || {}), anon: t, ref: p.ref };
168
+ }
169
+ const pk = b.text.match(/sb_publishable_[0-9A-Za-z_-]{20,}/);
170
+ if (pk) supabase = { ...(supabase || {}), anon: supabase?.anon || pk[0] };
171
+ const su = b.text.match(SUPABASE_URL);
172
+ if (su) supabase = { ...(supabase || {}), url: su[0], ref: supabase?.ref || su[1] };
173
+ const fp = b.text.match(FB_PROJECT);
174
+ if (fp && /firebase/i.test(b.text)) {
175
+ firebase = { ...(firebase || {}), project: fp[1] };
176
+ const db = b.text.match(FB_DB);
177
+ if (db) firebase.db = db[1];
178
+ const k = b.text.match(FB_KEY);
179
+ if (k) firebase.key = k[1];
180
+ }
181
+ if (b.url && /sourceMappingURL=([^\s'"]+\.map)/.test(b.text)) {
182
+ const mapUrl = new URL(b.text.match(/sourceMappingURL=([^\s'"]+\.map)/)[1], b.url).href;
183
+ const r = await get(mapUrl, fetchOpts({ method: "HEAD" }));
184
+ if (r.status === 200 && !findings.some((f) => f.id === "sourcemaps"))
185
+ add({ id: "sourcemaps", severity: "medium", where: new URL(mapUrl).pathname, title: "Source maps are public", why: "Anyone can read your app's original source code, including comments and any logic you assumed was hidden.", fix: "Turn off source maps in production builds (for Vite: build.sourcemap = false).", ...P.sourceMaps() });
186
+ }
187
+ }
188
+
189
+ const database = supabase?.url && supabase.anon ? "Supabase" : firebase?.project ? "Firebase" : null;
190
+ if (database && !mine)
191
+ add({ id: "db:skipped", severity: "low", where: database, title: "Database checks skipped", why: `This app uses ${database}. Crumple can check which of its data anyone on the internet can read, but only for an app you own.`, fix: `Run \`npx crumplelab site ${url.host} --mine\` if this is your app.`, first: null, prompt: null });
192
+
193
+ // Supabase: with the public anon key, which tables can anyone read?
194
+ let tables = null;
195
+ if (mine && supabase?.url && supabase.anon) {
196
+ // The newer sb_publishable_ keys aren't JWTs and go in the apikey header only.
197
+ const h = { apikey: supabase.anon, ...(supabase.anon.startsWith("eyJ") && { authorization: `Bearer ${supabase.anon}` }) };
198
+ const api = await get(`${supabase.url}/rest/v1/`, fetchOpts({ headers: h }));
199
+ let names = [];
200
+ try {
201
+ names = Object.keys(JSON.parse(api.text).paths || {}).map((p) => p.slice(1)).filter((p) => p && !p.startsWith("rpc/"));
202
+ } catch {}
203
+ tables = [];
204
+ for (const t of names.slice(0, 60)) {
205
+ // A HEAD request with count=exact returns how many rows are visible, and no rows.
206
+ const r = await get(`${supabase.url}/rest/v1/${encodeURIComponent(t)}?select=*`, fetchOpts({ method: "HEAD", headers: { ...h, prefer: "count=exact", range: "0-0" } }));
207
+ const n = Number((r.headers?.get("content-range") || "").split("/")[1]);
208
+ if (r.status < 300 && n > 0) tables.push({ table: t, rows: n });
209
+ }
210
+ for (const { table, rows } of tables) {
211
+ const personal = PERSONAL.test(table);
212
+ const why = personal
213
+ ? "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."
214
+ : "The anon key is public (it ships in your app). If this table is meant to be public, like a product list, reading it is fine, but make sure strangers can't change it. If it isn't, it's leaking.";
215
+ add({ id: `supabase:table:${table}`, severity: personal ? "critical" : "medium", where: `Supabase table "${table}"`, title: `Anyone on the internet can read ${rows.toLocaleString("en-US")} row${rows > 1 ? "s" : ""} of "${table}"`, why, fix: `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).`, ...P.rls(table, personal) });
216
+ }
217
+ }
218
+
219
+ // Firebase: is the Realtime Database or a common Firestore collection open to anyone?
220
+ let firebaseOpen = null;
221
+ if (mine && firebase?.project) {
222
+ firebaseOpen = [];
223
+ if (firebase.db) {
224
+ // shallow=true returns only the top-level key names, each mapped to true; we count them and drop them.
225
+ const r = await get(`${firebase.db}/.json?shallow=true`, fetchOpts());
226
+ let keys = 0;
227
+ try {
228
+ const v = JSON.parse(r.text);
229
+ keys = v && typeof v === "object" ? Object.keys(v).length : v != null ? 1 : 0;
230
+ } catch {}
231
+ if (r.status === 200 && keys > 0) {
232
+ firebaseOpen.push({ where: "Realtime Database", keys });
233
+ add({ id: "firebase:rtdb", severity: "critical", where: "Firebase Realtime Database", title: "Anyone on the internet can read your Firebase Realtime Database", why: `Its rules let visitors who aren't signed in read it (${keys} top-level ${keys > 1 ? "branches" : "branch"} visible). Everything under them is open.`, fix: "In Firebase → Realtime Database → Rules, require sign-in and limit each user to their own data.", ...P.rtdb() });
234
+ }
235
+ }
236
+ for (const coll of FIRESTORE_GUESSES) {
237
+ // pageSize=1 and a mask on a field that doesn't exist: at most one document name comes back, no fields.
238
+ const q = `pageSize=1&mask.fieldPaths=__crumple_none${firebase.key ? `&key=${firebase.key}` : ""}`;
239
+ const r = await get(`https://firestore.googleapis.com/v1/projects/${firebase.project}/databases/(default)/documents/${coll}?${q}`, fetchOpts());
240
+ let n = 0;
241
+ try {
242
+ n = (JSON.parse(r.text).documents || []).length;
243
+ } catch {}
244
+ if (r.status === 200 && n > 0) {
245
+ firebaseOpen.push({ where: `Firestore "${coll}"` });
246
+ add({ id: `firebase:firestore:${coll}`, severity: PERSONAL.test(coll) ? "critical" : "high", where: `Firestore collection "${coll}"`, title: `Anyone on the internet can read the Firestore collection "${coll}"`, why: "Your Firestore rules let visitors who aren't signed in read it. Crumple asked for one document name and no fields; a stranger would take everything.", fix: `In Firebase → Firestore → Rules, close "${coll}" to everyone except its owner.`, ...P.firestore(coll) });
247
+ }
248
+ }
249
+ }
250
+
251
+ if (!page.headers?.get("content-security-policy") && !/http-equiv=["']Content-Security-Policy/i.test(page.text))
252
+ add({ id: "csp", severity: "low", where: "response headers", title: "No Content-Security-Policy", why: "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.", fix: "Add a Content-Security-Policy header in your host's settings.", ...P.csp([...thirdParty].slice(0, 6)) });
253
+
254
+ const penalty = findings.reduce((a, f) => a + SEVERITY[f.severity], 0);
255
+ const score = Math.max(0, 100 - penalty);
256
+ const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
257
+ const order = ["critical", "high", "medium", "low"];
258
+ findings.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
259
+ return {
260
+ url: origin,
261
+ builder,
262
+ score,
263
+ grade,
264
+ verdict: VERDICT[grade],
265
+ mine,
266
+ scripts: bundles.length - 1,
267
+ supabase: supabase ? { project: supabase.ref, checked: mine, readable: tables } : null,
268
+ firebase: firebase ? { project: firebase.project, checked: mine, readable: firebaseOpen } : null,
269
+ findings,
270
+ pasteInto: builder || "your AI app builder or coding agent",
271
+ };
272
+ }
273
+
274
+ export const VERDICT = {
275
+ A: "Nothing serious a stranger can use against you.",
276
+ B: "No open doors, a few loose ends.",
277
+ C: "Fix these before you share the link widely.",
278
+ D: "A stranger can do real damage. Fix the red items today.",
279
+ F: "Your users' data or your accounts are exposed right now. Fix the red items before anything else.",
280
+ };
281
+
282
+ // --- re-check ------------------------------------------------------------------------------------
283
+ // The last result for each site is kept on this machine (ids, titles and score only: no keys, no data),
284
+ // so running the check again shows what the fixes changed.
285
+ const HISTORY = () => path.join(process.env.CRUMPLE_HOME ?? path.join(os.homedir(), ".crumple"), "sites");
286
+ const historyFile = (origin, mine, dir) => path.join(dir, `${new URL(origin).host.replace(/[^\w.-]/g, "_")}${mine ? ".mine" : ""}.json`);
287
+
288
+ export function loadLast(origin, mine, dir = HISTORY()) {
289
+ try {
290
+ return JSON.parse(fs.readFileSync(historyFile(origin, mine, dir), "utf8"));
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+
296
+ export function saveRun(result, dir = HISTORY()) {
297
+ const slim = { at: new Date().toISOString(), score: result.score, grade: result.grade, findings: result.findings.map(({ id, severity, title }) => ({ id, severity, title })) };
298
+ try {
299
+ fs.mkdirSync(dir, { recursive: true });
300
+ fs.writeFileSync(historyFile(result.url, result.mine, dir), JSON.stringify(slim, null, 2) + "\n");
301
+ } catch {}
302
+ return slim;
303
+ }
304
+
305
+ // What changed between two runs: fixed, still open, new.
306
+ export function compare(prev, result) {
307
+ if (!prev) return null;
308
+ const now = new Map(result.findings.map((f) => [f.id, f]));
309
+ const before = new Map(prev.findings.map((f) => [f.id, f]));
310
+ return {
311
+ since: prev.at,
312
+ before: { score: prev.score, grade: prev.grade },
313
+ after: { score: result.score, grade: result.grade },
314
+ fixed: prev.findings.filter((f) => !now.has(f.id)).map((f) => f.title),
315
+ open: result.findings.filter((f) => before.has(f.id)).map((f) => f.title),
316
+ new: result.findings.filter((f) => !before.has(f.id)).map((f) => f.title),
317
+ };
318
+ }
319
+
320
+ // --- the report a person reads ---------------------------------------------------------------------
321
+ const wrap = (s, w = 92, pad = " ") => {
322
+ const out = [];
323
+ for (const para of String(s).split("\n")) {
324
+ let line = "";
325
+ for (const word of para.split(" ")) {
326
+ if ((line + " " + word).trim().length > w) (out.push(pad + line.trim()), (line = word));
327
+ else line += " " + word;
328
+ }
329
+ out.push(pad + line.trim());
330
+ }
331
+ return out.join("\n");
332
+ };
333
+
334
+ export function renderSite(r, change, { color = true, mine = r.mine } = {}) {
335
+ const k = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
336
+ const red = k("31"), green = k("32"), yellow = k("33"), dim = k("2"), bold = k("1"), cyan = k("36");
337
+ const col = { critical: red, high: red, medium: yellow, low: dim };
338
+ const gcol = r.score >= 75 ? green : r.score >= 55 ? yellow : red;
339
+ const L = [];
340
+ L.push("", `${bold("Crumple site check")} · ${r.url}${r.builder ? ` · built with ${r.builder}` : ""} · ${r.scripts} scripts read${r.supabase ? " · Supabase" : ""}${r.firebase ? " · Firebase" : ""}`, "");
341
+ L.push(` Grade ${gcol(bold(r.grade))} ${gcol(`${r.score}/100`)} ${r.verdict}`);
342
+ if (change) {
343
+ const arrow = change.after.score > change.before.score ? green : change.after.score < change.before.score ? red : dim;
344
+ L.push(` ${arrow(`Was ${change.before.grade} (${change.before.score}) on ${change.since.slice(0, 10)}, now ${change.after.grade} (${change.after.score}).`)}`);
345
+ for (const t of change.fixed) L.push(` ${green("✓ fixed")} ${t}`);
346
+ for (const t of change.new) L.push(` ${red("+ new")} ${t}`);
347
+ if (!change.fixed.length && !change.new.length) L.push(dim(" Nothing changed since the last check."));
348
+ }
349
+ L.push("");
350
+ if (!r.findings.length) L.push(green(" Nothing found."), "");
351
+ let n = 0;
352
+ for (const f of r.findings) {
353
+ const num = f.prompt ? `${++n}.` : " ";
354
+ L.push(`${num.padStart(3)} ${col[f.severity](f.severity.toUpperCase().padEnd(8))} ${bold(f.title)} ${dim(`(${f.where})`)}`);
355
+ L.push(wrap(f.why, 88, " "));
356
+ if (f.first) L.push(wrap(`${yellow("Do this yourself first:")} ${f.first}`, 88, " "));
357
+ if (f.prompt) {
358
+ L.push(` ${cyan(`Then paste this into ${r.pasteInto}:`)}`);
359
+ L.push(wrap(f.prompt, 84, ` ${dim("│")} `));
360
+ } else L.push(wrap(`${green("Next:")} ${f.fix}`, 88, " "));
361
+ L.push("");
362
+ }
363
+ L.push(dim(`Read only what the site serves to every visitor${mine ? "; database checked with counts and names only, never data" : ""}.`));
364
+ if (n) {
365
+ L.push(dim(`Fix the red items first, one prompt at a time. Copy one prompt: npx crumplelab site ${new URL(r.url).host}${mine ? " --mine" : ""} --prompt 1 | pbcopy`));
366
+ L.push(dim("After the fixes are live, run this same command again: it shows what got fixed."));
367
+ }
368
+ return L.join("\n");
369
+ }