auditai-scan 0.7.1 → 0.7.3
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 +671 -69
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -253,6 +253,19 @@ var COMMANDS = /* @__PURE__ */ new Set(["select", "insert", "update", "delete",
|
|
|
253
253
|
var HEADER = (title) => `-- ${title}
|
|
254
254
|
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
|
|
255
255
|
`;
|
|
256
|
+
function policedTables(f, model) {
|
|
257
|
+
const names = f.tables ?? [];
|
|
258
|
+
if (names.length === 0)
|
|
259
|
+
return null;
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const n of names) {
|
|
262
|
+
const t = table(model, n);
|
|
263
|
+
if (!t || !t.rlsEnabled || t.policies.length === 0)
|
|
264
|
+
return null;
|
|
265
|
+
out.push(qualified(t.table));
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
256
269
|
function sqlFunctionFix(finding4, model) {
|
|
257
270
|
const name = finding4.evidence[0]?.data?.function;
|
|
258
271
|
const f = fn(model, typeof name === "string" ? name : void 0);
|
|
@@ -260,6 +273,21 @@ function sqlFunctionFix(finding4, model) {
|
|
|
260
273
|
return null;
|
|
261
274
|
const sig = signatureOf(f);
|
|
262
275
|
const ambiguous = sig.endsWith("(...)");
|
|
276
|
+
const policed = f.returns !== void 0 && f.returns !== "void" ? policedTables(f, model) : null;
|
|
277
|
+
if (policed && !ambiguous) {
|
|
278
|
+
return {
|
|
279
|
+
file: `fix_security_invoker_${f.name.replace(/[^a-z0-9]+/gi, "_").toLowerCase()}.sql`,
|
|
280
|
+
sql: [
|
|
281
|
+
HEADER(`Run ${f.name} with the caller's rights, so row level security applies inside it`),
|
|
282
|
+
`-- It reads ${policed.join(", ")}, and each of them has row level security with policies.
|
|
283
|
+
`,
|
|
284
|
+
`alter function ${sig} security invoker;
|
|
285
|
+
`
|
|
286
|
+
].join(""),
|
|
287
|
+
summary: `Run ${f.name} with the caller's rights (security invoker)`,
|
|
288
|
+
rationale: `${f.name} only reads tables that already have row level security with policies (${policed.join(", ")}), so running it with the caller's rights lets those policies decide what it returns, and calls from your app keep working. If it must keep its owner's rights on purpose (for example it counts rows a caller may not see), revoke execute from public, anon and authenticated instead, or add a caller check inside the body.`
|
|
289
|
+
};
|
|
290
|
+
}
|
|
263
291
|
const body = [
|
|
264
292
|
HEADER(`Stop anon and authenticated from calling ${f.name} directly`),
|
|
265
293
|
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" : "",
|
|
@@ -1490,6 +1518,18 @@ function rowComparisons(tail) {
|
|
|
1490
1518
|
const { parent } = outerOf(tail);
|
|
1491
1519
|
if (!parent || !ts4.isVariableDeclaration(parent)) return [];
|
|
1492
1520
|
const { data } = resultNames(parent.name);
|
|
1521
|
+
const fn2 = enclosingFunction(parent);
|
|
1522
|
+
const scope = fn2 ? fn2.body : parent.getSourceFile();
|
|
1523
|
+
if (scope) {
|
|
1524
|
+
walkOwn(scope, (n) => {
|
|
1525
|
+
if (!ts4.isVariableDeclaration(n) || !n.initializer || n.pos < parent.end) return;
|
|
1526
|
+
if (!ts4.isIdentifier(n.name)) return;
|
|
1527
|
+
const init = unwrap(n.initializer);
|
|
1528
|
+
if (!ts4.isPropertyAccessExpression(init) && !ts4.isElementAccessExpression(init)) return;
|
|
1529
|
+
const r = rootName(init);
|
|
1530
|
+
if (r !== null && data.has(r)) data.add(n.name.text);
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1493
1533
|
const out = [];
|
|
1494
1534
|
for (const s of ifsAfter(parent)) {
|
|
1495
1535
|
const exit = exitKind(s.thenStatement);
|
|
@@ -2647,6 +2687,14 @@ function newFunctionRegistry() {
|
|
|
2647
2687
|
publicSchemaPublic: false
|
|
2648
2688
|
};
|
|
2649
2689
|
}
|
|
2690
|
+
function functionBodyOf(reg, q) {
|
|
2691
|
+
const direct = reg.byKey.get(qualifiedKey(q));
|
|
2692
|
+
if (direct) return direct.body;
|
|
2693
|
+
if (q.schema !== null) return null;
|
|
2694
|
+
const lower = q.name.toLowerCase();
|
|
2695
|
+
for (const f of reg.byKey.values()) if (f.name.toLowerCase() === lower) return f.body;
|
|
2696
|
+
return null;
|
|
2697
|
+
}
|
|
2650
2698
|
var CALLER_CHECKS = [
|
|
2651
2699
|
/"?\bauth"?\s*\.\s*"?(?:uid|jwt|email)"?\s*\(\s*\)/i,
|
|
2652
2700
|
/\bcurrent_setting\s*\(\s*'request\.jwt/i
|
|
@@ -2709,7 +2757,8 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2709
2757
|
const q = readQualifiedName(tk, i + 1);
|
|
2710
2758
|
if (!q || !isPunct(tk[q.next], "(")) return;
|
|
2711
2759
|
const argsEnd = groupEnd(tk, q.next);
|
|
2712
|
-
const
|
|
2760
|
+
const params = readParams(stmt, tk, q.next, argsEnd);
|
|
2761
|
+
const args = params === null ? null : params.map((p) => p.type).join(", ");
|
|
2713
2762
|
i = argsEnd + 1;
|
|
2714
2763
|
let securityDefiner = false;
|
|
2715
2764
|
let returns = null;
|
|
@@ -2749,6 +2798,7 @@ function applyCreateFunction(reg, stmt, file) {
|
|
|
2749
2798
|
securityDefiner,
|
|
2750
2799
|
returns,
|
|
2751
2800
|
args,
|
|
2801
|
+
params,
|
|
2752
2802
|
body: code,
|
|
2753
2803
|
directCheck: CALLER_CHECKS.some((re) => re.test(code)),
|
|
2754
2804
|
acl: reg.byKey.get(key)?.acl ?? initialAcl(reg, q.schema),
|
|
@@ -2815,8 +2865,8 @@ var TYPE_WORD = /* @__PURE__ */ new Set([
|
|
|
2815
2865
|
"void",
|
|
2816
2866
|
"xml"
|
|
2817
2867
|
]);
|
|
2818
|
-
function
|
|
2819
|
-
if (close <= open + 1) return
|
|
2868
|
+
function readParams(stmt, tokens, open, close) {
|
|
2869
|
+
if (close <= open + 1) return [];
|
|
2820
2870
|
const parts = [];
|
|
2821
2871
|
let depth = 0;
|
|
2822
2872
|
let start = open + 1;
|
|
@@ -2833,11 +2883,18 @@ function argumentTypes(stmt, tokens, open, close) {
|
|
|
2833
2883
|
pieces.push([start, close]);
|
|
2834
2884
|
for (const [from, to] of pieces) {
|
|
2835
2885
|
const words = [];
|
|
2886
|
+
let hasDefault = false;
|
|
2887
|
+
let callerDefault = false;
|
|
2836
2888
|
for (let i = from; i < to; i++) {
|
|
2837
2889
|
const t = tokens[i];
|
|
2838
2890
|
if (!t) continue;
|
|
2839
|
-
if (isWord(t, "default"))
|
|
2840
|
-
|
|
2891
|
+
if (isWord(t, "default") || t.kind === "punct" && t.value === "=") {
|
|
2892
|
+
hasDefault = true;
|
|
2893
|
+
const last2 = tokens[to - 1];
|
|
2894
|
+
const text = last2 ? stmt.text.slice(t.end - stmt.start, last2.end - stmt.start) : "";
|
|
2895
|
+
callerDefault = CALLER_CHECKS.some((re) => re.test(text));
|
|
2896
|
+
break;
|
|
2897
|
+
}
|
|
2841
2898
|
words.push(t);
|
|
2842
2899
|
}
|
|
2843
2900
|
if (words.length === 0) return null;
|
|
@@ -2854,11 +2911,14 @@ function argumentTypes(stmt, tokens, open, close) {
|
|
|
2854
2911
|
const first = words[typeStart];
|
|
2855
2912
|
const last = words[words.length - 1];
|
|
2856
2913
|
if (!first || !last) return null;
|
|
2857
|
-
parts.push(
|
|
2858
|
-
|
|
2859
|
-
|
|
2914
|
+
parts.push({
|
|
2915
|
+
name: named && firstWord ? firstWord.value.toLowerCase() : null,
|
|
2916
|
+
type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase(),
|
|
2917
|
+
hasDefault,
|
|
2918
|
+
callerDefault
|
|
2919
|
+
});
|
|
2860
2920
|
}
|
|
2861
|
-
return parts
|
|
2921
|
+
return parts;
|
|
2862
2922
|
}
|
|
2863
2923
|
function readFunctionList(tokens, from) {
|
|
2864
2924
|
const keys = [];
|
|
@@ -3033,16 +3093,187 @@ function applyDefaultPrivileges(reg, stmt) {
|
|
|
3033
3093
|
}
|
|
3034
3094
|
}
|
|
3035
3095
|
var CALL = /(?<![A-Za-z0-9_$])(?:"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?\s*\(/g;
|
|
3096
|
+
function calledFunction(reg, m, byName) {
|
|
3097
|
+
const name = m[2];
|
|
3098
|
+
if (!name) return void 0;
|
|
3099
|
+
return reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
|
|
3100
|
+
}
|
|
3036
3101
|
function callees(reg, f, byName) {
|
|
3037
3102
|
const out = /* @__PURE__ */ new Set();
|
|
3038
3103
|
for (const m of f.body.matchAll(CALL)) {
|
|
3039
|
-
const
|
|
3040
|
-
if (!name) continue;
|
|
3041
|
-
const g = reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
|
|
3104
|
+
const g = calledFunction(reg, m, byName);
|
|
3042
3105
|
if (g && g !== f) out.add(g);
|
|
3043
3106
|
}
|
|
3044
3107
|
return out;
|
|
3045
3108
|
}
|
|
3109
|
+
var MAX_CALL_ARGS_CHARS = 4e3;
|
|
3110
|
+
function callArguments(body, from) {
|
|
3111
|
+
const args = [];
|
|
3112
|
+
let depth = 0;
|
|
3113
|
+
let quoted = false;
|
|
3114
|
+
let start = from;
|
|
3115
|
+
const end = Math.min(body.length, from + MAX_CALL_ARGS_CHARS);
|
|
3116
|
+
for (let i = from; i < end; i++) {
|
|
3117
|
+
const c = body[i];
|
|
3118
|
+
if (quoted) {
|
|
3119
|
+
if (c === "'") quoted = false;
|
|
3120
|
+
} else if (c === "'") {
|
|
3121
|
+
quoted = true;
|
|
3122
|
+
} else if (c === "(") {
|
|
3123
|
+
depth += 1;
|
|
3124
|
+
} else if (c === ")") {
|
|
3125
|
+
if (depth === 0) {
|
|
3126
|
+
const last = body.slice(start, i).trim();
|
|
3127
|
+
if (last !== "" || args.length > 0) args.push(last);
|
|
3128
|
+
return args;
|
|
3129
|
+
}
|
|
3130
|
+
depth -= 1;
|
|
3131
|
+
} else if (c === "," && depth === 0) {
|
|
3132
|
+
args.push(body.slice(start, i).trim());
|
|
3133
|
+
start = i + 1;
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
return null;
|
|
3137
|
+
}
|
|
3138
|
+
function callsWithCallerDefault(reg, f, byName) {
|
|
3139
|
+
for (const m of f.body.matchAll(CALL)) {
|
|
3140
|
+
const g = calledFunction(reg, m, byName);
|
|
3141
|
+
if (!g || g === f || !g.params?.some((p) => p.callerDefault)) continue;
|
|
3142
|
+
const args = callArguments(f.body, (m.index ?? 0) + m[0].length);
|
|
3143
|
+
if (args === null) continue;
|
|
3144
|
+
const named = args.filter((a) => /=>|:=/.test(a)).map((a) => (a.split(/=>|:=/)[0] ?? "").trim().replace(/"/g, "").toLowerCase());
|
|
3145
|
+
const positional = args.length - named.length;
|
|
3146
|
+
const omitted = g.params.some(
|
|
3147
|
+
(p, i) => p.callerDefault && i >= positional && (p.name === null || !named.includes(p.name))
|
|
3148
|
+
);
|
|
3149
|
+
if (omitted) return true;
|
|
3150
|
+
}
|
|
3151
|
+
return false;
|
|
3152
|
+
}
|
|
3153
|
+
var RELATION = /(?<![A-Za-z0-9_$])(?:from|join)\s+(?:only\s+)?(?:"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?\s*\.\s*)?"?([A-Za-z_][A-Za-z0-9_$]{0,62})"?(?![A-Za-z0-9_$"]|\s*[.(])/gi;
|
|
3154
|
+
function relationsOf(body) {
|
|
3155
|
+
const out = [];
|
|
3156
|
+
for (const m of body.matchAll(RELATION)) {
|
|
3157
|
+
const schema = m[1]?.toLowerCase();
|
|
3158
|
+
const name = m[2]?.toLowerCase();
|
|
3159
|
+
if (!name || name === "only" || name === "lateral") continue;
|
|
3160
|
+
const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
|
|
3161
|
+
if (!out.includes(key)) out.push(key);
|
|
3162
|
+
}
|
|
3163
|
+
return out;
|
|
3164
|
+
}
|
|
3165
|
+
var IDENT_SRC = "[A-Za-z_][A-Za-z0-9_$]{0,62}";
|
|
3166
|
+
var WRITE = new RegExp(
|
|
3167
|
+
`(?<![A-Za-z0-9_$])(?:update|insert\\s+into|delete\\s+from)\\s+(?:only\\s+)?(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?(?![A-Za-z0-9_$"]|\\s*\\.)`,
|
|
3168
|
+
"gi"
|
|
3169
|
+
);
|
|
3170
|
+
var NOT_A_RELATION = /* @__PURE__ */ new Set(["set", "of", "skip", "nowait", "only", "on"]);
|
|
3171
|
+
function writesOf(body) {
|
|
3172
|
+
const out = [];
|
|
3173
|
+
for (const m of body.matchAll(WRITE)) {
|
|
3174
|
+
const schema = m[1]?.toLowerCase();
|
|
3175
|
+
const name = m[2]?.toLowerCase();
|
|
3176
|
+
if (!name || NOT_A_RELATION.has(name)) continue;
|
|
3177
|
+
const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
|
|
3178
|
+
if (!out.includes(key)) out.push(key);
|
|
3179
|
+
}
|
|
3180
|
+
return out;
|
|
3181
|
+
}
|
|
3182
|
+
var ALIASED = new RegExp(
|
|
3183
|
+
`(?<![A-Za-z0-9_$])(?:from|join|update|into)\\s+(?:only\\s+)?(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?(?:\\s+(?:as\\s+)?"?(${IDENT_SRC})"?)?`,
|
|
3184
|
+
"gi"
|
|
3185
|
+
);
|
|
3186
|
+
var SQL_WORDS = /* @__PURE__ */ new Set([
|
|
3187
|
+
"and",
|
|
3188
|
+
"as",
|
|
3189
|
+
"conflict",
|
|
3190
|
+
"cross",
|
|
3191
|
+
"default",
|
|
3192
|
+
"do",
|
|
3193
|
+
"else",
|
|
3194
|
+
"elsif",
|
|
3195
|
+
"end",
|
|
3196
|
+
"except",
|
|
3197
|
+
"for",
|
|
3198
|
+
"from",
|
|
3199
|
+
"full",
|
|
3200
|
+
"group",
|
|
3201
|
+
"having",
|
|
3202
|
+
"if",
|
|
3203
|
+
"in",
|
|
3204
|
+
"inner",
|
|
3205
|
+
"intersect",
|
|
3206
|
+
"into",
|
|
3207
|
+
"is",
|
|
3208
|
+
"join",
|
|
3209
|
+
"lateral",
|
|
3210
|
+
"left",
|
|
3211
|
+
"limit",
|
|
3212
|
+
"loop",
|
|
3213
|
+
"natural",
|
|
3214
|
+
"not",
|
|
3215
|
+
"nowait",
|
|
3216
|
+
"of",
|
|
3217
|
+
"offset",
|
|
3218
|
+
"on",
|
|
3219
|
+
"only",
|
|
3220
|
+
"or",
|
|
3221
|
+
"order",
|
|
3222
|
+
"returning",
|
|
3223
|
+
"right",
|
|
3224
|
+
"select",
|
|
3225
|
+
"set",
|
|
3226
|
+
"skip",
|
|
3227
|
+
"then",
|
|
3228
|
+
"union",
|
|
3229
|
+
"using",
|
|
3230
|
+
"values",
|
|
3231
|
+
"when",
|
|
3232
|
+
"where",
|
|
3233
|
+
"window",
|
|
3234
|
+
"with"
|
|
3235
|
+
]);
|
|
3236
|
+
var CLAUSE = /(?<![A-Za-z0-9_$])(set|where|on|and|or|when|if|elsif|having|values|select|returning|then|else)(?![A-Za-z0-9_$])/gi;
|
|
3237
|
+
var COMPARING = /* @__PURE__ */ new Set(["where", "on", "and", "or", "when", "if", "elsif", "having"]);
|
|
3238
|
+
var MAX_KEYED_PARAMS = 32;
|
|
3239
|
+
var CLAUSE_WINDOW = 400;
|
|
3240
|
+
function paramKeysOf(body, params) {
|
|
3241
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
3242
|
+
const relations = [];
|
|
3243
|
+
for (const m of body.matchAll(ALIASED)) {
|
|
3244
|
+
const schema = m[1]?.toLowerCase();
|
|
3245
|
+
const name = m[2]?.toLowerCase();
|
|
3246
|
+
if (!name || SQL_WORDS.has(name)) continue;
|
|
3247
|
+
const table2 = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
|
|
3248
|
+
if (!relations.includes(table2)) relations.push(table2);
|
|
3249
|
+
aliases.set(name, table2);
|
|
3250
|
+
const alias = m[3]?.toLowerCase();
|
|
3251
|
+
if (alias && !SQL_WORDS.has(alias)) aliases.set(alias, table2);
|
|
3252
|
+
}
|
|
3253
|
+
const side = `(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?`;
|
|
3254
|
+
const out = [];
|
|
3255
|
+
for (const param of params.slice(0, MAX_KEYED_PARAMS)) {
|
|
3256
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(param)) continue;
|
|
3257
|
+
const re = new RegExp(
|
|
3258
|
+
`${side}\\s*(?<![<>!:])=\\s*"?${param}"?(?![A-Za-z0-9_$.(])|(?<![A-Za-z0-9_$."])"?${param}"?\\s*=(?!=)\\s*${side}(?!\\s*\\()`,
|
|
3259
|
+
"gi"
|
|
3260
|
+
);
|
|
3261
|
+
for (const m of body.matchAll(re)) {
|
|
3262
|
+
const at = m.index ?? 0;
|
|
3263
|
+
const clauses = [...body.slice(Math.max(0, at - CLAUSE_WINDOW), at).matchAll(CLAUSE)];
|
|
3264
|
+
const clause = clauses[clauses.length - 1]?.[1]?.toLowerCase();
|
|
3265
|
+
if (clause === void 0 || !COMPARING.has(clause)) continue;
|
|
3266
|
+
const qualifier = (m[1] ?? m[3])?.toLowerCase();
|
|
3267
|
+
const column = (m[2] ?? m[4])?.toLowerCase();
|
|
3268
|
+
if (!column || column === param || SQL_WORDS.has(column)) continue;
|
|
3269
|
+
const table2 = qualifier !== void 0 ? aliases.get(qualifier) : relations.length === 1 ? relations[0] : void 0;
|
|
3270
|
+
if (table2 === void 0) continue;
|
|
3271
|
+
if (!out.some((k) => k.param === param && k.table === table2 && k.column === column))
|
|
3272
|
+
out.push({ param, table: table2, column });
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return out;
|
|
3276
|
+
}
|
|
3046
3277
|
function effectiveRoles(acl) {
|
|
3047
3278
|
const out = [];
|
|
3048
3279
|
if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
|
|
@@ -3060,10 +3291,15 @@ function finishFunctions(reg) {
|
|
|
3060
3291
|
}
|
|
3061
3292
|
}
|
|
3062
3293
|
const callers = /* @__PURE__ */ new Map();
|
|
3294
|
+
const calls = /* @__PURE__ */ new Map();
|
|
3063
3295
|
for (const f of fns) {
|
|
3064
|
-
|
|
3296
|
+
const called = callees(reg, f, byName);
|
|
3297
|
+
calls.set(f, called);
|
|
3298
|
+
for (const g of called) callers.set(g, [...callers.get(g) ?? [], f]);
|
|
3065
3299
|
}
|
|
3066
|
-
const checks = new Set(
|
|
3300
|
+
const checks = new Set(
|
|
3301
|
+
fns.filter((f) => f.directCheck || callsWithCallerDefault(reg, f, byName))
|
|
3302
|
+
);
|
|
3067
3303
|
const queue = [...checks];
|
|
3068
3304
|
for (let g = queue.pop(); g !== void 0; g = queue.pop()) {
|
|
3069
3305
|
for (const f of callers.get(g) ?? []) {
|
|
@@ -3082,6 +3318,25 @@ function finishFunctions(reg) {
|
|
|
3082
3318
|
};
|
|
3083
3319
|
if (f.returns !== null) info.returns = f.returns;
|
|
3084
3320
|
if (f.args !== null) info.args = f.args;
|
|
3321
|
+
if (f.params?.every((x) => x.name !== null)) {
|
|
3322
|
+
const params = f.params.map((x) => ({
|
|
3323
|
+
name: x.name ?? "",
|
|
3324
|
+
type: x.type,
|
|
3325
|
+
...x.hasDefault ? { default: true } : {}
|
|
3326
|
+
}));
|
|
3327
|
+
info.params = params;
|
|
3328
|
+
const keys = paramKeysOf(
|
|
3329
|
+
f.body,
|
|
3330
|
+
params.map((x) => x.name)
|
|
3331
|
+
);
|
|
3332
|
+
if (keys.length > 0) info.keys = keys;
|
|
3333
|
+
}
|
|
3334
|
+
const tables = relationsOf(f.body);
|
|
3335
|
+
if (tables.length > 0) info.tables = tables;
|
|
3336
|
+
const called = [...calls.get(f) ?? []].map((g) => qualifiedKey(g));
|
|
3337
|
+
if (called.length > 0) info.calls = called;
|
|
3338
|
+
const writes = writesOf(f.body);
|
|
3339
|
+
if (writes.length > 0) info.writes = writes;
|
|
3085
3340
|
return info;
|
|
3086
3341
|
});
|
|
3087
3342
|
}
|
|
@@ -3203,6 +3458,7 @@ function schemaStateFor(tables) {
|
|
|
3203
3458
|
enums: /* @__PURE__ */ new Map(),
|
|
3204
3459
|
functions: newFunctionRegistry(),
|
|
3205
3460
|
buckets: newBucketRegistry(),
|
|
3461
|
+
triggers: /* @__PURE__ */ new Map(),
|
|
3206
3462
|
warnings: []
|
|
3207
3463
|
};
|
|
3208
3464
|
STATES.set(tables, state);
|
|
@@ -3645,6 +3901,96 @@ function doBlock(state, stmt, file) {
|
|
|
3645
3901
|
}
|
|
3646
3902
|
}
|
|
3647
3903
|
}
|
|
3904
|
+
var isTriggerEvent = (v) => v === "insert" || v === "update" || v === "delete" || v === "truncate";
|
|
3905
|
+
function createTrigger(state, stmt, file) {
|
|
3906
|
+
const tk = stmt.tokens;
|
|
3907
|
+
let i = 1;
|
|
3908
|
+
if (isWord(tk[i], "or") && isWord(tk[i + 1], "replace")) i += 2;
|
|
3909
|
+
if (isWord(tk[i], "constraint")) i += 1;
|
|
3910
|
+
if (!isWord(tk[i], "trigger")) return;
|
|
3911
|
+
const name = identOf(tk[i + 1]);
|
|
3912
|
+
if (name === null || name === "") return;
|
|
3913
|
+
i += 2;
|
|
3914
|
+
let timing;
|
|
3915
|
+
if (isWord(tk[i], "before")) timing = "before";
|
|
3916
|
+
else if (isWord(tk[i], "after")) timing = "after";
|
|
3917
|
+
else if (isWord(tk[i], "instead") && isWord(tk[i + 1], "of")) {
|
|
3918
|
+
timing = "instead of";
|
|
3919
|
+
i += 1;
|
|
3920
|
+
} else return;
|
|
3921
|
+
i += 1;
|
|
3922
|
+
const events = [];
|
|
3923
|
+
const ofColumns = [];
|
|
3924
|
+
while (i < tk.length && !isWord(tk[i], "on")) {
|
|
3925
|
+
const t = tk[i];
|
|
3926
|
+
if (t?.kind === "word" && isTriggerEvent(t.value)) {
|
|
3927
|
+
events.push(t.value);
|
|
3928
|
+
i += 1;
|
|
3929
|
+
} else if (isWord(t, "of")) {
|
|
3930
|
+
i += 1;
|
|
3931
|
+
while (i < tk.length && !isWord(tk[i], "or") && !isWord(tk[i], "on")) {
|
|
3932
|
+
const col = identOf(tk[i]);
|
|
3933
|
+
if (col !== null && col !== "") ofColumns.push(col.toLowerCase());
|
|
3934
|
+
i += 1;
|
|
3935
|
+
}
|
|
3936
|
+
} else {
|
|
3937
|
+
i += 1;
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
const table2 = readQualifiedName(tk, i + 1);
|
|
3941
|
+
if (!table2 || events.length === 0) return;
|
|
3942
|
+
let j = table2.next;
|
|
3943
|
+
while (j < tk.length && !isWord(tk[j], "execute")) j += 1;
|
|
3944
|
+
if (!isWord(tk[j + 1], "function") && !isWord(tk[j + 1], "procedure")) return;
|
|
3945
|
+
const fn2 = readQualifiedName(tk, j + 2);
|
|
3946
|
+
if (!fn2) return;
|
|
3947
|
+
const key = qualifiedKey(table2);
|
|
3948
|
+
state.triggers.set(`${key}#${name.toLowerCase()}`, {
|
|
3949
|
+
name: name.toLowerCase(),
|
|
3950
|
+
table: key,
|
|
3951
|
+
timing,
|
|
3952
|
+
events,
|
|
3953
|
+
ofColumns,
|
|
3954
|
+
fn: { schema: fn2.schema, name: fn2.name },
|
|
3955
|
+
location: { file, line: stmt.line }
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
function dropTrigger(state, stmt) {
|
|
3959
|
+
const tk = stmt.tokens;
|
|
3960
|
+
let i = 2;
|
|
3961
|
+
if (isWord(tk[i], "if") && isWord(tk[i + 1], "exists")) i += 2;
|
|
3962
|
+
const name = identOf(tk[i]);
|
|
3963
|
+
if (name === null || !isWord(tk[i + 1], "on")) return;
|
|
3964
|
+
const table2 = readQualifiedName(tk, i + 2);
|
|
3965
|
+
if (table2) state.triggers.delete(`${qualifiedKey(table2)}#${name.toLowerCase()}`);
|
|
3966
|
+
}
|
|
3967
|
+
var NEW_COLUMN = /(?<![A-Za-z0-9_$])new\s*\.\s*"?([A-Za-z_][A-Za-z0-9_]{0,62})"?/gi;
|
|
3968
|
+
var MAX_TRIGGER_COLUMNS = 200;
|
|
3969
|
+
function finishTrigger(state, t) {
|
|
3970
|
+
const body = functionBodyOf(state.functions, t.fn) ?? "";
|
|
3971
|
+
const raises = /(?<![A-Za-z0-9_$])raise(?![A-Za-z0-9_$])/i.test(body);
|
|
3972
|
+
const read = /* @__PURE__ */ new Set();
|
|
3973
|
+
for (const m of body.matchAll(NEW_COLUMN)) {
|
|
3974
|
+
const col = m[1]?.toLowerCase();
|
|
3975
|
+
if (col) read.add(col);
|
|
3976
|
+
if (read.size >= MAX_TRIGGER_COLUMNS) break;
|
|
3977
|
+
}
|
|
3978
|
+
const checked = new Set(t.ofColumns);
|
|
3979
|
+
for (const col of read) {
|
|
3980
|
+
const old = new RegExp(`(?<![A-Za-z0-9_$])old\\s*\\.\\s*"?${col}"?(?![A-Za-z0-9_$])`, "i");
|
|
3981
|
+
const assigned = new RegExp(`(?<![A-Za-z0-9_$])new\\s*\\.\\s*"?${col}"?\\s*:=`, "i");
|
|
3982
|
+
if (raises || old.test(body) || assigned.test(body)) checked.add(col);
|
|
3983
|
+
}
|
|
3984
|
+
return {
|
|
3985
|
+
name: t.name,
|
|
3986
|
+
table: t.table,
|
|
3987
|
+
timing: t.timing,
|
|
3988
|
+
events: [...t.events],
|
|
3989
|
+
checkedColumns: [...checked],
|
|
3990
|
+
function: qualifiedKey(t.fn),
|
|
3991
|
+
location: t.location
|
|
3992
|
+
};
|
|
3993
|
+
}
|
|
3648
3994
|
function applySchemaStatement(state, stmt, file) {
|
|
3649
3995
|
const tk = stmt.tokens;
|
|
3650
3996
|
const w0 = tk[0]?.kind === "word" ? tk[0].value : "";
|
|
@@ -3655,6 +4001,7 @@ function applySchemaStatement(state, stmt, file) {
|
|
|
3655
4001
|
else if (kind === "type") createType(state, stmt);
|
|
3656
4002
|
else if (kind === "function") applyCreateFunction(state.functions, stmt, file);
|
|
3657
4003
|
else if (kind === "unique") createUniqueIndex(state, stmt);
|
|
4004
|
+
else if (kind === "trigger" || kind === "constraint") createTrigger(state, stmt, file);
|
|
3658
4005
|
} else if (w0 === "alter") {
|
|
3659
4006
|
if (w1 === "table") alterTable(state, stmt, file, false);
|
|
3660
4007
|
else if (w1 === "type") alterType(state, stmt);
|
|
@@ -3664,6 +4011,7 @@ function applySchemaStatement(state, stmt, file) {
|
|
|
3664
4011
|
if (w1 === "table") dropTable(state, stmt);
|
|
3665
4012
|
else if (w1 === "type") dropType(state, stmt);
|
|
3666
4013
|
else if (w1 === "function" || w1 === "routine") applyDropFunction(state.functions, stmt);
|
|
4014
|
+
else if (w1 === "trigger") dropTrigger(state, stmt);
|
|
3667
4015
|
} else if (w0 === "grant" || w0 === "revoke") {
|
|
3668
4016
|
applyGrantRevoke(state.functions, stmt);
|
|
3669
4017
|
} else if (w0 === "do") {
|
|
@@ -3678,6 +4026,7 @@ function finishSchema(state) {
|
|
|
3678
4026
|
enums: Object.fromEntries([...state.enums].map(([k, v]) => [k, [...v]])),
|
|
3679
4027
|
sqlFunctions: finishFunctions(state.functions),
|
|
3680
4028
|
storageBuckets: finishBuckets(state.buckets),
|
|
4029
|
+
triggers: [...state.triggers.values()].map((t) => finishTrigger(state, t)),
|
|
3681
4030
|
warnings: [...state.warnings]
|
|
3682
4031
|
};
|
|
3683
4032
|
}
|
|
@@ -3708,6 +4057,32 @@ var DROP_POLICY = new RegExp(
|
|
|
3708
4057
|
function isAppliedSqlFile(rel) {
|
|
3709
4058
|
return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
|
|
3710
4059
|
}
|
|
4060
|
+
var REFERENCE_SQL_DIRS = /* @__PURE__ */ new Set([
|
|
4061
|
+
"doc",
|
|
4062
|
+
"docs",
|
|
4063
|
+
"documentation",
|
|
4064
|
+
"legacy",
|
|
4065
|
+
"archive",
|
|
4066
|
+
"archives",
|
|
4067
|
+
"archived",
|
|
4068
|
+
"backup",
|
|
4069
|
+
"backups",
|
|
4070
|
+
"deprecated",
|
|
4071
|
+
"example",
|
|
4072
|
+
"examples",
|
|
4073
|
+
"old"
|
|
4074
|
+
]);
|
|
4075
|
+
function appliedSqlFiles(rels) {
|
|
4076
|
+
const applied = rels.filter(isAppliedSqlFile);
|
|
4077
|
+
const slashed = (rel) => rel.split("\\").join("/");
|
|
4078
|
+
const hasMigrations = applied.some(
|
|
4079
|
+
(rel) => /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i.test(slashed(rel))
|
|
4080
|
+
);
|
|
4081
|
+
if (!hasMigrations) return applied;
|
|
4082
|
+
return applied.filter(
|
|
4083
|
+
(rel) => !slashed(rel).split("/").slice(0, -1).some((dir) => REFERENCE_SQL_DIRS.has(dir.toLowerCase()))
|
|
4084
|
+
);
|
|
4085
|
+
}
|
|
3711
4086
|
function parseSqlForRls(rel, text, into) {
|
|
3712
4087
|
if (!isAppliedSqlFile(rel)) return;
|
|
3713
4088
|
const state = schemaStateFor(into);
|
|
@@ -3783,7 +4158,7 @@ function sqlSchemaFor(into) {
|
|
|
3783
4158
|
|
|
3784
4159
|
// packages/parser/src/role-gates.ts
|
|
3785
4160
|
import ts7 from "typescript";
|
|
3786
|
-
var ROLE_PROPERTY = /^(role|roles|app_metadata|is_?admin|is_?superuser|permissions?|claims?)$/i;
|
|
4161
|
+
var ROLE_PROPERTY = /^(role|roles|rolle|rol|user_?role|access_?level|app_metadata|admin|is_?admin|superuser|is_?superuser|permissions?|claims?)$/i;
|
|
3787
4162
|
var EMAIL_PROPERTY = /^email$/i;
|
|
3788
4163
|
function propertyPath(e) {
|
|
3789
4164
|
const u = unwrap(e);
|
|
@@ -4869,6 +5244,9 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4869
5244
|
const names = boundNames(decl.name);
|
|
4870
5245
|
const text = init.getText(sf);
|
|
4871
5246
|
aliasLocal(frame, decl.name, init);
|
|
5247
|
+
const who = identityBinding(p, frame, init, scope);
|
|
5248
|
+
if (who !== void 0)
|
|
5249
|
+
for (const nm of identityNamesOf(decl.name)) frame.identities.set(nm, who);
|
|
4872
5250
|
const client = ts11.isCallExpression(init) ? classifyCall(p, init, sf, scope, frame, 0) : null;
|
|
4873
5251
|
if (client && ts11.isIdentifier(decl.name)) {
|
|
4874
5252
|
frame.clients.set(decl.name.text, client);
|
|
@@ -4950,6 +5328,21 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4950
5328
|
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4951
5329
|
}
|
|
4952
5330
|
}
|
|
5331
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
5332
|
+
for (const asg of collect(body, ts11.isBinaryExpression)) {
|
|
5333
|
+
if (asg.operatorToken.kind !== ts11.SyntaxKind.EqualsToken || !ts11.isIdentifier(asg.left))
|
|
5334
|
+
continue;
|
|
5335
|
+
if (isLiteralValue(unwrap(asg.right))) continue;
|
|
5336
|
+
const list = assigned.get(asg.left.text) ?? [];
|
|
5337
|
+
list.push(identityBinding(p, frame, asg.right, scope));
|
|
5338
|
+
assigned.set(asg.left.text, list);
|
|
5339
|
+
}
|
|
5340
|
+
for (const [name, whos] of assigned) {
|
|
5341
|
+
const [first] = whos;
|
|
5342
|
+
if (first !== void 0 && whos.every((w) => w !== void 0))
|
|
5343
|
+
frame.identities.set(name, first);
|
|
5344
|
+
else frame.identities.delete(name);
|
|
5345
|
+
}
|
|
4953
5346
|
if (frame.depth === 0) {
|
|
4954
5347
|
walk(body, (n) => {
|
|
4955
5348
|
if (ts11.isPropertyAccessExpression(n) && ts11.isIdentifier(n.expression) && n.expression.text === "params") {
|
|
@@ -4975,16 +5368,22 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4975
5368
|
const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
|
|
4976
5369
|
if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
|
|
4977
5370
|
}
|
|
4978
|
-
const isSessionCall = (call) =>
|
|
5371
|
+
const isSessionCall = (call) => callsSession(p, call, sf, scope);
|
|
4979
5372
|
for (const call of collect(body, ts11.isCallExpression)) {
|
|
4980
5373
|
if (isSessionCall(call)) acc.authChecks.push({ ...loc2(call), kind: "session" });
|
|
4981
5374
|
}
|
|
4982
|
-
|
|
5375
|
+
const sessionNames = sessionNamesIn(body, isSessionCall);
|
|
5376
|
+
for (const name of frame.identities.keys()) sessionNames.add(name);
|
|
5377
|
+
for (const gate of roleGatesIn(body, sessionNames)) {
|
|
4983
5378
|
if (gate.exit === "return" && !frame.exitPropagates) continue;
|
|
5379
|
+
const [root, ...rest] = gate.source.split(".");
|
|
5380
|
+
const table2 = root === void 0 ? void 0 : frame.identities.get(root);
|
|
5381
|
+
const column = rest[rest.length - 1];
|
|
4984
5382
|
acc.roleChecks.push({
|
|
4985
5383
|
...loc2(gate.node),
|
|
4986
5384
|
source: gate.source,
|
|
4987
|
-
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
|
|
5385
|
+
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160),
|
|
5386
|
+
...typeof table2 === "string" && column !== void 0 ? { table: table2, column } : {}
|
|
4988
5387
|
});
|
|
4989
5388
|
}
|
|
4990
5389
|
for (const check of secretChecksIn(fn2, sf)) {
|
|
@@ -5044,7 +5443,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5044
5443
|
method: f.method,
|
|
5045
5444
|
column: f.column,
|
|
5046
5445
|
valueText: f.value ? f.value.getText(sf) : f.text,
|
|
5047
|
-
inputDerived: f.value ? derivedIn(frame, f.value) : false
|
|
5446
|
+
inputDerived: f.value ? derivedIn(frame, f.value) : false,
|
|
5447
|
+
...f.value && identityOf(frame, f.value) !== void 0 ? { identity: true } : {}
|
|
5048
5448
|
},
|
|
5049
5449
|
f.value
|
|
5050
5450
|
);
|
|
@@ -5094,7 +5494,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5094
5494
|
method: "match",
|
|
5095
5495
|
column: pr.name.getText(sf).replace(/['"]/g, ""),
|
|
5096
5496
|
valueText: pr.initializer.getText(sf),
|
|
5097
|
-
inputDerived: derivedIn(frame, pr.initializer)
|
|
5497
|
+
inputDerived: derivedIn(frame, pr.initializer),
|
|
5498
|
+
...identityOf(frame, pr.initializer) !== void 0 ? { identity: true } : {}
|
|
5098
5499
|
};
|
|
5099
5500
|
filters.push(valued(f2, pr.initializer));
|
|
5100
5501
|
}
|
|
@@ -5106,7 +5507,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5106
5507
|
method: s.name,
|
|
5107
5508
|
column: stringLiteralValue(firstArg),
|
|
5108
5509
|
valueText: val ? val.getText(sf) : "",
|
|
5109
|
-
inputDerived: val ? derivedIn(frame, val) : false
|
|
5510
|
+
inputDerived: val ? derivedIn(frame, val) : false,
|
|
5511
|
+
...val && identityOf(frame, val) !== void 0 ? { identity: true } : {}
|
|
5110
5512
|
};
|
|
5111
5513
|
filters.push(valued(f, val));
|
|
5112
5514
|
}
|
|
@@ -5242,7 +5644,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5242
5644
|
method: "compare",
|
|
5243
5645
|
column: c.column,
|
|
5244
5646
|
valueText: c.value.getText(sf).replace(/\s+/g, " "),
|
|
5245
|
-
inputDerived: derivedIn(frame, c.value)
|
|
5647
|
+
inputDerived: derivedIn(frame, c.value),
|
|
5648
|
+
...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
|
|
5246
5649
|
}));
|
|
5247
5650
|
if (checks.length > 0) query.ownerChecks = checks;
|
|
5248
5651
|
acc.reads.push({
|
|
@@ -5294,7 +5697,8 @@ function childFrame(p, call, target, frame) {
|
|
|
5294
5697
|
key: `${target.facts.file}#${target.name}@${call.getStart(frame.sf)}`,
|
|
5295
5698
|
aliases: /* @__PURE__ */ new Map(),
|
|
5296
5699
|
pathPos: [...frame.pathPos, call.getStart(frame.sf)],
|
|
5297
|
-
exitPropagates: frame.exitPropagates && callResultChecked(call)
|
|
5700
|
+
exitPropagates: frame.exitPropagates && callResultChecked(call),
|
|
5701
|
+
identities: /* @__PURE__ */ new Map()
|
|
5298
5702
|
};
|
|
5299
5703
|
const cx = wholeContext(p, frame);
|
|
5300
5704
|
target.fn.parameters.forEach((param, i) => {
|
|
@@ -5309,6 +5713,9 @@ function childFrame(p, call, target, frame) {
|
|
|
5309
5713
|
child.instances.set(head, ab.instance);
|
|
5310
5714
|
}
|
|
5311
5715
|
for (const nm of names) if (ab.isRequest) child.reqNames.add(nm);
|
|
5716
|
+
const who = arg ? identityOf(frame, arg) : void 0;
|
|
5717
|
+
if (who !== void 0 && ts11.isIdentifier(param.name))
|
|
5718
|
+
child.identities.set(param.name.text, who);
|
|
5312
5719
|
if (ab.tainted) {
|
|
5313
5720
|
bindParamTaint(child, param.name, arg, frame);
|
|
5314
5721
|
for (const nm of wholeParamNames(param.name, arg, cx)) {
|
|
@@ -5326,6 +5733,7 @@ function frameSignature(child) {
|
|
|
5326
5733
|
...[...child.inputNames].map((n) => `${n}!${child.wholeNames.has(n) ? "*" : ""}`),
|
|
5327
5734
|
...[...child.partialInputs].map(([n, s]) => `${n}.{${[...s].sort().join("|")}}`),
|
|
5328
5735
|
...[...child.reqNames].map((n) => `${n}?`),
|
|
5736
|
+
...[...child.identities].map(([n, t]) => `${n}@${t ?? ""}`),
|
|
5329
5737
|
...[...child.thisProps].map(([n, b]) => `this.${n}=${b.client?.kind ?? "-"}`),
|
|
5330
5738
|
...[...child.aliases].map(([n, k]) => `${n}~${k}`),
|
|
5331
5739
|
`exit=${child.exitPropagates}`
|
|
@@ -5581,6 +5989,111 @@ function returnsIdentity(p, call, scope) {
|
|
|
5581
5989
|
if (symOfCallee(p, call.expression, scope)?.kind === "auth") return true;
|
|
5582
5990
|
return IDENTITY_CALLEE.test(calleePath(call.expression));
|
|
5583
5991
|
}
|
|
5992
|
+
function callsSession(p, call, sf, scope) {
|
|
5993
|
+
return /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
|
|
5994
|
+
}
|
|
5995
|
+
function identityOf(frame, e) {
|
|
5996
|
+
if (frame.identities.size === 0 || derivedIn(frame, e)) return void 0;
|
|
5997
|
+
let u = unwrap(e);
|
|
5998
|
+
let part = false;
|
|
5999
|
+
while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
|
|
6000
|
+
if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return void 0;
|
|
6001
|
+
u = unwrap(u.expression);
|
|
6002
|
+
part = true;
|
|
6003
|
+
}
|
|
6004
|
+
if (!ts11.isIdentifier(u) || !frame.identities.has(u.text)) return void 0;
|
|
6005
|
+
const who = frame.identities.get(u.text) ?? null;
|
|
6006
|
+
return part && who !== null ? "" : who;
|
|
6007
|
+
}
|
|
6008
|
+
function identityNamesOf(name) {
|
|
6009
|
+
if (ts11.isIdentifier(name)) return [name.text];
|
|
6010
|
+
if (ts11.isArrayBindingPattern(name)) {
|
|
6011
|
+
const first = name.elements[0];
|
|
6012
|
+
return first && !ts11.isOmittedExpression(first) ? boundNames(first.name) : [];
|
|
6013
|
+
}
|
|
6014
|
+
const data = name.elements.find((el) => propertyKeyOf(el.propertyName ?? el.name) === "data");
|
|
6015
|
+
if (data) return boundNames(data.name);
|
|
6016
|
+
return name.elements.filter((el) => propertyKeyOf(el.propertyName ?? el.name) !== "error").flatMap((el) => boundNames(el.name));
|
|
6017
|
+
}
|
|
6018
|
+
function identityRowOf(frame, call) {
|
|
6019
|
+
const { segments } = flattenChain(call);
|
|
6020
|
+
const table2 = stringLiteralValue(segments.find((s) => s.name === "from")?.args[0]);
|
|
6021
|
+
if (table2 === null) return void 0;
|
|
6022
|
+
const keyed = (column, value) => value !== void 0 && (identityOf(frame, value) !== void 0 || isCredentialColumn(column) && derivedIn(frame, value));
|
|
6023
|
+
for (const s of segments) {
|
|
6024
|
+
if (s.name === "eq" && keyed(stringLiteralValue(s.args[0]), s.args[1])) return table2;
|
|
6025
|
+
const obj = s.name === "match" ? s.args[0] : void 0;
|
|
6026
|
+
if (!obj || !ts11.isObjectLiteralExpression(obj)) continue;
|
|
6027
|
+
for (const pr of obj.properties) {
|
|
6028
|
+
if (ts11.isPropertyAssignment(pr) && keyed(propertyKeyOf(pr.name), pr.initializer))
|
|
6029
|
+
return table2;
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
return void 0;
|
|
6033
|
+
}
|
|
6034
|
+
function identityBinding(p, frame, init, scope) {
|
|
6035
|
+
let out;
|
|
6036
|
+
for (const leaf of branchesOf(init)) {
|
|
6037
|
+
if (isLiteralValue(leaf)) continue;
|
|
6038
|
+
const who = identityLeaf(p, frame, leaf, scope);
|
|
6039
|
+
if (who === void 0) return void 0;
|
|
6040
|
+
if (out === void 0) out = who;
|
|
6041
|
+
}
|
|
6042
|
+
return out;
|
|
6043
|
+
}
|
|
6044
|
+
function identityLeaf(p, frame, leaf, scope) {
|
|
6045
|
+
const u = unwrap(leaf);
|
|
6046
|
+
if (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
|
|
6047
|
+
let base = u;
|
|
6048
|
+
while (ts11.isPropertyAccessExpression(base) || ts11.isElementAccessExpression(base)) {
|
|
6049
|
+
if (ts11.isPropertyAccessExpression(base) && base.name.text === "user_metadata")
|
|
6050
|
+
return void 0;
|
|
6051
|
+
base = unwrap(base.expression);
|
|
6052
|
+
}
|
|
6053
|
+
if (!ts11.isCallExpression(base)) return identityOf(frame, u);
|
|
6054
|
+
if (derivedIn(frame, u)) return void 0;
|
|
6055
|
+
const who = identityLeaf(p, frame, base, scope);
|
|
6056
|
+
return who === void 0 || who === null ? who : "";
|
|
6057
|
+
}
|
|
6058
|
+
if (ts11.isIdentifier(u)) return identityOf(frame, u);
|
|
6059
|
+
if (!ts11.isCallExpression(u)) return void 0;
|
|
6060
|
+
if (callsSession(p, u, frame.sf, scope)) return null;
|
|
6061
|
+
if (isChainWithQuery(u)) return identityRowOf(frame, u);
|
|
6062
|
+
if (isDbChain(u, frame)) return void 0;
|
|
6063
|
+
return returnIdentity(p, u, frame, scope);
|
|
6064
|
+
}
|
|
6065
|
+
function returnIdentity(p, call, frame, scope) {
|
|
6066
|
+
if (frame.depth >= MAX_DEPTH) return void 0;
|
|
6067
|
+
const target = callTarget(p, call, frame, scope);
|
|
6068
|
+
if (!target) return void 0;
|
|
6069
|
+
const child = childFrame(p, call, target, frame);
|
|
6070
|
+
if (!child) return void 0;
|
|
6071
|
+
const key = `${target.facts.file}#${target.name}#${frameSignature(child)}`;
|
|
6072
|
+
const cached = p.returnIdentities.get(key);
|
|
6073
|
+
if (cached !== void 0) return cached === false ? void 0 : cached.who;
|
|
6074
|
+
p.returnIdentities.set(key, false);
|
|
6075
|
+
bindDeclarations(p, child, {
|
|
6076
|
+
inputs: [],
|
|
6077
|
+
authChecks: [],
|
|
6078
|
+
roleChecks: [],
|
|
6079
|
+
queries: [],
|
|
6080
|
+
metadataAccesses: [],
|
|
6081
|
+
visited: /* @__PURE__ */ new Set(),
|
|
6082
|
+
reads: []
|
|
6083
|
+
});
|
|
6084
|
+
const childScope = scopeOf(p, child.facts);
|
|
6085
|
+
let out = false;
|
|
6086
|
+
for (const ret of ownReturns(target.fn)) {
|
|
6087
|
+
for (const leaf of branchesOf(ret)) {
|
|
6088
|
+
if (isLiteralValue(leaf)) continue;
|
|
6089
|
+
const who = identityLeaf(p, child, leaf, childScope);
|
|
6090
|
+
if (who === void 0) return void 0;
|
|
6091
|
+
if (out === false) out = { who };
|
|
6092
|
+
}
|
|
6093
|
+
}
|
|
6094
|
+
p.returnIdentities.set(key, out);
|
|
6095
|
+
return out === false ? void 0 : out.who;
|
|
6096
|
+
}
|
|
5584
6097
|
function calleePath(e) {
|
|
5585
6098
|
const u = unwrap(e);
|
|
5586
6099
|
if (ts11.isIdentifier(u)) return u.text;
|
|
@@ -5666,7 +6179,8 @@ function analyzeHandler(p, h) {
|
|
|
5666
6179
|
key: "h",
|
|
5667
6180
|
aliases: /* @__PURE__ */ new Map(),
|
|
5668
6181
|
pathPos: [],
|
|
5669
|
-
exitPropagates: true
|
|
6182
|
+
exitPropagates: true,
|
|
6183
|
+
identities: /* @__PURE__ */ new Map()
|
|
5670
6184
|
};
|
|
5671
6185
|
const first = fn2.parameters[0];
|
|
5672
6186
|
if (h.kind === "route" && first) {
|
|
@@ -5775,7 +6289,7 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5775
6289
|
}
|
|
5776
6290
|
}
|
|
5777
6291
|
const tables = /* @__PURE__ */ new Map();
|
|
5778
|
-
for (const rel of sql) {
|
|
6292
|
+
for (const rel of appliedSqlFiles(sql)) {
|
|
5779
6293
|
try {
|
|
5780
6294
|
parseSqlForRls(rel, readFileSync2(resolve2(root, rel), "utf8"), tables);
|
|
5781
6295
|
} catch (e) {
|
|
@@ -5806,6 +6320,7 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5806
6320
|
drizzleTablesByExport,
|
|
5807
6321
|
prismaModels,
|
|
5808
6322
|
returnTaints: /* @__PURE__ */ new Map(),
|
|
6323
|
+
returnIdentities: /* @__PURE__ */ new Map(),
|
|
5809
6324
|
tables,
|
|
5810
6325
|
warnings
|
|
5811
6326
|
};
|
|
@@ -5839,7 +6354,8 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5839
6354
|
warnings,
|
|
5840
6355
|
enums: schema.enums,
|
|
5841
6356
|
sqlFunctions: schema.sqlFunctions,
|
|
5842
|
-
storageBuckets: schema.storageBuckets
|
|
6357
|
+
storageBuckets: schema.storageBuckets,
|
|
6358
|
+
...schema.triggers.length > 0 ? { sqlTriggers: schema.triggers } : {}
|
|
5843
6359
|
};
|
|
5844
6360
|
}
|
|
5845
6361
|
var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
|
|
@@ -6055,9 +6571,35 @@ function publicReadNote(p, table2) {
|
|
|
6055
6571
|
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.`;
|
|
6056
6572
|
}
|
|
6057
6573
|
function adminOnly(ctx, h, t) {
|
|
6058
|
-
|
|
6059
|
-
|
|
6060
|
-
|
|
6574
|
+
if (h.roleChecks.length === 0 || !t?.known || !singleTenantTable(ctx, t.table)) return {};
|
|
6575
|
+
let selfGrant;
|
|
6576
|
+
for (const check of h.roleChecks) {
|
|
6577
|
+
const open = selfGrantable(ctx, check);
|
|
6578
|
+
if (open === null) return { check };
|
|
6579
|
+
selfGrant ??= open;
|
|
6580
|
+
}
|
|
6581
|
+
return selfGrant === void 0 ? {} : { selfGrant };
|
|
6582
|
+
}
|
|
6583
|
+
function selfGrantable(ctx, check) {
|
|
6584
|
+
if (check.table === void 0 || check.column === void 0) return null;
|
|
6585
|
+
const column = check.column.toLowerCase();
|
|
6586
|
+
if (check.table === "")
|
|
6587
|
+
return `the role is read off a row whose table could not be told (${check.source}), so nothing shows users cannot change it`;
|
|
6588
|
+
const where2 = `public.${check.table}.${column}`;
|
|
6589
|
+
const info = ctx.model.tables.find((x) => x.table === check.table?.toLowerCase());
|
|
6590
|
+
if (!info)
|
|
6591
|
+
return `the role comes from ${where2}, which the migrations do not define, so nothing shows users cannot change it`;
|
|
6592
|
+
if (!info.rlsEnabled)
|
|
6593
|
+
return `the role comes from ${where2}, and RLS is off on public.${info.table}, so a signed-in user can rewrite it`;
|
|
6594
|
+
const writable = info.policyDetails.find(
|
|
6595
|
+
(pl) => (pl.command === "update" || pl.command === "all") && (pl.roles.length === 0 || pl.roles.some((r) => r === "authenticated" || r === "public")) && policyScopesToCaller(pl.using)
|
|
6596
|
+
);
|
|
6597
|
+
if (!writable) return null;
|
|
6598
|
+
const guarded = (ctx.model.sqlTriggers ?? []).some(
|
|
6599
|
+
(tr) => tr.table === info.table && tr.timing === "before" && tr.events.includes("update") && tr.checkedColumns.includes(column)
|
|
6600
|
+
);
|
|
6601
|
+
if (guarded) return null;
|
|
6602
|
+
return `the role comes from ${where2}, policy "${writable.name}" (${writable.location.file}:${writable.location.line}) lets a user update their own row, and no BEFORE UPDATE trigger in the migrations checks ${column}, so the admin check may be self-granted`;
|
|
6061
6603
|
}
|
|
6062
6604
|
function singleTenantTable(ctx, table2) {
|
|
6063
6605
|
const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
|
|
@@ -6076,10 +6618,12 @@ function tableDataOf(ctx, table2) {
|
|
|
6076
6618
|
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
6077
6619
|
}
|
|
6078
6620
|
function callerCheck(checks) {
|
|
6079
|
-
return checks?.find((c) =>
|
|
6621
|
+
return checks?.find((c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column)));
|
|
6080
6622
|
}
|
|
6081
6623
|
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
6082
|
-
const callerFilter = guard.filters.find(
|
|
6624
|
+
const callerFilter = guard.filters.find(
|
|
6625
|
+
(f) => !f.inputDerived && (f.identity === true || isScopeColumn(f.column))
|
|
6626
|
+
);
|
|
6083
6627
|
if (callerFilter) {
|
|
6084
6628
|
return {
|
|
6085
6629
|
tied: true,
|
|
@@ -6134,7 +6678,10 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6134
6678
|
if (!["select", "update", "delete"].includes(q.operation)) continue;
|
|
6135
6679
|
const filters = q.filters;
|
|
6136
6680
|
const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
|
|
6137
|
-
|
|
6681
|
+
const scoped = filters.some(
|
|
6682
|
+
(f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
|
|
6683
|
+
);
|
|
6684
|
+
if (!idFilter || scoped) continue;
|
|
6138
6685
|
if (q.operation === "select" && callerCheck(q.ownerChecks)) continue;
|
|
6139
6686
|
const guard = q.guard;
|
|
6140
6687
|
const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
|
|
@@ -6144,10 +6691,11 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6144
6691
|
const guardNote = guard && tied && (tied.tied || guard.client === "user_scoped") ? ` An earlier read of ${guardWhat} at ${guard.location.file}:${guard.location.line} (${tied.how}) could be an ownership check, but ${tied.tied ? "the entry point does not stop when it finds no row" : tied.why}.` : "";
|
|
6145
6692
|
const tableName = v.tableData?.table ?? q.table;
|
|
6146
6693
|
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.";
|
|
6147
|
-
const
|
|
6694
|
+
const gate = adminOnly(ctx, h, v.tableData);
|
|
6695
|
+
const admin = gate.check;
|
|
6148
6696
|
const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
|
|
6149
6697
|
const downgrade = admin || anonPolicy ? "medium" : void 0;
|
|
6150
|
-
const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
|
|
6698
|
+
const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
|
|
6151
6699
|
const path = [
|
|
6152
6700
|
h.data.kind === "server_action" ? "Server action call" : "HTTP request",
|
|
6153
6701
|
h.data.entry,
|
|
@@ -6276,6 +6824,7 @@ function emitGroups(ctx, rule, groups) {
|
|
|
6276
6824
|
}
|
|
6277
6825
|
return out;
|
|
6278
6826
|
}
|
|
6827
|
+
var REQUEST_ROLES = /* @__PURE__ */ new Set(["public", "anon", "authenticated"]);
|
|
6279
6828
|
var rlsPolicyWithoutCallerPredicate = {
|
|
6280
6829
|
id: "supabase.rls-policy-without-caller-predicate",
|
|
6281
6830
|
title: "RLS policy grants rows without a caller predicate",
|
|
@@ -6296,6 +6845,7 @@ var rlsPolicyWithoutCallerPredicate = {
|
|
|
6296
6845
|
const op = v.data.operation === "unknown" ? "select" : v.data.operation;
|
|
6297
6846
|
for (const p of t.policyDetails) {
|
|
6298
6847
|
if (p.command !== "all" && p.command !== op) continue;
|
|
6848
|
+
if (p.roles.length > 0 && !p.roles.some((r) => REQUEST_ROLES.has(r))) continue;
|
|
6299
6849
|
const expr = op === "insert" ? p.check : p.using;
|
|
6300
6850
|
if (expr === null || policyScopesToCaller(expr) || callsFunctionIn(expr, callerFns)) {
|
|
6301
6851
|
continue;
|
|
@@ -6528,6 +7078,26 @@ var serviceRoleQueryWithoutAuthentication = {
|
|
|
6528
7078
|
return out;
|
|
6529
7079
|
}
|
|
6530
7080
|
};
|
|
7081
|
+
function rlsOnWrite(t, operation) {
|
|
7082
|
+
if (!t?.known || !t.rlsEnabled) return void 0;
|
|
7083
|
+
const commands = operation === "upsert" ? ["insert", "update"] : [operation];
|
|
7084
|
+
const covering = t.policyDetails.filter(
|
|
7085
|
+
(pl) => (pl.command === "all" || commands.includes(pl.command)) && !(pl.roles.length > 0 && pl.roles.every((r) => r === "service_role"))
|
|
7086
|
+
);
|
|
7087
|
+
if (covering.length === 0) return "refused";
|
|
7088
|
+
return covering.every((pl) => /auth\.uid\(\)|auth\.jwt\(\)/i.test(pinExpression(pl))) ? covering : void 0;
|
|
7089
|
+
}
|
|
7090
|
+
function pinExpression(pl) {
|
|
7091
|
+
return pl.check ?? (pl.command === "insert" ? "" : pl.using ?? "");
|
|
7092
|
+
}
|
|
7093
|
+
function mentionsColumn(expr, column) {
|
|
7094
|
+
const c = column.replace(/[^A-Za-z0-9_]/g, "");
|
|
7095
|
+
return c !== "" && new RegExp(`(?<![A-Za-z0-9_])"?${c}"?(?![A-Za-z0-9_])`, "i").test(expr);
|
|
7096
|
+
}
|
|
7097
|
+
function ownedWriteNote(policies, table2, pinned) {
|
|
7098
|
+
const names = policies.map((pl) => `"${pl.name}" (${pl.location.file}:${pl.location.line})`).join(", ");
|
|
7099
|
+
return ` RLS still binds this client: ${policies.length === 1 ? "policy" : "policies"} ${names} only accept rows tied to the caller${pinned.length > 0 ? ` (${pinned.join(", ")})` : ""}, and public.${table2} has no other column that grants a role, a price or a status, so the caller can only fill in the ordinary columns of their own row.`;
|
|
7100
|
+
}
|
|
6531
7101
|
var massAssignmentFromRequestBody = {
|
|
6532
7102
|
id: "supabase.mass-assignment-from-request-body",
|
|
6533
7103
|
title: "Request body written to a table without an allow-list",
|
|
@@ -6546,6 +7116,11 @@ var massAssignmentFromRequestBody = {
|
|
|
6546
7116
|
const sensitive = cols.filter(
|
|
6547
7117
|
(c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
|
|
6548
7118
|
);
|
|
7119
|
+
const kind = v.clientData?.kind;
|
|
7120
|
+
const rls = kind === "anon" || kind === "user_scoped" ? rlsOnWrite(v.tableData, v.data.operation) : void 0;
|
|
7121
|
+
if (rls === "refused") continue;
|
|
7122
|
+
const pinned = rls ? cols.filter((c) => rls.some((pl) => mentionsColumn(pinExpression(pl), c))) : [];
|
|
7123
|
+
const ownedNote = rls && sensitive.every((c) => pinned.includes(c)) ? ownedWriteNote(rls, tableName, pinned) : "";
|
|
6549
7124
|
const path = [
|
|
6550
7125
|
"HTTP request",
|
|
6551
7126
|
h.data.entry,
|
|
@@ -6554,22 +7129,27 @@ var massAssignmentFromRequestBody = {
|
|
|
6554
7129
|
`public.${tableName}.${v.data.operation}`
|
|
6555
7130
|
];
|
|
6556
7131
|
out.push(
|
|
6557
|
-
finding(
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
{
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
7132
|
+
finding(
|
|
7133
|
+
ctx,
|
|
7134
|
+
this,
|
|
7135
|
+
{
|
|
7136
|
+
title: `Mass assignment into "${tableName}" from the request body`,
|
|
7137
|
+
entrypoints: [h.data.entry],
|
|
7138
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
7139
|
+
sinks: [`supabase.${v.data.operation}:public.${tableName}`],
|
|
7140
|
+
path,
|
|
7141
|
+
evidence: [
|
|
7142
|
+
{
|
|
7143
|
+
kind: "rule",
|
|
7144
|
+
summary: `${v.data.operation} on public.${tableName} writes ${p.text} directly.${ownedNote || ` ${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.`,
|
|
7145
|
+
locations: locations(h.handler.location, v.query.location),
|
|
7146
|
+
data: { deterministic: false, ruleId: this.id, payload: p.text }
|
|
7147
|
+
},
|
|
7148
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
7149
|
+
]
|
|
7150
|
+
},
|
|
7151
|
+
ownedNote ? "medium" : void 0
|
|
7152
|
+
)
|
|
6573
7153
|
);
|
|
6574
7154
|
}
|
|
6575
7155
|
}
|
|
@@ -7041,32 +7621,51 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
7041
7621
|
`every object in ${where2}`
|
|
7042
7622
|
];
|
|
7043
7623
|
const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
|
|
7624
|
+
const insertOnly = p.command === "insert";
|
|
7625
|
+
const insertNote = insertOnly ? " An INSERT policy cannot read, replace or delete existing files, so this is reported as medium: what it allows is placing files in other users' folders." : "";
|
|
7044
7626
|
out.push(
|
|
7045
|
-
finding3(
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7051
|
-
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
|
|
7055
|
-
{
|
|
7056
|
-
|
|
7057
|
-
|
|
7058
|
-
|
|
7059
|
-
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7627
|
+
finding3(
|
|
7628
|
+
ctx,
|
|
7629
|
+
this,
|
|
7630
|
+
{
|
|
7631
|
+
title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
|
|
7632
|
+
entrypoints: [...entries, storageEntry],
|
|
7633
|
+
sources: unique([
|
|
7634
|
+
...reached.flatMap((r) => r.inputs.map((i) => `${i.kind}:${i.name}`)),
|
|
7635
|
+
"storage API (caller's own session)"
|
|
7636
|
+
]),
|
|
7637
|
+
sinks: buckets.length > 0 ? buckets.map((b) => `storage.objects:${b}`) : ["storage.objects"],
|
|
7638
|
+
path,
|
|
7639
|
+
evidence: [
|
|
7640
|
+
{
|
|
7641
|
+
kind: "rule",
|
|
7642
|
+
summary: `Policy "${p.name}" for ${p.command} on storage.objects ${clause} (${expr}) checks only the bucket: no auth.uid(), no storage.foldername(name) ownership, no owner column. So ${whoLong} can ${verb} every object in ${where2}, including other users' files, straight through the Storage API.${privateNote}${reachNote}${insertNote} Scope it to the owner, e.g. bucket_id = '${buckets[0] ?? "<bucket>"}' and (storage.foldername(name))[1] = (select auth.uid())::text.`,
|
|
7643
|
+
locations: locations3(p.location, ...reached.map((r) => r.query.location)),
|
|
7644
|
+
data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
|
|
7645
|
+
},
|
|
7646
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
7647
|
+
]
|
|
7648
|
+
},
|
|
7649
|
+
insertOnly ? "medium" : void 0
|
|
7650
|
+
)
|
|
7064
7651
|
);
|
|
7065
7652
|
}
|
|
7066
7653
|
}
|
|
7067
7654
|
return out;
|
|
7068
7655
|
}
|
|
7069
7656
|
};
|
|
7657
|
+
var EVERY_ROW = /^\(*\s*true\s*\)*$/i;
|
|
7658
|
+
var MIGRATION_SQL = /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i;
|
|
7659
|
+
function readsOnlyPublicRows(fn2, tables, fromMigrationsOnly) {
|
|
7660
|
+
if ((fn2.writes?.length ?? 0) > 0 || (fn2.calls?.length ?? 0) > 0) return false;
|
|
7661
|
+
const read = fn2.tables ?? [];
|
|
7662
|
+
return read.length > 0 && read.every((name) => {
|
|
7663
|
+
const t = tables.get(name);
|
|
7664
|
+
return t?.rlsEnabled === true && t.policyDetails.some(
|
|
7665
|
+
(p) => (p.command === "select" || p.command === "all") && opensToEveryone(p) && p.using !== null && EVERY_ROW.test(p.using.trim()) && (!fromMigrationsOnly || MIGRATION_SQL.test(p.location.file))
|
|
7666
|
+
);
|
|
7667
|
+
});
|
|
7668
|
+
}
|
|
7070
7669
|
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
7071
7670
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
7072
7671
|
id: "supabase.security-definer-function-without-caller-check",
|
|
@@ -7083,6 +7682,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7083
7682
|
const key = r.data.table.toLowerCase();
|
|
7084
7683
|
rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
|
|
7085
7684
|
}
|
|
7685
|
+
const tables = new Map(ctx.model.tables.map((t) => [t.table, t]));
|
|
7686
|
+
const hasMigrations = ctx.model.files.some((f) => MIGRATION_SQL.test(f));
|
|
7086
7687
|
const out = [];
|
|
7087
7688
|
for (const fn2 of fns) {
|
|
7088
7689
|
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
@@ -7090,6 +7691,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7090
7691
|
if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
|
|
7091
7692
|
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
7092
7693
|
if (roles.length === 0) continue;
|
|
7694
|
+
if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
|
|
7093
7695
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
7094
7696
|
const sites = rpcByName.get(fn2.name) ?? [];
|
|
7095
7697
|
const entries = unique(sites.map((s) => s.handlerData.entry));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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",
|