auditai-scan 0.1.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 +14 -0
- package/dist/auditai-scan.mjs +1811 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1811 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// packages/scanner/src/bin.ts
|
|
4
|
+
import { resolve as resolve3 } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
|
|
7
|
+
// packages/core/src/coverage.ts
|
|
8
|
+
function summarizeCoverage(findings) {
|
|
9
|
+
const s = {
|
|
10
|
+
checked: findings.length,
|
|
11
|
+
verified: 0,
|
|
12
|
+
confirmed: 0,
|
|
13
|
+
unverified: 0,
|
|
14
|
+
candidates: 0,
|
|
15
|
+
suppressed: 0
|
|
16
|
+
};
|
|
17
|
+
for (const f of findings) {
|
|
18
|
+
switch (f.status) {
|
|
19
|
+
case "verified":
|
|
20
|
+
s.verified += 1;
|
|
21
|
+
break;
|
|
22
|
+
case "confirmed":
|
|
23
|
+
case "fix_proposed":
|
|
24
|
+
case "fix_applied":
|
|
25
|
+
s.confirmed += 1;
|
|
26
|
+
break;
|
|
27
|
+
case "unverified":
|
|
28
|
+
s.unverified += 1;
|
|
29
|
+
break;
|
|
30
|
+
case "suppressed":
|
|
31
|
+
s.suppressed += 1;
|
|
32
|
+
break;
|
|
33
|
+
case "candidate":
|
|
34
|
+
case "likely":
|
|
35
|
+
s.candidates += 1;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
function renderCoverageStatement(s, riskClass = "authorization/RLS") {
|
|
42
|
+
return `Checked ${s.checked} risk${s.checked === 1 ? "" : "s"} in class ${riskClass}. Verified: ${s.verified}. Confirmed (no sandbox): ${s.confirmed}. Unverified: ${s.unverified}.`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// packages/core/src/finding.ts
|
|
46
|
+
var FINDING_STATUSES = [
|
|
47
|
+
"candidate",
|
|
48
|
+
"likely",
|
|
49
|
+
"confirmed",
|
|
50
|
+
"unverified",
|
|
51
|
+
"fix_proposed",
|
|
52
|
+
"fix_applied",
|
|
53
|
+
"verified",
|
|
54
|
+
"suppressed"
|
|
55
|
+
];
|
|
56
|
+
var TRANSITIONS = {
|
|
57
|
+
candidate: ["likely", "unverified", "suppressed"],
|
|
58
|
+
likely: ["confirmed", "unverified", "suppressed"],
|
|
59
|
+
confirmed: ["fix_proposed", "suppressed"],
|
|
60
|
+
unverified: ["likely", "confirmed", "suppressed"],
|
|
61
|
+
fix_proposed: ["fix_applied", "confirmed", "suppressed"],
|
|
62
|
+
fix_applied: ["verified", "fix_proposed", "suppressed"],
|
|
63
|
+
verified: ["suppressed"],
|
|
64
|
+
suppressed: []
|
|
65
|
+
};
|
|
66
|
+
function canTransition(from, to) {
|
|
67
|
+
return TRANSITIONS[from].includes(to);
|
|
68
|
+
}
|
|
69
|
+
var IllegalTransitionError = class extends Error {
|
|
70
|
+
constructor(findingId, from, to) {
|
|
71
|
+
super(`Finding ${findingId}: illegal transition ${from} -> ${to}`);
|
|
72
|
+
this.findingId = findingId;
|
|
73
|
+
this.from = from;
|
|
74
|
+
this.to = to;
|
|
75
|
+
}
|
|
76
|
+
name = "IllegalTransitionError";
|
|
77
|
+
};
|
|
78
|
+
function transition(finding2, to, opts = {}) {
|
|
79
|
+
if (!canTransition(finding2.status, to)) {
|
|
80
|
+
throw new IllegalTransitionError(finding2.id, finding2.status, to);
|
|
81
|
+
}
|
|
82
|
+
const requiresEvidence = to === "confirmed" || to === "verified";
|
|
83
|
+
if (requiresEvidence && !opts.evidence) {
|
|
84
|
+
throw new Error(`Finding ${finding2.id}: transition to ${to} requires evidence`);
|
|
85
|
+
}
|
|
86
|
+
if (to === "verified" && !isVerificationPassing(finding2.verification)) {
|
|
87
|
+
throw new Error(`Finding ${finding2.id}: cannot mark verified without a passing verification`);
|
|
88
|
+
}
|
|
89
|
+
const evidence = opts.evidence ? [...finding2.evidence, opts.evidence] : finding2.evidence;
|
|
90
|
+
return {
|
|
91
|
+
...finding2,
|
|
92
|
+
status: to,
|
|
93
|
+
evidence,
|
|
94
|
+
updatedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function isVerificationPassing(v) {
|
|
98
|
+
return v !== void 0 && v.securityTestBefore === "failed" && v.securityTestAfter === "passed" && (v.existingTests === "passed" || v.existingTests === "skipped") && v.rescan === "passed";
|
|
99
|
+
}
|
|
100
|
+
function isDeterministic(finding2) {
|
|
101
|
+
return finding2.evidence.some((e) => e.kind === "rule" && e.data?.deterministic === true);
|
|
102
|
+
}
|
|
103
|
+
var DEFAULT_BLOCKING_POLICY = {
|
|
104
|
+
minConfidence: 0.8,
|
|
105
|
+
blockOnConfirmedSeverities: ["high", "critical"],
|
|
106
|
+
blockDeterministicCritical: true
|
|
107
|
+
};
|
|
108
|
+
var CONFIRMED_OR_LATER = ["confirmed", "fix_proposed", "fix_applied"];
|
|
109
|
+
function isBlocking(finding2, policy = DEFAULT_BLOCKING_POLICY) {
|
|
110
|
+
if (finding2.status === "suppressed") return false;
|
|
111
|
+
if (finding2.status === "verified") return true;
|
|
112
|
+
if (finding2.confidence < policy.minConfidence) return false;
|
|
113
|
+
if (CONFIRMED_OR_LATER.includes(finding2.status) && policy.blockOnConfirmedSeverities.includes(finding2.severity)) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
if (policy.blockDeterministicCritical && finding2.severity === "critical" && isDeterministic(finding2) && finding2.status !== "unverified") {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// packages/scanner/src/format.ts
|
|
123
|
+
function loc(f) {
|
|
124
|
+
const first = f.evidence.find((e) => e.locations && e.locations.length > 0)?.locations?.[0];
|
|
125
|
+
return first ? `${first.file}:${first.line}` : "";
|
|
126
|
+
}
|
|
127
|
+
function where(f) {
|
|
128
|
+
const seen = /* @__PURE__ */ new Set();
|
|
129
|
+
for (const e of f.evidence) for (const l of e.locations ?? []) seen.add(`${l.file}:${l.line}`);
|
|
130
|
+
return [...seen].join(", ");
|
|
131
|
+
}
|
|
132
|
+
function formatFinding(f) {
|
|
133
|
+
const why = f.evidence.find((e) => e.kind === "rule")?.summary ?? "";
|
|
134
|
+
const lines = [
|
|
135
|
+
`${f.id} ${f.status.toUpperCase()} ${f.severity.toUpperCase()} ${f.title}`,
|
|
136
|
+
` Entry ${f.entrypoints.join(", ")} ${loc(f)}`,
|
|
137
|
+
` Path ${f.path.join(" -> ")}`,
|
|
138
|
+
` Why ${why}`,
|
|
139
|
+
` Where ${where(f)}`,
|
|
140
|
+
` Rule ${f.ruleId} \xB7 ${(f.cwe ?? []).join(", ")} \xB7 confidence ${f.confidence.toFixed(2)}`
|
|
141
|
+
];
|
|
142
|
+
if (f.status === "suppressed") {
|
|
143
|
+
const s = [...f.evidence].reverse().find((e) => e.data?.suppressed === true);
|
|
144
|
+
if (s) lines.push(` Ignored ${s.summary}`);
|
|
145
|
+
}
|
|
146
|
+
return lines.join("\n");
|
|
147
|
+
}
|
|
148
|
+
function formatScanText(r) {
|
|
149
|
+
const s = r.summary;
|
|
150
|
+
const out = [
|
|
151
|
+
`Audit AI scan ${s.root}`,
|
|
152
|
+
`Files ${s.files} \xB7 Routes ${s.routes} \xB7 Supabase queries ${s.queries} \xB7 Tables with RLS ${s.tablesWithRls}/${s.tablesKnown} \xB7 Rules ${s.rules}`,
|
|
153
|
+
""
|
|
154
|
+
];
|
|
155
|
+
if (r.findings.length === 0) {
|
|
156
|
+
out.push(
|
|
157
|
+
`No findings. ${s.routes} route${s.routes === 1 ? "" : "s"} and ${s.queries} quer${s.queries === 1 ? "y" : "ies"} checked.`
|
|
158
|
+
);
|
|
159
|
+
} else {
|
|
160
|
+
for (const f of r.findings) out.push(formatFinding(f), "");
|
|
161
|
+
}
|
|
162
|
+
out.push("", r.coverageStatement);
|
|
163
|
+
const likely = r.findings.filter((f) => f.status === "likely" || f.status === "candidate").length;
|
|
164
|
+
if (likely > 0) {
|
|
165
|
+
out.push(
|
|
166
|
+
`${likely} likely finding${likely === 1 ? "" : "s"} need${likely === 1 ? "s" : ""} reasoning and verification (audit explain / audit verify, coming in later weeks).`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
out.push(r.blocking ? "Blocking findings present." : "No blocking findings.");
|
|
170
|
+
for (const w of s.warnings) out.push(`warning: ${w}`);
|
|
171
|
+
return `${out.join("\n")}
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// packages/scanner/src/scan.ts
|
|
176
|
+
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
177
|
+
import { join as join3 } from "node:path";
|
|
178
|
+
|
|
179
|
+
// packages/graph/src/graph.ts
|
|
180
|
+
var SecurityGraph = class {
|
|
181
|
+
nodes = /* @__PURE__ */ new Map();
|
|
182
|
+
edges = [];
|
|
183
|
+
addNode(node) {
|
|
184
|
+
const existing = this.nodes.get(node.id);
|
|
185
|
+
if (existing) return existing;
|
|
186
|
+
this.nodes.set(node.id, node);
|
|
187
|
+
return node;
|
|
188
|
+
}
|
|
189
|
+
addEdge(from, to, kind) {
|
|
190
|
+
if (!this.nodes.has(from) || !this.nodes.has(to))
|
|
191
|
+
throw new Error(`edge ${kind} references unknown node: ${from} -> ${to}`);
|
|
192
|
+
if (!this.edges.some((e) => e.from === from && e.to === to && e.kind === kind))
|
|
193
|
+
this.edges.push({ from, to, kind });
|
|
194
|
+
}
|
|
195
|
+
nodesOfKind(kind) {
|
|
196
|
+
return [...this.nodes.values()].filter((n) => n.kind === kind);
|
|
197
|
+
}
|
|
198
|
+
/** Targets of edges leaving `id`, optionally filtered by edge kind. */
|
|
199
|
+
out(id, kind) {
|
|
200
|
+
return this.edges.filter((e) => e.from === id && (kind === void 0 || e.kind === kind)).map((e) => this.nodes.get(e.to)).filter((n) => n !== void 0);
|
|
201
|
+
}
|
|
202
|
+
/** Sources of edges entering `id`, optionally filtered by edge kind. */
|
|
203
|
+
in(id, kind) {
|
|
204
|
+
return this.edges.filter((e) => e.to === id && (kind === void 0 || e.kind === kind)).map((e) => this.nodes.get(e.from)).filter((n) => n !== void 0);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
function buildGraph(model) {
|
|
208
|
+
const g = new SecurityGraph();
|
|
209
|
+
const tableInfo = new Map(model.tables.map((t) => [t.table, t]));
|
|
210
|
+
const tableNode = (table) => {
|
|
211
|
+
const info = tableInfo.get(table.toLowerCase());
|
|
212
|
+
const data = {
|
|
213
|
+
table,
|
|
214
|
+
known: info !== void 0,
|
|
215
|
+
rlsEnabled: info?.rlsEnabled ?? false,
|
|
216
|
+
policies: info?.policies ?? [],
|
|
217
|
+
policyDetails: info?.policyDetails ?? [],
|
|
218
|
+
columns: info?.columns ?? []
|
|
219
|
+
};
|
|
220
|
+
const node = g.addNode(
|
|
221
|
+
info ? {
|
|
222
|
+
id: `table:${table}`,
|
|
223
|
+
kind: "Table",
|
|
224
|
+
label: `public.${table}`,
|
|
225
|
+
data: { ...data },
|
|
226
|
+
location: info.location
|
|
227
|
+
} : { id: `table:${table}`, kind: "Table", label: `public.${table}`, data: { ...data } }
|
|
228
|
+
);
|
|
229
|
+
for (const p of data.policies) {
|
|
230
|
+
const pn = g.addNode({
|
|
231
|
+
id: `policy:${table}:${p}`,
|
|
232
|
+
kind: "RLSPolicy",
|
|
233
|
+
label: p,
|
|
234
|
+
data: { table, name: p }
|
|
235
|
+
});
|
|
236
|
+
g.addEdge(node.id, pn.id, "GUARDED_BY");
|
|
237
|
+
}
|
|
238
|
+
return node;
|
|
239
|
+
};
|
|
240
|
+
for (const h of model.routes) {
|
|
241
|
+
const route = g.addNode({
|
|
242
|
+
id: `route:${h.entry}`,
|
|
243
|
+
kind: "Route",
|
|
244
|
+
label: h.entry,
|
|
245
|
+
data: { method: h.method, route: h.route, kind: h.kind }
|
|
246
|
+
});
|
|
247
|
+
const hd = {
|
|
248
|
+
kind: h.kind,
|
|
249
|
+
entry: h.entry,
|
|
250
|
+
method: h.method,
|
|
251
|
+
route: h.route,
|
|
252
|
+
inputs: h.inputs,
|
|
253
|
+
metadataAccesses: h.metadataAccesses
|
|
254
|
+
};
|
|
255
|
+
const handler = g.addNode({
|
|
256
|
+
id: `handler:${h.location.file}:${h.location.line}`,
|
|
257
|
+
kind: "Handler",
|
|
258
|
+
label: `${h.entry} handler`,
|
|
259
|
+
data: { ...hd },
|
|
260
|
+
location: h.location
|
|
261
|
+
});
|
|
262
|
+
g.addEdge(route.id, handler.id, "HANDLES");
|
|
263
|
+
for (const i of h.inputs) {
|
|
264
|
+
const s = g.addNode({
|
|
265
|
+
id: `source:${handler.id}:${i.kind}:${i.name}`,
|
|
266
|
+
kind: "Source",
|
|
267
|
+
label: `${i.kind}:${i.name}`,
|
|
268
|
+
data: { kind: i.kind, name: i.name },
|
|
269
|
+
location: i.location
|
|
270
|
+
});
|
|
271
|
+
g.addEdge(handler.id, s.id, "READS");
|
|
272
|
+
}
|
|
273
|
+
for (const a of h.authChecks) {
|
|
274
|
+
const an = g.addNode({
|
|
275
|
+
id: `auth:${a.file}:${a.line}`,
|
|
276
|
+
kind: "AuthCheck",
|
|
277
|
+
label: "auth check",
|
|
278
|
+
data: {},
|
|
279
|
+
location: a
|
|
280
|
+
});
|
|
281
|
+
g.addEdge(handler.id, an.id, "AUTHENTICATED_BY");
|
|
282
|
+
}
|
|
283
|
+
h.queries.forEach((q, i) => {
|
|
284
|
+
const qd = {
|
|
285
|
+
operation: q.operation,
|
|
286
|
+
filters: q.filters,
|
|
287
|
+
payload: q.payload,
|
|
288
|
+
text: q.text,
|
|
289
|
+
table: q.table
|
|
290
|
+
};
|
|
291
|
+
const qn = g.addNode({
|
|
292
|
+
id: `query:${q.location.file}:${q.location.line}:${i}`,
|
|
293
|
+
kind: "Query",
|
|
294
|
+
label: `${q.table}.${q.operation}`,
|
|
295
|
+
data: { ...qd },
|
|
296
|
+
location: q.location
|
|
297
|
+
});
|
|
298
|
+
g.addEdge(handler.id, qn.id, "CALLS");
|
|
299
|
+
const cd = { kind: q.client, name: q.clientName ?? "inline" };
|
|
300
|
+
const cn = g.addNode(
|
|
301
|
+
q.clientLocation ? {
|
|
302
|
+
id: `client:${cd.name}:${cd.kind}`,
|
|
303
|
+
kind: "Client",
|
|
304
|
+
label: `${cd.name} (${cd.kind})`,
|
|
305
|
+
data: { ...cd },
|
|
306
|
+
location: q.clientLocation
|
|
307
|
+
} : {
|
|
308
|
+
id: `client:${cd.name}:${cd.kind}`,
|
|
309
|
+
kind: "Client",
|
|
310
|
+
label: `${cd.name} (${cd.kind})`,
|
|
311
|
+
data: { ...cd }
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
g.addEdge(qn.id, cn.id, "USES_CLIENT");
|
|
315
|
+
g.addEdge(qn.id, tableNode(q.table).id, "TARGETS");
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return g;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// packages/parser/src/discover.ts
|
|
322
|
+
import { readdirSync, statSync } from "node:fs";
|
|
323
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
324
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
325
|
+
"node_modules",
|
|
326
|
+
".next",
|
|
327
|
+
"dist",
|
|
328
|
+
".git",
|
|
329
|
+
".vercel",
|
|
330
|
+
".supabase",
|
|
331
|
+
"coverage",
|
|
332
|
+
".turbo",
|
|
333
|
+
"build",
|
|
334
|
+
"out"
|
|
335
|
+
]);
|
|
336
|
+
var SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
337
|
+
function globToRegExp(glob) {
|
|
338
|
+
const g = glob.replace(/^\.\//, "").replace(/\/$/, "");
|
|
339
|
+
let re = "";
|
|
340
|
+
let i = 0;
|
|
341
|
+
while (i < g.length) {
|
|
342
|
+
if (g.startsWith("**/", i)) {
|
|
343
|
+
re += "(?:.*/)?";
|
|
344
|
+
i += 3;
|
|
345
|
+
} else if (g.startsWith("/**", i) && i + 3 === g.length) {
|
|
346
|
+
re += "(?:/.*)?";
|
|
347
|
+
i += 3;
|
|
348
|
+
} else if (g.startsWith("**", i)) {
|
|
349
|
+
re += ".*";
|
|
350
|
+
i += 2;
|
|
351
|
+
} else if (g[i] === "*") {
|
|
352
|
+
re += "[^/]*";
|
|
353
|
+
i += 1;
|
|
354
|
+
} else if (g[i] === "?") {
|
|
355
|
+
re += "[^/]";
|
|
356
|
+
i += 1;
|
|
357
|
+
} else {
|
|
358
|
+
re += (g[i] ?? "").replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
359
|
+
i += 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return new RegExp(`^${re}(?:/.*)?$`);
|
|
363
|
+
}
|
|
364
|
+
function isIgnored(rel, patterns) {
|
|
365
|
+
return patterns.some((p) => p.test(rel));
|
|
366
|
+
}
|
|
367
|
+
function discoverFiles(root, extraSqlDirs = [], ignoreGlobs = []) {
|
|
368
|
+
const source = [];
|
|
369
|
+
const sql = [];
|
|
370
|
+
const ignore = ignoreGlobs.map(globToRegExp);
|
|
371
|
+
const walk2 = (dir, sqlOnly = false) => {
|
|
372
|
+
let entries;
|
|
373
|
+
try {
|
|
374
|
+
entries = readdirSync(dir);
|
|
375
|
+
} catch {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
for (const name of entries) {
|
|
379
|
+
const full = join(dir, name);
|
|
380
|
+
let isDir;
|
|
381
|
+
try {
|
|
382
|
+
isDir = statSync(full).isDirectory();
|
|
383
|
+
} catch {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (isDir) {
|
|
387
|
+
const relDir = relative(root, full).split(sep).join("/");
|
|
388
|
+
if (!SKIP_DIRS.has(name) && !name.startsWith(".") && !isIgnored(relDir, ignore))
|
|
389
|
+
walk2(full, sqlOnly);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
393
|
+
if (isIgnored(rel, ignore)) continue;
|
|
394
|
+
if (name.endsWith(".sql")) {
|
|
395
|
+
if (!sql.includes(rel)) sql.push(rel);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (sqlOnly || name.endsWith(".d.ts")) continue;
|
|
399
|
+
if (SOURCE_EXT.test(name)) source.push(rel);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
walk2(root);
|
|
403
|
+
for (const extra of extraSqlDirs) walk2(resolve(root, extra), true);
|
|
404
|
+
source.sort();
|
|
405
|
+
sql.sort();
|
|
406
|
+
return { source, sql };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// packages/parser/src/model.ts
|
|
410
|
+
var HTTP_METHODS = [
|
|
411
|
+
"GET",
|
|
412
|
+
"POST",
|
|
413
|
+
"PUT",
|
|
414
|
+
"PATCH",
|
|
415
|
+
"DELETE",
|
|
416
|
+
"HEAD",
|
|
417
|
+
"OPTIONS"
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
// packages/parser/src/nextjs.ts
|
|
421
|
+
import ts2 from "typescript";
|
|
422
|
+
|
|
423
|
+
// packages/parser/src/ast.ts
|
|
424
|
+
import ts from "typescript";
|
|
425
|
+
function parseSource(fileName, text) {
|
|
426
|
+
const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
427
|
+
return ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true, kind);
|
|
428
|
+
}
|
|
429
|
+
function lineOf(sf, node) {
|
|
430
|
+
return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
431
|
+
}
|
|
432
|
+
function walk(node, visit) {
|
|
433
|
+
if (visit(node) === false) return;
|
|
434
|
+
node.forEachChild((child) => {
|
|
435
|
+
walk(child, visit);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
function collect(root, pred) {
|
|
439
|
+
const out = [];
|
|
440
|
+
walk(root, (n) => {
|
|
441
|
+
if (pred(n)) out.push(n);
|
|
442
|
+
return void 0;
|
|
443
|
+
});
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
function stringLiteralValue(e) {
|
|
447
|
+
if (!e) return null;
|
|
448
|
+
if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return e.text;
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
function unwrap(e) {
|
|
452
|
+
let cur = e;
|
|
453
|
+
for (; ; ) {
|
|
454
|
+
if (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAsExpression(cur) || ts.isTypeAssertionExpression(cur) || ts.isSatisfiesExpression(cur)) {
|
|
455
|
+
cur = cur.expression;
|
|
456
|
+
} else {
|
|
457
|
+
return cur;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function flattenChain(call) {
|
|
462
|
+
const segments = [];
|
|
463
|
+
let expr = call;
|
|
464
|
+
for (; ; ) {
|
|
465
|
+
expr = unwrap(expr);
|
|
466
|
+
if (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression)) {
|
|
467
|
+
segments.unshift({ name: expr.expression.name.text, args: expr.arguments, node: expr });
|
|
468
|
+
expr = expr.expression.expression;
|
|
469
|
+
} else {
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { root: expr, segments };
|
|
474
|
+
}
|
|
475
|
+
function isChainTail(call) {
|
|
476
|
+
let p = call.parent;
|
|
477
|
+
while (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p) || ts.isNonNullExpression(p) || ts.isAsExpression(p))) {
|
|
478
|
+
p = p.parent;
|
|
479
|
+
}
|
|
480
|
+
if (p && ts.isPropertyAccessExpression(p) && p.parent && ts.isCallExpression(p.parent)) {
|
|
481
|
+
return p.parent.expression !== p;
|
|
482
|
+
}
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
function hasExportModifier(node) {
|
|
486
|
+
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0;
|
|
487
|
+
return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
488
|
+
}
|
|
489
|
+
function exportedFunctions(sf) {
|
|
490
|
+
const out = [];
|
|
491
|
+
for (const stmt of sf.statements) {
|
|
492
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name && hasExportModifier(stmt)) {
|
|
493
|
+
out.push({ name: stmt.name.text, fn: stmt, node: stmt });
|
|
494
|
+
} else if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
|
|
495
|
+
for (const d of stmt.declarationList.declarations) {
|
|
496
|
+
if (ts.isIdentifier(d.name) && d.initializer) {
|
|
497
|
+
const init = unwrap(d.initializer);
|
|
498
|
+
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
|
|
499
|
+
out.push({ name: d.name.text, fn: init, node: d });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
function boundNames(name) {
|
|
508
|
+
if (ts.isIdentifier(name)) return [name.text];
|
|
509
|
+
const out = [];
|
|
510
|
+
for (const el of name.elements) {
|
|
511
|
+
if (ts.isOmittedExpression(el)) continue;
|
|
512
|
+
out.push(...boundNames(el.name));
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
function identifiersIn(node) {
|
|
517
|
+
const out = /* @__PURE__ */ new Set();
|
|
518
|
+
walk(node, (n) => {
|
|
519
|
+
if (ts.isIdentifier(n)) out.add(n.text);
|
|
520
|
+
return void 0;
|
|
521
|
+
});
|
|
522
|
+
return out;
|
|
523
|
+
}
|
|
524
|
+
var IGNORE_RE = /auditai:ignore(?:\s+([A-Za-z0-9_.*-]+))?(?:\s*(?:--|:)\s*(.*))?/;
|
|
525
|
+
function parseIgnoreDirectives(sf, pos) {
|
|
526
|
+
const out = [];
|
|
527
|
+
const ranges = ts.getLeadingCommentRanges(sf.text, pos) ?? [];
|
|
528
|
+
for (const r of ranges) {
|
|
529
|
+
const text = sf.text.slice(r.pos, r.end);
|
|
530
|
+
const m = IGNORE_RE.exec(text);
|
|
531
|
+
if (!m) continue;
|
|
532
|
+
const reason = (m[2] ?? "").replace(/\*\/\s*$/, "").trim();
|
|
533
|
+
out.push({
|
|
534
|
+
ruleId: m[1] ?? "*",
|
|
535
|
+
reason: reason || "(no reason given)",
|
|
536
|
+
line: sf.getLineAndCharacterOfPosition(r.pos).line + 1
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
function enclosingStatement(node) {
|
|
542
|
+
let cur = node;
|
|
543
|
+
while (cur.parent && !ts.isSourceFile(cur.parent)) cur = cur.parent;
|
|
544
|
+
return cur;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// packages/parser/src/nextjs.ts
|
|
548
|
+
var ROUTE_FILE = /^(?:(.*?)\/)?(?:src\/)?app\/(.*?)\/?route\.(ts|tsx|js|jsx|mjs)$/;
|
|
549
|
+
function appRootOf(rel) {
|
|
550
|
+
const m = /^(?:(.*?)\/)?(?:src\/)?app\//.exec(rel);
|
|
551
|
+
if (!m) return null;
|
|
552
|
+
const prefix = m[1] ?? "";
|
|
553
|
+
return prefix;
|
|
554
|
+
}
|
|
555
|
+
function routeFromFile(rel) {
|
|
556
|
+
const m = ROUTE_FILE.exec(rel);
|
|
557
|
+
if (!m) return null;
|
|
558
|
+
const dir = m[2] ?? "";
|
|
559
|
+
const parts = dir.split("/").filter((p) => p.length > 0 && !p.startsWith("(") && !p.startsWith("@"));
|
|
560
|
+
return `/${parts.join("/")}`;
|
|
561
|
+
}
|
|
562
|
+
function routeHandlersIn(sf) {
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const ex of exportedFunctions(sf)) {
|
|
565
|
+
if (HTTP_METHODS.includes(ex.name)) {
|
|
566
|
+
out.push({ method: ex.name, exported: ex });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
function fileDirective(sf) {
|
|
572
|
+
const first = sf.statements[0];
|
|
573
|
+
if (first && ts2.isExpressionStatement(first) && ts2.isStringLiteral(first.expression))
|
|
574
|
+
return first.expression.text;
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
function isServerActionFile(sf) {
|
|
578
|
+
return fileDirective(sf) === "use server";
|
|
579
|
+
}
|
|
580
|
+
function isClientComponentFile(sf) {
|
|
581
|
+
return fileDirective(sf) === "use client";
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// packages/parser/src/parse-project.ts
|
|
585
|
+
import { readFileSync } from "node:fs";
|
|
586
|
+
import { join as join2, posix, resolve as resolve2 } from "node:path";
|
|
587
|
+
import ts4 from "typescript";
|
|
588
|
+
|
|
589
|
+
// packages/parser/src/rls.ts
|
|
590
|
+
var CONSTRAINT_WORDS = /* @__PURE__ */ new Set([
|
|
591
|
+
"primary",
|
|
592
|
+
"unique",
|
|
593
|
+
"constraint",
|
|
594
|
+
"foreign",
|
|
595
|
+
"check",
|
|
596
|
+
"exclude",
|
|
597
|
+
"like"
|
|
598
|
+
]);
|
|
599
|
+
function lineAt(text, index) {
|
|
600
|
+
let line = 1;
|
|
601
|
+
for (let i = 0; i < index && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1;
|
|
602
|
+
return line;
|
|
603
|
+
}
|
|
604
|
+
function balanced(text, open) {
|
|
605
|
+
if (text[open] !== "(") return null;
|
|
606
|
+
let depth = 0;
|
|
607
|
+
for (let i = open; i < text.length; i += 1) {
|
|
608
|
+
const ch = text[i];
|
|
609
|
+
if (ch === "(") depth += 1;
|
|
610
|
+
else if (ch === ")") {
|
|
611
|
+
depth -= 1;
|
|
612
|
+
if (depth === 0) return { inner: text.slice(open + 1, i), end: i };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
function splitTopLevel(text) {
|
|
618
|
+
const out = [];
|
|
619
|
+
let depth = 0;
|
|
620
|
+
let cur = "";
|
|
621
|
+
for (const ch of text) {
|
|
622
|
+
if (ch === "(") depth += 1;
|
|
623
|
+
if (ch === ")") depth -= 1;
|
|
624
|
+
if (ch === "," && depth === 0) {
|
|
625
|
+
out.push(cur);
|
|
626
|
+
cur = "";
|
|
627
|
+
} else cur += ch;
|
|
628
|
+
}
|
|
629
|
+
if (cur.trim()) out.push(cur);
|
|
630
|
+
return out.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
631
|
+
}
|
|
632
|
+
var IDENT = String.raw`(?:public\.)?"?([A-Za-z_][A-Za-z0-9_]*)"?`;
|
|
633
|
+
var CREATE_TABLE = new RegExp(
|
|
634
|
+
String.raw`^create\s+table\s+(?:if\s+not\s+exists\s+)?${IDENT}\s*\(`,
|
|
635
|
+
"i"
|
|
636
|
+
);
|
|
637
|
+
var ENABLE_RLS = new RegExp(
|
|
638
|
+
String.raw`^alter\s+table\s+(?:only\s+)?(?:if\s+exists\s+)?${IDENT}\s+(enable|disable)\s+row\s+level\s+security`,
|
|
639
|
+
"i"
|
|
640
|
+
);
|
|
641
|
+
var CREATE_POLICY = new RegExp(
|
|
642
|
+
String.raw`^create\s+policy\s+("([^"]+)"|\S+)\s+on\s+${IDENT}`,
|
|
643
|
+
"i"
|
|
644
|
+
);
|
|
645
|
+
function parseSqlForRls(rel, text, into) {
|
|
646
|
+
const stripped = text.replace(/--[^\n]*/g, "").replace(/\$[A-Za-z_]*\$[\s\S]*?\$[A-Za-z_]*\$/g, "$$body$$");
|
|
647
|
+
const ensure = (name, index) => {
|
|
648
|
+
const key = name.toLowerCase();
|
|
649
|
+
let t = into.get(key);
|
|
650
|
+
if (!t) {
|
|
651
|
+
t = {
|
|
652
|
+
table: key,
|
|
653
|
+
rlsEnabled: false,
|
|
654
|
+
policies: [],
|
|
655
|
+
policyDetails: [],
|
|
656
|
+
columns: [],
|
|
657
|
+
location: { file: rel, line: lineAt(stripped, index) }
|
|
658
|
+
};
|
|
659
|
+
into.set(key, t);
|
|
660
|
+
}
|
|
661
|
+
return t;
|
|
662
|
+
};
|
|
663
|
+
let offset = 0;
|
|
664
|
+
for (const raw of stripped.split(";")) {
|
|
665
|
+
const start = offset;
|
|
666
|
+
offset += raw.length + 1;
|
|
667
|
+
const stmt = raw.trim();
|
|
668
|
+
if (!stmt) continue;
|
|
669
|
+
const at = start + raw.indexOf(stmt);
|
|
670
|
+
const ct = CREATE_TABLE.exec(stmt);
|
|
671
|
+
if (ct?.[1]) {
|
|
672
|
+
const t = ensure(ct[1], at);
|
|
673
|
+
const open = stmt.indexOf("(", ct[0].length - 1);
|
|
674
|
+
const body = balanced(stmt, open);
|
|
675
|
+
if (body) {
|
|
676
|
+
for (const part of splitTopLevel(body.inner)) {
|
|
677
|
+
const first = part.split(/\s+/)[0]?.replace(/^"|"$/g, "") ?? "";
|
|
678
|
+
if (first && !CONSTRAINT_WORDS.has(first.toLowerCase()))
|
|
679
|
+
t.columns.push(first.toLowerCase());
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const rls = ENABLE_RLS.exec(stmt);
|
|
685
|
+
if (rls?.[1] && rls[2]) {
|
|
686
|
+
ensure(rls[1], at).rlsEnabled = rls[2].toLowerCase() === "enable";
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const cp = CREATE_POLICY.exec(stmt);
|
|
690
|
+
if (cp?.[1] && cp[3]) {
|
|
691
|
+
const t = ensure(cp[3], at);
|
|
692
|
+
const name = cp[2] ?? cp[1];
|
|
693
|
+
const rest = stmt.slice(cp[0].length);
|
|
694
|
+
const cmdMatch = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(rest);
|
|
695
|
+
const command = cmdMatch?.[1]?.toLowerCase() ?? "all";
|
|
696
|
+
const rolesMatch = /\bto\s+([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)/i.exec(
|
|
697
|
+
rest
|
|
698
|
+
);
|
|
699
|
+
const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.toLowerCase()) : [];
|
|
700
|
+
let using = null;
|
|
701
|
+
let check = null;
|
|
702
|
+
const u = /\busing\s*\(/i.exec(rest);
|
|
703
|
+
if (u) using = balanced(rest, u.index + u[0].length - 1)?.inner.trim() ?? null;
|
|
704
|
+
const c = /\bwith\s+check\s*\(/i.exec(rest);
|
|
705
|
+
if (c) check = balanced(rest, c.index + c[0].length - 1)?.inner.trim() ?? null;
|
|
706
|
+
t.policies.push(name);
|
|
707
|
+
t.policyDetails.push({
|
|
708
|
+
name,
|
|
709
|
+
command,
|
|
710
|
+
roles,
|
|
711
|
+
using,
|
|
712
|
+
check,
|
|
713
|
+
location: { file: rel, line: lineAt(stripped, at) }
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// packages/parser/src/supabase.ts
|
|
720
|
+
import ts3 from "typescript";
|
|
721
|
+
var CREATE_CLIENT_CALLEES = /^(createClient|createServerClient|createBrowserClient)$/;
|
|
722
|
+
function isCreateClientCall(call, sf) {
|
|
723
|
+
const callee = call.expression;
|
|
724
|
+
if (ts3.isIdentifier(callee)) return CREATE_CLIENT_CALLEES.test(callee.text);
|
|
725
|
+
if (ts3.isPropertyAccessExpression(callee)) return CREATE_CLIENT_CALLEES.test(callee.name.text);
|
|
726
|
+
return CREATE_CLIENT_CALLEES.test(callee.getText(sf));
|
|
727
|
+
}
|
|
728
|
+
function resolveArgText(expr, sf) {
|
|
729
|
+
const u = unwrap(expr);
|
|
730
|
+
if (!ts3.isIdentifier(u)) return expr.getText(sf);
|
|
731
|
+
let scope = expr.parent;
|
|
732
|
+
while (scope) {
|
|
733
|
+
if (ts3.isFunctionLike(scope) || ts3.isBlock(scope) || ts3.isSourceFile(scope)) {
|
|
734
|
+
const decl = collect(scope, ts3.isVariableDeclaration).find(
|
|
735
|
+
(d) => ts3.isIdentifier(d.name) && d.name.text === u.text && d.initializer
|
|
736
|
+
);
|
|
737
|
+
if (decl?.initializer) return `${u.text} = ${decl.initializer.getText(sf)}`;
|
|
738
|
+
}
|
|
739
|
+
scope = scope.parent;
|
|
740
|
+
}
|
|
741
|
+
return expr.getText(sf);
|
|
742
|
+
}
|
|
743
|
+
function classifyCreateClientCall(call, sf) {
|
|
744
|
+
const callee = call.expression.getText(sf);
|
|
745
|
+
const args = call.arguments.map((a) => resolveArgText(a, sf));
|
|
746
|
+
const keyArg = args[1] ?? "";
|
|
747
|
+
const optArg = args[2] ?? "";
|
|
748
|
+
if (/createServerClient|createBrowserClient/.test(callee)) {
|
|
749
|
+
return {
|
|
750
|
+
kind: "user_scoped",
|
|
751
|
+
evidence: `${callee}() from @supabase/ssr acts as the signed-in user; RLS applies`
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
if (/SERVICE_ROLE|service_role|SECRET_KEY|SB_SECRET|sb_secret/i.test(keyArg)) {
|
|
755
|
+
return {
|
|
756
|
+
kind: "service_role",
|
|
757
|
+
evidence: `key ${keyArg} is a service-role secret; RLS is bypassed`
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
const anonKey = /ANON|PUBLISHABLE|anon|publishable/.test(keyArg);
|
|
761
|
+
if (anonKey && /Authorization|headers/i.test(optArg)) {
|
|
762
|
+
return {
|
|
763
|
+
kind: "user_scoped",
|
|
764
|
+
evidence: "anon key with a per-request Authorization header; RLS applies as the caller"
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
if (anonKey) {
|
|
768
|
+
return {
|
|
769
|
+
kind: "anon",
|
|
770
|
+
evidence: "anon key without a user token; RLS applies as the anonymous role"
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
return { kind: "unknown", evidence: `could not classify key argument ${keyArg || "(none)"}` };
|
|
774
|
+
}
|
|
775
|
+
var AUTH_CALL = /\.auth\.(getUser|getSession|getClaims)\s*\(/;
|
|
776
|
+
function analyzeModule(rel, sf) {
|
|
777
|
+
const imports = /* @__PURE__ */ new Map();
|
|
778
|
+
for (const stmt of sf.statements) {
|
|
779
|
+
if (!ts3.isImportDeclaration(stmt) || !ts3.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
780
|
+
const spec = stmt.moduleSpecifier.text;
|
|
781
|
+
const clause = stmt.importClause;
|
|
782
|
+
if (!clause) continue;
|
|
783
|
+
if (clause.name) imports.set(clause.name.text, spec);
|
|
784
|
+
const nb = clause.namedBindings;
|
|
785
|
+
if (nb && ts3.isNamedImports(nb)) for (const el of nb.elements) imports.set(el.name.text, spec);
|
|
786
|
+
if (nb && ts3.isNamespaceImport(nb)) imports.set(nb.name.text, spec);
|
|
787
|
+
}
|
|
788
|
+
const clientFactories = [];
|
|
789
|
+
const authHelpers = [];
|
|
790
|
+
for (const ex of exportedFunctions(sf)) {
|
|
791
|
+
const text = ex.fn.getText(sf);
|
|
792
|
+
const location = { file: rel, line: lineOf(sf, ex.node) };
|
|
793
|
+
if (AUTH_CALL.test(text)) {
|
|
794
|
+
authHelpers.push({
|
|
795
|
+
name: ex.name,
|
|
796
|
+
location,
|
|
797
|
+
evidence: "calls supabase auth.getUser/getSession/getClaims"
|
|
798
|
+
});
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
const creates = collect(ex.fn, ts3.isCallExpression).filter((c) => isCreateClientCall(c, sf));
|
|
802
|
+
const first = creates[0];
|
|
803
|
+
if (first) {
|
|
804
|
+
const { kind, evidence } = classifyCreateClientCall(first, sf);
|
|
805
|
+
clientFactories.push({ name: ex.name, kind, location, evidence });
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
return { file: rel, clientFactories, authHelpers, imports };
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// packages/parser/src/parse-project.ts
|
|
812
|
+
var FILTER_METHODS = /* @__PURE__ */ new Set([
|
|
813
|
+
"eq",
|
|
814
|
+
"neq",
|
|
815
|
+
"gt",
|
|
816
|
+
"gte",
|
|
817
|
+
"lt",
|
|
818
|
+
"lte",
|
|
819
|
+
"like",
|
|
820
|
+
"ilike",
|
|
821
|
+
"is",
|
|
822
|
+
"in",
|
|
823
|
+
"contains",
|
|
824
|
+
"containedBy",
|
|
825
|
+
"match",
|
|
826
|
+
"filter",
|
|
827
|
+
"not",
|
|
828
|
+
"or",
|
|
829
|
+
"textSearch"
|
|
830
|
+
]);
|
|
831
|
+
var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["insert", "update", "upsert"]);
|
|
832
|
+
var OPERATIONS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete", "upsert"]);
|
|
833
|
+
var PUBLIC_SECRET_ENV = /process\.env\.NEXT_PUBLIC_[A-Z0-9_]*(?:SERVICE_ROLE|SECRET)[A-Z0-9_]*/g;
|
|
834
|
+
function resolveImport(spec, fromRel, files) {
|
|
835
|
+
let base;
|
|
836
|
+
const bases = [];
|
|
837
|
+
if (spec.startsWith("@/") || spec.startsWith("~/")) {
|
|
838
|
+
base = spec.slice(2);
|
|
839
|
+
const appRoot = appRootOf(fromRel);
|
|
840
|
+
if (appRoot) bases.push(posix.join(appRoot, base), posix.join(appRoot, "src", base));
|
|
841
|
+
bases.push(base, `src/${base}`);
|
|
842
|
+
} else if (spec.startsWith(".")) {
|
|
843
|
+
base = posix.normalize(posix.join(posix.dirname(fromRel), spec));
|
|
844
|
+
bases.push(base);
|
|
845
|
+
} else return null;
|
|
846
|
+
for (const b of bases) {
|
|
847
|
+
for (const c of [
|
|
848
|
+
`${b}.ts`,
|
|
849
|
+
`${b}.tsx`,
|
|
850
|
+
`${b}.js`,
|
|
851
|
+
`${b}.jsx`,
|
|
852
|
+
`${b}/index.ts`,
|
|
853
|
+
`${b}/index.tsx`,
|
|
854
|
+
b
|
|
855
|
+
]) {
|
|
856
|
+
if (files.has(c)) return c;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
function analyzeHandler(ctx) {
|
|
862
|
+
const { rel, sf, fn, facts, registry, files } = ctx;
|
|
863
|
+
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
864
|
+
const factories = /* @__PURE__ */ new Map();
|
|
865
|
+
const authHelpers = /* @__PURE__ */ new Map();
|
|
866
|
+
for (const [local, spec] of facts.imports) {
|
|
867
|
+
const target = resolveImport(spec, rel, files);
|
|
868
|
+
const tf = target ? registry.get(target) : void 0;
|
|
869
|
+
if (tf) {
|
|
870
|
+
const cf = tf.clientFactories.find((c) => c.name === local);
|
|
871
|
+
if (cf) factories.set(local, cf);
|
|
872
|
+
const ah = tf.authHelpers.find((a) => a.name === local);
|
|
873
|
+
if (ah) authHelpers.set(local, ah);
|
|
874
|
+
} else if (spec.startsWith(".") || spec.startsWith("@/") || spec.startsWith("~/")) {
|
|
875
|
+
let matched = false;
|
|
876
|
+
for (const f of registry.values()) {
|
|
877
|
+
const cf = f.clientFactories.find((c) => c.name === local);
|
|
878
|
+
if (cf) {
|
|
879
|
+
factories.set(local, cf);
|
|
880
|
+
matched = true;
|
|
881
|
+
}
|
|
882
|
+
const ah = f.authHelpers.find((a) => a.name === local);
|
|
883
|
+
if (ah) {
|
|
884
|
+
authHelpers.set(local, ah);
|
|
885
|
+
matched = true;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if (matched)
|
|
889
|
+
ctx.warnings.push(`unresolved import "${spec}" in ${rel}; matched "${local}" by name`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
for (const cf of facts.clientFactories) factories.set(cf.name, cf);
|
|
893
|
+
for (const ah of facts.authHelpers) authHelpers.set(ah.name, ah);
|
|
894
|
+
const body = fn.body ?? fn;
|
|
895
|
+
const firstParam = fn.parameters[0];
|
|
896
|
+
const reqName = firstParam && ts4.isIdentifier(firstParam.name) ? firstParam.name.text : null;
|
|
897
|
+
const inputs = [];
|
|
898
|
+
const inputNames = /* @__PURE__ */ new Set(["params", "searchParams"]);
|
|
899
|
+
const addInput = (kind, name, n, bind) => {
|
|
900
|
+
if (!inputs.some((i) => i.kind === kind && i.name === name))
|
|
901
|
+
inputs.push({ kind, name, location: loc2(n) });
|
|
902
|
+
if (bind) inputNames.add(name);
|
|
903
|
+
};
|
|
904
|
+
if (ctx.kind === "server_action") {
|
|
905
|
+
for (const p of fn.parameters)
|
|
906
|
+
for (const nm of boundNames(p.name)) addInput("action_arg", nm, p, true);
|
|
907
|
+
}
|
|
908
|
+
walk(body, (n) => {
|
|
909
|
+
if (ts4.isVariableDeclaration(n) && n.initializer) {
|
|
910
|
+
const init = unwrap(n.initializer);
|
|
911
|
+
const text = init.getText(sf);
|
|
912
|
+
if (/^(params|context\.params|ctx\.params|props\.params)$/.test(text)) {
|
|
913
|
+
for (const nm of boundNames(n.name)) addInput("route_param", nm, n, true);
|
|
914
|
+
} else if (ts4.isCallExpression(init) && /\.(json|formData|text)\(\)$/.test(text) && (reqName ? text.startsWith(`${reqName}.`) : /^(req|request)\./.test(text))) {
|
|
915
|
+
for (const nm of boundNames(n.name)) addInput("body", nm, n, true);
|
|
916
|
+
} else if (/searchParams\.get\(|\.searchParams$|^new URL\(/.test(text)) {
|
|
917
|
+
for (const nm of boundNames(n.name)) addInput("query", nm, n, true);
|
|
918
|
+
} else if (/headers\.get\(/.test(text)) {
|
|
919
|
+
for (const nm of boundNames(n.name)) addInput("header", nm, n, true);
|
|
920
|
+
} else if (ts4.isIdentifier(init) && inputNames.has(init.text)) {
|
|
921
|
+
for (const nm of boundNames(n.name)) addInput("body", nm, n, true);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if (ts4.isPropertyAccessExpression(n) && ts4.isIdentifier(n.expression) && n.expression.text === "params") {
|
|
925
|
+
addInput("route_param", n.name.text, n, false);
|
|
926
|
+
}
|
|
927
|
+
return void 0;
|
|
928
|
+
});
|
|
929
|
+
const derived = (e) => {
|
|
930
|
+
for (const id of identifiersIn(e)) if (inputNames.has(id)) return true;
|
|
931
|
+
return /^(params|body|query|searchParams)\b/.test(e.getText(sf));
|
|
932
|
+
};
|
|
933
|
+
const isWholeInput = (e) => {
|
|
934
|
+
const u = unwrap(e);
|
|
935
|
+
if (ts4.isIdentifier(u)) return inputNames.has(u.text);
|
|
936
|
+
if (ts4.isObjectLiteralExpression(u)) {
|
|
937
|
+
return u.properties.some(
|
|
938
|
+
(p) => ts4.isSpreadAssignment(p) && ts4.isIdentifier(unwrap(p.expression)) && inputNames.has(unwrap(p.expression).text)
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
return false;
|
|
942
|
+
};
|
|
943
|
+
const classifyClientCall = (call) => {
|
|
944
|
+
if (ts4.isIdentifier(call.expression)) {
|
|
945
|
+
const f = factories.get(call.expression.text);
|
|
946
|
+
if (f) return { kind: f.kind, name: f.name, location: f.location };
|
|
947
|
+
}
|
|
948
|
+
if (isCreateClientCall(call, sf)) {
|
|
949
|
+
const c = classifyCreateClientCall(call, sf);
|
|
950
|
+
return { kind: c.kind, name: call.expression.getText(sf), location: loc2(call) };
|
|
951
|
+
}
|
|
952
|
+
return null;
|
|
953
|
+
};
|
|
954
|
+
const clients = /* @__PURE__ */ new Map();
|
|
955
|
+
const authChecks = [];
|
|
956
|
+
for (const call of collect(body, ts4.isCallExpression)) {
|
|
957
|
+
const calleeText = call.expression.getText(sf);
|
|
958
|
+
if (/\.auth\.(getUser|getSession|getClaims)$/.test(calleeText)) authChecks.push(loc2(call));
|
|
959
|
+
else if (ts4.isIdentifier(call.expression) && authHelpers.has(call.expression.text))
|
|
960
|
+
authChecks.push(loc2(call));
|
|
961
|
+
}
|
|
962
|
+
for (const decl of collect(body, ts4.isVariableDeclaration)) {
|
|
963
|
+
if (!decl.initializer || !ts4.isIdentifier(decl.name)) continue;
|
|
964
|
+
const init = unwrap(decl.initializer);
|
|
965
|
+
if (!ts4.isCallExpression(init)) continue;
|
|
966
|
+
const binding = classifyClientCall(init);
|
|
967
|
+
if (binding) clients.set(decl.name.text, binding);
|
|
968
|
+
}
|
|
969
|
+
const metadataAccesses = [];
|
|
970
|
+
for (const pa of collect(body, ts4.isPropertyAccessExpression)) {
|
|
971
|
+
const inner = pa.expression;
|
|
972
|
+
if (ts4.isPropertyAccessExpression(inner) && (inner.name.text === "user_metadata" || inner.name.text === "app_metadata")) {
|
|
973
|
+
metadataAccesses.push({ path: pa.getText(sf), bucket: inner.name.text, location: loc2(pa) });
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const queries = [];
|
|
977
|
+
const seen = /* @__PURE__ */ new Set();
|
|
978
|
+
for (const call of collect(body, ts4.isCallExpression)) {
|
|
979
|
+
if (!isChainTail(call)) continue;
|
|
980
|
+
const chain = flattenChain(call);
|
|
981
|
+
const fromIdx = chain.segments.findIndex((s) => s.name === "from");
|
|
982
|
+
const rpcIdx = chain.segments.findIndex((s) => s.name === "rpc");
|
|
983
|
+
const anchorIdx = fromIdx >= 0 ? fromIdx : rpcIdx;
|
|
984
|
+
const anchor = chain.segments[anchorIdx];
|
|
985
|
+
if (anchorIdx < 0 || !anchor) continue;
|
|
986
|
+
if (seen.has(anchor.node.pos)) continue;
|
|
987
|
+
seen.add(anchor.node.pos);
|
|
988
|
+
let client = "unknown";
|
|
989
|
+
let clientName = null;
|
|
990
|
+
let clientLocation = null;
|
|
991
|
+
const root = unwrap(chain.root);
|
|
992
|
+
let binding = null;
|
|
993
|
+
if (ts4.isIdentifier(root)) {
|
|
994
|
+
binding = clients.get(root.text) ?? null;
|
|
995
|
+
if (!binding) clientName = root.text;
|
|
996
|
+
} else if (ts4.isCallExpression(root)) {
|
|
997
|
+
binding = classifyClientCall(root);
|
|
998
|
+
}
|
|
999
|
+
if (binding) {
|
|
1000
|
+
client = binding.kind;
|
|
1001
|
+
clientName = binding.name;
|
|
1002
|
+
clientLocation = binding.location;
|
|
1003
|
+
}
|
|
1004
|
+
const after = chain.segments.slice(anchorIdx + 1);
|
|
1005
|
+
let operation = fromIdx >= 0 ? "unknown" : "rpc";
|
|
1006
|
+
let payload = null;
|
|
1007
|
+
for (const s of after) {
|
|
1008
|
+
if (OPERATIONS.has(s.name)) {
|
|
1009
|
+
operation = s.name;
|
|
1010
|
+
const arg = s.args[0];
|
|
1011
|
+
if (WRITE_OPERATIONS.has(operation) && arg) {
|
|
1012
|
+
payload = {
|
|
1013
|
+
text: arg.getText(sf).replace(/\s+/g, " ").slice(0, 200),
|
|
1014
|
+
inputDerived: derived(arg),
|
|
1015
|
+
wholeInput: isWholeInput(arg)
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
break;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
const filters = [];
|
|
1022
|
+
for (const s of after) {
|
|
1023
|
+
if (!FILTER_METHODS.has(s.name)) continue;
|
|
1024
|
+
const first = s.args[0];
|
|
1025
|
+
if (s.name === "match" && first && ts4.isObjectLiteralExpression(first)) {
|
|
1026
|
+
for (const p of first.properties) {
|
|
1027
|
+
if (ts4.isPropertyAssignment(p)) {
|
|
1028
|
+
filters.push({
|
|
1029
|
+
method: "match",
|
|
1030
|
+
column: p.name.getText(sf).replace(/['"]/g, ""),
|
|
1031
|
+
valueText: p.initializer.getText(sf),
|
|
1032
|
+
inputDerived: derived(p.initializer)
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
const val = s.args[1];
|
|
1039
|
+
filters.push({
|
|
1040
|
+
method: s.name,
|
|
1041
|
+
column: stringLiteralValue(first),
|
|
1042
|
+
valueText: val ? val.getText(sf) : "",
|
|
1043
|
+
inputDerived: val ? derived(val) : false
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
queries.push({
|
|
1047
|
+
table: stringLiteralValue(anchor.args[0]) ?? "(dynamic)",
|
|
1048
|
+
operation,
|
|
1049
|
+
client,
|
|
1050
|
+
clientName,
|
|
1051
|
+
clientLocation,
|
|
1052
|
+
filters,
|
|
1053
|
+
payload,
|
|
1054
|
+
location: loc2(anchor.node),
|
|
1055
|
+
text: call.getText(sf).replace(/\s+/g, " ").slice(0, 200)
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
const entry = ctx.kind === "route" ? `${ctx.method} ${ctx.route}` : `server action ${ctx.route}`;
|
|
1059
|
+
const stmt = enclosingStatement(ctx.node);
|
|
1060
|
+
const ignores = parseIgnoreDirectives(sf, stmt.getFullStart()).map((d) => ({
|
|
1061
|
+
ruleId: d.ruleId,
|
|
1062
|
+
reason: d.reason,
|
|
1063
|
+
location: { file: rel, line: d.line }
|
|
1064
|
+
}));
|
|
1065
|
+
return {
|
|
1066
|
+
kind: ctx.kind,
|
|
1067
|
+
route: ctx.route,
|
|
1068
|
+
method: ctx.method,
|
|
1069
|
+
entry,
|
|
1070
|
+
location: loc2(ctx.node),
|
|
1071
|
+
inputs,
|
|
1072
|
+
authChecks,
|
|
1073
|
+
queries,
|
|
1074
|
+
metadataAccesses,
|
|
1075
|
+
ignores
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function findExposures(rel, sf) {
|
|
1079
|
+
const out = [];
|
|
1080
|
+
const text = sf.text;
|
|
1081
|
+
for (const m of text.matchAll(PUBLIC_SECRET_ENV)) {
|
|
1082
|
+
const line = sf.getLineAndCharacterOfPosition(m.index ?? 0).line + 1;
|
|
1083
|
+
out.push({
|
|
1084
|
+
kind: "public_env_service_role",
|
|
1085
|
+
location: { file: rel, line },
|
|
1086
|
+
evidence: `${m[0]} is inlined into the browser bundle by Next.js because of the NEXT_PUBLIC_ prefix`
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
if (isClientComponentFile(sf)) {
|
|
1090
|
+
for (const call of collect(sf, ts4.isCallExpression)) {
|
|
1091
|
+
if (!isCreateClientCall(call, sf)) continue;
|
|
1092
|
+
const c = classifyCreateClientCall(call, sf);
|
|
1093
|
+
if (c.kind === "service_role") {
|
|
1094
|
+
out.push({
|
|
1095
|
+
kind: "service_role_in_client_component",
|
|
1096
|
+
location: { file: rel, line: lineOf(sf, call) },
|
|
1097
|
+
evidence: `"use client" file creates a Supabase client with a service-role key: ${c.evidence}`
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (out.some((x) => x.kind === "service_role_in_client_component")) {
|
|
1103
|
+
return out.filter((x) => x.kind !== "public_env_service_role");
|
|
1104
|
+
}
|
|
1105
|
+
return out;
|
|
1106
|
+
}
|
|
1107
|
+
function parseProject(rootInput, opts = {}) {
|
|
1108
|
+
const root = resolve2(rootInput);
|
|
1109
|
+
const { source, sql } = discoverFiles(root, opts.sqlDirs ?? [], opts.ignore ?? []);
|
|
1110
|
+
const warnings = [];
|
|
1111
|
+
const sources = /* @__PURE__ */ new Map();
|
|
1112
|
+
for (const rel of source) {
|
|
1113
|
+
try {
|
|
1114
|
+
sources.set(rel, parseSource(rel, readFileSync(join2(root, rel), "utf8")));
|
|
1115
|
+
} catch (e) {
|
|
1116
|
+
warnings.push(`could not read ${rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const registry = /* @__PURE__ */ new Map();
|
|
1120
|
+
for (const [rel, sf] of sources) registry.set(rel, analyzeModule(rel, sf));
|
|
1121
|
+
const tables = /* @__PURE__ */ new Map();
|
|
1122
|
+
for (const rel of sql) {
|
|
1123
|
+
try {
|
|
1124
|
+
parseSqlForRls(rel, readFileSync(resolve2(root, rel), "utf8"), tables);
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
warnings.push(`could not read ${rel}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
const files = new Set(source);
|
|
1130
|
+
const routes = [];
|
|
1131
|
+
const exposures = [];
|
|
1132
|
+
const fileIgnores = {};
|
|
1133
|
+
for (const [rel, sf] of sources) {
|
|
1134
|
+
const top = parseIgnoreDirectives(sf, 0).map((d) => ({
|
|
1135
|
+
ruleId: d.ruleId,
|
|
1136
|
+
reason: d.reason,
|
|
1137
|
+
location: { file: rel, line: d.line }
|
|
1138
|
+
}));
|
|
1139
|
+
if (top.length > 0) fileIgnores[rel] = top;
|
|
1140
|
+
exposures.push(...findExposures(rel, sf));
|
|
1141
|
+
const facts = registry.get(rel) ?? analyzeModule(rel, sf);
|
|
1142
|
+
const common = { rel, sf, facts, registry, files, warnings };
|
|
1143
|
+
const route = routeFromFile(rel);
|
|
1144
|
+
if (route) {
|
|
1145
|
+
for (const { method, exported } of routeHandlersIn(sf)) {
|
|
1146
|
+
routes.push(
|
|
1147
|
+
analyzeHandler({
|
|
1148
|
+
...common,
|
|
1149
|
+
kind: "route",
|
|
1150
|
+
route,
|
|
1151
|
+
method,
|
|
1152
|
+
fn: exported.fn,
|
|
1153
|
+
node: exported.node
|
|
1154
|
+
})
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
} else if (isServerActionFile(sf)) {
|
|
1158
|
+
for (const ex of exportedFunctions(sf)) {
|
|
1159
|
+
routes.push(
|
|
1160
|
+
analyzeHandler({
|
|
1161
|
+
...common,
|
|
1162
|
+
kind: "server_action",
|
|
1163
|
+
route: ex.name,
|
|
1164
|
+
method: "ACTION",
|
|
1165
|
+
fn: ex.fn,
|
|
1166
|
+
node: ex.node
|
|
1167
|
+
})
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
routes.sort((a, b) => a.entry.localeCompare(b.entry));
|
|
1173
|
+
const all = [...registry.values()];
|
|
1174
|
+
return {
|
|
1175
|
+
root,
|
|
1176
|
+
files: [...source, ...sql],
|
|
1177
|
+
routes,
|
|
1178
|
+
clientFactories: all.flatMap((f) => f.clientFactories),
|
|
1179
|
+
authHelpers: all.flatMap((f) => f.authHelpers),
|
|
1180
|
+
tables: [...tables.values()],
|
|
1181
|
+
exposures,
|
|
1182
|
+
fileIgnores,
|
|
1183
|
+
warnings
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// packages/rules/src/packs/supabase-authorization.ts
|
|
1188
|
+
var SCOPE_COLUMNS = /* @__PURE__ */ new Set([
|
|
1189
|
+
"tenant_id",
|
|
1190
|
+
"org_id",
|
|
1191
|
+
"organization_id",
|
|
1192
|
+
"workspace_id",
|
|
1193
|
+
"team_id",
|
|
1194
|
+
"account_id",
|
|
1195
|
+
"company_id",
|
|
1196
|
+
"owner_id",
|
|
1197
|
+
"user_id",
|
|
1198
|
+
"created_by",
|
|
1199
|
+
"author_id",
|
|
1200
|
+
"profile_id"
|
|
1201
|
+
]);
|
|
1202
|
+
function isScopeColumn(column) {
|
|
1203
|
+
return column !== null && SCOPE_COLUMNS.has(column.toLowerCase());
|
|
1204
|
+
}
|
|
1205
|
+
function isObjectIdColumn(column) {
|
|
1206
|
+
if (column === null || isScopeColumn(column)) return false;
|
|
1207
|
+
const c = column.toLowerCase();
|
|
1208
|
+
return c === "id" || c === "uuid" || c === "slug" || c.endsWith("_id") || c.endsWith("id");
|
|
1209
|
+
}
|
|
1210
|
+
function policyScopesToCaller(expr) {
|
|
1211
|
+
if (expr === null) return false;
|
|
1212
|
+
return /auth\.uid\(\)|auth\.jwt\(\)|current_setting\(|current_tenant|current_user_id|is_member|has_role|tenant_id|owner_id|user_id|created_by/i.test(
|
|
1213
|
+
expr
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
function handlerViews(ctx) {
|
|
1217
|
+
return ctx.graph.nodesOfKind("Handler").map((handler) => {
|
|
1218
|
+
const data = handler.data;
|
|
1219
|
+
return {
|
|
1220
|
+
handler,
|
|
1221
|
+
data,
|
|
1222
|
+
inputs: data.inputs ?? [],
|
|
1223
|
+
authenticated: ctx.graph.out(handler.id, "AUTHENTICATED_BY").length > 0
|
|
1224
|
+
};
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
function queryViews(ctx, handler) {
|
|
1228
|
+
return ctx.graph.out(handler.id, "CALLS").map((query) => {
|
|
1229
|
+
const client = ctx.graph.out(query.id, "USES_CLIENT")[0];
|
|
1230
|
+
const table = ctx.graph.out(query.id, "TARGETS")[0];
|
|
1231
|
+
return {
|
|
1232
|
+
query,
|
|
1233
|
+
data: query.data,
|
|
1234
|
+
client,
|
|
1235
|
+
clientData: client?.data,
|
|
1236
|
+
table,
|
|
1237
|
+
tableData: table?.data
|
|
1238
|
+
};
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
function locations(...refs) {
|
|
1242
|
+
return refs.filter((r) => r !== void 0);
|
|
1243
|
+
}
|
|
1244
|
+
function rlsNote(t, tableName) {
|
|
1245
|
+
if (!t?.known) return `public.${tableName} was not found in migrations; RLS state unknown.`;
|
|
1246
|
+
if (!t.rlsEnabled) return `RLS is disabled on public.${tableName}.`;
|
|
1247
|
+
return `RLS is enabled on public.${tableName} with ${t.policies.length} polic${t.policies.length === 1 ? "y" : "ies"}, but the service role bypasses it.`;
|
|
1248
|
+
}
|
|
1249
|
+
function finding(ctx, rule, partial) {
|
|
1250
|
+
return {
|
|
1251
|
+
id: ctx.nextId(),
|
|
1252
|
+
ruleId: rule.id,
|
|
1253
|
+
status: "likely",
|
|
1254
|
+
severity: rule.severity,
|
|
1255
|
+
confidence: rule.confidence,
|
|
1256
|
+
cwe: rule.cwe,
|
|
1257
|
+
createdAt: ctx.now,
|
|
1258
|
+
updatedAt: ctx.now,
|
|
1259
|
+
...partial
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
function coveredByObjectAccessRule(q) {
|
|
1263
|
+
const idFilter = q.filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
|
|
1264
|
+
return idFilter !== void 0 && !q.filters.some((f) => isScopeColumn(f.column));
|
|
1265
|
+
}
|
|
1266
|
+
var serviceRoleObjectAccessWithoutTenantScope = {
|
|
1267
|
+
id: "supabase.service-role-object-access-without-tenant-scope",
|
|
1268
|
+
title: "Object access via service-role client without tenant scope",
|
|
1269
|
+
description: "A query filtered by a user-controlled id runs with the service-role key, so Row Level Security does not apply, and nothing restricts the row to the caller's tenant or ownership.",
|
|
1270
|
+
severity: "critical",
|
|
1271
|
+
confidence: 0.85,
|
|
1272
|
+
cwe: ["CWE-639", "CWE-284"],
|
|
1273
|
+
evaluate(ctx) {
|
|
1274
|
+
const out = [];
|
|
1275
|
+
for (const h of handlerViews(ctx)) {
|
|
1276
|
+
for (const v of queryViews(ctx, h.handler)) {
|
|
1277
|
+
const q = v.data;
|
|
1278
|
+
if (v.clientData?.kind !== "service_role") continue;
|
|
1279
|
+
if (!["select", "update", "delete"].includes(q.operation)) continue;
|
|
1280
|
+
const filters = q.filters;
|
|
1281
|
+
const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
|
|
1282
|
+
if (!idFilter || filters.some((f) => isScopeColumn(f.column))) continue;
|
|
1283
|
+
const tableName = v.tableData?.table ?? q.table;
|
|
1284
|
+
const authNote = h.authenticated ? "The handler authenticates the caller but never checks that the row belongs to them." : "The handler does not authenticate the caller at all.";
|
|
1285
|
+
const path = [
|
|
1286
|
+
h.data.kind === "server_action" ? "Server action call" : "HTTP request",
|
|
1287
|
+
h.data.entry,
|
|
1288
|
+
`${idFilter.column} ${idFilter.method} ${idFilter.valueText} (user-controlled)`,
|
|
1289
|
+
`${v.clientData.name} (service role, bypasses RLS)`,
|
|
1290
|
+
`public.${tableName}.${q.operation}`
|
|
1291
|
+
];
|
|
1292
|
+
const evidence = [
|
|
1293
|
+
{
|
|
1294
|
+
kind: "rule",
|
|
1295
|
+
summary: `${q.operation} on public.${tableName} filtered by user-controlled "${idFilter.column}" through a service-role client, with no tenant/owner scoping. ${authNote} ${rlsNote(v.tableData, tableName)}`,
|
|
1296
|
+
locations: locations(h.handler.location, v.query.location, v.client?.location),
|
|
1297
|
+
data: {
|
|
1298
|
+
deterministic: false,
|
|
1299
|
+
ruleId: this.id,
|
|
1300
|
+
authenticated: h.authenticated,
|
|
1301
|
+
query: q.text
|
|
1302
|
+
}
|
|
1303
|
+
},
|
|
1304
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1305
|
+
];
|
|
1306
|
+
out.push(
|
|
1307
|
+
finding(ctx, this, {
|
|
1308
|
+
title: `${h.authenticated ? "Cross-tenant" : "Unauthenticated"} ${q.operation} on "${tableName}" via service-role client`,
|
|
1309
|
+
entrypoints: [h.data.entry],
|
|
1310
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
1311
|
+
sinks: [`supabase.${q.operation}:public.${tableName}`],
|
|
1312
|
+
path,
|
|
1313
|
+
evidence
|
|
1314
|
+
})
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return out;
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
var tableWithoutRls = {
|
|
1322
|
+
id: "supabase.table-without-rls",
|
|
1323
|
+
title: "Table queried by a user-facing client has RLS disabled",
|
|
1324
|
+
description: "Queries made with the anon key run as the caller against PostgREST. Without Row Level Security the table is fully exposed to anyone with the public key.",
|
|
1325
|
+
severity: "high",
|
|
1326
|
+
confidence: 0.9,
|
|
1327
|
+
cwe: ["CWE-284", "CWE-862"],
|
|
1328
|
+
evaluate(ctx) {
|
|
1329
|
+
const out = [];
|
|
1330
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1331
|
+
for (const h of handlerViews(ctx)) {
|
|
1332
|
+
for (const v of queryViews(ctx, h.handler)) {
|
|
1333
|
+
const c = v.clientData;
|
|
1334
|
+
if (c?.kind !== "anon" && c?.kind !== "user_scoped") continue;
|
|
1335
|
+
const t = v.tableData;
|
|
1336
|
+
if (!t?.known || t.rlsEnabled) continue;
|
|
1337
|
+
const key = `${t.table}:${h.data.entry}`;
|
|
1338
|
+
if (seen.has(key)) continue;
|
|
1339
|
+
seen.add(key);
|
|
1340
|
+
const path = [
|
|
1341
|
+
"HTTP request",
|
|
1342
|
+
h.data.entry,
|
|
1343
|
+
`${c.name} (${c.kind}, RLS would apply)`,
|
|
1344
|
+
`public.${t.table} (RLS disabled)`
|
|
1345
|
+
];
|
|
1346
|
+
out.push(
|
|
1347
|
+
finding(ctx, this, {
|
|
1348
|
+
title: `Table "${t.table}" is exposed without RLS`,
|
|
1349
|
+
entrypoints: [h.data.entry],
|
|
1350
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
1351
|
+
sinks: [`supabase.${v.data.operation}:public.${t.table}`],
|
|
1352
|
+
path,
|
|
1353
|
+
evidence: [
|
|
1354
|
+
{
|
|
1355
|
+
kind: "rule",
|
|
1356
|
+
summary: `public.${t.table} has no "enable row level security" in migrations but is queried with a ${c.kind} client. Anyone holding the public anon key can read every row directly through PostgREST.`,
|
|
1357
|
+
locations: locations(v.query.location, v.table?.location),
|
|
1358
|
+
data: { deterministic: true, ruleId: this.id }
|
|
1359
|
+
},
|
|
1360
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1361
|
+
]
|
|
1362
|
+
})
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return out;
|
|
1367
|
+
}
|
|
1368
|
+
};
|
|
1369
|
+
var rlsPolicyWithoutCallerPredicate = {
|
|
1370
|
+
id: "supabase.rls-policy-without-caller-predicate",
|
|
1371
|
+
title: "RLS policy grants rows without a caller predicate",
|
|
1372
|
+
description: "A policy such as `using (true)` or one that never references auth.uid()/tenant makes RLS a no-op for that command: every authenticated user sees every row.",
|
|
1373
|
+
severity: "high",
|
|
1374
|
+
confidence: 0.85,
|
|
1375
|
+
cwe: ["CWE-863", "CWE-284"],
|
|
1376
|
+
evaluate(ctx) {
|
|
1377
|
+
const out = [];
|
|
1378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1379
|
+
for (const h of handlerViews(ctx)) {
|
|
1380
|
+
for (const v of queryViews(ctx, h.handler)) {
|
|
1381
|
+
const c = v.clientData;
|
|
1382
|
+
const t = v.tableData;
|
|
1383
|
+
if (c?.kind !== "anon" && c?.kind !== "user_scoped") continue;
|
|
1384
|
+
if (!t?.known || !t.rlsEnabled) continue;
|
|
1385
|
+
if (!t.columns.some((col) => isScopeColumn(col))) continue;
|
|
1386
|
+
const op = v.data.operation === "unknown" ? "select" : v.data.operation;
|
|
1387
|
+
for (const p of t.policyDetails) {
|
|
1388
|
+
if (p.command !== "all" && p.command !== op) continue;
|
|
1389
|
+
const expr = op === "insert" ? p.check : p.using;
|
|
1390
|
+
if (expr === null || policyScopesToCaller(expr)) continue;
|
|
1391
|
+
const key = `${t.table}:${p.name}:${h.data.entry}`;
|
|
1392
|
+
if (seen.has(key)) continue;
|
|
1393
|
+
seen.add(key);
|
|
1394
|
+
const path = [
|
|
1395
|
+
"HTTP request",
|
|
1396
|
+
h.data.entry,
|
|
1397
|
+
`${c.name} (${c.kind})`,
|
|
1398
|
+
`public.${t.table} policy "${p.name}" ${op === "insert" ? "with check" : "using"} (${expr})`
|
|
1399
|
+
];
|
|
1400
|
+
out.push(
|
|
1401
|
+
finding(ctx, this, {
|
|
1402
|
+
title: `RLS policy "${p.name}" on "${t.table}" does not scope rows to the caller`,
|
|
1403
|
+
entrypoints: [h.data.entry],
|
|
1404
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
1405
|
+
sinks: [`supabase.${op}:public.${t.table}`],
|
|
1406
|
+
path,
|
|
1407
|
+
evidence: [
|
|
1408
|
+
{
|
|
1409
|
+
kind: "rule",
|
|
1410
|
+
summary: `Policy "${p.name}" for ${p.command} on public.${t.table} uses (${expr}). The table has a scope column (${t.columns.filter(isScopeColumn).join(", ")}) but the policy never compares it to auth.uid() or the caller's tenant, so RLS lets every ${p.roles.join("/") || "authenticated"} user through.`,
|
|
1411
|
+
locations: locations(v.query.location, p.location),
|
|
1412
|
+
data: { deterministic: false, ruleId: this.id, policy: p.name }
|
|
1413
|
+
},
|
|
1414
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1415
|
+
]
|
|
1416
|
+
})
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return out;
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
var userControlledTenantScope = {
|
|
1425
|
+
id: "supabase.user-controlled-tenant-scope",
|
|
1426
|
+
title: "Tenant scope taken from the request",
|
|
1427
|
+
description: "The query is scoped by a tenant/owner column, but the value comes from user input. An attacker supplies another tenant's id and the service-role client happily returns its rows.",
|
|
1428
|
+
severity: "critical",
|
|
1429
|
+
confidence: 0.85,
|
|
1430
|
+
cwe: ["CWE-639", "CWE-566"],
|
|
1431
|
+
evaluate(ctx) {
|
|
1432
|
+
const out = [];
|
|
1433
|
+
for (const h of handlerViews(ctx)) {
|
|
1434
|
+
for (const v of queryViews(ctx, h.handler)) {
|
|
1435
|
+
if (v.clientData?.kind !== "service_role") continue;
|
|
1436
|
+
const scope = v.data.filters.find((f) => isScopeColumn(f.column) && f.inputDerived);
|
|
1437
|
+
if (!scope) continue;
|
|
1438
|
+
const tableName = v.tableData?.table ?? v.data.table;
|
|
1439
|
+
const path = [
|
|
1440
|
+
"HTTP request",
|
|
1441
|
+
h.data.entry,
|
|
1442
|
+
`${scope.column} ${scope.method} ${scope.valueText} (user-controlled)`,
|
|
1443
|
+
`${v.clientData.name} (service role, bypasses RLS)`,
|
|
1444
|
+
`public.${tableName}.${v.data.operation}`
|
|
1445
|
+
];
|
|
1446
|
+
out.push(
|
|
1447
|
+
finding(ctx, this, {
|
|
1448
|
+
title: `Tenant scope for "${tableName}" comes from the request`,
|
|
1449
|
+
entrypoints: [h.data.entry],
|
|
1450
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
1451
|
+
sinks: [`supabase.${v.data.operation}:public.${tableName}`],
|
|
1452
|
+
path,
|
|
1453
|
+
evidence: [
|
|
1454
|
+
{
|
|
1455
|
+
kind: "rule",
|
|
1456
|
+
summary: `${v.data.operation} on public.${tableName} is filtered by "${scope.column}" = ${scope.valueText}, which the caller controls. The tenant must come from the authenticated session (profile lookup or JWT claim), never from the request. ${rlsNote(v.tableData, tableName)}`,
|
|
1457
|
+
locations: locations(h.handler.location, v.query.location, v.client?.location),
|
|
1458
|
+
data: { deterministic: false, ruleId: this.id, authenticated: h.authenticated }
|
|
1459
|
+
},
|
|
1460
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1461
|
+
]
|
|
1462
|
+
})
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return out;
|
|
1467
|
+
}
|
|
1468
|
+
};
|
|
1469
|
+
var roleFromUserMetadata = {
|
|
1470
|
+
id: "supabase.role-check-from-user-metadata",
|
|
1471
|
+
title: "Authorization based on user-editable metadata",
|
|
1472
|
+
description: "user.user_metadata is writable by the user themselves via updateUser(). Roles and flags must live in app_metadata or a server-controlled table.",
|
|
1473
|
+
severity: "high",
|
|
1474
|
+
confidence: 0.85,
|
|
1475
|
+
cwe: ["CWE-602", "CWE-863"],
|
|
1476
|
+
evaluate(ctx) {
|
|
1477
|
+
const out = [];
|
|
1478
|
+
for (const h of handlerViews(ctx)) {
|
|
1479
|
+
const hits = h.data.metadataAccesses.filter(
|
|
1480
|
+
(m) => m.bucket === "user_metadata" && /role|admin|permission|plan|tier|scope|is_/i.test(m.path)
|
|
1481
|
+
);
|
|
1482
|
+
const first = hits[0];
|
|
1483
|
+
if (!first) continue;
|
|
1484
|
+
const path = [
|
|
1485
|
+
"HTTP request",
|
|
1486
|
+
h.data.entry,
|
|
1487
|
+
`${first.path} (end-user editable)`,
|
|
1488
|
+
"authorization decision"
|
|
1489
|
+
];
|
|
1490
|
+
out.push(
|
|
1491
|
+
finding(ctx, this, {
|
|
1492
|
+
title: `Role check reads user_metadata in ${h.data.entry}`,
|
|
1493
|
+
entrypoints: [h.data.entry],
|
|
1494
|
+
sources: ["auth.user_metadata (editable by the user)"],
|
|
1495
|
+
sinks: [h.data.entry],
|
|
1496
|
+
path,
|
|
1497
|
+
evidence: [
|
|
1498
|
+
{
|
|
1499
|
+
kind: "rule",
|
|
1500
|
+
summary: `${hits.map((m) => m.path).join(", ")} is read from user_metadata, which any signed-in user can change with supabase.auth.updateUser({ data: { role: "admin" } }). Use app_metadata (service-role only) or a profiles.role column.`,
|
|
1501
|
+
locations: locations(h.handler.location, ...hits.map((m) => m.location)),
|
|
1502
|
+
data: { deterministic: false, ruleId: this.id }
|
|
1503
|
+
},
|
|
1504
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1505
|
+
]
|
|
1506
|
+
})
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
return out;
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
var serviceRoleKeyExposedToClient = {
|
|
1513
|
+
id: "supabase.service-role-key-exposed-to-client",
|
|
1514
|
+
title: "Service-role key shipped to the browser",
|
|
1515
|
+
description: "The service-role key bypasses RLS entirely. Anything in a NEXT_PUBLIC_ variable or a client component is downloadable by every visitor.",
|
|
1516
|
+
severity: "critical",
|
|
1517
|
+
confidence: 1,
|
|
1518
|
+
cwe: ["CWE-798", "CWE-200"],
|
|
1519
|
+
evaluate(ctx) {
|
|
1520
|
+
return ctx.model.exposures.map(
|
|
1521
|
+
(x) => finding(ctx, this, {
|
|
1522
|
+
title: x.kind === "public_env_service_role" ? `Service-role key in a NEXT_PUBLIC_ variable (${x.location.file})` : `Service-role client inside a client component (${x.location.file})`,
|
|
1523
|
+
entrypoints: [x.location.file],
|
|
1524
|
+
sources: ["browser bundle"],
|
|
1525
|
+
sinks: ["supabase service role"],
|
|
1526
|
+
path: [
|
|
1527
|
+
"browser bundle",
|
|
1528
|
+
x.location.file,
|
|
1529
|
+
"service-role key",
|
|
1530
|
+
"full database access, RLS bypassed"
|
|
1531
|
+
],
|
|
1532
|
+
evidence: [
|
|
1533
|
+
{
|
|
1534
|
+
kind: "rule",
|
|
1535
|
+
summary: x.evidence,
|
|
1536
|
+
locations: [x.location],
|
|
1537
|
+
data: { deterministic: true, ruleId: this.id, exposure: x.kind }
|
|
1538
|
+
},
|
|
1539
|
+
{
|
|
1540
|
+
kind: "trace",
|
|
1541
|
+
summary: `browser bundle -> ${x.location.file}:${x.location.line} -> service-role key`
|
|
1542
|
+
}
|
|
1543
|
+
]
|
|
1544
|
+
})
|
|
1545
|
+
);
|
|
1546
|
+
}
|
|
1547
|
+
};
|
|
1548
|
+
var serviceRoleQueryWithoutAuthentication = {
|
|
1549
|
+
id: "supabase.service-role-query-without-authentication",
|
|
1550
|
+
title: "Service-role query in an unauthenticated handler",
|
|
1551
|
+
description: "The handler performs privileged database access with the service-role key but never establishes who is calling. Anyone on the internet can invoke it.",
|
|
1552
|
+
severity: "critical",
|
|
1553
|
+
confidence: 0.8,
|
|
1554
|
+
cwe: ["CWE-306", "CWE-284"],
|
|
1555
|
+
evaluate(ctx) {
|
|
1556
|
+
const out = [];
|
|
1557
|
+
for (const h of handlerViews(ctx)) {
|
|
1558
|
+
if (h.authenticated) continue;
|
|
1559
|
+
const views = queryViews(ctx, h.handler).filter(
|
|
1560
|
+
(v2) => v2.clientData?.kind === "service_role" && !coveredByObjectAccessRule(v2.data)
|
|
1561
|
+
);
|
|
1562
|
+
const v = views[0];
|
|
1563
|
+
if (!v) continue;
|
|
1564
|
+
const tables = [...new Set(views.map((x) => x.tableData?.table ?? x.data.table))];
|
|
1565
|
+
const path = [
|
|
1566
|
+
h.data.kind === "server_action" ? "Server action call" : "HTTP request",
|
|
1567
|
+
h.data.entry,
|
|
1568
|
+
"no authentication",
|
|
1569
|
+
`${v.clientData?.name ?? "client"} (service role, bypasses RLS)`,
|
|
1570
|
+
`public.${tables.join(", public.")}`
|
|
1571
|
+
];
|
|
1572
|
+
out.push(
|
|
1573
|
+
finding(ctx, this, {
|
|
1574
|
+
title: `Unauthenticated service-role access to "${tables.join('", "')}" in ${h.data.entry}`,
|
|
1575
|
+
entrypoints: [h.data.entry],
|
|
1576
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
1577
|
+
sinks: views.map(
|
|
1578
|
+
(x) => `supabase.${x.data.operation}:public.${x.tableData?.table ?? x.data.table}`
|
|
1579
|
+
),
|
|
1580
|
+
path,
|
|
1581
|
+
evidence: [
|
|
1582
|
+
{
|
|
1583
|
+
kind: "rule",
|
|
1584
|
+
summary: `${h.data.entry} runs ${views.length} service-role quer${views.length === 1 ? "y" : "ies"} (${tables.join(", ")}) and contains no auth.getUser/getSession/getClaims call or auth helper. If this is a webhook or cron endpoint, it needs signature verification, which was not detected either.`,
|
|
1585
|
+
locations: locations(h.handler.location, ...views.map((x) => x.query.location)),
|
|
1586
|
+
data: { deterministic: false, ruleId: this.id }
|
|
1587
|
+
},
|
|
1588
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1589
|
+
]
|
|
1590
|
+
})
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
return out;
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
var massAssignmentFromRequestBody = {
|
|
1597
|
+
id: "supabase.mass-assignment-from-request-body",
|
|
1598
|
+
title: "Request body written to a table without an allow-list",
|
|
1599
|
+
description: "insert/update/upsert receives the parsed request body as-is. Any column the caller names gets written, including role, tenant_id or owner_id.",
|
|
1600
|
+
severity: "high",
|
|
1601
|
+
confidence: 0.85,
|
|
1602
|
+
cwe: ["CWE-915"],
|
|
1603
|
+
evaluate(ctx) {
|
|
1604
|
+
const out = [];
|
|
1605
|
+
for (const h of handlerViews(ctx)) {
|
|
1606
|
+
for (const v of queryViews(ctx, h.handler)) {
|
|
1607
|
+
const p = v.data.payload;
|
|
1608
|
+
if (!p?.wholeInput) continue;
|
|
1609
|
+
const tableName = v.tableData?.table ?? v.data.table;
|
|
1610
|
+
const cols = v.tableData?.columns ?? [];
|
|
1611
|
+
const sensitive = cols.filter(
|
|
1612
|
+
(c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
|
|
1613
|
+
);
|
|
1614
|
+
const path = [
|
|
1615
|
+
"HTTP request",
|
|
1616
|
+
h.data.entry,
|
|
1617
|
+
`${p.text} (entire request input)`,
|
|
1618
|
+
`${v.clientData?.name ?? "client"} (${v.clientData?.kind ?? "unknown"})`,
|
|
1619
|
+
`public.${tableName}.${v.data.operation}`
|
|
1620
|
+
];
|
|
1621
|
+
out.push(
|
|
1622
|
+
finding(ctx, this, {
|
|
1623
|
+
title: `Mass assignment into "${tableName}" from the request body`,
|
|
1624
|
+
entrypoints: [h.data.entry],
|
|
1625
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
1626
|
+
sinks: [`supabase.${v.data.operation}:public.${tableName}`],
|
|
1627
|
+
path,
|
|
1628
|
+
evidence: [
|
|
1629
|
+
{
|
|
1630
|
+
kind: "rule",
|
|
1631
|
+
summary: `${v.data.operation} on public.${tableName} writes ${p.text} directly. ${sensitive.length > 0 ? `Columns the caller could set: ${sensitive.join(", ")}.` : "Every column of the table is writable by the caller."} Pick the allowed fields explicitly.`,
|
|
1632
|
+
locations: locations(h.handler.location, v.query.location),
|
|
1633
|
+
data: { deterministic: false, ruleId: this.id, payload: p.text }
|
|
1634
|
+
},
|
|
1635
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
1636
|
+
]
|
|
1637
|
+
})
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
return out;
|
|
1642
|
+
}
|
|
1643
|
+
};
|
|
1644
|
+
var supabaseAuthorizationPack = [
|
|
1645
|
+
serviceRoleKeyExposedToClient,
|
|
1646
|
+
serviceRoleObjectAccessWithoutTenantScope,
|
|
1647
|
+
userControlledTenantScope,
|
|
1648
|
+
serviceRoleQueryWithoutAuthentication,
|
|
1649
|
+
massAssignmentFromRequestBody,
|
|
1650
|
+
roleFromUserMetadata,
|
|
1651
|
+
tableWithoutRls,
|
|
1652
|
+
rlsPolicyWithoutCallerPredicate
|
|
1653
|
+
];
|
|
1654
|
+
|
|
1655
|
+
// packages/rules/src/rule.ts
|
|
1656
|
+
function runRules(rules, model, graph, opts = {}) {
|
|
1657
|
+
const now = opts.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1658
|
+
const prefix = opts.idPrefix ?? "AUDIT";
|
|
1659
|
+
let counter = 0;
|
|
1660
|
+
const nextId = () => {
|
|
1661
|
+
counter += 1;
|
|
1662
|
+
return `${prefix}-${String(counter).padStart(3, "0")}`;
|
|
1663
|
+
};
|
|
1664
|
+
let findings = [];
|
|
1665
|
+
for (const rule of rules) {
|
|
1666
|
+
try {
|
|
1667
|
+
findings.push(...rule.evaluate({ model, graph, now, nextId }));
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
model.warnings.push(`rule ${rule.id} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
findings = applySuppressions(findings, model, now);
|
|
1673
|
+
return findings;
|
|
1674
|
+
}
|
|
1675
|
+
function applySuppressions(findings, model, now) {
|
|
1676
|
+
const byEntry = new Map(model.routes.map((h) => [h.entry, h.ignores]));
|
|
1677
|
+
return findings.map((f) => {
|
|
1678
|
+
if (f.status === "suppressed") return f;
|
|
1679
|
+
const candidates = [...byEntry.get(f.entrypoints[0] ?? "") ?? []];
|
|
1680
|
+
for (const ev of f.evidence)
|
|
1681
|
+
for (const l of ev.locations ?? []) candidates.push(...model.fileIgnores[l.file] ?? []);
|
|
1682
|
+
const hit = candidates.find((d) => d.ruleId === "*" || d.ruleId === f.ruleId);
|
|
1683
|
+
if (!hit) return f;
|
|
1684
|
+
return transition(f, "suppressed", {
|
|
1685
|
+
evidence: {
|
|
1686
|
+
kind: "rule",
|
|
1687
|
+
summary: `Suppressed by auditai:ignore at ${hit.location.file}:${hit.location.line}: ${hit.reason}`,
|
|
1688
|
+
locations: [hit.location],
|
|
1689
|
+
data: { suppressed: true, ruleId: hit.ruleId }
|
|
1690
|
+
},
|
|
1691
|
+
now
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// packages/rules/src/index.ts
|
|
1697
|
+
var defaultRules = [...supabaseAuthorizationPack];
|
|
1698
|
+
|
|
1699
|
+
// packages/scanner/src/scan.ts
|
|
1700
|
+
function readAuditConfig(root) {
|
|
1701
|
+
const p = join3(root, "audit.config.json");
|
|
1702
|
+
if (!existsSync(p)) return {};
|
|
1703
|
+
try {
|
|
1704
|
+
const raw = JSON.parse(readFileSync2(p, "utf8"));
|
|
1705
|
+
return {
|
|
1706
|
+
...Array.isArray(raw.ignore) ? { ignore: raw.ignore.filter((x) => typeof x === "string") } : {},
|
|
1707
|
+
...Array.isArray(raw.migrations) ? { migrations: raw.migrations.filter((x) => typeof x === "string") } : {}
|
|
1708
|
+
};
|
|
1709
|
+
} catch {
|
|
1710
|
+
return {};
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
function summarize(model, rules) {
|
|
1714
|
+
return {
|
|
1715
|
+
root: model.root,
|
|
1716
|
+
files: model.files.length,
|
|
1717
|
+
routes: model.routes.length,
|
|
1718
|
+
queries: model.routes.reduce((n, r) => n + r.queries.length, 0),
|
|
1719
|
+
tablesKnown: model.tables.length,
|
|
1720
|
+
tablesWithRls: model.tables.filter((t) => t.rlsEnabled).length,
|
|
1721
|
+
rules,
|
|
1722
|
+
warnings: model.warnings
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
function runScan(path, opts = {}) {
|
|
1726
|
+
const cfg = readAuditConfig(path);
|
|
1727
|
+
const sqlDirs = [...cfg.migrations ?? [], ...opts.sqlDirs ?? []];
|
|
1728
|
+
const ignore = [...cfg.ignore ?? [], ...opts.ignore ?? []];
|
|
1729
|
+
const model = parseProject(path, {
|
|
1730
|
+
...sqlDirs.length > 0 ? { sqlDirs } : {},
|
|
1731
|
+
...ignore.length > 0 ? { ignore } : {}
|
|
1732
|
+
});
|
|
1733
|
+
const graph = buildGraph(model);
|
|
1734
|
+
const runOpts = opts.now === void 0 ? {} : { now: opts.now };
|
|
1735
|
+
const findings = runRules(defaultRules, model, graph, runOpts);
|
|
1736
|
+
const coverage = summarizeCoverage(findings);
|
|
1737
|
+
return {
|
|
1738
|
+
summary: summarize(model, defaultRules.length),
|
|
1739
|
+
findings,
|
|
1740
|
+
coverage,
|
|
1741
|
+
coverageStatement: renderCoverageStatement(coverage),
|
|
1742
|
+
blocking: findings.some((f) => isBlocking(f))
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// packages/scanner/src/bin.ts
|
|
1747
|
+
var USAGE = `auditai-scan \u2014 deterministic security scan for Next.js + Supabase apps (open source)
|
|
1748
|
+
|
|
1749
|
+
Usage:
|
|
1750
|
+
auditai-scan [path] [--json] [--fail-on <status>] [--migrations <dir>]...
|
|
1751
|
+
|
|
1752
|
+
Options:
|
|
1753
|
+
--json machine-readable output
|
|
1754
|
+
--fail-on <status> exit 1 when a finding reaches this status (default: confirmed)
|
|
1755
|
+
one of: candidate, likely, confirmed, verified
|
|
1756
|
+
--migrations <dir> extra directory with Supabase migration SQL (repeatable)
|
|
1757
|
+
-h, --help show this help
|
|
1758
|
+
|
|
1759
|
+
Config: <path>/audit.config.json { "ignore": ["evals/**"], "migrations": ["supabase/migrations"] }
|
|
1760
|
+
Suppress a finding: // auditai:ignore <ruleId|*> -- reason (above the handler or at the top of a file)
|
|
1761
|
+
`;
|
|
1762
|
+
var RANK = {
|
|
1763
|
+
suppressed: -1,
|
|
1764
|
+
unverified: -1,
|
|
1765
|
+
candidate: 0,
|
|
1766
|
+
likely: 1,
|
|
1767
|
+
confirmed: 2,
|
|
1768
|
+
fix_proposed: 2,
|
|
1769
|
+
fix_applied: 2,
|
|
1770
|
+
verified: 3
|
|
1771
|
+
};
|
|
1772
|
+
function main(argv) {
|
|
1773
|
+
const { values, positionals } = parseArgs({
|
|
1774
|
+
args: argv,
|
|
1775
|
+
allowPositionals: true,
|
|
1776
|
+
options: {
|
|
1777
|
+
json: { type: "boolean", default: false },
|
|
1778
|
+
"fail-on": { type: "string", default: "confirmed" },
|
|
1779
|
+
migrations: { type: "string", multiple: true, default: [] },
|
|
1780
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1781
|
+
}
|
|
1782
|
+
});
|
|
1783
|
+
if (values.help) {
|
|
1784
|
+
process.stdout.write(USAGE);
|
|
1785
|
+
return 0;
|
|
1786
|
+
}
|
|
1787
|
+
const failOn = values["fail-on"];
|
|
1788
|
+
if (!FINDING_STATUSES.includes(failOn)) {
|
|
1789
|
+
process.stderr.write(`error: --fail-on must be one of ${FINDING_STATUSES.join(", ")}
|
|
1790
|
+
`);
|
|
1791
|
+
return 2;
|
|
1792
|
+
}
|
|
1793
|
+
const migrations = values.migrations.map((m) => resolve3(m));
|
|
1794
|
+
const result = runScan(
|
|
1795
|
+
positionals[0] ?? ".",
|
|
1796
|
+
migrations.length > 0 ? { sqlDirs: migrations } : {}
|
|
1797
|
+
);
|
|
1798
|
+
process.stdout.write(
|
|
1799
|
+
values.json ? `${JSON.stringify(result, null, 2)}
|
|
1800
|
+
` : formatScanText(result)
|
|
1801
|
+
);
|
|
1802
|
+
const threshold = RANK[failOn];
|
|
1803
|
+
return result.findings.some((f) => RANK[f.status] >= 0 && RANK[f.status] >= threshold) ? 1 : 0;
|
|
1804
|
+
}
|
|
1805
|
+
try {
|
|
1806
|
+
process.exitCode = main(process.argv.slice(2));
|
|
1807
|
+
} catch (e) {
|
|
1808
|
+
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}
|
|
1809
|
+
`);
|
|
1810
|
+
process.exitCode = 2;
|
|
1811
|
+
}
|