crumplelab 0.3.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +10 -3
- package/bin/crumple.mjs +18 -16
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/mcp.mjs +11 -2
- package/src/scan.mjs +3 -0
- package/src/site.mjs +267 -40
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
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
|
+
|
|
3
20
|
## 0.3.0
|
|
4
21
|
|
|
5
22
|
- `crumple site <url>`: checks a deployed app (Lovable, Bolt, v0, Replit, Emergent or hand-built) for secret keys
|
package/README.md
CHANGED
|
@@ -79,9 +79,15 @@ most often are visible to anyone who opens the site.
|
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
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
|
|
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
|
|
83
84
|
```
|
|
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
|
+
|
|
85
91
|
`site` reads the page and the JavaScript it serves, the way a curious stranger would, and looks for:
|
|
86
92
|
|
|
87
93
|
- secret keys in code every visitor downloads (Stripe, OpenAI, Anthropic, AWS, GitHub, Slack, Resend,
|
|
@@ -92,7 +98,8 @@ npx crumplelab site myapp.lovable.app --mine # also: which Supabase tables any
|
|
|
92
98
|
|
|
93
99
|
With `--mine`, for an app you own, it takes the public Supabase anon key the app already ships and asks the
|
|
94
100
|
database which tables anyone can read. It sends `HEAD` requests with `count=exact`, so Postgres returns a row
|
|
95
|
-
count and never 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
|
|
96
103
|
finding says which switch to flip in Supabase.
|
|
97
104
|
|
|
98
105
|
## Fix it
|
|
@@ -177,7 +184,7 @@ Or one app at a time:
|
|
|
177
184
|
ask "is my agent setup safe?"
|
|
178
185
|
- **Gemini CLI extension:** `gemini extensions install https://github.com/priberry-data/crumplelab`
|
|
179
186
|
- **Skill (Claude Code or Codex):** copy `skills/crumple` into `~/.claude/skills/` or `~/.codex/skills/`.
|
|
180
|
-
- **Any MCP client:** `{"command": "npx", "args": ["-y", "crumplelab@0.3.
|
|
187
|
+
- **Any MCP client:** `{"command": "npx", "args": ["-y", "crumplelab@0.3.1", "mcp"]}`. Tools: `crumple_scan`
|
|
181
188
|
(the setup scan, local and instant), `crumple_site` (what a deployed app shows every visitor) and
|
|
182
189
|
`crumple_traps`. The full crash test spends API credit, so it stays a command you run on purpose.
|
|
183
190
|
|
package/bin/crumple.mjs
CHANGED
|
@@ -51,6 +51,7 @@ try {
|
|
|
51
51
|
help: { type: "boolean", short: "h" },
|
|
52
52
|
version: { type: "boolean", short: "v" },
|
|
53
53
|
mine: { type: "boolean" },
|
|
54
|
+
prompt: { type: "string" },
|
|
54
55
|
undo: { type: "boolean" },
|
|
55
56
|
},
|
|
56
57
|
});
|
|
@@ -76,7 +77,7 @@ const SEVERITY_OF = (s) => ({ critical: 40, high: 15, medium: 5, low: 1 })[s] ??
|
|
|
76
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` };
|
|
77
78
|
|
|
78
79
|
// The guard speaks JSON to the agent over stdout, so it (and any machine-readable output) never gets the banner.
|
|
79
|
-
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" }));
|
|
80
81
|
|
|
81
82
|
if (positionals[0] === "mcp") {
|
|
82
83
|
// MCP over stdio: the scan and the trap list, for any agent or editor that speaks MCP.
|
|
@@ -135,30 +136,31 @@ if (positionals[0] === "mcp") {
|
|
|
135
136
|
console.log(renderSetup(rows, { name: "Crumple", undo: opt.undo, dryRun: opt["dry-run"], color: Boolean(process.stdout.isTTY && !process.env.NO_COLOR) }));
|
|
136
137
|
} else if (positionals[0] === "site") {
|
|
137
138
|
if (!positionals[1]) {
|
|
138
|
-
console.error("Usage: npx crumplelab site <url> [--mine] checks a deployed app (Lovable, Bolt, v0, Replit, any site)");
|
|
139
|
+
console.error("Usage: npx crumplelab site <url> [--mine] [--prompt N] [--json] checks a deployed app (Lovable, Bolt, v0, Replit, Emergent, any site)");
|
|
139
140
|
process.exit(2);
|
|
140
141
|
}
|
|
141
|
-
const { siteScan } = await import("../src/site.mjs");
|
|
142
|
+
const { siteScan, loadLast, saveRun, compare, renderSite } = await import("../src/site.mjs");
|
|
142
143
|
const run = () => siteScan(positionals[1], { mine: opt.mine });
|
|
143
144
|
let result;
|
|
144
145
|
try {
|
|
145
|
-
result = opt.json ? await run() : await spin(`looking at ${positionals[1]} the way a stranger would`, run);
|
|
146
|
+
result = opt.json || opt.prompt ? await run() : await spin(`looking at ${positionals[1]} the way a stranger would`, run);
|
|
146
147
|
} catch (e) {
|
|
147
148
|
console.error(c.red(`crumple: ${e.message}`));
|
|
148
149
|
process.exit(1);
|
|
149
150
|
}
|
|
150
|
-
if (opt.
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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`);
|
|
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);
|
|
159
157
|
}
|
|
160
|
-
|
|
161
|
-
|
|
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) }));
|
|
162
164
|
}
|
|
163
165
|
if (opt["fail-on"] && result.findings.some((f) => SEVERITY_OF(f.severity) >= SEVERITY_OF(opt["fail-on"]))) process.exitCode = 1;
|
|
164
166
|
} else if (positionals[0] === "lock") {
|
|
@@ -205,7 +207,7 @@ if (positionals[0] === "mcp") {
|
|
|
205
207
|
console.log(`Usage: crumple attack [options]
|
|
206
208
|
crumple scan [--sarif f] [--fail-on high] instant local audit of your agent setup (no key, no Docker)
|
|
207
209
|
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]
|
|
210
|
+
crumple site <url> [--mine] [--prompt N] grade a deployed app (Lovable, Bolt, v0, Replit, Emergent, any site): leaked keys, open data, fix prompts
|
|
209
211
|
crumple fix [--sandbox] [--managed] [--dry-run] harden this project's agent setup, then attack again
|
|
210
212
|
crumple lock [--check] pin your MCP servers' tools; report any that change
|
|
211
213
|
crumple list [--pack dir] every trap, its source and the standards it maps to
|
package/gemini-extension.json
CHANGED
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"url": "https://github.com/priberry-data/crumplelab",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.3.
|
|
11
|
+
"version": "0.3.1",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "crumplelab",
|
|
16
|
-
"version": "0.3.
|
|
16
|
+
"version": "0.3.1",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|
package/src/mcp.mjs
CHANGED
|
@@ -15,7 +15,7 @@ const TOOLS = [
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
name: "crumple_site",
|
|
18
|
-
description: "
|
|
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
19
|
inputSchema: { type: "object", properties: { url: { type: "string", description: "The app's address, for example myapp.lovable.app" } }, required: ["url"], additionalProperties: false },
|
|
20
20
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
21
21
|
},
|
|
@@ -27,12 +27,21 @@ const TOOLS = [
|
|
|
27
27
|
},
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
const lastSite = new Map();
|
|
31
|
+
|
|
30
32
|
async function call(name, args = {}) {
|
|
31
33
|
if (name === "crumple_scan") {
|
|
32
34
|
const dir = path.resolve(args.path || process.cwd());
|
|
33
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)." };
|
|
34
36
|
}
|
|
35
|
-
if (name === "crumple_site")
|
|
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
|
+
}
|
|
36
45
|
if (name === "crumple_traps") return { traps: SCENARIOS.map((s) => ({ id: s.id, title: s.title, risk: s.risk })) };
|
|
37
46
|
throw new Error(`unknown tool ${name}`);
|
|
38
47
|
}
|
package/src/scan.mjs
CHANGED
|
@@ -77,6 +77,9 @@ export function scan(projectDir, { home = os.homedir() } = {}) {
|
|
|
77
77
|
// An exact rule only ever approves that one command, so only wildcards can carry your data out.
|
|
78
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.`);
|
|
79
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.`);
|
|
80
83
|
// A single MCP tool (mcp__server__tool) is a narrow approval; a whole server or open WebFetch is not.
|
|
81
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.`);
|
|
82
85
|
}
|
package/src/site.mjs
CHANGED
|
@@ -1,28 +1,52 @@
|
|
|
1
1
|
// crumple site <url>: checks a deployed app the way a curious stranger would, for the mistakes that
|
|
2
2
|
// AI app builders (Lovable, Bolt, v0, Replit, Emergent and the rest) ship most often:
|
|
3
3
|
// - secret keys inside the JavaScript every visitor downloads
|
|
4
|
-
// - a Supabase database
|
|
4
|
+
// - a Supabase or Firebase database that anyone can read with the app's public key (with --mine only)
|
|
5
5
|
// - source maps that hand out the whole source code
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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";
|
|
8
13
|
import { SEVERITY } from "./scan.mjs";
|
|
9
14
|
|
|
15
|
+
// [pattern, id, what it is, what a stranger can do with it, where to revoke it]
|
|
10
16
|
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.)"],
|
|
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"],
|
|
23
29
|
];
|
|
24
30
|
const JWT = /eyJ[0-9A-Za-z_-]{10,}\.eyJ[0-9A-Za-z_-]{10,}\.[0-9A-Za-z_-]{10,}/g;
|
|
25
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
|
+
}
|
|
26
50
|
|
|
27
51
|
const decodeJwt = (t) => {
|
|
28
52
|
try {
|
|
@@ -46,15 +70,56 @@ async function get(url, { timeout = 15000, headers = {}, method = "GET", max = 8
|
|
|
46
70
|
}
|
|
47
71
|
}
|
|
48
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 -----------------------------------------------------------------------------------
|
|
49
113
|
export async function siteScan(target, { mine = false, fetcher = fetch, maxScripts = 40 } = {}) {
|
|
50
114
|
const fetchOpts = (o = {}) => ({ ...o, fetcher });
|
|
51
115
|
const url = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`);
|
|
52
116
|
const findings = [];
|
|
53
|
-
const add = (
|
|
117
|
+
const add = (f) => findings.push(f);
|
|
54
118
|
|
|
55
119
|
const page = await get(url.href, fetchOpts());
|
|
56
120
|
if (!page.status || page.status >= 400) throw new Error(`could not load ${url.href} (${page.error || page.status})`);
|
|
57
121
|
const origin = new URL(page.url || url.href).origin;
|
|
122
|
+
const builder = detectBuilder(new URL(origin).host, page.text);
|
|
58
123
|
|
|
59
124
|
// Every script the page loads from its own site, plus the scripts those import.
|
|
60
125
|
const srcs = new Set();
|
|
@@ -77,13 +142,19 @@ export async function siteScan(target, { mine = false, fetcher = fetch, maxScrip
|
|
|
77
142
|
}
|
|
78
143
|
|
|
79
144
|
let supabase = null;
|
|
145
|
+
let firebase = null;
|
|
80
146
|
const keysSeen = new Set();
|
|
147
|
+
const thirdParty = new Set();
|
|
81
148
|
for (const b of bundles) {
|
|
82
|
-
for (const
|
|
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) {
|
|
83
154
|
const m = b.text.match(re);
|
|
84
155
|
if (m && !keysSeen.has(m[0])) {
|
|
85
156
|
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.");
|
|
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) });
|
|
87
158
|
}
|
|
88
159
|
}
|
|
89
160
|
for (const t of b.text.match(JWT) || []) {
|
|
@@ -91,52 +162,208 @@ export async function siteScan(target, { mine = false, fetcher = fetch, maxScrip
|
|
|
91
162
|
if (!p) continue;
|
|
92
163
|
if (p.role === "service_role" && !keysSeen.has(t)) {
|
|
93
164
|
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.");
|
|
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() });
|
|
95
166
|
}
|
|
96
167
|
if (p.role === "anon" && p.iss === "supabase") supabase = { ...(supabase || {}), anon: t, ref: p.ref };
|
|
97
168
|
}
|
|
169
|
+
const pk = b.text.match(/sb_publishable_[0-9A-Za-z_-]{20,}/);
|
|
170
|
+
if (pk) supabase = { ...(supabase || {}), anon: supabase?.anon || pk[0] };
|
|
98
171
|
const su = b.text.match(SUPABASE_URL);
|
|
99
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
|
+
}
|
|
100
181
|
if (b.url && /sourceMappingURL=([^\s'"]+\.map)/.test(b.text)) {
|
|
101
182
|
const mapUrl = new URL(b.text.match(/sourceMappingURL=([^\s'"]+\.map)/)[1], b.url).href;
|
|
102
183
|
const r = await get(mapUrl, fetchOpts({ method: "HEAD" }));
|
|
103
|
-
if (r.status === 200 && !findings.some((f) => f.
|
|
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).");
|
|
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() });
|
|
105
186
|
}
|
|
106
187
|
}
|
|
107
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
|
+
|
|
108
193
|
// Supabase: with the public anon key, which tables can anyone read?
|
|
109
194
|
let tables = null;
|
|
110
|
-
if (supabase?.url && supabase.anon) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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;
|
|
117
227
|
try {
|
|
118
|
-
|
|
228
|
+
const v = JSON.parse(r.text);
|
|
229
|
+
keys = v && typeof v === "object" ? Object.keys(v).length : v != null ? 1 : 0;
|
|
119
230
|
} catch {}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 });
|
|
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() });
|
|
126
234
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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) });
|
|
130
247
|
}
|
|
131
248
|
}
|
|
132
249
|
}
|
|
133
250
|
|
|
134
|
-
if (!page.headers?.get("content-security-policy")
|
|
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)) });
|
|
135
253
|
|
|
136
254
|
const penalty = findings.reduce((a, f) => a + SEVERITY[f.severity], 0);
|
|
137
255
|
const score = Math.max(0, 100 - penalty);
|
|
138
256
|
const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : score >= 40 ? "D" : "F";
|
|
139
257
|
const order = ["critical", "high", "medium", "low"];
|
|
140
258
|
findings.sort((a, b) => order.indexOf(a.severity) - order.indexOf(b.severity));
|
|
141
|
-
return {
|
|
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");
|
|
142
369
|
}
|