auditai-scan 0.6.0 → 0.7.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/dist/auditai-scan.mjs +597 -124
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -138,6 +138,9 @@ function where(f) {
|
|
|
138
138
|
for (const e of f.evidence) for (const l of e.locations ?? []) seen.add(`${l.file}:${l.line}`);
|
|
139
139
|
return [...seen].join(", ");
|
|
140
140
|
}
|
|
141
|
+
function fixBody(diff) {
|
|
142
|
+
return diff.split("\n").filter((l) => l.startsWith("+") && !l.startsWith("+++")).map((l) => l.slice(1));
|
|
143
|
+
}
|
|
141
144
|
function formatFinding(f) {
|
|
142
145
|
const why = f.evidence.find((e) => e.kind === "rule")?.summary ?? "";
|
|
143
146
|
const lines = [
|
|
@@ -152,6 +155,10 @@ function formatFinding(f) {
|
|
|
152
155
|
const s = [...f.evidence].reverse().find((e) => e.data?.suppressed === true);
|
|
153
156
|
if (s) lines.push(` Ignored ${s.summary}`);
|
|
154
157
|
}
|
|
158
|
+
if (f.fix) {
|
|
159
|
+
lines.push(` Fix ${f.fix.summary} (${f.fix.touchedFiles.join(", ")})`);
|
|
160
|
+
for (const line of fixBody(f.fix.diff)) lines.push(` ${line}`);
|
|
161
|
+
}
|
|
155
162
|
return lines.join("\n");
|
|
156
163
|
}
|
|
157
164
|
function formatScanText(r) {
|
|
@@ -190,6 +197,222 @@ function formatScanText(r) {
|
|
|
190
197
|
// packages/scanner/src/scan.ts
|
|
191
198
|
import { statSync as statSync2 } from "node:fs";
|
|
192
199
|
|
|
200
|
+
// packages/fixes/dist/sql-fixes.js
|
|
201
|
+
var OWNER_COLUMNS = [
|
|
202
|
+
"user_id",
|
|
203
|
+
"owner_id",
|
|
204
|
+
"profile_id",
|
|
205
|
+
"author_id",
|
|
206
|
+
"created_by",
|
|
207
|
+
"account_id",
|
|
208
|
+
"tenant_id",
|
|
209
|
+
"organization_id",
|
|
210
|
+
"org_id",
|
|
211
|
+
"workspace_id",
|
|
212
|
+
"team_id"
|
|
213
|
+
];
|
|
214
|
+
var API_ROLES = "public, anon, authenticated";
|
|
215
|
+
function table(model, name) {
|
|
216
|
+
if (!name)
|
|
217
|
+
return void 0;
|
|
218
|
+
const key = name.toLowerCase();
|
|
219
|
+
return model.tables.find((t) => t.table.toLowerCase() === key);
|
|
220
|
+
}
|
|
221
|
+
function ownerColumn(t) {
|
|
222
|
+
if (!t)
|
|
223
|
+
return null;
|
|
224
|
+
const cols = t.columns.map((c) => c.toLowerCase());
|
|
225
|
+
for (const candidate of OWNER_COLUMNS)
|
|
226
|
+
if (cols.includes(candidate))
|
|
227
|
+
return candidate;
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
function qualified(name) {
|
|
231
|
+
return name.includes(".") ? name : `public.${name}`;
|
|
232
|
+
}
|
|
233
|
+
function fn(model, name) {
|
|
234
|
+
if (!name)
|
|
235
|
+
return void 0;
|
|
236
|
+
const key = name.toLowerCase();
|
|
237
|
+
return (model.sqlFunctions ?? []).find((f) => f.name.toLowerCase() === key);
|
|
238
|
+
}
|
|
239
|
+
function signatureOf(f) {
|
|
240
|
+
const name = f.name.includes(".") ? f.name : `public.${f.name}`;
|
|
241
|
+
return f.args === void 0 ? `${name}(...)` : `${name}(${f.args})`;
|
|
242
|
+
}
|
|
243
|
+
var HEADER = (title) => `-- ${title}
|
|
244
|
+
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
|
|
245
|
+
`;
|
|
246
|
+
function sqlFunctionFix(finding4, model) {
|
|
247
|
+
const name = finding4.evidence[0]?.data?.function;
|
|
248
|
+
const f = fn(model, typeof name === "string" ? name : void 0);
|
|
249
|
+
if (!f)
|
|
250
|
+
return null;
|
|
251
|
+
const sig = signatureOf(f);
|
|
252
|
+
const ambiguous = sig.endsWith("(...)");
|
|
253
|
+
const body = [
|
|
254
|
+
HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
|
|
255
|
+
ambiguous ? "-- The argument types could not be read from the migrations; put the real signature in\n-- place of (...) before applying. `\\df public.*` in psql prints it.\n" : "",
|
|
256
|
+
`revoke execute on function ${sig} from ${API_ROLES};
|
|
257
|
+
`,
|
|
258
|
+
"-- Leave this line out if nothing calls the function with the service role.\n",
|
|
259
|
+
`grant execute on function ${sig} to service_role;
|
|
260
|
+
`
|
|
261
|
+
].join("");
|
|
262
|
+
return {
|
|
263
|
+
file: `fix_revoke_execute_${f.name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
264
|
+
sql: body,
|
|
265
|
+
summary: `Revoke execute on ${f.name} from the API roles`,
|
|
266
|
+
rationale: "The function runs as its creator, so row level security does not apply inside it, and Supabase grants execute on new functions in schema public to anon and authenticated directly. Revoking from PUBLIC alone does not remove those grants, which is why a function that looks locked down is still callable with the public key. If a browser is supposed to call it, do not apply this: add a caller check inside the body instead (`where owner_id = (select auth.uid())`)."
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function enableRlsFix(finding4, model, withPolicy) {
|
|
270
|
+
const name = finding4.evidence[0]?.data?.table;
|
|
271
|
+
if (typeof name !== "string")
|
|
272
|
+
return null;
|
|
273
|
+
const t = table(model, name);
|
|
274
|
+
const full = qualified(name);
|
|
275
|
+
const owner = ownerColumn(t);
|
|
276
|
+
const lines = [HEADER(`Turn on row level security for ${full}`)];
|
|
277
|
+
lines.push(`alter table ${full} enable row level security;
|
|
278
|
+
`);
|
|
279
|
+
if (withPolicy) {
|
|
280
|
+
if (owner) {
|
|
281
|
+
lines.push(`
|
|
282
|
+
create policy "${name}: owner reads" on ${full}
|
|
283
|
+
for select to authenticated
|
|
284
|
+
using (${owner} = (select auth.uid()));
|
|
285
|
+
`, `
|
|
286
|
+
create policy "${name}: owner writes" on ${full}
|
|
287
|
+
for all to authenticated
|
|
288
|
+
using (${owner} = (select auth.uid()))
|
|
289
|
+
with check (${owner} = (select auth.uid()));
|
|
290
|
+
`);
|
|
291
|
+
} else {
|
|
292
|
+
lines.push(`
|
|
293
|
+
-- No column of ${full} ties a row to a person (looked for ${OWNER_COLUMNS.slice(0, 4).join(", ")}\u2026),
|
|
294
|
+
-- so no policy is proposed: with RLS on and no policy the table is readable only with the
|
|
295
|
+
-- service role, which is the safe default. Add a policy once you decide who owns a row.
|
|
296
|
+
`);
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
lines.push(`
|
|
300
|
+
-- The policies this table already has start applying the moment row level security is on;
|
|
301
|
+
-- read them once before applying, because until now they have never run.
|
|
302
|
+
`);
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
file: `fix_enable_rls_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
306
|
+
sql: lines.join(""),
|
|
307
|
+
summary: `Enable row level security on ${full}`,
|
|
308
|
+
rationale: withPolicy ? "Without row level security the anon key reads and writes every row of the table through PostgREST. Turning it on denies everything by default, so apply this together with a policy, and check that your own server code uses the service role where it needs full access." : "The table already carries policies, which is what makes this a defect rather than a choice: they have no effect until row level security is on. Read them once before applying \u2014 they have never run, so they may not say what their author believed."
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function anonWriteFix(finding4, model) {
|
|
312
|
+
const data = finding4.evidence[0]?.data ?? {};
|
|
313
|
+
const name = data.table;
|
|
314
|
+
const policy = data.policy;
|
|
315
|
+
const command = data.command;
|
|
316
|
+
if (typeof name !== "string" || typeof policy !== "string")
|
|
317
|
+
return null;
|
|
318
|
+
const full = qualified(name);
|
|
319
|
+
const owner = ownerColumn(table(model, name));
|
|
320
|
+
const cmd = typeof command === "string" ? command : "all";
|
|
321
|
+
const safeName = policy.replace(/"/g, '""');
|
|
322
|
+
const lines = [HEADER(`Close the open write policy "${policy}" on ${full}`)];
|
|
323
|
+
if (owner) {
|
|
324
|
+
lines.push(`drop policy "${safeName}" on ${full};
|
|
325
|
+
`, `
|
|
326
|
+
create policy "${safeName}" on ${full}
|
|
327
|
+
for ${cmd} to authenticated
|
|
328
|
+
`, cmd === "insert" ? ` with check (${owner} = (select auth.uid()));
|
|
329
|
+
` : ` using (${owner} = (select auth.uid()))${cmd === "all" ? `
|
|
330
|
+
with check (${owner} = (select auth.uid()))` : ""};
|
|
331
|
+
`);
|
|
332
|
+
} else {
|
|
333
|
+
lines.push(`-- No column of ${full} ties a row to a person, so there is nothing to compare the caller
|
|
334
|
+
-- with. Either add one, or take the policy away and let your server write the table with the
|
|
335
|
+
-- service role after it has checked the caller itself.
|
|
336
|
+
`, `drop policy "${safeName}" on ${full};
|
|
337
|
+
`);
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_${cmd}.sql`,
|
|
341
|
+
sql: lines.join(""),
|
|
342
|
+
summary: `Tie the write policy on ${full} to the caller`,
|
|
343
|
+
rationale: owner ? `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. The replacement keeps the same command and ties the row to the signed-in caller through ${owner}. If this table is meant to accept rows from strangers (a contact form, a newsletter), keep the insert open but give it a predicate on the row's shape and a rate limit.` : `The policy decides with a tautology and is open to anon, so anyone holding the public key can write ${full} straight through PostgREST. Nothing in the table identifies an owner, so the honest fix is to remove the policy and write the table from your server after it has checked the caller.`
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function userMetadataFix(finding4) {
|
|
347
|
+
const data = finding4.evidence[0]?.data ?? {};
|
|
348
|
+
const name = data.table;
|
|
349
|
+
const policy = data.policy;
|
|
350
|
+
if (typeof name !== "string" || typeof policy !== "string")
|
|
351
|
+
return null;
|
|
352
|
+
const full = qualified(name);
|
|
353
|
+
return {
|
|
354
|
+
file: `fix_policy_${name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}_app_metadata.sql`,
|
|
355
|
+
sql: [
|
|
356
|
+
HEADER(`Stop the policy "${policy}" on ${full} from trusting user_metadata`),
|
|
357
|
+
"-- Re-create the policy with the claim read from app_metadata, which only the service role\n",
|
|
358
|
+
"-- writes. Copy the predicate from your own migration and change the one word: replace\n",
|
|
359
|
+
"-- (select auth.jwt()) -> 'user_metadata' ->> '<claim>'\n",
|
|
360
|
+
"-- with\n",
|
|
361
|
+
"-- (select auth.jwt()) -> 'app_metadata' ->> '<claim>'\n",
|
|
362
|
+
`--
|
|
363
|
+
-- drop policy "${policy.replace(/"/g, '""')}" on ${full};
|
|
364
|
+
`,
|
|
365
|
+
`-- create policy "${policy.replace(/"/g, '""')}" on ${full} ... using (...);
|
|
366
|
+
`,
|
|
367
|
+
"\n-- Then set the claim where the user cannot reach it, from a server with the service role:\n",
|
|
368
|
+
"-- await admin.auth.admin.updateUserById(id, { app_metadata: { is_admin: true } });\n"
|
|
369
|
+
].join(""),
|
|
370
|
+
summary: `Move the claim behind "${policy}" from user_metadata to app_metadata`,
|
|
371
|
+
rationale: "user_metadata is written by the user themselves with supabase.auth.updateUser({ data }), and it is copied into their next access token without review, so a policy that reads it grants itself. app_metadata can only be set with the service role. This fix is a template rather than a finished statement: the predicate belongs to your policy, and moving the claim only helps once something server-side actually sets app_metadata."
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function sqlFixFor(finding4, model) {
|
|
375
|
+
switch (finding4.ruleId) {
|
|
376
|
+
case "supabase.security-definer-function-without-caller-check":
|
|
377
|
+
return sqlFunctionFix(finding4, model);
|
|
378
|
+
case "supabase.table-without-rls":
|
|
379
|
+
return enableRlsFix(finding4, model, true);
|
|
380
|
+
case "supabase.policies-without-rls-enabled":
|
|
381
|
+
return enableRlsFix(finding4, model, false);
|
|
382
|
+
case "supabase.anon-write-policy":
|
|
383
|
+
return anonWriteFix(finding4, model);
|
|
384
|
+
case "supabase.rls-policy-trusts-user-metadata":
|
|
385
|
+
return userMetadataFix(finding4);
|
|
386
|
+
default:
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function addFileDiff(path, body) {
|
|
391
|
+
const lines = body.replace(/\n$/, "").split("\n");
|
|
392
|
+
return [
|
|
393
|
+
`diff --git a/${path} b/${path}`,
|
|
394
|
+
"new file mode 100644",
|
|
395
|
+
"--- /dev/null",
|
|
396
|
+
`+++ b/${path}`,
|
|
397
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
398
|
+
...lines.map((l) => `+${l}`),
|
|
399
|
+
""
|
|
400
|
+
].join("\n");
|
|
401
|
+
}
|
|
402
|
+
function deterministicFix(finding4, model, migrationsDir = "supabase/migrations") {
|
|
403
|
+
const fix = sqlFixFor(finding4, model);
|
|
404
|
+
if (!fix)
|
|
405
|
+
return null;
|
|
406
|
+
const stamp = (finding4.createdAt ?? "").replace(/\D/g, "").slice(0, 14) || "00000000000000";
|
|
407
|
+
const path = `${migrationsDir}/${stamp}_${fix.file}`;
|
|
408
|
+
return {
|
|
409
|
+
summary: fix.summary,
|
|
410
|
+
diff: addFileDiff(path, fix.sql),
|
|
411
|
+
touchedFiles: [path],
|
|
412
|
+
rationale: fix.rationale
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
193
416
|
// packages/graph/src/graph.ts
|
|
194
417
|
var SecurityGraph = class {
|
|
195
418
|
nodes = /* @__PURE__ */ new Map();
|
|
@@ -221,10 +444,10 @@ var SecurityGraph = class {
|
|
|
221
444
|
function buildGraph(model) {
|
|
222
445
|
const g = new SecurityGraph();
|
|
223
446
|
const tableInfo = new Map(model.tables.map((t) => [t.table, t]));
|
|
224
|
-
const tableNode = (
|
|
225
|
-
const info = tableInfo.get(
|
|
447
|
+
const tableNode = (table2) => {
|
|
448
|
+
const info = tableInfo.get(table2.toLowerCase());
|
|
226
449
|
const data = {
|
|
227
|
-
table,
|
|
450
|
+
table: table2,
|
|
228
451
|
known: info !== void 0,
|
|
229
452
|
rlsEnabled: info?.rlsEnabled ?? false,
|
|
230
453
|
policies: info?.policies ?? [],
|
|
@@ -233,19 +456,19 @@ function buildGraph(model) {
|
|
|
233
456
|
};
|
|
234
457
|
const node = g.addNode(
|
|
235
458
|
info ? {
|
|
236
|
-
id: `table:${
|
|
459
|
+
id: `table:${table2}`,
|
|
237
460
|
kind: "Table",
|
|
238
|
-
label: `public.${
|
|
461
|
+
label: `public.${table2}`,
|
|
239
462
|
data: { ...data },
|
|
240
463
|
location: info.location
|
|
241
|
-
} : { id: `table:${
|
|
464
|
+
} : { id: `table:${table2}`, kind: "Table", label: `public.${table2}`, data: { ...data } }
|
|
242
465
|
);
|
|
243
466
|
for (const p of data.policies) {
|
|
244
467
|
const pn = g.addNode({
|
|
245
|
-
id: `policy:${
|
|
468
|
+
id: `policy:${table2}:${p}`,
|
|
246
469
|
kind: "RLSPolicy",
|
|
247
470
|
label: p,
|
|
248
|
-
data: { table, name: p }
|
|
471
|
+
data: { table: table2, name: p }
|
|
249
472
|
});
|
|
250
473
|
g.addEdge(node.id, pn.id, "GUARDED_BY");
|
|
251
474
|
}
|
|
@@ -746,11 +969,11 @@ function walkOwn(body, visit) {
|
|
|
746
969
|
};
|
|
747
970
|
go(body);
|
|
748
971
|
}
|
|
749
|
-
function ownReturns(
|
|
750
|
-
if (!
|
|
751
|
-
if (!ts.isBlock(
|
|
972
|
+
function ownReturns(fn2) {
|
|
973
|
+
if (!fn2.body) return [];
|
|
974
|
+
if (!ts.isBlock(fn2.body)) return [fn2.body];
|
|
752
975
|
const out = [];
|
|
753
|
-
walkOwn(
|
|
976
|
+
walkOwn(fn2.body, (n) => {
|
|
754
977
|
if (ts.isReturnStatement(n) && n.expression) out.push(n.expression);
|
|
755
978
|
});
|
|
756
979
|
return out;
|
|
@@ -921,10 +1144,10 @@ function growNames(decls, scope) {
|
|
|
921
1144
|
if (scope.names.size === before) break;
|
|
922
1145
|
}
|
|
923
1146
|
}
|
|
924
|
-
function ownDeclarations(
|
|
1147
|
+
function ownDeclarations(fn2) {
|
|
925
1148
|
const out = [];
|
|
926
|
-
if (
|
|
927
|
-
walkOwn(
|
|
1149
|
+
if (fn2.body) {
|
|
1150
|
+
walkOwn(fn2.body, (n) => {
|
|
928
1151
|
if (ts3.isVariableDeclaration(n)) out.push(n);
|
|
929
1152
|
});
|
|
930
1153
|
}
|
|
@@ -953,14 +1176,29 @@ function moduleSecretScope(sf) {
|
|
|
953
1176
|
moduleScopes.set(sf, scope);
|
|
954
1177
|
return scope;
|
|
955
1178
|
}
|
|
956
|
-
function secretScopeFor(
|
|
1179
|
+
function secretScopeFor(fn2, sf) {
|
|
957
1180
|
const mod = moduleSecretScope(sf);
|
|
958
1181
|
const scope = { names: new Set(mod.names), fns: mod.fns };
|
|
959
|
-
growNames(ownDeclarations(
|
|
1182
|
+
growNames(ownDeclarations(fn2), scope);
|
|
960
1183
|
return scope;
|
|
961
1184
|
}
|
|
1185
|
+
var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
|
|
1186
|
+
function readsStoredSecret(e) {
|
|
1187
|
+
let hit = false;
|
|
1188
|
+
const visit = (n) => {
|
|
1189
|
+
if (hit) return;
|
|
1190
|
+
if (ts3.isPropertyAccessExpression(n) && STORED_SECRET_FIELD.test(n.name.text.toLowerCase())) {
|
|
1191
|
+
hit = true;
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
n.forEachChild(visit);
|
|
1195
|
+
};
|
|
1196
|
+
visit(e);
|
|
1197
|
+
return hit;
|
|
1198
|
+
}
|
|
962
1199
|
function isSecretish(e, scope) {
|
|
963
1200
|
if (envNamesIn(e).some(isSecretEnvName)) return true;
|
|
1201
|
+
if (readsStoredSecret(e)) return true;
|
|
964
1202
|
for (const id of identifiersIn(e)) if (scope.names.has(id)) return true;
|
|
965
1203
|
let calls = false;
|
|
966
1204
|
const visit = (n) => {
|
|
@@ -984,7 +1222,7 @@ var EQUALITY = /* @__PURE__ */ new Set([
|
|
|
984
1222
|
ts3.SyntaxKind.EqualsEqualsToken,
|
|
985
1223
|
ts3.SyntaxKind.ExclamationEqualsToken
|
|
986
1224
|
]);
|
|
987
|
-
var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals
|
|
1225
|
+
var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?|secretsMatch|secretMatch|matchesSecret|tokensMatch|tokenMatches|sameSecret|checkSecret|validSecret|isValidSecret)$/i;
|
|
988
1226
|
var VERIFY_CALLEE = /^(verify|verifySync|jwtVerify|constructEvent|constructEventAsync|verifySignature|verifyWebhook|validateSignature)$/i;
|
|
989
1227
|
var THROWING_VERIFIER = /^(jwtVerify|constructEvent|constructEventAsync)$/;
|
|
990
1228
|
var THROWING_VERIFY_RECEIVER = /jwt|jose|jsonwebtoken/i;
|
|
@@ -1035,11 +1273,39 @@ function gates(node, fnBody) {
|
|
|
1035
1273
|
}
|
|
1036
1274
|
return false;
|
|
1037
1275
|
}
|
|
1038
|
-
function
|
|
1039
|
-
if (!
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1276
|
+
function receiverSecret(callee, secretish, builtFromSecret) {
|
|
1277
|
+
if (!ts3.isPropertyAccessExpression(callee)) return false;
|
|
1278
|
+
const recv = callee.expression;
|
|
1279
|
+
if (ts3.isNewExpression(recv)) return (recv.arguments ?? []).some(secretish);
|
|
1280
|
+
if (ts3.isIdentifier(recv) && builtFromSecret.has(recv.text)) return true;
|
|
1281
|
+
return secretish(recv);
|
|
1282
|
+
}
|
|
1283
|
+
function instancesBuiltFromSecret(body, secretish) {
|
|
1284
|
+
const out = /* @__PURE__ */ new Set();
|
|
1285
|
+
for (const d of collect(body, ts3.isVariableDeclaration)) {
|
|
1286
|
+
if (!d.initializer || !ts3.isIdentifier(d.name) || !ts3.isNewExpression(d.initializer)) continue;
|
|
1287
|
+
if ((d.initializer.arguments ?? []).some(secretish)) out.add(d.name.text);
|
|
1288
|
+
}
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
function gatesByThrow(node, fnBody) {
|
|
1292
|
+
let child = node;
|
|
1293
|
+
let cur = node.parent;
|
|
1294
|
+
while (cur && cur !== fnBody && !isFunctionLikeNode(cur)) {
|
|
1295
|
+
if (ts3.isTryStatement(cur) && cur.tryBlock === child && cur.catchClause) {
|
|
1296
|
+
return exitKind(cur.catchClause.block) !== null;
|
|
1297
|
+
}
|
|
1298
|
+
child = cur;
|
|
1299
|
+
cur = cur.parent;
|
|
1300
|
+
}
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
function secretChecksIn(fn2, sf) {
|
|
1304
|
+
if (!fn2.body) return [];
|
|
1305
|
+
const body = fn2.body;
|
|
1306
|
+
const secrets = secretScopeFor(fn2, sf);
|
|
1042
1307
|
const secretish = (e) => isSecretish(e, secrets);
|
|
1308
|
+
const builtFromSecret = instancesBuiltFromSecret(body, secretish);
|
|
1043
1309
|
const out = [];
|
|
1044
1310
|
const push = (n) => {
|
|
1045
1311
|
out.push({ node: n, text: n.getText(sf).replace(/\s+/g, " ").slice(0, 160) });
|
|
@@ -1058,8 +1324,9 @@ function secretChecksIn(fn, sf) {
|
|
|
1058
1324
|
const verify = VERIFY_CALLEE.test(name);
|
|
1059
1325
|
if (!compare && !verify) return;
|
|
1060
1326
|
const secretArgs = n.arguments.filter(secretish).length;
|
|
1061
|
-
|
|
1062
|
-
if (
|
|
1327
|
+
const receiverHoldsSecret = verify && receiverSecret(n.expression, secretish, builtFromSecret);
|
|
1328
|
+
if (!receiverHoldsSecret && (secretArgs === 0 || secretArgs === n.arguments.length)) return;
|
|
1329
|
+
if (verify && throwsOnBadCredential(n) || gates(n, body) || gatesByThrow(n, body)) push(n);
|
|
1063
1330
|
});
|
|
1064
1331
|
return out;
|
|
1065
1332
|
}
|
|
@@ -1086,7 +1353,7 @@ function nextAuthSessionNames(stmt, nextAuthLocal) {
|
|
|
1086
1353
|
}
|
|
1087
1354
|
return out;
|
|
1088
1355
|
}
|
|
1089
|
-
var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret)$/;
|
|
1356
|
+
var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret|token|sessioncode|invitecode|accesscode|sharecode)$/;
|
|
1090
1357
|
function isCredentialColumn(column) {
|
|
1091
1358
|
return column !== null && CREDENTIAL_COLUMN.test(column.toLowerCase().replace(/_/g, ""));
|
|
1092
1359
|
}
|
|
@@ -1149,8 +1416,8 @@ function mentions(cond, names) {
|
|
|
1149
1416
|
return false;
|
|
1150
1417
|
}
|
|
1151
1418
|
function ifsAfter(node) {
|
|
1152
|
-
const
|
|
1153
|
-
const scope =
|
|
1419
|
+
const fn2 = enclosingFunction(node);
|
|
1420
|
+
const scope = fn2 ? fn2.body : node.getSourceFile();
|
|
1154
1421
|
const out = [];
|
|
1155
1422
|
if (!scope) return out;
|
|
1156
1423
|
walkOwn(scope, (n) => {
|
|
@@ -2417,7 +2684,9 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2417
2684
|
if (!isWord(tk[i], "function")) return;
|
|
2418
2685
|
const q = readQualifiedName(tk, i + 1);
|
|
2419
2686
|
if (!q || !isPunct(tk[q.next], "(")) return;
|
|
2420
|
-
|
|
2687
|
+
const argsEnd = groupEnd(tk, q.next);
|
|
2688
|
+
const args = argumentTypes(stmt, tk, q.next, argsEnd);
|
|
2689
|
+
i = argsEnd + 1;
|
|
2421
2690
|
let securityDefiner = false;
|
|
2422
2691
|
let returns = null;
|
|
2423
2692
|
let body = "";
|
|
@@ -2450,17 +2719,122 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2450
2719
|
}
|
|
2451
2720
|
const key = qualifiedKey(q);
|
|
2452
2721
|
const code = maskSqlComments(body);
|
|
2453
|
-
const
|
|
2722
|
+
const fn2 = {
|
|
2454
2723
|
schema: q.schema,
|
|
2455
2724
|
name: q.name,
|
|
2456
2725
|
securityDefiner,
|
|
2457
2726
|
returns,
|
|
2727
|
+
args,
|
|
2458
2728
|
body: code,
|
|
2459
2729
|
directCheck: CALLER_CHECKS.some((re) => re.test(code)),
|
|
2460
2730
|
acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
|
|
2461
2731
|
location: { file, line: stmt.line }
|
|
2462
2732
|
};
|
|
2463
|
-
reg.byKey.set(key,
|
|
2733
|
+
reg.byKey.set(key, fn2);
|
|
2734
|
+
}
|
|
2735
|
+
var TYPE_WORD = /* @__PURE__ */ new Set([
|
|
2736
|
+
"anyarray",
|
|
2737
|
+
"anyelement",
|
|
2738
|
+
"bigint",
|
|
2739
|
+
"bigserial",
|
|
2740
|
+
"bit",
|
|
2741
|
+
"bool",
|
|
2742
|
+
"boolean",
|
|
2743
|
+
"box",
|
|
2744
|
+
"bytea",
|
|
2745
|
+
"char",
|
|
2746
|
+
"character",
|
|
2747
|
+
"cidr",
|
|
2748
|
+
"circle",
|
|
2749
|
+
"date",
|
|
2750
|
+
"decimal",
|
|
2751
|
+
"double",
|
|
2752
|
+
"float",
|
|
2753
|
+
"float4",
|
|
2754
|
+
"float8",
|
|
2755
|
+
"inet",
|
|
2756
|
+
"int",
|
|
2757
|
+
"int2",
|
|
2758
|
+
"int4",
|
|
2759
|
+
"int8",
|
|
2760
|
+
"integer",
|
|
2761
|
+
"interval",
|
|
2762
|
+
"json",
|
|
2763
|
+
"jsonb",
|
|
2764
|
+
"line",
|
|
2765
|
+
"lseg",
|
|
2766
|
+
"macaddr",
|
|
2767
|
+
"money",
|
|
2768
|
+
"name",
|
|
2769
|
+
"numeric",
|
|
2770
|
+
"oid",
|
|
2771
|
+
"path",
|
|
2772
|
+
"point",
|
|
2773
|
+
"polygon",
|
|
2774
|
+
"real",
|
|
2775
|
+
"record",
|
|
2776
|
+
"regclass",
|
|
2777
|
+
"serial",
|
|
2778
|
+
"smallint",
|
|
2779
|
+
"smallserial",
|
|
2780
|
+
"text",
|
|
2781
|
+
"time",
|
|
2782
|
+
"timestamp",
|
|
2783
|
+
"timestamptz",
|
|
2784
|
+
"timetz",
|
|
2785
|
+
"trigger",
|
|
2786
|
+
"tsquery",
|
|
2787
|
+
"tsvector",
|
|
2788
|
+
"uuid",
|
|
2789
|
+
"varbit",
|
|
2790
|
+
"varchar",
|
|
2791
|
+
"void",
|
|
2792
|
+
"xml"
|
|
2793
|
+
]);
|
|
2794
|
+
function argumentTypes(stmt, tokens, open, close) {
|
|
2795
|
+
if (close <= open + 1) return "";
|
|
2796
|
+
const parts = [];
|
|
2797
|
+
let depth = 0;
|
|
2798
|
+
let start = open + 1;
|
|
2799
|
+
const pieces = [];
|
|
2800
|
+
for (let i = open + 1; i < close; i++) {
|
|
2801
|
+
const t = tokens[i];
|
|
2802
|
+
if (isPunct(t, "(") || isPunct(t, "[")) depth += 1;
|
|
2803
|
+
else if (isPunct(t, ")") || isPunct(t, "]")) depth -= 1;
|
|
2804
|
+
else if (depth === 0 && isPunct(t, ",")) {
|
|
2805
|
+
pieces.push([start, i]);
|
|
2806
|
+
start = i + 1;
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
pieces.push([start, close]);
|
|
2810
|
+
for (const [from, to] of pieces) {
|
|
2811
|
+
const words = [];
|
|
2812
|
+
for (let i = from; i < to; i++) {
|
|
2813
|
+
const t = tokens[i];
|
|
2814
|
+
if (!t) continue;
|
|
2815
|
+
if (isWord(t, "default")) break;
|
|
2816
|
+
if (t.kind === "punct" && t.value === "=") break;
|
|
2817
|
+
words.push(t);
|
|
2818
|
+
}
|
|
2819
|
+
if (words.length === 0) return null;
|
|
2820
|
+
let k = 0;
|
|
2821
|
+
if (isWord(words[k], "in") || isWord(words[k], "out") || isWord(words[k], "inout")) {
|
|
2822
|
+
if (isWord(words[k], "out")) continue;
|
|
2823
|
+
k += 1;
|
|
2824
|
+
} else if (isWord(words[k], "variadic")) {
|
|
2825
|
+
k += 1;
|
|
2826
|
+
}
|
|
2827
|
+
const firstWord = words[k];
|
|
2828
|
+
const named = firstWord !== void 0 && firstWord.kind === "word" && !TYPE_WORD.has(firstWord.value.toLowerCase()) && words.slice(k + 1).some((w) => w.kind === "word");
|
|
2829
|
+
const typeStart = named ? k + 1 : k;
|
|
2830
|
+
const first = words[typeStart];
|
|
2831
|
+
const last = words[words.length - 1];
|
|
2832
|
+
if (!first || !last) return null;
|
|
2833
|
+
parts.push(
|
|
2834
|
+
stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
return parts.join(", ");
|
|
2464
2838
|
}
|
|
2465
2839
|
function readFunctionList(tokens, from) {
|
|
2466
2840
|
const keys = [];
|
|
@@ -2486,28 +2860,28 @@ function applyAlterFunction(reg, stmt) {
|
|
|
2486
2860
|
const tk = stmt.tokens;
|
|
2487
2861
|
const list = readFunctionList(tk, 2);
|
|
2488
2862
|
const key = list.keys[0];
|
|
2489
|
-
const
|
|
2490
|
-
if (!
|
|
2863
|
+
const fn2 = key === void 0 ? void 0 : reg.byKey.get(key);
|
|
2864
|
+
if (!fn2 || key === void 0) return;
|
|
2491
2865
|
let i = list.next;
|
|
2492
2866
|
if (isWord(tk[i], "external")) i += 1;
|
|
2493
2867
|
if (isWord(tk[i], "security")) {
|
|
2494
|
-
if (isWord(tk[i + 1], "definer"))
|
|
2495
|
-
else if (isWord(tk[i + 1], "invoker"))
|
|
2868
|
+
if (isWord(tk[i + 1], "definer")) fn2.securityDefiner = true;
|
|
2869
|
+
else if (isWord(tk[i + 1], "invoker")) fn2.securityDefiner = false;
|
|
2496
2870
|
return;
|
|
2497
2871
|
}
|
|
2498
2872
|
let moved = null;
|
|
2499
2873
|
if (isWord(tk[i], "rename") && isWord(tk[i + 1], "to")) {
|
|
2500
2874
|
const name = identOf(tk[i + 2]);
|
|
2501
|
-
if (name) moved = { schema:
|
|
2875
|
+
if (name) moved = { schema: fn2.schema, name, next: 0 };
|
|
2502
2876
|
} else if (isWord(tk[i], "set") && isWord(tk[i + 1], "schema")) {
|
|
2503
2877
|
const schema = identOf(tk[i + 2]);
|
|
2504
|
-
if (schema) moved = { schema, name:
|
|
2878
|
+
if (schema) moved = { schema, name: fn2.name, next: 0 };
|
|
2505
2879
|
}
|
|
2506
2880
|
if (!moved) return;
|
|
2507
2881
|
reg.byKey.delete(key);
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
reg.byKey.set(qualifiedKey(moved),
|
|
2882
|
+
fn2.schema = moved.schema;
|
|
2883
|
+
fn2.name = moved.name;
|
|
2884
|
+
reg.byKey.set(qualifiedKey(moved), fn2);
|
|
2511
2885
|
}
|
|
2512
2886
|
function readRoles(tokens, from) {
|
|
2513
2887
|
const roles = [];
|
|
@@ -2562,8 +2936,8 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2562
2936
|
if (isWord(tk[i], "function") || isWord(tk[i], "routine")) {
|
|
2563
2937
|
const list = readFunctionList(tk, i + 1);
|
|
2564
2938
|
targets = list.keys.flatMap((k) => {
|
|
2565
|
-
const
|
|
2566
|
-
return
|
|
2939
|
+
const fn2 = reg.byKey.get(k);
|
|
2940
|
+
return fn2 ? [fn2] : [];
|
|
2567
2941
|
});
|
|
2568
2942
|
i = list.next;
|
|
2569
2943
|
} else if (isWord(tk[i], "all") && (isWord(tk[i + 1], "functions") || isWord(tk[i + 1], "routines")) && isWord(tk[i + 2], "in") && isWord(tk[i + 3], "schema")) {
|
|
@@ -2578,7 +2952,7 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2578
2952
|
i += 1;
|
|
2579
2953
|
}
|
|
2580
2954
|
targets = [...reg.byKey.values()].filter(
|
|
2581
|
-
(
|
|
2955
|
+
(fn2) => schemas.includes((fn2.schema ?? "public").toLowerCase())
|
|
2582
2956
|
);
|
|
2583
2957
|
} else {
|
|
2584
2958
|
return;
|
|
@@ -2586,7 +2960,7 @@ function applyGrantRevoke(reg, stmt) {
|
|
|
2586
2960
|
const [start, end] = roleClause(tk, i, grant ? "to" : "from");
|
|
2587
2961
|
if (start < 0) return;
|
|
2588
2962
|
const roles = readRoles(tk.slice(0, end), start);
|
|
2589
|
-
for (const
|
|
2963
|
+
for (const fn2 of targets) applyToAcl(fn2.acl, grant, roles);
|
|
2590
2964
|
}
|
|
2591
2965
|
function applyDefaultPrivileges(reg, stmt) {
|
|
2592
2966
|
const tk = stmt.tokens;
|
|
@@ -2683,6 +3057,7 @@ function finishFunctions(reg) {
|
|
|
2683
3057
|
location: f.location
|
|
2684
3058
|
};
|
|
2685
3059
|
if (f.returns !== null) info.returns = f.returns;
|
|
3060
|
+
if (f.args !== null) info.args = f.args;
|
|
2686
3061
|
return info;
|
|
2687
3062
|
});
|
|
2688
3063
|
}
|
|
@@ -2882,9 +3257,9 @@ function sync(state, key) {
|
|
|
2882
3257
|
function findColumn(meta, name) {
|
|
2883
3258
|
return meta.columns.find((c) => c.name === name);
|
|
2884
3259
|
}
|
|
2885
|
-
function pkOf(state,
|
|
2886
|
-
if (
|
|
2887
|
-
return state.meta.get(
|
|
3260
|
+
function pkOf(state, table2) {
|
|
3261
|
+
if (table2 === "auth.users") return ["id"];
|
|
3262
|
+
return state.meta.get(table2)?.pk ?? [];
|
|
2888
3263
|
}
|
|
2889
3264
|
function resolveRefs(state, meta) {
|
|
2890
3265
|
for (const col of meta.columns) {
|
|
@@ -2893,12 +3268,12 @@ function resolveRefs(state, meta) {
|
|
|
2893
3268
|
}
|
|
2894
3269
|
}
|
|
2895
3270
|
}
|
|
2896
|
-
function clearRefsTo(state,
|
|
3271
|
+
function clearRefsTo(state, table2, column) {
|
|
2897
3272
|
for (const [key, meta] of state.meta) {
|
|
2898
3273
|
let touched = false;
|
|
2899
3274
|
for (const col of meta.columns) {
|
|
2900
3275
|
const r = col.references;
|
|
2901
|
-
if (!r || r.table !==
|
|
3276
|
+
if (!r || r.table !== table2 || column !== void 0 && r.column !== column) continue;
|
|
2902
3277
|
col.references = null;
|
|
2903
3278
|
col.fkName = null;
|
|
2904
3279
|
touched = true;
|
|
@@ -3748,9 +4123,9 @@ function analyzeModule(rel, sf) {
|
|
|
3748
4123
|
if (!ts9.isCallExpression(init) && !ts9.isNewExpression(init)) continue;
|
|
3749
4124
|
if (ts9.isCallExpression(init)) {
|
|
3750
4125
|
const callee = init.expression;
|
|
3751
|
-
const
|
|
4126
|
+
const fn2 = ts9.isIdentifier(callee) ? callee.text : ts9.isPropertyAccessExpression(callee) ? callee.name.text : "";
|
|
3752
4127
|
const tableName = stringLiteralValue(init.arguments[0]);
|
|
3753
|
-
if ((DRIZZLE_TABLE_FNS.has(
|
|
4128
|
+
if ((DRIZZLE_TABLE_FNS.has(fn2) || fn2 === "table") && tableName !== null) {
|
|
3754
4129
|
drizzleTables.set(d.name.text, tableName);
|
|
3755
4130
|
continue;
|
|
3756
4131
|
}
|
|
@@ -3838,10 +4213,20 @@ var LOGICAL = /* @__PURE__ */ new Set([
|
|
|
3838
4213
|
ts10.SyntaxKind.BarBarToken,
|
|
3839
4214
|
ts10.SyntaxKind.AmpersandAmpersandToken
|
|
3840
4215
|
]);
|
|
4216
|
+
var SCHEMA_PARSE = /^(parse|safeParse|parseAsync|safeParseAsync)$/;
|
|
4217
|
+
function isSchemaParse(call, cx) {
|
|
4218
|
+
const callee = call.expression;
|
|
4219
|
+
if (!ts10.isPropertyAccessExpression(callee) || !SCHEMA_PARSE.test(callee.name.text)) return false;
|
|
4220
|
+
const recv = callee.expression;
|
|
4221
|
+
if (isWholeInput(recv, cx)) return false;
|
|
4222
|
+
return cx.strippingSchema(recv);
|
|
4223
|
+
}
|
|
3841
4224
|
function isWholeInput(e, cx) {
|
|
3842
4225
|
const u = unwrap(e);
|
|
3843
4226
|
if (ts10.isIdentifier(u)) return cx.wholeName(u.text);
|
|
3844
4227
|
if (ts10.isPropertyAccessExpression(u) || ts10.isElementAccessExpression(u)) {
|
|
4228
|
+
const inner = unwrap(u.expression);
|
|
4229
|
+
if (ts10.isCallExpression(inner) && isSchemaParse(inner, cx)) return false;
|
|
3845
4230
|
return isWholeInput(u.expression, cx);
|
|
3846
4231
|
}
|
|
3847
4232
|
if (ts10.isObjectLiteralExpression(u)) {
|
|
@@ -3861,6 +4246,7 @@ function isWholeInput(e, cx) {
|
|
|
3861
4246
|
}
|
|
3862
4247
|
function isWholeCall(call, cx) {
|
|
3863
4248
|
if (cx.requestBody(call)) return true;
|
|
4249
|
+
if (isSchemaParse(call, cx)) return false;
|
|
3864
4250
|
const callee = call.expression;
|
|
3865
4251
|
if (ts10.isPropertyAccessExpression(callee)) {
|
|
3866
4252
|
const method = callee.name.text;
|
|
@@ -3882,7 +4268,8 @@ function callbackKeepsWhole(cb, receiverWhole, cx) {
|
|
|
3882
4268
|
const inner = {
|
|
3883
4269
|
wholeName: (n) => elementNames.has(n) || cx.wholeName(n),
|
|
3884
4270
|
requestBody: cx.requestBody,
|
|
3885
|
-
requestName: cx.requestName
|
|
4271
|
+
requestName: cx.requestName,
|
|
4272
|
+
strippingSchema: cx.strippingSchema
|
|
3886
4273
|
};
|
|
3887
4274
|
return ownReturns(f).some((r) => isWholeInput(r, inner));
|
|
3888
4275
|
}
|
|
@@ -3940,12 +4327,12 @@ var NO_ARG = {
|
|
|
3940
4327
|
whole: false,
|
|
3941
4328
|
isRequest: false
|
|
3942
4329
|
};
|
|
3943
|
-
function ownFunctionSym(facts, name,
|
|
4330
|
+
function ownFunctionSym(facts, name, fn2) {
|
|
3944
4331
|
const factory = facts.clientFactories.find((c) => c.name === name);
|
|
3945
4332
|
if (factory) return { kind: "factory", factory };
|
|
3946
4333
|
const helper = facts.authHelpers.find((a) => a.name === name);
|
|
3947
|
-
if (helper) return { kind: "auth", helper, fn, facts };
|
|
3948
|
-
return { kind: "function", name, fn, facts };
|
|
4334
|
+
if (helper) return { kind: "auth", helper, fn: fn2, facts };
|
|
4335
|
+
return { kind: "function", name, fn: fn2, facts };
|
|
3949
4336
|
}
|
|
3950
4337
|
function exportedSym(p, tf, name, depth) {
|
|
3951
4338
|
if (depth > 5) return null;
|
|
@@ -3992,7 +4379,7 @@ function scopeOf(p, facts) {
|
|
|
3992
4379
|
for (const [name, v] of facts.moduleVars) {
|
|
3993
4380
|
scope.set(name, { kind: "var", name, init: v.init, facts });
|
|
3994
4381
|
}
|
|
3995
|
-
for (const [name,
|
|
4382
|
+
for (const [name, table2] of facts.drizzleTables) scope.set(name, { kind: "table", table: table2 });
|
|
3996
4383
|
for (const [name, v] of facts.authVars) scope.set(name, { kind: "auth", helper: v.helper });
|
|
3997
4384
|
for (const [local, ref] of facts.imports) {
|
|
3998
4385
|
const target = p.resolver.resolve(ref.spec, facts.file);
|
|
@@ -4018,9 +4405,9 @@ function scopeOf(p, facts) {
|
|
|
4018
4405
|
}
|
|
4019
4406
|
if (!/^[.~]|^@\//.test(ref.spec)) continue;
|
|
4020
4407
|
for (const f of p.registry.values()) {
|
|
4021
|
-
const
|
|
4022
|
-
if (!
|
|
4023
|
-
const sym = ownFunctionSym(f, ref.imported,
|
|
4408
|
+
const fn2 = f.functions.get(ref.imported);
|
|
4409
|
+
if (!fn2?.exported) continue;
|
|
4410
|
+
const sym = ownFunctionSym(f, ref.imported, fn2.fn);
|
|
4024
4411
|
if (sym.kind === "factory" || sym.kind === "auth") {
|
|
4025
4412
|
scope.set(local, sym);
|
|
4026
4413
|
p.warnings.push(
|
|
@@ -4040,10 +4427,10 @@ function symOfCallee(p, callee, scope) {
|
|
|
4040
4427
|
}
|
|
4041
4428
|
return void 0;
|
|
4042
4429
|
}
|
|
4043
|
-
function returnedExpressions(
|
|
4044
|
-
if (!
|
|
4045
|
-
if (!ts11.isBlock(
|
|
4046
|
-
return collect(
|
|
4430
|
+
function returnedExpressions(fn2) {
|
|
4431
|
+
if (!fn2.body) return [];
|
|
4432
|
+
if (!ts11.isBlock(fn2.body)) return [fn2.body];
|
|
4433
|
+
return collect(fn2.body, ts11.isReturnStatement).map((r) => r.expression).filter((e) => e !== void 0);
|
|
4047
4434
|
}
|
|
4048
4435
|
function factoryOfFunction(p, sym, depth) {
|
|
4049
4436
|
const key = `${sym.facts.file}#${sym.name}`;
|
|
@@ -4207,6 +4594,7 @@ function usesInput(frame, e) {
|
|
|
4207
4594
|
});
|
|
4208
4595
|
return hit;
|
|
4209
4596
|
}
|
|
4597
|
+
var CALLER_READ = /\b(?:await\s+)?cookies\(\)\s*(?:\.|$)|cookieStore\s*\.\s*get\s*\(|\bcookies\s*\.\s*get\s*\(|\b(?:await\s+)?headers\(\)\s*\.\s*get\s*\(/;
|
|
4210
4598
|
var REQUEST_MEMBER = /^(json|formData|text|arrayBuffer|blob|body|headers|cookies|url|nextUrl|query|params|ip|geo)$/;
|
|
4211
4599
|
var REQUEST_CLIENT_CALLEE = /client|supabase|prisma|drizzle/i;
|
|
4212
4600
|
function handsOverRequest(frame, call) {
|
|
@@ -4275,13 +4663,28 @@ function isRequestBodyCall(frame, call) {
|
|
|
4275
4663
|
if (frame.reqNames.has(recv.text)) return true;
|
|
4276
4664
|
return frame.depth === 0 && frame.reqNames.size === 0 && REQUEST_NAME.test(recv.text);
|
|
4277
4665
|
}
|
|
4278
|
-
function wholeContext(frame) {
|
|
4666
|
+
function wholeContext(p, frame) {
|
|
4279
4667
|
return {
|
|
4280
4668
|
wholeName: (n) => frame.wholeNames.has(n),
|
|
4281
4669
|
requestBody: (c) => isRequestBodyCall(frame, c),
|
|
4282
|
-
requestName: (n) => frame.reqNames.has(n)
|
|
4670
|
+
requestName: (n) => frame.reqNames.has(n),
|
|
4671
|
+
strippingSchema: (e) => isStrippingSchema(p, frame, e)
|
|
4283
4672
|
};
|
|
4284
4673
|
}
|
|
4674
|
+
var OBJECT_SCHEMA = /\b(?:z|zod|v|valibot|yup)\s*\.\s*object\s*\(/;
|
|
4675
|
+
var SCHEMA_KEEPS_UNKNOWN = /\.\s*(?:passthrough|catchall|nonstrict|unknown)\s*\(/;
|
|
4676
|
+
function isStrippingSchema(p, frame, e) {
|
|
4677
|
+
const u = unwrap(e);
|
|
4678
|
+
if (ts11.isCallExpression(u) || ts11.isPropertyAccessExpression(u)) {
|
|
4679
|
+
const text2 = u.getText();
|
|
4680
|
+
return OBJECT_SCHEMA.test(text2) && !SCHEMA_KEEPS_UNKNOWN.test(text2);
|
|
4681
|
+
}
|
|
4682
|
+
if (!ts11.isIdentifier(u)) return false;
|
|
4683
|
+
const sym = scopeOf(p, frame.facts).get(u.text);
|
|
4684
|
+
if (sym?.kind !== "var") return false;
|
|
4685
|
+
const text = sym.init.getText();
|
|
4686
|
+
return OBJECT_SCHEMA.test(text) && !SCHEMA_KEEPS_UNKNOWN.test(text);
|
|
4687
|
+
}
|
|
4285
4688
|
function receiverTainted(frame, call) {
|
|
4286
4689
|
const callee = call.expression;
|
|
4287
4690
|
if (!ts11.isPropertyAccessExpression(callee) && !ts11.isElementAccessExpression(callee)) return false;
|
|
@@ -4336,7 +4739,7 @@ function argBinding(p, arg, frame) {
|
|
|
4336
4739
|
client,
|
|
4337
4740
|
instance,
|
|
4338
4741
|
tainted: derivedIn(frame, arg),
|
|
4339
|
-
whole: isWholeInput(arg, wholeContext(frame)),
|
|
4742
|
+
whole: isWholeInput(arg, wholeContext(p, frame)),
|
|
4340
4743
|
isRequest
|
|
4341
4744
|
};
|
|
4342
4745
|
}
|
|
@@ -4405,10 +4808,10 @@ function callTarget(p, call, frame, scope) {
|
|
|
4405
4808
|
return null;
|
|
4406
4809
|
}
|
|
4407
4810
|
function bindDeclarations(p, frame, acc) {
|
|
4408
|
-
const { rel, sf, fn } = frame;
|
|
4811
|
+
const { rel, sf, fn: fn2 } = frame;
|
|
4409
4812
|
const scope = scopeOf(p, frame.facts);
|
|
4410
4813
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
4411
|
-
const body =
|
|
4814
|
+
const body = fn2.body ?? fn2;
|
|
4412
4815
|
for (const [name, sym] of scope) {
|
|
4413
4816
|
if (sym.kind !== "var" || frame.clients.has(name) || frame.instances.has(name)) continue;
|
|
4414
4817
|
const vb = varBinding(p, sym);
|
|
@@ -4466,6 +4869,11 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4466
4869
|
continue;
|
|
4467
4870
|
}
|
|
4468
4871
|
}
|
|
4872
|
+
if (CALLER_READ.test(text)) {
|
|
4873
|
+
if (frame.depth === 0) handlerInput("header", names, decl);
|
|
4874
|
+
else bindInput(names, false);
|
|
4875
|
+
continue;
|
|
4876
|
+
}
|
|
4469
4877
|
if (ts11.isCallExpression(init)) {
|
|
4470
4878
|
const inst = instanceOfCall(p, init, frame, scope);
|
|
4471
4879
|
if (inst && ts11.isIdentifier(decl.name)) {
|
|
@@ -4478,7 +4886,7 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4478
4886
|
if (args.some((a) => a.tainted || a.isRequest) || receiverTainted(frame, init)) {
|
|
4479
4887
|
const rt = returnTaint(p, init, frame, scope, 0);
|
|
4480
4888
|
if (rt === null || rt.tainted && rt.props === null) {
|
|
4481
|
-
bindInput(names, isWholeInput(init, wholeContext(frame)));
|
|
4889
|
+
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4482
4890
|
} else if (rt.tainted && rt.props !== null) {
|
|
4483
4891
|
if (ts11.isIdentifier(decl.name))
|
|
4484
4892
|
frame.partialInputs.set(decl.name.text, new Set(rt.props));
|
|
@@ -4515,7 +4923,7 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4515
4923
|
} else if (partial) bindPatternFrom(frame, decl.name, partial);
|
|
4516
4924
|
}
|
|
4517
4925
|
} else if (!isChainWithQuery(init) && derivedIn(frame, init)) {
|
|
4518
|
-
bindInput(names, isWholeInput(init, wholeContext(frame)));
|
|
4926
|
+
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4519
4927
|
}
|
|
4520
4928
|
}
|
|
4521
4929
|
if (frame.depth === 0) {
|
|
@@ -4528,10 +4936,10 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4528
4936
|
}
|
|
4529
4937
|
}
|
|
4530
4938
|
function analyzeFrame(p, frame, acc) {
|
|
4531
|
-
const { rel, sf, fn } = frame;
|
|
4939
|
+
const { rel, sf, fn: fn2 } = frame;
|
|
4532
4940
|
const scope = scopeOf(p, frame.facts);
|
|
4533
4941
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
4534
|
-
const body =
|
|
4942
|
+
const body = fn2.body ?? fn2;
|
|
4535
4943
|
bindDeclarations(p, frame, acc);
|
|
4536
4944
|
for (const call of collect(body, ts11.isCallExpression)) {
|
|
4537
4945
|
const callee = call.expression;
|
|
@@ -4539,8 +4947,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4539
4947
|
continue;
|
|
4540
4948
|
if (!ts11.isIdentifier(callee.expression)) continue;
|
|
4541
4949
|
const outer = frame.clients.get(callee.expression.text);
|
|
4542
|
-
const
|
|
4543
|
-
const param =
|
|
4950
|
+
const fn3 = call.arguments.map((a) => unwrap(a)).find((a) => ts11.isArrowFunction(a) || ts11.isFunctionExpression(a));
|
|
4951
|
+
const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
|
|
4544
4952
|
if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
|
|
4545
4953
|
}
|
|
4546
4954
|
const isSessionCall = (call) => /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
|
|
@@ -4555,7 +4963,7 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4555
4963
|
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
|
|
4556
4964
|
});
|
|
4557
4965
|
}
|
|
4558
|
-
for (const check of secretChecksIn(
|
|
4966
|
+
for (const check of secretChecksIn(fn2, sf)) {
|
|
4559
4967
|
acc.authChecks.push({ ...loc2(check.node), kind: "secret" });
|
|
4560
4968
|
}
|
|
4561
4969
|
for (const pa of collect(body, ts11.isPropertyAccessExpression)) {
|
|
@@ -4619,7 +5027,7 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4619
5027
|
const payloadOf = (arg) => arg ? {
|
|
4620
5028
|
text: arg.getText(sf).replace(/\s+/g, " ").slice(0, 200),
|
|
4621
5029
|
inputDerived: derivedIn(frame, arg),
|
|
4622
|
-
wholeInput: isWholeInput(arg, wholeContext(frame))
|
|
5030
|
+
wholeInput: isWholeInput(arg, wholeContext(p, frame))
|
|
4623
5031
|
} : null;
|
|
4624
5032
|
const storageHandles = storageBindingsIn(body);
|
|
4625
5033
|
let callerScope = null;
|
|
@@ -4864,7 +5272,7 @@ function childFrame(p, call, target, frame) {
|
|
|
4864
5272
|
pathPos: [...frame.pathPos, call.getStart(frame.sf)],
|
|
4865
5273
|
exitPropagates: frame.exitPropagates && callResultChecked(call)
|
|
4866
5274
|
};
|
|
4867
|
-
const cx = wholeContext(frame);
|
|
5275
|
+
const cx = wholeContext(p, frame);
|
|
4868
5276
|
target.fn.parameters.forEach((param, i) => {
|
|
4869
5277
|
const arg = call.arguments[i];
|
|
4870
5278
|
const ab = argBinding(p, arg, frame);
|
|
@@ -5060,16 +5468,16 @@ function enclosingCondition(node, body) {
|
|
|
5060
5468
|
return null;
|
|
5061
5469
|
}
|
|
5062
5470
|
var normalizeColumn = (c) => (c ?? "").toLowerCase().replace(/_/g, "");
|
|
5063
|
-
function singular(
|
|
5064
|
-
if (
|
|
5065
|
-
if (
|
|
5066
|
-
return
|
|
5471
|
+
function singular(table2) {
|
|
5472
|
+
if (table2.endsWith("ies")) return `${table2.slice(0, -3)}y`;
|
|
5473
|
+
if (table2.endsWith("ses") || table2.endsWith("xes")) return table2.slice(0, -2);
|
|
5474
|
+
return table2.endsWith("s") ? table2.slice(0, -1) : table2;
|
|
5067
5475
|
}
|
|
5068
|
-
function columnNamesTable(column,
|
|
5476
|
+
function columnNamesTable(column, table2) {
|
|
5069
5477
|
const c = normalizeColumn(column);
|
|
5070
5478
|
if (!c.endsWith("id") || c === "id") return false;
|
|
5071
5479
|
const stem = c.slice(0, -2);
|
|
5072
|
-
const t = normalizeColumn(
|
|
5480
|
+
const t = normalizeColumn(table2);
|
|
5073
5481
|
return stem === t || stem === singular(t);
|
|
5074
5482
|
}
|
|
5075
5483
|
function guardMatch(q, g, tables) {
|
|
@@ -5157,8 +5565,53 @@ function calleePath(e) {
|
|
|
5157
5565
|
if (ts11.isElementAccessExpression(u)) return `${calleePath(u.expression)}[]`;
|
|
5158
5566
|
return "";
|
|
5159
5567
|
}
|
|
5568
|
+
var EMPTY_GUARDS = { authChecks: [], roleChecks: [] };
|
|
5569
|
+
function layoutGuards(p, pageRel, cache) {
|
|
5570
|
+
const parts = pageRel.split("/");
|
|
5571
|
+
parts.pop();
|
|
5572
|
+
const out = { authChecks: [], roleChecks: [] };
|
|
5573
|
+
for (let i = parts.length; i > 0; i--) {
|
|
5574
|
+
const g = layoutGuardsFor(p, parts.slice(0, i).join("/"), cache);
|
|
5575
|
+
out.authChecks.push(...g.authChecks);
|
|
5576
|
+
out.roleChecks.push(...g.roleChecks);
|
|
5577
|
+
}
|
|
5578
|
+
return out;
|
|
5579
|
+
}
|
|
5580
|
+
var LAYOUT_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
|
|
5581
|
+
function layoutGuardsFor(p, dir, cache) {
|
|
5582
|
+
const hit = cache.get(dir);
|
|
5583
|
+
if (hit) return hit;
|
|
5584
|
+
let guards = EMPTY_GUARDS;
|
|
5585
|
+
for (const ext of LAYOUT_EXTENSIONS) {
|
|
5586
|
+
const rel = `${dir}/layout.${ext}`;
|
|
5587
|
+
const sf = p.sources.get(rel);
|
|
5588
|
+
const facts = p.registry.get(rel);
|
|
5589
|
+
if (!sf || !facts || isClientComponentFile(sf)) continue;
|
|
5590
|
+
const fn2 = pageHandlerIn(sf);
|
|
5591
|
+
if (!fn2) continue;
|
|
5592
|
+
try {
|
|
5593
|
+
const analysed = analyzeHandler(p, {
|
|
5594
|
+
rel,
|
|
5595
|
+
sf,
|
|
5596
|
+
facts,
|
|
5597
|
+
kind: "page",
|
|
5598
|
+
route: dir,
|
|
5599
|
+
method: "PAGE",
|
|
5600
|
+
fn: fn2.fn,
|
|
5601
|
+
node: fn2.node,
|
|
5602
|
+
wrapper: fn2.wrapper
|
|
5603
|
+
});
|
|
5604
|
+
guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
|
|
5605
|
+
} catch {
|
|
5606
|
+
guards = EMPTY_GUARDS;
|
|
5607
|
+
}
|
|
5608
|
+
break;
|
|
5609
|
+
}
|
|
5610
|
+
cache.set(dir, guards);
|
|
5611
|
+
return guards;
|
|
5612
|
+
}
|
|
5160
5613
|
function analyzeHandler(p, h) {
|
|
5161
|
-
const { rel, sf, fn } = h;
|
|
5614
|
+
const { rel, sf, fn: fn2 } = h;
|
|
5162
5615
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
5163
5616
|
const acc = {
|
|
5164
5617
|
inputs: [],
|
|
@@ -5173,7 +5626,7 @@ function analyzeHandler(p, h) {
|
|
|
5173
5626
|
rel,
|
|
5174
5627
|
sf,
|
|
5175
5628
|
facts: h.facts,
|
|
5176
|
-
fn,
|
|
5629
|
+
fn: fn2,
|
|
5177
5630
|
depth: 0,
|
|
5178
5631
|
via: [],
|
|
5179
5632
|
inputNames: /* @__PURE__ */ new Set(["params", "searchParams"]),
|
|
@@ -5191,13 +5644,13 @@ function analyzeHandler(p, h) {
|
|
|
5191
5644
|
pathPos: [],
|
|
5192
5645
|
exitPropagates: true
|
|
5193
5646
|
};
|
|
5194
|
-
const first =
|
|
5647
|
+
const first = fn2.parameters[0];
|
|
5195
5648
|
if (h.kind === "route" && first) {
|
|
5196
5649
|
if (ts11.isIdentifier(first.name)) frame.reqNames.add(first.name.text);
|
|
5197
5650
|
else for (const nm of boundNames(first.name)) if (REQUEST_NAME.test(nm)) frame.reqNames.add(nm);
|
|
5198
5651
|
}
|
|
5199
5652
|
if (h.kind === "server_action") {
|
|
5200
|
-
const params = h.wrapper ?
|
|
5653
|
+
const params = h.wrapper ? fn2.parameters.slice(0, 1) : fn2.parameters;
|
|
5201
5654
|
for (const prm of params) {
|
|
5202
5655
|
for (const nm of boundNames(prm.name)) {
|
|
5203
5656
|
if (!acc.inputs.some((i) => i.kind === "action_arg" && i.name === nm)) {
|
|
@@ -5365,6 +5818,15 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5365
5818
|
storageBuckets: schema.storageBuckets
|
|
5366
5819
|
};
|
|
5367
5820
|
}
|
|
5821
|
+
var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
|
|
5822
|
+
function layoutCacheOf(p) {
|
|
5823
|
+
let m = LAYOUT_CACHE.get(p);
|
|
5824
|
+
if (!m) {
|
|
5825
|
+
m = /* @__PURE__ */ new Map();
|
|
5826
|
+
LAYOUT_CACHE.set(p, m);
|
|
5827
|
+
}
|
|
5828
|
+
return m;
|
|
5829
|
+
}
|
|
5368
5830
|
function analyzeFile(project, rel, sf, facts, out) {
|
|
5369
5831
|
const { routes, exposures, fileIgnores } = out;
|
|
5370
5832
|
{
|
|
@@ -5423,6 +5885,11 @@ function analyzeFile(project, rel, sf, facts, out) {
|
|
|
5423
5885
|
node: handler.node,
|
|
5424
5886
|
wrapper: handler.wrapper
|
|
5425
5887
|
});
|
|
5888
|
+
const guards = layoutGuards(project, rel, layoutCacheOf(project));
|
|
5889
|
+
analysed.authChecks.push(...guards.authChecks);
|
|
5890
|
+
if (guards.roleChecks.length > 0) {
|
|
5891
|
+
analysed.roleChecks = [...analysed.roleChecks ?? [], ...guards.roleChecks];
|
|
5892
|
+
}
|
|
5426
5893
|
if (analysed.queries.length > 0 || analysed.inputs.length > 0) routes.push(analysed);
|
|
5427
5894
|
}
|
|
5428
5895
|
}
|
|
@@ -5442,7 +5909,7 @@ function callerCheckingFunctions(model) {
|
|
|
5442
5909
|
);
|
|
5443
5910
|
}
|
|
5444
5911
|
var CALL2 = /(?:"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]*)"?\s*\(/g;
|
|
5445
|
-
function
|
|
5912
|
+
function qualified2(schema, name) {
|
|
5446
5913
|
const s = (schema ?? "public").toLowerCase();
|
|
5447
5914
|
const n = (name ?? "").toLowerCase();
|
|
5448
5915
|
return s === "public" ? n : `${s}.${n}`;
|
|
@@ -5450,7 +5917,7 @@ function qualified(schema, name) {
|
|
|
5450
5917
|
function callsFunctionIn(expr, names) {
|
|
5451
5918
|
if (names.size === 0) return false;
|
|
5452
5919
|
for (const c of expr.replace(/'(?:[^']|'')*'/g, "''").matchAll(CALL2)) {
|
|
5453
|
-
if (names.has(
|
|
5920
|
+
if (names.has(qualified2(c[1], c[2]))) return true;
|
|
5454
5921
|
}
|
|
5455
5922
|
return false;
|
|
5456
5923
|
}
|
|
@@ -5518,14 +5985,14 @@ function handlerViews(ctx) {
|
|
|
5518
5985
|
function queryViews(ctx, handler) {
|
|
5519
5986
|
return ctx.graph.out(handler.id, "CALLS").map((query) => {
|
|
5520
5987
|
const client = ctx.graph.out(query.id, "USES_CLIENT")[0];
|
|
5521
|
-
const
|
|
5988
|
+
const table2 = ctx.graph.out(query.id, "TARGETS")[0];
|
|
5522
5989
|
return {
|
|
5523
5990
|
query,
|
|
5524
5991
|
data: query.data,
|
|
5525
5992
|
client,
|
|
5526
5993
|
clientData: client?.data,
|
|
5527
|
-
table,
|
|
5528
|
-
tableData:
|
|
5994
|
+
table: table2,
|
|
5995
|
+
tableData: table2?.data
|
|
5529
5996
|
};
|
|
5530
5997
|
});
|
|
5531
5998
|
}
|
|
@@ -5560,16 +6027,16 @@ function anonReadPolicy(t) {
|
|
|
5560
6027
|
(p) => (p.command === "select" || p.command === "all") && (p.using ?? "").replace(/[\s()]/g, "").toLowerCase() === "true" && (p.roles.length === 0 || p.roles.some((r) => r === "anon" || r === "public"))
|
|
5561
6028
|
);
|
|
5562
6029
|
}
|
|
5563
|
-
function publicReadNote(p,
|
|
5564
|
-
return ` public.${
|
|
6030
|
+
function publicReadNote(p, table2) {
|
|
6031
|
+
return ` public.${table2} is readable by the anon role through RLS policy "${p.name}" (${p.location.file}:${p.location.line}), so this read leaks nothing beyond what the anon key already returns; the repository may not have intended that policy, so the finding stays at medium.`;
|
|
5565
6032
|
}
|
|
5566
6033
|
function adminOnly(ctx, h, t) {
|
|
5567
6034
|
const check = h.roleChecks[0];
|
|
5568
6035
|
if (!check || !t?.known || !singleTenantTable(ctx, t.table)) return void 0;
|
|
5569
6036
|
return check;
|
|
5570
6037
|
}
|
|
5571
|
-
function singleTenantTable(ctx,
|
|
5572
|
-
const info = ctx.model.tables.find((x) => x.table ===
|
|
6038
|
+
function singleTenantTable(ctx, table2) {
|
|
6039
|
+
const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
|
|
5573
6040
|
if (!info || info.columns.some((c) => isScopeColumn(c))) return false;
|
|
5574
6041
|
for (const col of info.columnInfo ?? []) {
|
|
5575
6042
|
if (!col.references || col.nullable) continue;
|
|
@@ -5578,16 +6045,16 @@ function singleTenantTable(ctx, table) {
|
|
|
5578
6045
|
}
|
|
5579
6046
|
return true;
|
|
5580
6047
|
}
|
|
5581
|
-
function adminOnlyNote(check,
|
|
5582
|
-
return ` Admin-only: the handler stops unless ${check.source} passes the role check at ${check.file}:${check.line} (${check.text}), and public.${
|
|
6048
|
+
function adminOnlyNote(check, table2) {
|
|
6049
|
+
return ` Admin-only: the handler stops unless ${check.source} passes the role check at ${check.file}:${check.line} (${check.text}), and public.${table2} has no tenant or owner column, so the row is shared site content rather than a tenant's; verify the admin check cannot be self-granted.`;
|
|
5583
6050
|
}
|
|
5584
|
-
function tableDataOf(ctx,
|
|
5585
|
-
return ctx.graph.nodes.get(`table:${
|
|
6051
|
+
function tableDataOf(ctx, table2) {
|
|
6052
|
+
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
5586
6053
|
}
|
|
5587
6054
|
function callerCheck(checks) {
|
|
5588
6055
|
return checks?.find((c) => isScopeColumn(c.column) && !c.inputDerived);
|
|
5589
6056
|
}
|
|
5590
|
-
function guardTiesRowToCaller(guard,
|
|
6057
|
+
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
5591
6058
|
const callerFilter = guard.filters.find((f) => isScopeColumn(f.column) && !f.inputDerived);
|
|
5592
6059
|
if (callerFilter) {
|
|
5593
6060
|
return {
|
|
@@ -5611,10 +6078,10 @@ function guardTiesRowToCaller(guard, table, callerFns) {
|
|
|
5611
6078
|
why: `the read runs with a ${guard.client === "anon" ? "public anon" : "privileged"} client and filters by no owner column, so it returns the row for anyone`
|
|
5612
6079
|
};
|
|
5613
6080
|
}
|
|
5614
|
-
const reads = (
|
|
6081
|
+
const reads = (table2?.policyDetails ?? []).filter(
|
|
5615
6082
|
(p) => p.command === "select" || p.command === "all"
|
|
5616
6083
|
);
|
|
5617
|
-
const scoped =
|
|
6084
|
+
const scoped = table2?.known === true && table2.rlsEnabled && reads.length > 0 && reads.every((p) => policyScopesToCaller(p.using) || callsFunctionIn(p.using ?? "", callerFns));
|
|
5618
6085
|
return {
|
|
5619
6086
|
tied: scoped,
|
|
5620
6087
|
how: `through ${guard.clientName ?? "a user-scoped client"}, so RLS applied`,
|
|
@@ -5732,7 +6199,7 @@ var tableWithoutRls = {
|
|
|
5732
6199
|
],
|
|
5733
6200
|
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.`,
|
|
5734
6201
|
title: `Table "${t.table}" is exposed without RLS`,
|
|
5735
|
-
data: { deterministic: true, ruleId: this.id },
|
|
6202
|
+
data: { deterministic: true, ruleId: this.id, table: t.table },
|
|
5736
6203
|
tail: locations(v.table?.location)
|
|
5737
6204
|
}));
|
|
5738
6205
|
addReach(g, h, v, `supabase.${v.data.operation}:public.${t.table}`);
|
|
@@ -6576,7 +7043,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6576
7043
|
return out;
|
|
6577
7044
|
}
|
|
6578
7045
|
};
|
|
6579
|
-
var
|
|
7046
|
+
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
6580
7047
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
6581
7048
|
id: "supabase.security-definer-function-without-caller-check",
|
|
6582
7049
|
title: "SECURITY DEFINER function without a caller check",
|
|
@@ -6593,22 +7060,22 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6593
7060
|
rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
|
|
6594
7061
|
}
|
|
6595
7062
|
const out = [];
|
|
6596
|
-
for (const
|
|
6597
|
-
if (!
|
|
6598
|
-
if (
|
|
6599
|
-
if (
|
|
6600
|
-
const roles =
|
|
7063
|
+
for (const fn2 of fns) {
|
|
7064
|
+
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
7065
|
+
if (fn2.name.includes(".")) continue;
|
|
7066
|
+
if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
|
|
7067
|
+
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
6601
7068
|
if (roles.length === 0) continue;
|
|
6602
7069
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
6603
|
-
const sites = rpcByName.get(
|
|
7070
|
+
const sites = rpcByName.get(fn2.name) ?? [];
|
|
6604
7071
|
const entries = unique(sites.map((s) => s.handlerData.entry));
|
|
6605
|
-
const endpoint = `POST /rest/v1/rpc/${
|
|
7072
|
+
const endpoint = `POST /rest/v1/rpc/${fn2.name}`;
|
|
6606
7073
|
const who = anonymous ? "Anonymous visitors can call it with the public anon key" : "Any signed-in user can call it";
|
|
6607
|
-
const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${
|
|
7074
|
+
const callNote = entries.length > 0 ? ` The app calls it with supabase.rpc("${fn2.name}") from ${entries.join(", ")}.` : "";
|
|
6608
7075
|
const path = [
|
|
6609
7076
|
entries[0] ?? endpoint,
|
|
6610
|
-
`supabase.rpc("${
|
|
6611
|
-
`public.${
|
|
7077
|
+
`supabase.rpc("${fn2.name}") (EXECUTE: ${roles.join(", ")})`,
|
|
7078
|
+
`public.${fn2.name}() SECURITY DEFINER (runs as its owner, RLS does not apply)`,
|
|
6612
7079
|
"no auth.uid() / auth.jwt() check"
|
|
6613
7080
|
];
|
|
6614
7081
|
out.push(
|
|
@@ -6616,27 +7083,27 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6616
7083
|
ctx,
|
|
6617
7084
|
this,
|
|
6618
7085
|
{
|
|
6619
|
-
title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${
|
|
7086
|
+
title: `${anonymous ? "Anonymous-callable " : ""}SECURITY DEFINER function "${fn2.name}" without a caller check`,
|
|
6620
7087
|
entrypoints: [...entries, endpoint],
|
|
6621
7088
|
sources: unique([
|
|
6622
7089
|
...sites.flatMap((s) => s.inputs.map((i) => `${i.kind}:${i.name}`)),
|
|
6623
7090
|
"rpc arguments"
|
|
6624
7091
|
]),
|
|
6625
|
-
sinks: [`postgres.function:public.${
|
|
7092
|
+
sinks: [`postgres.function:public.${fn2.name}`],
|
|
6626
7093
|
path,
|
|
6627
7094
|
evidence: [
|
|
6628
7095
|
{
|
|
6629
7096
|
kind: "rule",
|
|
6630
|
-
summary: `public.${
|
|
7097
|
+
summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
|
|
6631
7098
|
locations: locations3(
|
|
6632
|
-
|
|
7099
|
+
fn2.location,
|
|
6633
7100
|
...sites.flatMap((s) => [s.handler.location, s.query.location])
|
|
6634
7101
|
),
|
|
6635
7102
|
data: {
|
|
6636
7103
|
deterministic: false,
|
|
6637
7104
|
ruleId: this.id,
|
|
6638
|
-
function:
|
|
6639
|
-
grantedTo: [...
|
|
7105
|
+
function: fn2.name,
|
|
7106
|
+
grantedTo: [...fn2.grantedTo]
|
|
6640
7107
|
}
|
|
6641
7108
|
},
|
|
6642
7109
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
@@ -6689,9 +7156,9 @@ function applyPublicTables(findings, publicTables2, now) {
|
|
|
6689
7156
|
if (f.sinks.length === 0) return f;
|
|
6690
7157
|
const tables = [];
|
|
6691
7158
|
for (const sink of f.sinks) {
|
|
6692
|
-
const
|
|
6693
|
-
if (
|
|
6694
|
-
if (!tables.includes(
|
|
7159
|
+
const table2 = READ_SINK.exec(sink)?.[1];
|
|
7160
|
+
if (table2 === void 0 || !declared.has(table2.toLowerCase())) return f;
|
|
7161
|
+
if (!tables.includes(table2)) tables.push(table2);
|
|
6695
7162
|
}
|
|
6696
7163
|
const command = f.evidence[0]?.data?.command;
|
|
6697
7164
|
if (f.ruleId === "supabase.rls-policy-without-caller-predicate" && command !== "select") {
|
|
@@ -6914,7 +7381,13 @@ function runScan(path, opts = {}) {
|
|
|
6914
7381
|
const graph = buildGraph(model);
|
|
6915
7382
|
const publicTables2 = cfg.config.publicTables ?? [];
|
|
6916
7383
|
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
|
|
6917
|
-
const
|
|
7384
|
+
const rulesFindings = runRules(defaultRules, model, graph, runOpts);
|
|
7385
|
+
const migrationsDir = repoDirs.dirs[0] ?? "supabase/migrations";
|
|
7386
|
+
const findings = rulesFindings.map((f) => {
|
|
7387
|
+
if (f.status === "suppressed") return f;
|
|
7388
|
+
const fix = deterministicFix(f, model, migrationsDir);
|
|
7389
|
+
return fix ? { ...f, fix } : f;
|
|
7390
|
+
});
|
|
6918
7391
|
const coverage = summarizeCoverage(findings);
|
|
6919
7392
|
const summary = summarize(model, defaultRules.length, publicTables2);
|
|
6920
7393
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Deterministic security scanner for Next.js + Supabase apps: cross-tenant reads, RLS gaps, service-role misuse, mass assignment. No account, no model, seconds.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|