getadvantage 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/brief.mjs +634 -634
- package/checks-runner.mjs +136 -136
- package/checks.mjs +327 -327
- package/deploy.mjs +203 -203
- package/gauge.mjs +95 -0
- package/handoff.mjs +10 -3
- package/index.mjs +223 -181
- package/init.mjs +81 -0
- package/ledger.mjs +110 -0
- package/models.mjs +40 -0
- package/overviews.mjs +536 -536
- package/package.json +1 -1
- package/switch.mjs +60 -0
- package/util.mjs +142 -142
package/overviews.mjs
CHANGED
|
@@ -1,536 +1,536 @@
|
|
|
1
|
-
// Ship-Safe v1.1 — the OVERVIEW SCANNERS.
|
|
2
|
-
//
|
|
3
|
-
// Three READ-ONLY repo scans that hand the builder a MAP of what their app
|
|
4
|
-
// actually has. This is the "understand what you built" differentiator: an
|
|
5
|
-
// AI agent can ship a hundred routes in an afternoon, and the human has no
|
|
6
|
-
// idea which ones mutate data, who can reach them, what external services
|
|
7
|
-
// they call, or what runs unattended on a schedule. These scanners read the
|
|
8
|
-
// repo and answer those questions in plain language.
|
|
9
|
-
//
|
|
10
|
-
// 1. API surface map — every Next.js App Router route, its methods, and
|
|
11
|
-
// whether it looks auth-gated. ⚠ on a MUTATING route
|
|
12
|
-
// that looks UNauthenticated (the dangerous ones).
|
|
13
|
-
// 2. Agents & integrations map — external/LLM/3rd-party calls + which env
|
|
14
|
-
// keys back them. ⚠ if a secret looks reachable from
|
|
15
|
-
// the client or an unauthenticated path.
|
|
16
|
-
// 3. Schedules & jobs map — vercel.json crons + app/api/cron/* routes, their
|
|
17
|
-
// schedule, and whether each is gated. ⚠ on an
|
|
18
|
-
// ungated (publicly triggerable) cron.
|
|
19
|
-
//
|
|
20
|
-
// Every scanner returns the same `result(status, label, detail, extra)` shape
|
|
21
|
-
// the runner already renders. Node built-ins only; nothing here mutates the
|
|
22
|
-
// repo (it only READS files). Overall verdict is INFORMATIONAL — these emit
|
|
23
|
-
// `pass`/`warn`, never `fail`, so a surprising map never blocks a ship on its
|
|
24
|
-
// own (the v1 safety checks own the NO-GO).
|
|
25
|
-
|
|
26
|
-
import path from "node:path";
|
|
27
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
-
import { result, walkFiles, readText, relPath } from "./util.mjs";
|
|
29
|
-
|
|
30
|
-
// ===========================================================================
|
|
31
|
-
// shared helpers
|
|
32
|
-
// ===========================================================================
|
|
33
|
-
|
|
34
|
-
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
35
|
-
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
36
|
-
|
|
37
|
-
/** Find every App Router route handler (app/api/**\/route.ts|js). */
|
|
38
|
-
function routeFiles(cwd) {
|
|
39
|
-
const apiDir = path.join(cwd, "app", "api");
|
|
40
|
-
if (!existsSync(apiDir)) return [];
|
|
41
|
-
return walkFiles(apiDir, (abs) => /[\\/]route\.(ts|tsx|js|mjs)$/.test(abs)).sort();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Turn an absolute app/api/.../route.ts path into its public URL path.
|
|
46
|
-
* app/api/sites/route.ts → /api/sites
|
|
47
|
-
* app/api/team/[memberId]/route.ts → /api/team/[memberId]
|
|
48
|
-
* app/api/[transport]/route.ts → /api/[transport] (=> the MCP server)
|
|
49
|
-
*/
|
|
50
|
-
function routeUrlFromFile(abs, cwd) {
|
|
51
|
-
const rel = relPath(abs, cwd); // app/api/.../route.ts
|
|
52
|
-
const segs = rel.split("/");
|
|
53
|
-
// drop leading "app" and the trailing "route.xx"
|
|
54
|
-
const parts = segs.slice(1, -1); // ["api", "sites"] etc.
|
|
55
|
-
return "/" + parts.join("/");
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Which HTTP methods a route file exports (handles `export async function POST`,
|
|
59
|
-
* `export function GET`, and `export { handler as GET, handler as POST }`). */
|
|
60
|
-
function methodsExported(text) {
|
|
61
|
-
const found = new Set();
|
|
62
|
-
for (const m of HTTP_METHODS) {
|
|
63
|
-
// export (async)? function GET(...)
|
|
64
|
-
if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`).test(text)) found.add(m);
|
|
65
|
-
// export const GET = ...
|
|
66
|
-
if (new RegExp(`export\\s+const\\s+${m}\\b`).test(text)) found.add(m);
|
|
67
|
-
// export { handler as GET } / export { x as POST, y as GET }
|
|
68
|
-
if (new RegExp(`\\bas\\s+${m}\\b`).test(text)) found.add(m);
|
|
69
|
-
}
|
|
70
|
-
return [...found].sort((a, b) => HTTP_METHODS.indexOf(a) - HTTP_METHODS.indexOf(b));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Tokens that signal a handler authenticates / authorizes the caller. Drawn
|
|
74
|
-
// from this repo's real conventions (currentAccount, getSession, the API-key
|
|
75
|
-
// resolver, the per-caller rate-limiter) but kept generic enough to catch the
|
|
76
|
-
// usual names an AI agent emits in any Next.js app.
|
|
77
|
-
const AUTH_TOKENS = [
|
|
78
|
-
/\bcurrentAccount\b/,
|
|
79
|
-
/\bgetSession\b/,
|
|
80
|
-
/\brequireAuth\b/,
|
|
81
|
-
/\brequireSession\b/,
|
|
82
|
-
/\bverifySession\b/,
|
|
83
|
-
/\bresolveCaller\b/, // app/lib/server/api-auth (key-or-keyless)
|
|
84
|
-
/\bresolveApiKeySecret\b/, // API-key auth
|
|
85
|
-
/\brateLimitCaller\b/, // per-caller (key/plan) limiter
|
|
86
|
-
/\bgetServerSession\b/, // next-auth idiom
|
|
87
|
-
/\bauth\(\)/, // next-auth v5 idiom
|
|
88
|
-
];
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Strip line (`// …`) and block (`/* … *\/`) comments from JS/TS source so a
|
|
92
|
-
* commented-out auth/secret token never counts as real gating. This is a
|
|
93
|
-
* heuristic stripper (it does not parse strings/regex literals), which is the
|
|
94
|
-
* right trade-off here: the auth/secret tokens we grep for are identifiers that
|
|
95
|
-
* never legitimately live inside a string literal, but they DO commonly live in
|
|
96
|
-
* commented-out code (e.g. the deferred `withMcpAuth` block in
|
|
97
|
-
* app/api/[transport]/route.ts references `resolveApiKeySecret` only in a
|
|
98
|
-
* comment). Removing comments first keeps that out of the gating decision.
|
|
99
|
-
*/
|
|
100
|
-
function stripComments(text) {
|
|
101
|
-
let out = "";
|
|
102
|
-
let i = 0;
|
|
103
|
-
const n = text.length;
|
|
104
|
-
while (i < n) {
|
|
105
|
-
const ch = text[i];
|
|
106
|
-
const next = text[i + 1];
|
|
107
|
-
// Block comment /* … */
|
|
108
|
-
if (ch === "/" && next === "*") {
|
|
109
|
-
const end = text.indexOf("*/", i + 2);
|
|
110
|
-
i = end === -1 ? n : end + 2;
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
// Line comment // …
|
|
114
|
-
if (ch === "/" && next === "/") {
|
|
115
|
-
const end = text.indexOf("\n", i + 2);
|
|
116
|
-
i = end === -1 ? n : end; // keep the newline so line structure survives
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
out += ch;
|
|
120
|
-
i++;
|
|
121
|
-
}
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Does the handler reference an auth/session check? Comments are stripped first
|
|
126
|
-
* so commented-out gating never counts (see stripComments). */
|
|
127
|
-
function looksAuthGated(text) {
|
|
128
|
-
const code = stripComments(text);
|
|
129
|
-
return AUTH_TOKENS.some((re) => re.test(code));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Tokens signalling a route authorizes its caller via a CRON / shared secret
|
|
133
|
-
// (Vercel cron sends `Authorization: Bearer <CRON_SECRET>`; this repo also
|
|
134
|
-
// accepts x-cron-secret and compares constant-time). A route gated this way is
|
|
135
|
-
// NOT session-authed but is NOT publicly triggerable either — so the API
|
|
136
|
-
// surface map must not flag it as an "unauthenticated mutator".
|
|
137
|
-
const SECRET_GATE_TOKENS = [
|
|
138
|
-
/\bCRON_SECRET\b/,
|
|
139
|
-
/\btimingSafeEqual\b/,
|
|
140
|
-
/x-cron-secret/i,
|
|
141
|
-
];
|
|
142
|
-
|
|
143
|
-
/** Does the handler gate on a CRON / shared secret (vs. an auth header it might
|
|
144
|
-
* read for other reasons)? We require a concrete secret token, not just the
|
|
145
|
-
* word "authorization", to avoid false "gated" calls. Comments are stripped
|
|
146
|
-
* first so commented-out gating never counts (see stripComments). */
|
|
147
|
-
function looksSecretGated(text) {
|
|
148
|
-
const code = stripComments(text);
|
|
149
|
-
return SECRET_GATE_TOKENS.some((re) => re.test(code));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// A cron route is "gated" when it checks a CRON / shared secret — same precise
|
|
153
|
-
// secret-token detection the API surface map uses, so the two maps agree.
|
|
154
|
-
// (Declared here, next to looksSecretGated, so the shared scanners below — which
|
|
155
|
-
// run before section 3 in source order — can reference it; a `const` alias is
|
|
156
|
-
// not hoisted like a function declaration.)
|
|
157
|
-
const looksCronGated = looksSecretGated;
|
|
158
|
-
|
|
159
|
-
// ===========================================================================
|
|
160
|
-
// SHARED SCANNERS (pure data) — reused by both the `check` overview maps and
|
|
161
|
-
// the `brief` generator (PROJECT-BRIEF.md). These functions do the file
|
|
162
|
-
// reading + classification and return plain data; the `overview*` functions
|
|
163
|
-
// below wrap that data in the runner's `result()` shape, and brief.mjs renders
|
|
164
|
-
// the same data as markdown. ONE scan, two presentations — no duplicated logic.
|
|
165
|
-
// ===========================================================================
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Scan the API surface → structured rows + summary counts.
|
|
169
|
-
* @returns {{ rows: Array<{url,methods:string[],mutates,sessionGated,secretGated,devOnly}>,
|
|
170
|
-
* gatedCount:number, mutatingCount:number, dangerous:any[] }}
|
|
171
|
-
*/
|
|
172
|
-
export function scanApiSurface(cwd) {
|
|
173
|
-
const files = routeFiles(cwd);
|
|
174
|
-
const rows = [];
|
|
175
|
-
for (const abs of files) {
|
|
176
|
-
const text = readText(abs);
|
|
177
|
-
const url = routeUrlFromFile(abs, cwd);
|
|
178
|
-
const methods = methodsExported(text);
|
|
179
|
-
const mutates = methods.some((m) => MUTATING.has(m));
|
|
180
|
-
const sessionGated = looksAuthGated(text);
|
|
181
|
-
const secretGated = looksSecretGated(text);
|
|
182
|
-
const devOnly =
|
|
183
|
-
/NODE_ENV\s*===\s*["']production["']/.test(text) &&
|
|
184
|
-
/\b(?:404|"Not found"|Not found)\b/.test(text);
|
|
185
|
-
rows.push({ url, methods, mutates, sessionGated, secretGated, devOnly });
|
|
186
|
-
}
|
|
187
|
-
const gatedCount = rows.filter((r) => r.sessionGated || r.secretGated).length;
|
|
188
|
-
const mutatingCount = rows.filter((r) => r.mutates).length;
|
|
189
|
-
const dangerous = rows.filter(
|
|
190
|
-
(r) => r.mutates && !r.sessionGated && !r.secretGated && !r.devOnly,
|
|
191
|
-
);
|
|
192
|
-
return { rows, gatedCount, mutatingCount, dangerous };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** A short human tag for a single API row (shared by the map + the brief). */
|
|
196
|
-
export function apiRowTag(r) {
|
|
197
|
-
if (r.devOnly) return "dev-only (inert in prod)";
|
|
198
|
-
if (r.sessionGated) return "auth-gated";
|
|
199
|
-
if (r.secretGated) return "secret-gated (cron/shared)";
|
|
200
|
-
if (r.mutates) return "PUBLIC + mutates ⚠";
|
|
201
|
-
return "public (read-only)";
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Scan integrations → label → { files:Set, keys:Set }, plus client-secret hits
|
|
206
|
-
* and whether an MCP server route was found.
|
|
207
|
-
* @returns {{ integrations: Map, clientSecretHits: Array<{file,key}>, mcpRouteFound: boolean }}
|
|
208
|
-
*/
|
|
209
|
-
export function scanIntegrations(cwd) {
|
|
210
|
-
const appDir = path.join(cwd, "app");
|
|
211
|
-
const integrations = new Map(); // label -> { files:Set, keys:Set }
|
|
212
|
-
const clientSecretHits = [];
|
|
213
|
-
let mcpRouteFound = false;
|
|
214
|
-
if (!existsSync(appDir)) return { integrations, clientSecretHits, mcpRouteFound };
|
|
215
|
-
|
|
216
|
-
const files = walkFiles(appDir, (abs) => /\.(ts|tsx|js|mjs)$/.test(abs));
|
|
217
|
-
function note(label, keys, file) {
|
|
218
|
-
if (!integrations.has(label)) integrations.set(label, { files: new Set(), keys: new Set() });
|
|
219
|
-
const e = integrations.get(label);
|
|
220
|
-
e.files.add(file);
|
|
221
|
-
for (const k of keys) e.keys.add(k);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
for (const abs of files) {
|
|
225
|
-
const text = readText(abs);
|
|
226
|
-
if (!text) continue;
|
|
227
|
-
const rel = relPath(abs, cwd);
|
|
228
|
-
|
|
229
|
-
for (const h of INTEGRATION_HOSTS) {
|
|
230
|
-
if (text.includes(h.host)) note(h.label, h.keys, rel);
|
|
231
|
-
}
|
|
232
|
-
for (const s of INTEGRATION_SDKS) {
|
|
233
|
-
if (s.re.test(text)) {
|
|
234
|
-
note(s.label, s.keys, rel);
|
|
235
|
-
if (s.label.startsWith("MCP server")) mcpRouteFound = true;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (isClientFile(text)) {
|
|
240
|
-
let m;
|
|
241
|
-
SECRET_ENV_RE.lastIndex = 0;
|
|
242
|
-
while ((m = SECRET_ENV_RE.exec(text)) !== null) {
|
|
243
|
-
const key = m[1];
|
|
244
|
-
if (key.startsWith("NEXT_PUBLIC_")) continue;
|
|
245
|
-
clientSecretHits.push({ file: rel, key });
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return { integrations, clientSecretHits, mcpRouteFound };
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Scan schedules/jobs → rows + derived buckets.
|
|
254
|
-
* @returns {{ rows: any[], ungated:any[], orphanRoutes:any[], missing:any[], cronCount:number }}
|
|
255
|
-
*/
|
|
256
|
-
export function scanSchedules(cwd) {
|
|
257
|
-
const crons = vercelCrons(cwd);
|
|
258
|
-
const allRoutes = routeFiles(cwd);
|
|
259
|
-
const cronRouteFiles = allRoutes.filter((abs) => {
|
|
260
|
-
const url = routeUrlFromFile(abs, cwd);
|
|
261
|
-
return url.startsWith("/api/cron") || crons.has(url);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
const rows = [];
|
|
265
|
-
const seenUrls = new Set();
|
|
266
|
-
for (const abs of cronRouteFiles) {
|
|
267
|
-
const url = routeUrlFromFile(abs, cwd);
|
|
268
|
-
seenUrls.add(url);
|
|
269
|
-
const text = readText(abs);
|
|
270
|
-
rows.push({
|
|
271
|
-
url,
|
|
272
|
-
schedule: crons.get(url) || null,
|
|
273
|
-
gated: looksCronGated(text),
|
|
274
|
-
scheduled: crons.has(url),
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
for (const [p, sched] of crons) {
|
|
278
|
-
if (!seenUrls.has(p)) {
|
|
279
|
-
rows.push({ url: p, schedule: sched, gated: null, scheduled: true, missing: true });
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
rows.sort((a, b) => a.url.localeCompare(b.url));
|
|
283
|
-
|
|
284
|
-
const ungated = rows.filter((r) => r.gated === false);
|
|
285
|
-
const orphanRoutes = rows.filter((r) => !r.scheduled && !r.missing);
|
|
286
|
-
const missing = rows.filter((r) => r.missing);
|
|
287
|
-
return { rows, ungated, orphanRoutes, missing, cronCount: crons.size };
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** A short human tag for a single schedule row (shared by the map + the brief). */
|
|
291
|
-
export function scheduleRowTag(r) {
|
|
292
|
-
if (r.missing) return "no handler found ⚠";
|
|
293
|
-
if (r.gated === false) return "UNGATED ⚠ (publicly triggerable)";
|
|
294
|
-
if (r.gated) return "gated (CRON_SECRET)";
|
|
295
|
-
return "ungated?";
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// ===========================================================================
|
|
299
|
-
// 1. API SURFACE MAP
|
|
300
|
-
// ===========================================================================
|
|
301
|
-
// For each App Router route: URL path, exported HTTP methods, and whether it
|
|
302
|
-
// looks auth-gated. The dangerous case we flag is a route that MUTATES
|
|
303
|
-
// (POST/PUT/PATCH/DELETE) yet shows no sign of authenticating the caller —
|
|
304
|
-
// the classic "an AI agent wired up a write endpoint and forgot the gate".
|
|
305
|
-
//
|
|
306
|
-
// NOTE on gating: in this repo `proxy.ts` gates `/app/*` but its matcher
|
|
307
|
-
// EXCLUDES `/api/`, so API routes are NOT covered by the proxy — each handler
|
|
308
|
-
// must authenticate itself. We therefore judge each /api route by its own
|
|
309
|
-
// body. (A route under /app/* would be proxy-gated; we note that too.)
|
|
310
|
-
|
|
311
|
-
export function overviewApiSurface(cwd) {
|
|
312
|
-
const { rows, gatedCount, mutatingCount, dangerous } = scanApiSurface(cwd);
|
|
313
|
-
if (rows.length === 0) {
|
|
314
|
-
return result(
|
|
315
|
-
"pass",
|
|
316
|
-
"API surface map",
|
|
317
|
-
"No app/api/**/route files found — nothing to map.",
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// Build the listing (capped so a big app stays readable).
|
|
322
|
-
const lines = [];
|
|
323
|
-
const CAP = 60;
|
|
324
|
-
for (const r of rows.slice(0, CAP)) {
|
|
325
|
-
const methodStr = r.methods.length ? r.methods.join(",") : "—";
|
|
326
|
-
lines.push(`${r.url} [${methodStr}] ${apiRowTag(r)}`);
|
|
327
|
-
}
|
|
328
|
-
if (rows.length > CAP) lines.push(`…and ${rows.length - CAP} more route(s)`);
|
|
329
|
-
|
|
330
|
-
const detail =
|
|
331
|
-
`${rows.length} route(s) · ${gatedCount} look gated (session or cron secret) · ` +
|
|
332
|
-
`${mutatingCount} mutate (write) · ${dangerous.length} mutate without any obvious gate.`;
|
|
333
|
-
|
|
334
|
-
if (dangerous.length > 0) {
|
|
335
|
-
// Surface the dangerous ones FIRST, then the full map below them.
|
|
336
|
-
const flagged = dangerous
|
|
337
|
-
.slice(0, 20)
|
|
338
|
-
.map((r) => `⚠ ${r.url} [${r.methods.join(",")}] — mutates but no auth/session check found`);
|
|
339
|
-
return result(
|
|
340
|
-
"warn",
|
|
341
|
-
"API surface map",
|
|
342
|
-
detail,
|
|
343
|
-
[
|
|
344
|
-
...flagged,
|
|
345
|
-
...(dangerous.length > 20 ? [`…and ${dangerous.length - 20} more flagged`] : []),
|
|
346
|
-
"Confirm each ⚠ route is meant to be public (some, like a rate-limited lead/contact",
|
|
347
|
-
"endpoint, legitimately are) — and that the others authenticate the caller before writing.",
|
|
348
|
-
"— full map —",
|
|
349
|
-
...lines,
|
|
350
|
-
],
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return result("pass", "API surface map", detail, lines);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// ===========================================================================
|
|
358
|
-
// 2. AGENTS & INTEGRATIONS MAP
|
|
359
|
-
// ===========================================================================
|
|
360
|
-
// What external services + LLMs the app talks to, and which env keys back
|
|
361
|
-
// them. We detect via (a) known host strings in fetch()/URL literals,
|
|
362
|
-
// (b) well-known SDK imports, (c) the MCP server route, then map each to the
|
|
363
|
-
// env key(s) it reads. The risk flag: a secret/LLM key that looks reachable
|
|
364
|
-
// from the CLIENT BUNDLE or an UNAUTHENTICATED route.
|
|
365
|
-
|
|
366
|
-
// host substring → { label, keys[] }. Keys are the env vars that typically
|
|
367
|
-
// back that integration in this kind of app (names only — never values).
|
|
368
|
-
const INTEGRATION_HOSTS = [
|
|
369
|
-
{ host: "api.openai.com", label: "OpenAI", keys: ["OPENAI_API_KEY"] },
|
|
370
|
-
{ host: "api.anthropic.com", label: "Anthropic (Claude)", keys: ["ANTHROPIC_API_KEY"] },
|
|
371
|
-
{ host: "api.perplexity.ai", label: "Perplexity", keys: ["PERPLEXITY_API_KEY"] },
|
|
372
|
-
{ host: "generativelanguage.googleapis.com", label: "Google Gemini", keys: ["GEMINI_API_KEY"] },
|
|
373
|
-
{ host: "api.stripe.com", label: "Stripe (HTTP)", keys: ["STRIPE_SECRET_KEY"] },
|
|
374
|
-
{ host: "api.resend.com", label: "Resend (email)", keys: ["RESEND_API_KEY"] },
|
|
375
|
-
{ host: "api.improvmx.com", label: "ImprovMX (email aliases)", keys: ["IMPROVMX_API_TOKEN"] },
|
|
376
|
-
{ host: "api.vercel.com", label: "Vercel API", keys: ["VERCEL_TOKEN"] },
|
|
377
|
-
{ host: "api.cal.com", label: "Cal.com (booking)", keys: ["CAL_API_KEY"] },
|
|
378
|
-
{ host: "crt.sh", label: "crt.sh (CT-log harvest)", keys: [] },
|
|
379
|
-
];
|
|
380
|
-
|
|
381
|
-
// SDK import → { label, keys[] }. Catches integrations that go through an SDK
|
|
382
|
-
// rather than a literal fetch URL (e.g. the Stripe + Resend SDKs).
|
|
383
|
-
const INTEGRATION_SDKS = [
|
|
384
|
-
{ re: /from\s+["']stripe["']/, label: "Stripe (SDK)", keys: ["STRIPE_SECRET_KEY"] },
|
|
385
|
-
{ re: /from\s+["']openai["']/, label: "OpenAI (SDK)", keys: ["OPENAI_API_KEY"] },
|
|
386
|
-
{ re: /from\s+["']@anthropic-ai\/sdk["']/, label: "Anthropic (SDK)", keys: ["ANTHROPIC_API_KEY"] },
|
|
387
|
-
{ re: /from\s+["']resend["']/, label: "Resend (SDK)", keys: ["RESEND_API_KEY"] },
|
|
388
|
-
{ re: /from\s+["']mcp-handler["']/, label: "MCP server (mcp-handler)", keys: [] },
|
|
389
|
-
];
|
|
390
|
-
|
|
391
|
-
// Env names that hold a SECRET (vs. public config). A match of one of these in
|
|
392
|
-
// a CLIENT-reachable file is the risk we flag. NEXT_PUBLIC_* is deliberately
|
|
393
|
-
// NOT secret (it's compiled into the browser bundle by design).
|
|
394
|
-
const SECRET_ENV_RE = /\bprocess\.env\.([A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)[A-Z0-9_]*)\b/g;
|
|
395
|
-
|
|
396
|
-
/** A module is "client-reachable" if it (or the file declaring it) is a Client
|
|
397
|
-
* Component — i.e. carries the "use client" directive. Server Components,
|
|
398
|
-
* route handlers and app/lib/server/* never ship to the browser. */
|
|
399
|
-
function isClientFile(text) {
|
|
400
|
-
// The directive must be at the very top (first non-comment statement). A loose
|
|
401
|
-
// check is fine for a heuristic: look for it in the first ~400 chars.
|
|
402
|
-
return /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']use client["']/.test(text.slice(0, 400));
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
export function overviewIntegrations(cwd) {
|
|
406
|
-
const appDir = path.join(cwd, "app");
|
|
407
|
-
if (!existsSync(appDir)) {
|
|
408
|
-
return result("pass", "Agents & integrations map", "No app/ directory found — nothing to map.");
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const { integrations, clientSecretHits, mcpRouteFound } = scanIntegrations(cwd);
|
|
412
|
-
|
|
413
|
-
if (integrations.size === 0 && clientSecretHits.length === 0) {
|
|
414
|
-
return result(
|
|
415
|
-
"pass",
|
|
416
|
-
"Agents & integrations map",
|
|
417
|
-
"No external LLM / 3rd-party integrations detected in app/.",
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// Build the integration listing, alphabetical, with backing keys.
|
|
422
|
-
const labels = [...integrations.keys()].sort();
|
|
423
|
-
const lines = [];
|
|
424
|
-
for (const label of labels) {
|
|
425
|
-
const e = integrations.get(label);
|
|
426
|
-
const keys = [...e.keys];
|
|
427
|
-
const keyStr = keys.length ? ` — key: ${keys.join(", ")}` : " — no key (public endpoint)";
|
|
428
|
-
lines.push(`${label}${keyStr} (${e.files.size} file${e.files.size === 1 ? "" : "s"})`);
|
|
429
|
-
}
|
|
430
|
-
if (mcpRouteFound) {
|
|
431
|
-
lines.push("MCP server is EXPOSED at /api/mcp — model-callable tools; confirm its auth posture (keyless vs key-scoped).");
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const detail = `${labels.length} integration(s) detected${mcpRouteFound ? " (incl. an MCP server)" : ""}.`;
|
|
435
|
-
|
|
436
|
-
// Risk flag: secret key reachable from the client bundle.
|
|
437
|
-
if (clientSecretHits.length > 0) {
|
|
438
|
-
const seen = new Set();
|
|
439
|
-
const flagged = [];
|
|
440
|
-
for (const h of clientSecretHits) {
|
|
441
|
-
const k = `${h.file}::${h.key}`;
|
|
442
|
-
if (seen.has(k)) continue;
|
|
443
|
-
seen.add(k);
|
|
444
|
-
flagged.push(`⚠ ${h.file} reads process.env.${h.key} in a "use client" component — it would ship in the browser bundle.`);
|
|
445
|
-
}
|
|
446
|
-
return result(
|
|
447
|
-
"warn",
|
|
448
|
-
"Agents & integrations map",
|
|
449
|
-
detail,
|
|
450
|
-
[
|
|
451
|
-
...flagged.slice(0, 15),
|
|
452
|
-
"Move secret reads to a server module (app/lib/server/* or a route handler). Only NEXT_PUBLIC_* belongs client-side.",
|
|
453
|
-
"— integrations —",
|
|
454
|
-
...lines,
|
|
455
|
-
],
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return result("pass", "Agents & integrations map", detail, lines);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// ===========================================================================
|
|
463
|
-
// 3. SCHEDULES & JOBS MAP
|
|
464
|
-
// ===========================================================================
|
|
465
|
-
// Cron / scheduled jobs: vercel.json "crons" (path + schedule) plus every
|
|
466
|
-
// app/api/cron/* route. For each: route, schedule (if declared), and whether
|
|
467
|
-
// it's GATED (checks CRON_SECRET / does a secret compare / inspects the
|
|
468
|
-
// authorization header). The flag: a cron route that looks publicly
|
|
469
|
-
// triggerable — anyone who knows the URL could fire it.
|
|
470
|
-
|
|
471
|
-
/** Parse vercel.json crons → Map(path → schedule). Best-effort; tolerates a
|
|
472
|
-
* missing or malformed file. */
|
|
473
|
-
function vercelCrons(cwd) {
|
|
474
|
-
const map = new Map();
|
|
475
|
-
const file = path.join(cwd, "vercel.json");
|
|
476
|
-
if (!existsSync(file)) return map;
|
|
477
|
-
let json;
|
|
478
|
-
try {
|
|
479
|
-
json = JSON.parse(readFileSync(file, "utf8"));
|
|
480
|
-
} catch {
|
|
481
|
-
return map;
|
|
482
|
-
}
|
|
483
|
-
const crons = Array.isArray(json?.crons) ? json.crons : [];
|
|
484
|
-
for (const c of crons) {
|
|
485
|
-
if (c && typeof c.path === "string") map.set(c.path, c.schedule || "(no schedule)");
|
|
486
|
-
}
|
|
487
|
-
return map;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
export function overviewSchedules(cwd) {
|
|
491
|
-
const { rows, ungated, orphanRoutes, missing, cronCount } = scanSchedules(cwd);
|
|
492
|
-
|
|
493
|
-
if (rows.length === 0) {
|
|
494
|
-
return result("pass", "Schedules & jobs map", "No cron routes or vercel.json crons found.");
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const lines = [];
|
|
498
|
-
for (const r of rows) {
|
|
499
|
-
const sched = r.schedule ? r.schedule : "not in vercel.json";
|
|
500
|
-
lines.push(`${r.url} [${sched}] ${scheduleRowTag(r)}`);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const detail =
|
|
504
|
-
`${rows.length} job(s) · ${cronCount} scheduled in vercel.json · ` +
|
|
505
|
-
`${ungated.length} ungated · ${orphanRoutes.length} cron route(s) not wired to a schedule.`;
|
|
506
|
-
|
|
507
|
-
const warnBits = [];
|
|
508
|
-
if (ungated.length > 0) {
|
|
509
|
-
warnBits.push(
|
|
510
|
-
...ungated.map((r) => `⚠ ${r.url} — no CRON_SECRET / auth-header check; anyone with the URL could trigger it.`),
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
if (missing.length > 0) {
|
|
514
|
-
warnBits.push(
|
|
515
|
-
...missing.map((r) => `⚠ ${r.url} — scheduled in vercel.json but no matching route handler found.`),
|
|
516
|
-
);
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (warnBits.length > 0) {
|
|
520
|
-
return result("warn", "Schedules & jobs map", detail, [
|
|
521
|
-
...warnBits,
|
|
522
|
-
"Gate each scheduled route on CRON_SECRET (constant-time compare of the Authorization bearer).",
|
|
523
|
-
"— full map —",
|
|
524
|
-
...lines,
|
|
525
|
-
]);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Orphan cron routes (handler exists, not scheduled) are informational, not a warn.
|
|
529
|
-
const extra = [...lines];
|
|
530
|
-
if (orphanRoutes.length > 0) {
|
|
531
|
-
extra.push(
|
|
532
|
-
`Note: ${orphanRoutes.length} cron route(s) have a handler but aren't wired into vercel.json — they only run if triggered manually.`,
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
return result("pass", "Schedules & jobs map", detail, extra);
|
|
536
|
-
}
|
|
1
|
+
// Ship-Safe v1.1 — the OVERVIEW SCANNERS.
|
|
2
|
+
//
|
|
3
|
+
// Three READ-ONLY repo scans that hand the builder a MAP of what their app
|
|
4
|
+
// actually has. This is the "understand what you built" differentiator: an
|
|
5
|
+
// AI agent can ship a hundred routes in an afternoon, and the human has no
|
|
6
|
+
// idea which ones mutate data, who can reach them, what external services
|
|
7
|
+
// they call, or what runs unattended on a schedule. These scanners read the
|
|
8
|
+
// repo and answer those questions in plain language.
|
|
9
|
+
//
|
|
10
|
+
// 1. API surface map — every Next.js App Router route, its methods, and
|
|
11
|
+
// whether it looks auth-gated. ⚠ on a MUTATING route
|
|
12
|
+
// that looks UNauthenticated (the dangerous ones).
|
|
13
|
+
// 2. Agents & integrations map — external/LLM/3rd-party calls + which env
|
|
14
|
+
// keys back them. ⚠ if a secret looks reachable from
|
|
15
|
+
// the client or an unauthenticated path.
|
|
16
|
+
// 3. Schedules & jobs map — vercel.json crons + app/api/cron/* routes, their
|
|
17
|
+
// schedule, and whether each is gated. ⚠ on an
|
|
18
|
+
// ungated (publicly triggerable) cron.
|
|
19
|
+
//
|
|
20
|
+
// Every scanner returns the same `result(status, label, detail, extra)` shape
|
|
21
|
+
// the runner already renders. Node built-ins only; nothing here mutates the
|
|
22
|
+
// repo (it only READS files). Overall verdict is INFORMATIONAL — these emit
|
|
23
|
+
// `pass`/`warn`, never `fail`, so a surprising map never blocks a ship on its
|
|
24
|
+
// own (the v1 safety checks own the NO-GO).
|
|
25
|
+
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import { result, walkFiles, readText, relPath } from "./util.mjs";
|
|
29
|
+
|
|
30
|
+
// ===========================================================================
|
|
31
|
+
// shared helpers
|
|
32
|
+
// ===========================================================================
|
|
33
|
+
|
|
34
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
|
|
35
|
+
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
36
|
+
|
|
37
|
+
/** Find every App Router route handler (app/api/**\/route.ts|js). */
|
|
38
|
+
function routeFiles(cwd) {
|
|
39
|
+
const apiDir = path.join(cwd, "app", "api");
|
|
40
|
+
if (!existsSync(apiDir)) return [];
|
|
41
|
+
return walkFiles(apiDir, (abs) => /[\\/]route\.(ts|tsx|js|mjs)$/.test(abs)).sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turn an absolute app/api/.../route.ts path into its public URL path.
|
|
46
|
+
* app/api/sites/route.ts → /api/sites
|
|
47
|
+
* app/api/team/[memberId]/route.ts → /api/team/[memberId]
|
|
48
|
+
* app/api/[transport]/route.ts → /api/[transport] (=> the MCP server)
|
|
49
|
+
*/
|
|
50
|
+
function routeUrlFromFile(abs, cwd) {
|
|
51
|
+
const rel = relPath(abs, cwd); // app/api/.../route.ts
|
|
52
|
+
const segs = rel.split("/");
|
|
53
|
+
// drop leading "app" and the trailing "route.xx"
|
|
54
|
+
const parts = segs.slice(1, -1); // ["api", "sites"] etc.
|
|
55
|
+
return "/" + parts.join("/");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Which HTTP methods a route file exports (handles `export async function POST`,
|
|
59
|
+
* `export function GET`, and `export { handler as GET, handler as POST }`). */
|
|
60
|
+
function methodsExported(text) {
|
|
61
|
+
const found = new Set();
|
|
62
|
+
for (const m of HTTP_METHODS) {
|
|
63
|
+
// export (async)? function GET(...)
|
|
64
|
+
if (new RegExp(`export\\s+(?:async\\s+)?function\\s+${m}\\b`).test(text)) found.add(m);
|
|
65
|
+
// export const GET = ...
|
|
66
|
+
if (new RegExp(`export\\s+const\\s+${m}\\b`).test(text)) found.add(m);
|
|
67
|
+
// export { handler as GET } / export { x as POST, y as GET }
|
|
68
|
+
if (new RegExp(`\\bas\\s+${m}\\b`).test(text)) found.add(m);
|
|
69
|
+
}
|
|
70
|
+
return [...found].sort((a, b) => HTTP_METHODS.indexOf(a) - HTTP_METHODS.indexOf(b));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Tokens that signal a handler authenticates / authorizes the caller. Drawn
|
|
74
|
+
// from this repo's real conventions (currentAccount, getSession, the API-key
|
|
75
|
+
// resolver, the per-caller rate-limiter) but kept generic enough to catch the
|
|
76
|
+
// usual names an AI agent emits in any Next.js app.
|
|
77
|
+
const AUTH_TOKENS = [
|
|
78
|
+
/\bcurrentAccount\b/,
|
|
79
|
+
/\bgetSession\b/,
|
|
80
|
+
/\brequireAuth\b/,
|
|
81
|
+
/\brequireSession\b/,
|
|
82
|
+
/\bverifySession\b/,
|
|
83
|
+
/\bresolveCaller\b/, // app/lib/server/api-auth (key-or-keyless)
|
|
84
|
+
/\bresolveApiKeySecret\b/, // API-key auth
|
|
85
|
+
/\brateLimitCaller\b/, // per-caller (key/plan) limiter
|
|
86
|
+
/\bgetServerSession\b/, // next-auth idiom
|
|
87
|
+
/\bauth\(\)/, // next-auth v5 idiom
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Strip line (`// …`) and block (`/* … *\/`) comments from JS/TS source so a
|
|
92
|
+
* commented-out auth/secret token never counts as real gating. This is a
|
|
93
|
+
* heuristic stripper (it does not parse strings/regex literals), which is the
|
|
94
|
+
* right trade-off here: the auth/secret tokens we grep for are identifiers that
|
|
95
|
+
* never legitimately live inside a string literal, but they DO commonly live in
|
|
96
|
+
* commented-out code (e.g. the deferred `withMcpAuth` block in
|
|
97
|
+
* app/api/[transport]/route.ts references `resolveApiKeySecret` only in a
|
|
98
|
+
* comment). Removing comments first keeps that out of the gating decision.
|
|
99
|
+
*/
|
|
100
|
+
function stripComments(text) {
|
|
101
|
+
let out = "";
|
|
102
|
+
let i = 0;
|
|
103
|
+
const n = text.length;
|
|
104
|
+
while (i < n) {
|
|
105
|
+
const ch = text[i];
|
|
106
|
+
const next = text[i + 1];
|
|
107
|
+
// Block comment /* … */
|
|
108
|
+
if (ch === "/" && next === "*") {
|
|
109
|
+
const end = text.indexOf("*/", i + 2);
|
|
110
|
+
i = end === -1 ? n : end + 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Line comment // …
|
|
114
|
+
if (ch === "/" && next === "/") {
|
|
115
|
+
const end = text.indexOf("\n", i + 2);
|
|
116
|
+
i = end === -1 ? n : end; // keep the newline so line structure survives
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
out += ch;
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Does the handler reference an auth/session check? Comments are stripped first
|
|
126
|
+
* so commented-out gating never counts (see stripComments). */
|
|
127
|
+
function looksAuthGated(text) {
|
|
128
|
+
const code = stripComments(text);
|
|
129
|
+
return AUTH_TOKENS.some((re) => re.test(code));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Tokens signalling a route authorizes its caller via a CRON / shared secret
|
|
133
|
+
// (Vercel cron sends `Authorization: Bearer <CRON_SECRET>`; this repo also
|
|
134
|
+
// accepts x-cron-secret and compares constant-time). A route gated this way is
|
|
135
|
+
// NOT session-authed but is NOT publicly triggerable either — so the API
|
|
136
|
+
// surface map must not flag it as an "unauthenticated mutator".
|
|
137
|
+
const SECRET_GATE_TOKENS = [
|
|
138
|
+
/\bCRON_SECRET\b/,
|
|
139
|
+
/\btimingSafeEqual\b/,
|
|
140
|
+
/x-cron-secret/i,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** Does the handler gate on a CRON / shared secret (vs. an auth header it might
|
|
144
|
+
* read for other reasons)? We require a concrete secret token, not just the
|
|
145
|
+
* word "authorization", to avoid false "gated" calls. Comments are stripped
|
|
146
|
+
* first so commented-out gating never counts (see stripComments). */
|
|
147
|
+
function looksSecretGated(text) {
|
|
148
|
+
const code = stripComments(text);
|
|
149
|
+
return SECRET_GATE_TOKENS.some((re) => re.test(code));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A cron route is "gated" when it checks a CRON / shared secret — same precise
|
|
153
|
+
// secret-token detection the API surface map uses, so the two maps agree.
|
|
154
|
+
// (Declared here, next to looksSecretGated, so the shared scanners below — which
|
|
155
|
+
// run before section 3 in source order — can reference it; a `const` alias is
|
|
156
|
+
// not hoisted like a function declaration.)
|
|
157
|
+
const looksCronGated = looksSecretGated;
|
|
158
|
+
|
|
159
|
+
// ===========================================================================
|
|
160
|
+
// SHARED SCANNERS (pure data) — reused by both the `check` overview maps and
|
|
161
|
+
// the `brief` generator (PROJECT-BRIEF.md). These functions do the file
|
|
162
|
+
// reading + classification and return plain data; the `overview*` functions
|
|
163
|
+
// below wrap that data in the runner's `result()` shape, and brief.mjs renders
|
|
164
|
+
// the same data as markdown. ONE scan, two presentations — no duplicated logic.
|
|
165
|
+
// ===========================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Scan the API surface → structured rows + summary counts.
|
|
169
|
+
* @returns {{ rows: Array<{url,methods:string[],mutates,sessionGated,secretGated,devOnly}>,
|
|
170
|
+
* gatedCount:number, mutatingCount:number, dangerous:any[] }}
|
|
171
|
+
*/
|
|
172
|
+
export function scanApiSurface(cwd) {
|
|
173
|
+
const files = routeFiles(cwd);
|
|
174
|
+
const rows = [];
|
|
175
|
+
for (const abs of files) {
|
|
176
|
+
const text = readText(abs);
|
|
177
|
+
const url = routeUrlFromFile(abs, cwd);
|
|
178
|
+
const methods = methodsExported(text);
|
|
179
|
+
const mutates = methods.some((m) => MUTATING.has(m));
|
|
180
|
+
const sessionGated = looksAuthGated(text);
|
|
181
|
+
const secretGated = looksSecretGated(text);
|
|
182
|
+
const devOnly =
|
|
183
|
+
/NODE_ENV\s*===\s*["']production["']/.test(text) &&
|
|
184
|
+
/\b(?:404|"Not found"|Not found)\b/.test(text);
|
|
185
|
+
rows.push({ url, methods, mutates, sessionGated, secretGated, devOnly });
|
|
186
|
+
}
|
|
187
|
+
const gatedCount = rows.filter((r) => r.sessionGated || r.secretGated).length;
|
|
188
|
+
const mutatingCount = rows.filter((r) => r.mutates).length;
|
|
189
|
+
const dangerous = rows.filter(
|
|
190
|
+
(r) => r.mutates && !r.sessionGated && !r.secretGated && !r.devOnly,
|
|
191
|
+
);
|
|
192
|
+
return { rows, gatedCount, mutatingCount, dangerous };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** A short human tag for a single API row (shared by the map + the brief). */
|
|
196
|
+
export function apiRowTag(r) {
|
|
197
|
+
if (r.devOnly) return "dev-only (inert in prod)";
|
|
198
|
+
if (r.sessionGated) return "auth-gated";
|
|
199
|
+
if (r.secretGated) return "secret-gated (cron/shared)";
|
|
200
|
+
if (r.mutates) return "PUBLIC + mutates ⚠";
|
|
201
|
+
return "public (read-only)";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Scan integrations → label → { files:Set, keys:Set }, plus client-secret hits
|
|
206
|
+
* and whether an MCP server route was found.
|
|
207
|
+
* @returns {{ integrations: Map, clientSecretHits: Array<{file,key}>, mcpRouteFound: boolean }}
|
|
208
|
+
*/
|
|
209
|
+
export function scanIntegrations(cwd) {
|
|
210
|
+
const appDir = path.join(cwd, "app");
|
|
211
|
+
const integrations = new Map(); // label -> { files:Set, keys:Set }
|
|
212
|
+
const clientSecretHits = [];
|
|
213
|
+
let mcpRouteFound = false;
|
|
214
|
+
if (!existsSync(appDir)) return { integrations, clientSecretHits, mcpRouteFound };
|
|
215
|
+
|
|
216
|
+
const files = walkFiles(appDir, (abs) => /\.(ts|tsx|js|mjs)$/.test(abs));
|
|
217
|
+
function note(label, keys, file) {
|
|
218
|
+
if (!integrations.has(label)) integrations.set(label, { files: new Set(), keys: new Set() });
|
|
219
|
+
const e = integrations.get(label);
|
|
220
|
+
e.files.add(file);
|
|
221
|
+
for (const k of keys) e.keys.add(k);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const abs of files) {
|
|
225
|
+
const text = readText(abs);
|
|
226
|
+
if (!text) continue;
|
|
227
|
+
const rel = relPath(abs, cwd);
|
|
228
|
+
|
|
229
|
+
for (const h of INTEGRATION_HOSTS) {
|
|
230
|
+
if (text.includes(h.host)) note(h.label, h.keys, rel);
|
|
231
|
+
}
|
|
232
|
+
for (const s of INTEGRATION_SDKS) {
|
|
233
|
+
if (s.re.test(text)) {
|
|
234
|
+
note(s.label, s.keys, rel);
|
|
235
|
+
if (s.label.startsWith("MCP server")) mcpRouteFound = true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (isClientFile(text)) {
|
|
240
|
+
let m;
|
|
241
|
+
SECRET_ENV_RE.lastIndex = 0;
|
|
242
|
+
while ((m = SECRET_ENV_RE.exec(text)) !== null) {
|
|
243
|
+
const key = m[1];
|
|
244
|
+
if (key.startsWith("NEXT_PUBLIC_")) continue;
|
|
245
|
+
clientSecretHits.push({ file: rel, key });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return { integrations, clientSecretHits, mcpRouteFound };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Scan schedules/jobs → rows + derived buckets.
|
|
254
|
+
* @returns {{ rows: any[], ungated:any[], orphanRoutes:any[], missing:any[], cronCount:number }}
|
|
255
|
+
*/
|
|
256
|
+
export function scanSchedules(cwd) {
|
|
257
|
+
const crons = vercelCrons(cwd);
|
|
258
|
+
const allRoutes = routeFiles(cwd);
|
|
259
|
+
const cronRouteFiles = allRoutes.filter((abs) => {
|
|
260
|
+
const url = routeUrlFromFile(abs, cwd);
|
|
261
|
+
return url.startsWith("/api/cron") || crons.has(url);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const rows = [];
|
|
265
|
+
const seenUrls = new Set();
|
|
266
|
+
for (const abs of cronRouteFiles) {
|
|
267
|
+
const url = routeUrlFromFile(abs, cwd);
|
|
268
|
+
seenUrls.add(url);
|
|
269
|
+
const text = readText(abs);
|
|
270
|
+
rows.push({
|
|
271
|
+
url,
|
|
272
|
+
schedule: crons.get(url) || null,
|
|
273
|
+
gated: looksCronGated(text),
|
|
274
|
+
scheduled: crons.has(url),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
for (const [p, sched] of crons) {
|
|
278
|
+
if (!seenUrls.has(p)) {
|
|
279
|
+
rows.push({ url: p, schedule: sched, gated: null, scheduled: true, missing: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
rows.sort((a, b) => a.url.localeCompare(b.url));
|
|
283
|
+
|
|
284
|
+
const ungated = rows.filter((r) => r.gated === false);
|
|
285
|
+
const orphanRoutes = rows.filter((r) => !r.scheduled && !r.missing);
|
|
286
|
+
const missing = rows.filter((r) => r.missing);
|
|
287
|
+
return { rows, ungated, orphanRoutes, missing, cronCount: crons.size };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A short human tag for a single schedule row (shared by the map + the brief). */
|
|
291
|
+
export function scheduleRowTag(r) {
|
|
292
|
+
if (r.missing) return "no handler found ⚠";
|
|
293
|
+
if (r.gated === false) return "UNGATED ⚠ (publicly triggerable)";
|
|
294
|
+
if (r.gated) return "gated (CRON_SECRET)";
|
|
295
|
+
return "ungated?";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ===========================================================================
|
|
299
|
+
// 1. API SURFACE MAP
|
|
300
|
+
// ===========================================================================
|
|
301
|
+
// For each App Router route: URL path, exported HTTP methods, and whether it
|
|
302
|
+
// looks auth-gated. The dangerous case we flag is a route that MUTATES
|
|
303
|
+
// (POST/PUT/PATCH/DELETE) yet shows no sign of authenticating the caller —
|
|
304
|
+
// the classic "an AI agent wired up a write endpoint and forgot the gate".
|
|
305
|
+
//
|
|
306
|
+
// NOTE on gating: in this repo `proxy.ts` gates `/app/*` but its matcher
|
|
307
|
+
// EXCLUDES `/api/`, so API routes are NOT covered by the proxy — each handler
|
|
308
|
+
// must authenticate itself. We therefore judge each /api route by its own
|
|
309
|
+
// body. (A route under /app/* would be proxy-gated; we note that too.)
|
|
310
|
+
|
|
311
|
+
export function overviewApiSurface(cwd) {
|
|
312
|
+
const { rows, gatedCount, mutatingCount, dangerous } = scanApiSurface(cwd);
|
|
313
|
+
if (rows.length === 0) {
|
|
314
|
+
return result(
|
|
315
|
+
"pass",
|
|
316
|
+
"API surface map",
|
|
317
|
+
"No app/api/**/route files found — nothing to map.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Build the listing (capped so a big app stays readable).
|
|
322
|
+
const lines = [];
|
|
323
|
+
const CAP = 60;
|
|
324
|
+
for (const r of rows.slice(0, CAP)) {
|
|
325
|
+
const methodStr = r.methods.length ? r.methods.join(",") : "—";
|
|
326
|
+
lines.push(`${r.url} [${methodStr}] ${apiRowTag(r)}`);
|
|
327
|
+
}
|
|
328
|
+
if (rows.length > CAP) lines.push(`…and ${rows.length - CAP} more route(s)`);
|
|
329
|
+
|
|
330
|
+
const detail =
|
|
331
|
+
`${rows.length} route(s) · ${gatedCount} look gated (session or cron secret) · ` +
|
|
332
|
+
`${mutatingCount} mutate (write) · ${dangerous.length} mutate without any obvious gate.`;
|
|
333
|
+
|
|
334
|
+
if (dangerous.length > 0) {
|
|
335
|
+
// Surface the dangerous ones FIRST, then the full map below them.
|
|
336
|
+
const flagged = dangerous
|
|
337
|
+
.slice(0, 20)
|
|
338
|
+
.map((r) => `⚠ ${r.url} [${r.methods.join(",")}] — mutates but no auth/session check found`);
|
|
339
|
+
return result(
|
|
340
|
+
"warn",
|
|
341
|
+
"API surface map",
|
|
342
|
+
detail,
|
|
343
|
+
[
|
|
344
|
+
...flagged,
|
|
345
|
+
...(dangerous.length > 20 ? [`…and ${dangerous.length - 20} more flagged`] : []),
|
|
346
|
+
"Confirm each ⚠ route is meant to be public (some, like a rate-limited lead/contact",
|
|
347
|
+
"endpoint, legitimately are) — and that the others authenticate the caller before writing.",
|
|
348
|
+
"— full map —",
|
|
349
|
+
...lines,
|
|
350
|
+
],
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result("pass", "API surface map", detail, lines);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ===========================================================================
|
|
358
|
+
// 2. AGENTS & INTEGRATIONS MAP
|
|
359
|
+
// ===========================================================================
|
|
360
|
+
// What external services + LLMs the app talks to, and which env keys back
|
|
361
|
+
// them. We detect via (a) known host strings in fetch()/URL literals,
|
|
362
|
+
// (b) well-known SDK imports, (c) the MCP server route, then map each to the
|
|
363
|
+
// env key(s) it reads. The risk flag: a secret/LLM key that looks reachable
|
|
364
|
+
// from the CLIENT BUNDLE or an UNAUTHENTICATED route.
|
|
365
|
+
|
|
366
|
+
// host substring → { label, keys[] }. Keys are the env vars that typically
|
|
367
|
+
// back that integration in this kind of app (names only — never values).
|
|
368
|
+
const INTEGRATION_HOSTS = [
|
|
369
|
+
{ host: "api.openai.com", label: "OpenAI", keys: ["OPENAI_API_KEY"] },
|
|
370
|
+
{ host: "api.anthropic.com", label: "Anthropic (Claude)", keys: ["ANTHROPIC_API_KEY"] },
|
|
371
|
+
{ host: "api.perplexity.ai", label: "Perplexity", keys: ["PERPLEXITY_API_KEY"] },
|
|
372
|
+
{ host: "generativelanguage.googleapis.com", label: "Google Gemini", keys: ["GEMINI_API_KEY"] },
|
|
373
|
+
{ host: "api.stripe.com", label: "Stripe (HTTP)", keys: ["STRIPE_SECRET_KEY"] },
|
|
374
|
+
{ host: "api.resend.com", label: "Resend (email)", keys: ["RESEND_API_KEY"] },
|
|
375
|
+
{ host: "api.improvmx.com", label: "ImprovMX (email aliases)", keys: ["IMPROVMX_API_TOKEN"] },
|
|
376
|
+
{ host: "api.vercel.com", label: "Vercel API", keys: ["VERCEL_TOKEN"] },
|
|
377
|
+
{ host: "api.cal.com", label: "Cal.com (booking)", keys: ["CAL_API_KEY"] },
|
|
378
|
+
{ host: "crt.sh", label: "crt.sh (CT-log harvest)", keys: [] },
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
// SDK import → { label, keys[] }. Catches integrations that go through an SDK
|
|
382
|
+
// rather than a literal fetch URL (e.g. the Stripe + Resend SDKs).
|
|
383
|
+
const INTEGRATION_SDKS = [
|
|
384
|
+
{ re: /from\s+["']stripe["']/, label: "Stripe (SDK)", keys: ["STRIPE_SECRET_KEY"] },
|
|
385
|
+
{ re: /from\s+["']openai["']/, label: "OpenAI (SDK)", keys: ["OPENAI_API_KEY"] },
|
|
386
|
+
{ re: /from\s+["']@anthropic-ai\/sdk["']/, label: "Anthropic (SDK)", keys: ["ANTHROPIC_API_KEY"] },
|
|
387
|
+
{ re: /from\s+["']resend["']/, label: "Resend (SDK)", keys: ["RESEND_API_KEY"] },
|
|
388
|
+
{ re: /from\s+["']mcp-handler["']/, label: "MCP server (mcp-handler)", keys: [] },
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
// Env names that hold a SECRET (vs. public config). A match of one of these in
|
|
392
|
+
// a CLIENT-reachable file is the risk we flag. NEXT_PUBLIC_* is deliberately
|
|
393
|
+
// NOT secret (it's compiled into the browser bundle by design).
|
|
394
|
+
const SECRET_ENV_RE = /\bprocess\.env\.([A-Z0-9_]*(?:API_KEY|SECRET|TOKEN|PASSWORD|PRIVATE_KEY)[A-Z0-9_]*)\b/g;
|
|
395
|
+
|
|
396
|
+
/** A module is "client-reachable" if it (or the file declaring it) is a Client
|
|
397
|
+
* Component — i.e. carries the "use client" directive. Server Components,
|
|
398
|
+
* route handlers and app/lib/server/* never ship to the browser. */
|
|
399
|
+
function isClientFile(text) {
|
|
400
|
+
// The directive must be at the very top (first non-comment statement). A loose
|
|
401
|
+
// check is fine for a heuristic: look for it in the first ~400 chars.
|
|
402
|
+
return /^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*["']use client["']/.test(text.slice(0, 400));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function overviewIntegrations(cwd) {
|
|
406
|
+
const appDir = path.join(cwd, "app");
|
|
407
|
+
if (!existsSync(appDir)) {
|
|
408
|
+
return result("pass", "Agents & integrations map", "No app/ directory found — nothing to map.");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { integrations, clientSecretHits, mcpRouteFound } = scanIntegrations(cwd);
|
|
412
|
+
|
|
413
|
+
if (integrations.size === 0 && clientSecretHits.length === 0) {
|
|
414
|
+
return result(
|
|
415
|
+
"pass",
|
|
416
|
+
"Agents & integrations map",
|
|
417
|
+
"No external LLM / 3rd-party integrations detected in app/.",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Build the integration listing, alphabetical, with backing keys.
|
|
422
|
+
const labels = [...integrations.keys()].sort();
|
|
423
|
+
const lines = [];
|
|
424
|
+
for (const label of labels) {
|
|
425
|
+
const e = integrations.get(label);
|
|
426
|
+
const keys = [...e.keys];
|
|
427
|
+
const keyStr = keys.length ? ` — key: ${keys.join(", ")}` : " — no key (public endpoint)";
|
|
428
|
+
lines.push(`${label}${keyStr} (${e.files.size} file${e.files.size === 1 ? "" : "s"})`);
|
|
429
|
+
}
|
|
430
|
+
if (mcpRouteFound) {
|
|
431
|
+
lines.push("MCP server is EXPOSED at /api/mcp — model-callable tools; confirm its auth posture (keyless vs key-scoped).");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const detail = `${labels.length} integration(s) detected${mcpRouteFound ? " (incl. an MCP server)" : ""}.`;
|
|
435
|
+
|
|
436
|
+
// Risk flag: secret key reachable from the client bundle.
|
|
437
|
+
if (clientSecretHits.length > 0) {
|
|
438
|
+
const seen = new Set();
|
|
439
|
+
const flagged = [];
|
|
440
|
+
for (const h of clientSecretHits) {
|
|
441
|
+
const k = `${h.file}::${h.key}`;
|
|
442
|
+
if (seen.has(k)) continue;
|
|
443
|
+
seen.add(k);
|
|
444
|
+
flagged.push(`⚠ ${h.file} reads process.env.${h.key} in a "use client" component — it would ship in the browser bundle.`);
|
|
445
|
+
}
|
|
446
|
+
return result(
|
|
447
|
+
"warn",
|
|
448
|
+
"Agents & integrations map",
|
|
449
|
+
detail,
|
|
450
|
+
[
|
|
451
|
+
...flagged.slice(0, 15),
|
|
452
|
+
"Move secret reads to a server module (app/lib/server/* or a route handler). Only NEXT_PUBLIC_* belongs client-side.",
|
|
453
|
+
"— integrations —",
|
|
454
|
+
...lines,
|
|
455
|
+
],
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return result("pass", "Agents & integrations map", detail, lines);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ===========================================================================
|
|
463
|
+
// 3. SCHEDULES & JOBS MAP
|
|
464
|
+
// ===========================================================================
|
|
465
|
+
// Cron / scheduled jobs: vercel.json "crons" (path + schedule) plus every
|
|
466
|
+
// app/api/cron/* route. For each: route, schedule (if declared), and whether
|
|
467
|
+
// it's GATED (checks CRON_SECRET / does a secret compare / inspects the
|
|
468
|
+
// authorization header). The flag: a cron route that looks publicly
|
|
469
|
+
// triggerable — anyone who knows the URL could fire it.
|
|
470
|
+
|
|
471
|
+
/** Parse vercel.json crons → Map(path → schedule). Best-effort; tolerates a
|
|
472
|
+
* missing or malformed file. */
|
|
473
|
+
function vercelCrons(cwd) {
|
|
474
|
+
const map = new Map();
|
|
475
|
+
const file = path.join(cwd, "vercel.json");
|
|
476
|
+
if (!existsSync(file)) return map;
|
|
477
|
+
let json;
|
|
478
|
+
try {
|
|
479
|
+
json = JSON.parse(readFileSync(file, "utf8"));
|
|
480
|
+
} catch {
|
|
481
|
+
return map;
|
|
482
|
+
}
|
|
483
|
+
const crons = Array.isArray(json?.crons) ? json.crons : [];
|
|
484
|
+
for (const c of crons) {
|
|
485
|
+
if (c && typeof c.path === "string") map.set(c.path, c.schedule || "(no schedule)");
|
|
486
|
+
}
|
|
487
|
+
return map;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function overviewSchedules(cwd) {
|
|
491
|
+
const { rows, ungated, orphanRoutes, missing, cronCount } = scanSchedules(cwd);
|
|
492
|
+
|
|
493
|
+
if (rows.length === 0) {
|
|
494
|
+
return result("pass", "Schedules & jobs map", "No cron routes or vercel.json crons found.");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const lines = [];
|
|
498
|
+
for (const r of rows) {
|
|
499
|
+
const sched = r.schedule ? r.schedule : "not in vercel.json";
|
|
500
|
+
lines.push(`${r.url} [${sched}] ${scheduleRowTag(r)}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const detail =
|
|
504
|
+
`${rows.length} job(s) · ${cronCount} scheduled in vercel.json · ` +
|
|
505
|
+
`${ungated.length} ungated · ${orphanRoutes.length} cron route(s) not wired to a schedule.`;
|
|
506
|
+
|
|
507
|
+
const warnBits = [];
|
|
508
|
+
if (ungated.length > 0) {
|
|
509
|
+
warnBits.push(
|
|
510
|
+
...ungated.map((r) => `⚠ ${r.url} — no CRON_SECRET / auth-header check; anyone with the URL could trigger it.`),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (missing.length > 0) {
|
|
514
|
+
warnBits.push(
|
|
515
|
+
...missing.map((r) => `⚠ ${r.url} — scheduled in vercel.json but no matching route handler found.`),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (warnBits.length > 0) {
|
|
520
|
+
return result("warn", "Schedules & jobs map", detail, [
|
|
521
|
+
...warnBits,
|
|
522
|
+
"Gate each scheduled route on CRON_SECRET (constant-time compare of the Authorization bearer).",
|
|
523
|
+
"— full map —",
|
|
524
|
+
...lines,
|
|
525
|
+
]);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Orphan cron routes (handler exists, not scheduled) are informational, not a warn.
|
|
529
|
+
const extra = [...lines];
|
|
530
|
+
if (orphanRoutes.length > 0) {
|
|
531
|
+
extra.push(
|
|
532
|
+
`Note: ${orphanRoutes.length} cron route(s) have a handler but aren't wired into vercel.json — they only run if triggered manually.`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return result("pass", "Schedules & jobs map", detail, extra);
|
|
536
|
+
}
|