auditai-scan 0.7.2 → 0.7.4
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 +884 -80
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -260,7 +260,7 @@ function policedTables(f, model) {
|
|
|
260
260
|
const out = [];
|
|
261
261
|
for (const n of names) {
|
|
262
262
|
const t = table(model, n);
|
|
263
|
-
if (!t
|
|
263
|
+
if (!t?.rlsEnabled || t.policies.length === 0)
|
|
264
264
|
return null;
|
|
265
265
|
out.push(qualified(t.table));
|
|
266
266
|
}
|
|
@@ -1518,6 +1518,18 @@ function rowComparisons(tail) {
|
|
|
1518
1518
|
const { parent } = outerOf(tail);
|
|
1519
1519
|
if (!parent || !ts4.isVariableDeclaration(parent)) return [];
|
|
1520
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
|
+
}
|
|
1521
1533
|
const out = [];
|
|
1522
1534
|
for (const s of ifsAfter(parent)) {
|
|
1523
1535
|
const exit = exitKind(s.thenStatement);
|
|
@@ -1556,6 +1568,45 @@ function callResultChecked(call) {
|
|
|
1556
1568
|
}
|
|
1557
1569
|
return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child && exitKind(cur.thenStatement) !== null;
|
|
1558
1570
|
}
|
|
1571
|
+
function rowNamesOf(tail) {
|
|
1572
|
+
const { parent } = outerOf(tail);
|
|
1573
|
+
if (!parent || !ts4.isVariableDeclaration(parent)) return /* @__PURE__ */ new Set();
|
|
1574
|
+
const rows = resultNames(parent.name).data;
|
|
1575
|
+
const fn2 = enclosingFunction(parent);
|
|
1576
|
+
const scope = fn2 ? fn2.body : parent.getSourceFile();
|
|
1577
|
+
if (scope) {
|
|
1578
|
+
walkOwn(scope, (n) => {
|
|
1579
|
+
if (!ts4.isVariableDeclaration(n) || !n.initializer || n.pos < parent.end) return;
|
|
1580
|
+
const init = unwrap(n.initializer);
|
|
1581
|
+
if (!ts4.isIdentifier(init) || !rows.has(init.text) || ts4.isIdentifier(n.name)) return;
|
|
1582
|
+
for (const name of resultNames(n.name).data) rows.add(name);
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
return rows;
|
|
1586
|
+
}
|
|
1587
|
+
function checkedResultExit(call) {
|
|
1588
|
+
const { node, parent } = outerOf(call);
|
|
1589
|
+
if (!parent) return null;
|
|
1590
|
+
if (ts4.isVariableDeclaration(parent) && parent.initializer === node) {
|
|
1591
|
+
const names = new Set(boundNames(parent.name));
|
|
1592
|
+
for (const s of ifsAfter(parent)) {
|
|
1593
|
+
if (!checksMissing(s.expression, names)) continue;
|
|
1594
|
+
const kind = exitKind(s.thenStatement);
|
|
1595
|
+
if (kind) return kind;
|
|
1596
|
+
}
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
if (!ts4.isPrefixUnaryExpression(parent) || parent.operator !== ts4.SyntaxKind.ExclamationToken) {
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
let cur = parent.parent;
|
|
1603
|
+
let child = parent;
|
|
1604
|
+
while (cur && ts4.isParenthesizedExpression(cur)) {
|
|
1605
|
+
child = cur;
|
|
1606
|
+
cur = cur.parent;
|
|
1607
|
+
}
|
|
1608
|
+
return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child ? exitKind(cur.thenStatement) : null;
|
|
1609
|
+
}
|
|
1559
1610
|
function compareOrder(a, b) {
|
|
1560
1611
|
const n = Math.min(a.length, b.length);
|
|
1561
1612
|
for (let i = 0; i < n; i++) {
|
|
@@ -2675,6 +2726,14 @@ function newFunctionRegistry() {
|
|
|
2675
2726
|
publicSchemaPublic: false
|
|
2676
2727
|
};
|
|
2677
2728
|
}
|
|
2729
|
+
function functionBodyOf(reg, q) {
|
|
2730
|
+
const direct = reg.byKey.get(qualifiedKey(q));
|
|
2731
|
+
if (direct) return direct.body;
|
|
2732
|
+
if (q.schema !== null) return null;
|
|
2733
|
+
const lower = q.name.toLowerCase();
|
|
2734
|
+
for (const f of reg.byKey.values()) if (f.name.toLowerCase() === lower) return f.body;
|
|
2735
|
+
return null;
|
|
2736
|
+
}
|
|
2678
2737
|
var CALLER_CHECKS = [
|
|
2679
2738
|
/"?\bauth"?\s*\.\s*"?(?:uid|jwt|email)"?\s*\(\s*\)/i,
|
|
2680
2739
|
/\bcurrent_setting\s*\(\s*'request\.jwt/i
|
|
@@ -2863,11 +2922,18 @@ function readParams(stmt, tokens, open, close) {
|
|
|
2863
2922
|
pieces.push([start, close]);
|
|
2864
2923
|
for (const [from, to] of pieces) {
|
|
2865
2924
|
const words = [];
|
|
2925
|
+
let hasDefault = false;
|
|
2926
|
+
let callerDefault = false;
|
|
2866
2927
|
for (let i = from; i < to; i++) {
|
|
2867
2928
|
const t = tokens[i];
|
|
2868
2929
|
if (!t) continue;
|
|
2869
|
-
if (isWord(t, "default"))
|
|
2870
|
-
|
|
2930
|
+
if (isWord(t, "default") || t.kind === "punct" && t.value === "=") {
|
|
2931
|
+
hasDefault = true;
|
|
2932
|
+
const last2 = tokens[to - 1];
|
|
2933
|
+
const text = last2 ? stmt.text.slice(t.end - stmt.start, last2.end - stmt.start) : "";
|
|
2934
|
+
callerDefault = CALLER_CHECKS.some((re) => re.test(text));
|
|
2935
|
+
break;
|
|
2936
|
+
}
|
|
2871
2937
|
words.push(t);
|
|
2872
2938
|
}
|
|
2873
2939
|
if (words.length === 0) return null;
|
|
@@ -2886,7 +2952,9 @@ function readParams(stmt, tokens, open, close) {
|
|
|
2886
2952
|
if (!first || !last) return null;
|
|
2887
2953
|
parts.push({
|
|
2888
2954
|
name: named && firstWord ? firstWord.value.toLowerCase() : null,
|
|
2889
|
-
type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase()
|
|
2955
|
+
type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase(),
|
|
2956
|
+
hasDefault,
|
|
2957
|
+
callerDefault
|
|
2890
2958
|
});
|
|
2891
2959
|
}
|
|
2892
2960
|
return parts;
|
|
@@ -3064,16 +3132,63 @@ function applyDefaultPrivileges(reg, stmt) {
|
|
|
3064
3132
|
}
|
|
3065
3133
|
}
|
|
3066
3134
|
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;
|
|
3135
|
+
function calledFunction(reg, m, byName) {
|
|
3136
|
+
const name = m[2];
|
|
3137
|
+
if (!name) return void 0;
|
|
3138
|
+
return reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
|
|
3139
|
+
}
|
|
3067
3140
|
function callees(reg, f, byName) {
|
|
3068
3141
|
const out = /* @__PURE__ */ new Set();
|
|
3069
3142
|
for (const m of f.body.matchAll(CALL)) {
|
|
3070
|
-
const
|
|
3071
|
-
if (!name) continue;
|
|
3072
|
-
const g = reg.byKey.get(qualifiedKey({ schema: m[1] ?? null, name })) ?? (m[1] === void 0 ? byName.get(name.toLowerCase()) : void 0);
|
|
3143
|
+
const g = calledFunction(reg, m, byName);
|
|
3073
3144
|
if (g && g !== f) out.add(g);
|
|
3074
3145
|
}
|
|
3075
3146
|
return out;
|
|
3076
3147
|
}
|
|
3148
|
+
var MAX_CALL_ARGS_CHARS = 4e3;
|
|
3149
|
+
function callArguments(body, from) {
|
|
3150
|
+
const args = [];
|
|
3151
|
+
let depth = 0;
|
|
3152
|
+
let quoted = false;
|
|
3153
|
+
let start = from;
|
|
3154
|
+
const end = Math.min(body.length, from + MAX_CALL_ARGS_CHARS);
|
|
3155
|
+
for (let i = from; i < end; i++) {
|
|
3156
|
+
const c = body[i];
|
|
3157
|
+
if (quoted) {
|
|
3158
|
+
if (c === "'") quoted = false;
|
|
3159
|
+
} else if (c === "'") {
|
|
3160
|
+
quoted = true;
|
|
3161
|
+
} else if (c === "(") {
|
|
3162
|
+
depth += 1;
|
|
3163
|
+
} else if (c === ")") {
|
|
3164
|
+
if (depth === 0) {
|
|
3165
|
+
const last = body.slice(start, i).trim();
|
|
3166
|
+
if (last !== "" || args.length > 0) args.push(last);
|
|
3167
|
+
return args;
|
|
3168
|
+
}
|
|
3169
|
+
depth -= 1;
|
|
3170
|
+
} else if (c === "," && depth === 0) {
|
|
3171
|
+
args.push(body.slice(start, i).trim());
|
|
3172
|
+
start = i + 1;
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
return null;
|
|
3176
|
+
}
|
|
3177
|
+
function callsWithCallerDefault(reg, f, byName) {
|
|
3178
|
+
for (const m of f.body.matchAll(CALL)) {
|
|
3179
|
+
const g = calledFunction(reg, m, byName);
|
|
3180
|
+
if (!g || g === f || !g.params?.some((p) => p.callerDefault)) continue;
|
|
3181
|
+
const args = callArguments(f.body, (m.index ?? 0) + m[0].length);
|
|
3182
|
+
if (args === null) continue;
|
|
3183
|
+
const named = args.filter((a) => /=>|:=/.test(a)).map((a) => (a.split(/=>|:=/)[0] ?? "").trim().replace(/"/g, "").toLowerCase());
|
|
3184
|
+
const positional = args.length - named.length;
|
|
3185
|
+
const omitted = g.params.some(
|
|
3186
|
+
(p, i) => p.callerDefault && i >= positional && (p.name === null || !named.includes(p.name))
|
|
3187
|
+
);
|
|
3188
|
+
if (omitted) return true;
|
|
3189
|
+
}
|
|
3190
|
+
return false;
|
|
3191
|
+
}
|
|
3077
3192
|
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;
|
|
3078
3193
|
function relationsOf(body) {
|
|
3079
3194
|
const out = [];
|
|
@@ -3086,6 +3201,118 @@ function relationsOf(body) {
|
|
|
3086
3201
|
}
|
|
3087
3202
|
return out;
|
|
3088
3203
|
}
|
|
3204
|
+
var IDENT_SRC = "[A-Za-z_][A-Za-z0-9_$]{0,62}";
|
|
3205
|
+
var WRITE = new RegExp(
|
|
3206
|
+
`(?<![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*\\.)`,
|
|
3207
|
+
"gi"
|
|
3208
|
+
);
|
|
3209
|
+
var NOT_A_RELATION = /* @__PURE__ */ new Set(["set", "of", "skip", "nowait", "only", "on"]);
|
|
3210
|
+
function writesOf(body) {
|
|
3211
|
+
const out = [];
|
|
3212
|
+
for (const m of body.matchAll(WRITE)) {
|
|
3213
|
+
const schema = m[1]?.toLowerCase();
|
|
3214
|
+
const name = m[2]?.toLowerCase();
|
|
3215
|
+
if (!name || NOT_A_RELATION.has(name)) continue;
|
|
3216
|
+
const key = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
|
|
3217
|
+
if (!out.includes(key)) out.push(key);
|
|
3218
|
+
}
|
|
3219
|
+
return out;
|
|
3220
|
+
}
|
|
3221
|
+
var ALIASED = new RegExp(
|
|
3222
|
+
`(?<![A-Za-z0-9_$])(?:from|join|update|into)\\s+(?:only\\s+)?(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?(?:\\s+(?:as\\s+)?"?(${IDENT_SRC})"?)?`,
|
|
3223
|
+
"gi"
|
|
3224
|
+
);
|
|
3225
|
+
var SQL_WORDS = /* @__PURE__ */ new Set([
|
|
3226
|
+
"and",
|
|
3227
|
+
"as",
|
|
3228
|
+
"conflict",
|
|
3229
|
+
"cross",
|
|
3230
|
+
"default",
|
|
3231
|
+
"do",
|
|
3232
|
+
"else",
|
|
3233
|
+
"elsif",
|
|
3234
|
+
"end",
|
|
3235
|
+
"except",
|
|
3236
|
+
"for",
|
|
3237
|
+
"from",
|
|
3238
|
+
"full",
|
|
3239
|
+
"group",
|
|
3240
|
+
"having",
|
|
3241
|
+
"if",
|
|
3242
|
+
"in",
|
|
3243
|
+
"inner",
|
|
3244
|
+
"intersect",
|
|
3245
|
+
"into",
|
|
3246
|
+
"is",
|
|
3247
|
+
"join",
|
|
3248
|
+
"lateral",
|
|
3249
|
+
"left",
|
|
3250
|
+
"limit",
|
|
3251
|
+
"loop",
|
|
3252
|
+
"natural",
|
|
3253
|
+
"not",
|
|
3254
|
+
"nowait",
|
|
3255
|
+
"of",
|
|
3256
|
+
"offset",
|
|
3257
|
+
"on",
|
|
3258
|
+
"only",
|
|
3259
|
+
"or",
|
|
3260
|
+
"order",
|
|
3261
|
+
"returning",
|
|
3262
|
+
"right",
|
|
3263
|
+
"select",
|
|
3264
|
+
"set",
|
|
3265
|
+
"skip",
|
|
3266
|
+
"then",
|
|
3267
|
+
"union",
|
|
3268
|
+
"using",
|
|
3269
|
+
"values",
|
|
3270
|
+
"when",
|
|
3271
|
+
"where",
|
|
3272
|
+
"window",
|
|
3273
|
+
"with"
|
|
3274
|
+
]);
|
|
3275
|
+
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;
|
|
3276
|
+
var COMPARING = /* @__PURE__ */ new Set(["where", "on", "and", "or", "when", "if", "elsif", "having"]);
|
|
3277
|
+
var MAX_KEYED_PARAMS = 32;
|
|
3278
|
+
var CLAUSE_WINDOW = 400;
|
|
3279
|
+
function paramKeysOf(body, params) {
|
|
3280
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
3281
|
+
const relations = [];
|
|
3282
|
+
for (const m of body.matchAll(ALIASED)) {
|
|
3283
|
+
const schema = m[1]?.toLowerCase();
|
|
3284
|
+
const name = m[2]?.toLowerCase();
|
|
3285
|
+
if (!name || SQL_WORDS.has(name)) continue;
|
|
3286
|
+
const table2 = schema === void 0 || schema === "public" ? name : `${schema}.${name}`;
|
|
3287
|
+
if (!relations.includes(table2)) relations.push(table2);
|
|
3288
|
+
aliases.set(name, table2);
|
|
3289
|
+
const alias = m[3]?.toLowerCase();
|
|
3290
|
+
if (alias && !SQL_WORDS.has(alias)) aliases.set(alias, table2);
|
|
3291
|
+
}
|
|
3292
|
+
const side = `(?:"?(${IDENT_SRC})"?\\s*\\.\\s*)?"?(${IDENT_SRC})"?`;
|
|
3293
|
+
const out = [];
|
|
3294
|
+
for (const param of params.slice(0, MAX_KEYED_PARAMS)) {
|
|
3295
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(param)) continue;
|
|
3296
|
+
const re = new RegExp(
|
|
3297
|
+
`${side}\\s*(?<![<>!:])=\\s*"?${param}"?(?![A-Za-z0-9_$.(])|(?<![A-Za-z0-9_$."])"?${param}"?\\s*=(?!=)\\s*${side}(?!\\s*\\()`,
|
|
3298
|
+
"gi"
|
|
3299
|
+
);
|
|
3300
|
+
for (const m of body.matchAll(re)) {
|
|
3301
|
+
const at = m.index ?? 0;
|
|
3302
|
+
const clauses = [...body.slice(Math.max(0, at - CLAUSE_WINDOW), at).matchAll(CLAUSE)];
|
|
3303
|
+
const clause = clauses[clauses.length - 1]?.[1]?.toLowerCase();
|
|
3304
|
+
if (clause === void 0 || !COMPARING.has(clause)) continue;
|
|
3305
|
+
const qualifier = (m[1] ?? m[3])?.toLowerCase();
|
|
3306
|
+
const column = (m[2] ?? m[4])?.toLowerCase();
|
|
3307
|
+
if (!column || column === param || SQL_WORDS.has(column)) continue;
|
|
3308
|
+
const table2 = qualifier !== void 0 ? aliases.get(qualifier) : relations.length === 1 ? relations[0] : void 0;
|
|
3309
|
+
if (table2 === void 0) continue;
|
|
3310
|
+
if (!out.some((k) => k.param === param && k.table === table2 && k.column === column))
|
|
3311
|
+
out.push({ param, table: table2, column });
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
return out;
|
|
3315
|
+
}
|
|
3089
3316
|
function effectiveRoles(acl) {
|
|
3090
3317
|
const out = [];
|
|
3091
3318
|
if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
|
|
@@ -3103,10 +3330,15 @@ function finishFunctions(reg) {
|
|
|
3103
3330
|
}
|
|
3104
3331
|
}
|
|
3105
3332
|
const callers = /* @__PURE__ */ new Map();
|
|
3333
|
+
const calls = /* @__PURE__ */ new Map();
|
|
3106
3334
|
for (const f of fns) {
|
|
3107
|
-
|
|
3335
|
+
const called = callees(reg, f, byName);
|
|
3336
|
+
calls.set(f, called);
|
|
3337
|
+
for (const g of called) callers.set(g, [...callers.get(g) ?? [], f]);
|
|
3108
3338
|
}
|
|
3109
|
-
const checks = new Set(
|
|
3339
|
+
const checks = new Set(
|
|
3340
|
+
fns.filter((f) => f.directCheck || callsWithCallerDefault(reg, f, byName))
|
|
3341
|
+
);
|
|
3110
3342
|
const queue = [...checks];
|
|
3111
3343
|
for (let g = queue.pop(); g !== void 0; g = queue.pop()) {
|
|
3112
3344
|
for (const f of callers.get(g) ?? []) {
|
|
@@ -3125,10 +3357,25 @@ function finishFunctions(reg) {
|
|
|
3125
3357
|
};
|
|
3126
3358
|
if (f.returns !== null) info.returns = f.returns;
|
|
3127
3359
|
if (f.args !== null) info.args = f.args;
|
|
3128
|
-
if (f.params?.every((x) => x.name !== null))
|
|
3129
|
-
|
|
3360
|
+
if (f.params?.every((x) => x.name !== null)) {
|
|
3361
|
+
const params = f.params.map((x) => ({
|
|
3362
|
+
name: x.name ?? "",
|
|
3363
|
+
type: x.type,
|
|
3364
|
+
...x.hasDefault ? { default: true } : {}
|
|
3365
|
+
}));
|
|
3366
|
+
info.params = params;
|
|
3367
|
+
const keys = paramKeysOf(
|
|
3368
|
+
f.body,
|
|
3369
|
+
params.map((x) => x.name)
|
|
3370
|
+
);
|
|
3371
|
+
if (keys.length > 0) info.keys = keys;
|
|
3372
|
+
}
|
|
3130
3373
|
const tables = relationsOf(f.body);
|
|
3131
3374
|
if (tables.length > 0) info.tables = tables;
|
|
3375
|
+
const called = [...calls.get(f) ?? []].map((g) => qualifiedKey(g));
|
|
3376
|
+
if (called.length > 0) info.calls = called;
|
|
3377
|
+
const writes = writesOf(f.body);
|
|
3378
|
+
if (writes.length > 0) info.writes = writes;
|
|
3132
3379
|
return info;
|
|
3133
3380
|
});
|
|
3134
3381
|
}
|
|
@@ -3250,6 +3497,7 @@ function schemaStateFor(tables) {
|
|
|
3250
3497
|
enums: /* @__PURE__ */ new Map(),
|
|
3251
3498
|
functions: newFunctionRegistry(),
|
|
3252
3499
|
buckets: newBucketRegistry(),
|
|
3500
|
+
triggers: /* @__PURE__ */ new Map(),
|
|
3253
3501
|
warnings: []
|
|
3254
3502
|
};
|
|
3255
3503
|
STATES.set(tables, state);
|
|
@@ -3692,6 +3940,96 @@ function doBlock(state, stmt, file) {
|
|
|
3692
3940
|
}
|
|
3693
3941
|
}
|
|
3694
3942
|
}
|
|
3943
|
+
var isTriggerEvent = (v) => v === "insert" || v === "update" || v === "delete" || v === "truncate";
|
|
3944
|
+
function createTrigger(state, stmt, file) {
|
|
3945
|
+
const tk = stmt.tokens;
|
|
3946
|
+
let i = 1;
|
|
3947
|
+
if (isWord(tk[i], "or") && isWord(tk[i + 1], "replace")) i += 2;
|
|
3948
|
+
if (isWord(tk[i], "constraint")) i += 1;
|
|
3949
|
+
if (!isWord(tk[i], "trigger")) return;
|
|
3950
|
+
const name = identOf(tk[i + 1]);
|
|
3951
|
+
if (name === null || name === "") return;
|
|
3952
|
+
i += 2;
|
|
3953
|
+
let timing;
|
|
3954
|
+
if (isWord(tk[i], "before")) timing = "before";
|
|
3955
|
+
else if (isWord(tk[i], "after")) timing = "after";
|
|
3956
|
+
else if (isWord(tk[i], "instead") && isWord(tk[i + 1], "of")) {
|
|
3957
|
+
timing = "instead of";
|
|
3958
|
+
i += 1;
|
|
3959
|
+
} else return;
|
|
3960
|
+
i += 1;
|
|
3961
|
+
const events = [];
|
|
3962
|
+
const ofColumns = [];
|
|
3963
|
+
while (i < tk.length && !isWord(tk[i], "on")) {
|
|
3964
|
+
const t = tk[i];
|
|
3965
|
+
if (t?.kind === "word" && isTriggerEvent(t.value)) {
|
|
3966
|
+
events.push(t.value);
|
|
3967
|
+
i += 1;
|
|
3968
|
+
} else if (isWord(t, "of")) {
|
|
3969
|
+
i += 1;
|
|
3970
|
+
while (i < tk.length && !isWord(tk[i], "or") && !isWord(tk[i], "on")) {
|
|
3971
|
+
const col = identOf(tk[i]);
|
|
3972
|
+
if (col !== null && col !== "") ofColumns.push(col.toLowerCase());
|
|
3973
|
+
i += 1;
|
|
3974
|
+
}
|
|
3975
|
+
} else {
|
|
3976
|
+
i += 1;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
const table2 = readQualifiedName(tk, i + 1);
|
|
3980
|
+
if (!table2 || events.length === 0) return;
|
|
3981
|
+
let j = table2.next;
|
|
3982
|
+
while (j < tk.length && !isWord(tk[j], "execute")) j += 1;
|
|
3983
|
+
if (!isWord(tk[j + 1], "function") && !isWord(tk[j + 1], "procedure")) return;
|
|
3984
|
+
const fn2 = readQualifiedName(tk, j + 2);
|
|
3985
|
+
if (!fn2) return;
|
|
3986
|
+
const key = qualifiedKey(table2);
|
|
3987
|
+
state.triggers.set(`${key}#${name.toLowerCase()}`, {
|
|
3988
|
+
name: name.toLowerCase(),
|
|
3989
|
+
table: key,
|
|
3990
|
+
timing,
|
|
3991
|
+
events,
|
|
3992
|
+
ofColumns,
|
|
3993
|
+
fn: { schema: fn2.schema, name: fn2.name },
|
|
3994
|
+
location: { file, line: stmt.line }
|
|
3995
|
+
});
|
|
3996
|
+
}
|
|
3997
|
+
function dropTrigger(state, stmt) {
|
|
3998
|
+
const tk = stmt.tokens;
|
|
3999
|
+
let i = 2;
|
|
4000
|
+
if (isWord(tk[i], "if") && isWord(tk[i + 1], "exists")) i += 2;
|
|
4001
|
+
const name = identOf(tk[i]);
|
|
4002
|
+
if (name === null || !isWord(tk[i + 1], "on")) return;
|
|
4003
|
+
const table2 = readQualifiedName(tk, i + 2);
|
|
4004
|
+
if (table2) state.triggers.delete(`${qualifiedKey(table2)}#${name.toLowerCase()}`);
|
|
4005
|
+
}
|
|
4006
|
+
var NEW_COLUMN = /(?<![A-Za-z0-9_$])new\s*\.\s*"?([A-Za-z_][A-Za-z0-9_]{0,62})"?/gi;
|
|
4007
|
+
var MAX_TRIGGER_COLUMNS = 200;
|
|
4008
|
+
function finishTrigger(state, t) {
|
|
4009
|
+
const body = functionBodyOf(state.functions, t.fn) ?? "";
|
|
4010
|
+
const raises = /(?<![A-Za-z0-9_$])raise(?![A-Za-z0-9_$])/i.test(body);
|
|
4011
|
+
const read = /* @__PURE__ */ new Set();
|
|
4012
|
+
for (const m of body.matchAll(NEW_COLUMN)) {
|
|
4013
|
+
const col = m[1]?.toLowerCase();
|
|
4014
|
+
if (col) read.add(col);
|
|
4015
|
+
if (read.size >= MAX_TRIGGER_COLUMNS) break;
|
|
4016
|
+
}
|
|
4017
|
+
const checked = new Set(t.ofColumns);
|
|
4018
|
+
for (const col of read) {
|
|
4019
|
+
const old = new RegExp(`(?<![A-Za-z0-9_$])old\\s*\\.\\s*"?${col}"?(?![A-Za-z0-9_$])`, "i");
|
|
4020
|
+
const assigned = new RegExp(`(?<![A-Za-z0-9_$])new\\s*\\.\\s*"?${col}"?\\s*:=`, "i");
|
|
4021
|
+
if (raises || old.test(body) || assigned.test(body)) checked.add(col);
|
|
4022
|
+
}
|
|
4023
|
+
return {
|
|
4024
|
+
name: t.name,
|
|
4025
|
+
table: t.table,
|
|
4026
|
+
timing: t.timing,
|
|
4027
|
+
events: [...t.events],
|
|
4028
|
+
checkedColumns: [...checked],
|
|
4029
|
+
function: qualifiedKey(t.fn),
|
|
4030
|
+
location: t.location
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
3695
4033
|
function applySchemaStatement(state, stmt, file) {
|
|
3696
4034
|
const tk = stmt.tokens;
|
|
3697
4035
|
const w0 = tk[0]?.kind === "word" ? tk[0].value : "";
|
|
@@ -3702,6 +4040,7 @@ function applySchemaStatement(state, stmt, file) {
|
|
|
3702
4040
|
else if (kind === "type") createType(state, stmt);
|
|
3703
4041
|
else if (kind === "function") applyCreateFunction(state.functions, stmt, file);
|
|
3704
4042
|
else if (kind === "unique") createUniqueIndex(state, stmt);
|
|
4043
|
+
else if (kind === "trigger" || kind === "constraint") createTrigger(state, stmt, file);
|
|
3705
4044
|
} else if (w0 === "alter") {
|
|
3706
4045
|
if (w1 === "table") alterTable(state, stmt, file, false);
|
|
3707
4046
|
else if (w1 === "type") alterType(state, stmt);
|
|
@@ -3711,6 +4050,7 @@ function applySchemaStatement(state, stmt, file) {
|
|
|
3711
4050
|
if (w1 === "table") dropTable(state, stmt);
|
|
3712
4051
|
else if (w1 === "type") dropType(state, stmt);
|
|
3713
4052
|
else if (w1 === "function" || w1 === "routine") applyDropFunction(state.functions, stmt);
|
|
4053
|
+
else if (w1 === "trigger") dropTrigger(state, stmt);
|
|
3714
4054
|
} else if (w0 === "grant" || w0 === "revoke") {
|
|
3715
4055
|
applyGrantRevoke(state.functions, stmt);
|
|
3716
4056
|
} else if (w0 === "do") {
|
|
@@ -3725,6 +4065,7 @@ function finishSchema(state) {
|
|
|
3725
4065
|
enums: Object.fromEntries([...state.enums].map(([k, v]) => [k, [...v]])),
|
|
3726
4066
|
sqlFunctions: finishFunctions(state.functions),
|
|
3727
4067
|
storageBuckets: finishBuckets(state.buckets),
|
|
4068
|
+
triggers: [...state.triggers.values()].map((t) => finishTrigger(state, t)),
|
|
3728
4069
|
warnings: [...state.warnings]
|
|
3729
4070
|
};
|
|
3730
4071
|
}
|
|
@@ -3755,6 +4096,41 @@ var DROP_POLICY = new RegExp(
|
|
|
3755
4096
|
function isAppliedSqlFile(rel) {
|
|
3756
4097
|
return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
|
|
3757
4098
|
}
|
|
4099
|
+
var REFERENCE_SQL_DIRS = /* @__PURE__ */ new Set([
|
|
4100
|
+
"doc",
|
|
4101
|
+
"docs",
|
|
4102
|
+
"documentation",
|
|
4103
|
+
"legacy",
|
|
4104
|
+
"archive",
|
|
4105
|
+
"archives",
|
|
4106
|
+
"archived",
|
|
4107
|
+
"backup",
|
|
4108
|
+
"backups",
|
|
4109
|
+
"deprecated",
|
|
4110
|
+
"example",
|
|
4111
|
+
"examples",
|
|
4112
|
+
"old"
|
|
4113
|
+
]);
|
|
4114
|
+
var CLI_MIGRATION = /(?:^|\/)supabase\/migrations\/[0-9]+_[^/]*\.sql$/;
|
|
4115
|
+
var CLI_SEED = /(?:^|\/)supabase\/seed\.sql$/;
|
|
4116
|
+
function appliedSqlFiles(rels) {
|
|
4117
|
+
const applied = rels.filter(isAppliedSqlFile);
|
|
4118
|
+
const slashed = (rel) => rel.split("\\").join("/");
|
|
4119
|
+
const hasMigrations = applied.some(
|
|
4120
|
+
(rel) => /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i.test(slashed(rel))
|
|
4121
|
+
);
|
|
4122
|
+
if (!hasMigrations) return applied;
|
|
4123
|
+
const kept = applied.filter(
|
|
4124
|
+
(rel) => !slashed(rel).split("/").slice(0, -1).some((dir) => REFERENCE_SQL_DIRS.has(dir.toLowerCase()))
|
|
4125
|
+
);
|
|
4126
|
+
const migrations = kept.filter((rel) => CLI_MIGRATION.test(slashed(rel)));
|
|
4127
|
+
if (migrations.length === 0) return kept;
|
|
4128
|
+
const seeds = kept.filter((rel) => CLI_SEED.test(slashed(rel)));
|
|
4129
|
+
const byHand = kept.filter(
|
|
4130
|
+
(rel) => !CLI_MIGRATION.test(slashed(rel)) && !CLI_SEED.test(slashed(rel))
|
|
4131
|
+
);
|
|
4132
|
+
return [...byHand, ...migrations, ...seeds];
|
|
4133
|
+
}
|
|
3758
4134
|
function parseSqlForRls(rel, text, into) {
|
|
3759
4135
|
if (!isAppliedSqlFile(rel)) return;
|
|
3760
4136
|
const state = schemaStateFor(into);
|
|
@@ -3830,7 +4206,7 @@ function sqlSchemaFor(into) {
|
|
|
3830
4206
|
|
|
3831
4207
|
// packages/parser/src/role-gates.ts
|
|
3832
4208
|
import ts7 from "typescript";
|
|
3833
|
-
var ROLE_PROPERTY = /^(role|roles|app_metadata|is_?admin|is_?superuser|permissions?|claims?)$/i;
|
|
4209
|
+
var ROLE_PROPERTY = /^(role|roles|rolle|rol|user_?role|access_?level|app_metadata|admin|is_?admin|superuser|is_?superuser|permissions?|claims?)$/i;
|
|
3834
4210
|
var EMAIL_PROPERTY = /^email$/i;
|
|
3835
4211
|
function propertyPath(e) {
|
|
3836
4212
|
const u = unwrap(e);
|
|
@@ -4916,6 +5292,9 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4916
5292
|
const names = boundNames(decl.name);
|
|
4917
5293
|
const text = init.getText(sf);
|
|
4918
5294
|
aliasLocal(frame, decl.name, init);
|
|
5295
|
+
const who = identityBinding(p, frame, init, scope);
|
|
5296
|
+
if (who !== void 0)
|
|
5297
|
+
for (const nm of identityNamesOf(decl.name)) frame.identities.set(nm, who);
|
|
4919
5298
|
const client = ts11.isCallExpression(init) ? classifyCall(p, init, sf, scope, frame, 0) : null;
|
|
4920
5299
|
if (client && ts11.isIdentifier(decl.name)) {
|
|
4921
5300
|
frame.clients.set(decl.name.text, client);
|
|
@@ -4997,6 +5376,21 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4997
5376
|
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4998
5377
|
}
|
|
4999
5378
|
}
|
|
5379
|
+
const assigned = /* @__PURE__ */ new Map();
|
|
5380
|
+
for (const asg of collect(body, ts11.isBinaryExpression)) {
|
|
5381
|
+
if (asg.operatorToken.kind !== ts11.SyntaxKind.EqualsToken || !ts11.isIdentifier(asg.left))
|
|
5382
|
+
continue;
|
|
5383
|
+
if (isLiteralValue(unwrap(asg.right))) continue;
|
|
5384
|
+
const list = assigned.get(asg.left.text) ?? [];
|
|
5385
|
+
list.push(identityBinding(p, frame, asg.right, scope));
|
|
5386
|
+
assigned.set(asg.left.text, list);
|
|
5387
|
+
}
|
|
5388
|
+
for (const [name, whos] of assigned) {
|
|
5389
|
+
const [first] = whos;
|
|
5390
|
+
if (first !== void 0 && whos.every((w) => w !== void 0))
|
|
5391
|
+
frame.identities.set(name, first);
|
|
5392
|
+
else frame.identities.delete(name);
|
|
5393
|
+
}
|
|
5000
5394
|
if (frame.depth === 0) {
|
|
5001
5395
|
walk(body, (n) => {
|
|
5002
5396
|
if (ts11.isPropertyAccessExpression(n) && ts11.isIdentifier(n.expression) && n.expression.text === "params") {
|
|
@@ -5022,16 +5416,22 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5022
5416
|
const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
|
|
5023
5417
|
if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
|
|
5024
5418
|
}
|
|
5025
|
-
const isSessionCall = (call) =>
|
|
5419
|
+
const isSessionCall = (call) => callsSession(p, call, sf, scope);
|
|
5026
5420
|
for (const call of collect(body, ts11.isCallExpression)) {
|
|
5027
5421
|
if (isSessionCall(call)) acc.authChecks.push({ ...loc2(call), kind: "session" });
|
|
5028
5422
|
}
|
|
5029
|
-
|
|
5423
|
+
const sessionNames = sessionNamesIn(body, isSessionCall);
|
|
5424
|
+
for (const name of frame.identities.keys()) sessionNames.add(name);
|
|
5425
|
+
for (const gate of roleGatesIn(body, sessionNames)) {
|
|
5030
5426
|
if (gate.exit === "return" && !frame.exitPropagates) continue;
|
|
5427
|
+
const [root, ...rest] = gate.source.split(".");
|
|
5428
|
+
const table2 = root === void 0 ? void 0 : frame.identities.get(root);
|
|
5429
|
+
const column = rest[rest.length - 1];
|
|
5031
5430
|
acc.roleChecks.push({
|
|
5032
5431
|
...loc2(gate.node),
|
|
5033
5432
|
source: gate.source,
|
|
5034
|
-
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160)
|
|
5433
|
+
text: gate.node.expression.getText(sf).replace(/\s+/g, " ").slice(0, 160),
|
|
5434
|
+
...typeof table2 === "string" && column !== void 0 ? { table: table2, column } : {}
|
|
5035
5435
|
});
|
|
5036
5436
|
}
|
|
5037
5437
|
for (const check of secretChecksIn(fn2, sf)) {
|
|
@@ -5091,7 +5491,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5091
5491
|
method: f.method,
|
|
5092
5492
|
column: f.column,
|
|
5093
5493
|
valueText: f.value ? f.value.getText(sf) : f.text,
|
|
5094
|
-
inputDerived: f.value ? derivedIn(frame, f.value) : false
|
|
5494
|
+
inputDerived: f.value ? derivedIn(frame, f.value) : false,
|
|
5495
|
+
...f.value && identityOf(frame, f.value) !== void 0 ? { identity: true } : {}
|
|
5095
5496
|
},
|
|
5096
5497
|
f.value
|
|
5097
5498
|
);
|
|
@@ -5141,7 +5542,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5141
5542
|
method: "match",
|
|
5142
5543
|
column: pr.name.getText(sf).replace(/['"]/g, ""),
|
|
5143
5544
|
valueText: pr.initializer.getText(sf),
|
|
5144
|
-
inputDerived: derivedIn(frame, pr.initializer)
|
|
5545
|
+
inputDerived: derivedIn(frame, pr.initializer),
|
|
5546
|
+
...identityOf(frame, pr.initializer) !== void 0 ? { identity: true } : {}
|
|
5145
5547
|
};
|
|
5146
5548
|
filters.push(valued(f2, pr.initializer));
|
|
5147
5549
|
}
|
|
@@ -5153,7 +5555,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5153
5555
|
method: s.name,
|
|
5154
5556
|
column: stringLiteralValue(firstArg),
|
|
5155
5557
|
valueText: val ? val.getText(sf) : "",
|
|
5156
|
-
inputDerived: val ? derivedIn(frame, val) : false
|
|
5558
|
+
inputDerived: val ? derivedIn(frame, val) : false,
|
|
5559
|
+
...val && identityOf(frame, val) !== void 0 ? { identity: true } : {}
|
|
5157
5560
|
};
|
|
5158
5561
|
filters.push(valued(f, val));
|
|
5159
5562
|
}
|
|
@@ -5285,13 +5688,23 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5285
5688
|
call,
|
|
5286
5689
|
chain.segments.map((s) => s.name)
|
|
5287
5690
|
);
|
|
5288
|
-
const
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
5292
|
-
|
|
5293
|
-
|
|
5691
|
+
const exits = rowExit === "throw" || rowExit === "return" && frame.exitPropagates;
|
|
5692
|
+
const checks = [
|
|
5693
|
+
// `if (!row || row.user_id !== user.id) return 404`: ownership checked in code after the read.
|
|
5694
|
+
...rowComparisons(call).filter((c) => c.exit === "throw" || frame.exitPropagates).map((c) => ({
|
|
5695
|
+
method: "compare",
|
|
5696
|
+
column: c.column,
|
|
5697
|
+
valueText: c.value.getText(sf).replace(/\s+/g, " "),
|
|
5698
|
+
inputDerived: derivedIn(frame, c.value),
|
|
5699
|
+
...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
|
|
5700
|
+
})),
|
|
5701
|
+
// `if (!(await canAccessRequest(user, request.org_id))) return null`: checked by a helper.
|
|
5702
|
+
...helperRowChecks(p, call, frame, scope)
|
|
5703
|
+
];
|
|
5294
5704
|
if (checks.length > 0) query.ownerChecks = checks;
|
|
5705
|
+
if (exits && query.operation === "select") {
|
|
5706
|
+
membershipChecks(acc.reads, query, filterValues, frame);
|
|
5707
|
+
}
|
|
5295
5708
|
acc.reads.push({
|
|
5296
5709
|
query,
|
|
5297
5710
|
keys: query.filters.map((f) => {
|
|
@@ -5299,8 +5712,10 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5299
5712
|
return v ? valueKey(frame, v) : null;
|
|
5300
5713
|
}),
|
|
5301
5714
|
order: [...frame.pathPos, parsed.anchor.getStart(sf)],
|
|
5302
|
-
exits
|
|
5303
|
-
checks
|
|
5715
|
+
exits,
|
|
5716
|
+
checks,
|
|
5717
|
+
frame: frame.key,
|
|
5718
|
+
rows: rowNamesOf(call)
|
|
5304
5719
|
});
|
|
5305
5720
|
bindBuilder(call, query);
|
|
5306
5721
|
}
|
|
@@ -5320,6 +5735,187 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5320
5735
|
analyzeFrame(p, child, acc);
|
|
5321
5736
|
}
|
|
5322
5737
|
}
|
|
5738
|
+
function paramRole(e, roles) {
|
|
5739
|
+
let u = unwrap(e);
|
|
5740
|
+
while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
|
|
5741
|
+
if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return null;
|
|
5742
|
+
u = unwrap(u.expression);
|
|
5743
|
+
}
|
|
5744
|
+
return ts11.isIdentifier(u) ? roles.get(u.text) ?? null : null;
|
|
5745
|
+
}
|
|
5746
|
+
var EQUALS = /* @__PURE__ */ new Set([
|
|
5747
|
+
ts11.SyntaxKind.EqualsEqualsEqualsToken,
|
|
5748
|
+
ts11.SyntaxKind.EqualsEqualsToken
|
|
5749
|
+
]);
|
|
5750
|
+
var NOT_EQUALS = /* @__PURE__ */ new Set([
|
|
5751
|
+
ts11.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
5752
|
+
ts11.SyntaxKind.ExclamationEqualsToken
|
|
5753
|
+
]);
|
|
5754
|
+
function comparesRowAndCaller(e, roles, ops) {
|
|
5755
|
+
const u = unwrap(e);
|
|
5756
|
+
if (!ts11.isBinaryExpression(u) || !ops.has(u.operatorToken.kind)) return false;
|
|
5757
|
+
const a = paramRole(u.left, roles);
|
|
5758
|
+
const b = paramRole(u.right, roles);
|
|
5759
|
+
return a === "row" && b === "caller" || a === "caller" && b === "row";
|
|
5760
|
+
}
|
|
5761
|
+
function isCallerRoleTest(e, roles) {
|
|
5762
|
+
const u = unwrap(e);
|
|
5763
|
+
const onCaller = (x) => {
|
|
5764
|
+
const ux = unwrap(x);
|
|
5765
|
+
return ts11.isPropertyAccessExpression(ux) && paramRole(ux, roles) === "caller";
|
|
5766
|
+
};
|
|
5767
|
+
if (ts11.isBinaryExpression(u)) {
|
|
5768
|
+
const op = u.operatorToken.kind;
|
|
5769
|
+
if (op === ts11.SyntaxKind.BarBarToken || op === ts11.SyntaxKind.AmpersandAmpersandToken) {
|
|
5770
|
+
return isCallerRoleTest(u.left, roles) && isCallerRoleTest(u.right, roles);
|
|
5771
|
+
}
|
|
5772
|
+
if (!EQUALS.has(op)) return false;
|
|
5773
|
+
return onCaller(u.left) && ts11.isStringLiteralLike(unwrap(u.right)) || onCaller(u.right) && ts11.isStringLiteralLike(unwrap(u.left));
|
|
5774
|
+
}
|
|
5775
|
+
if (!ts11.isCallExpression(u) || !ts11.isPropertyAccessExpression(u.expression) || u.expression.name.text !== "includes") {
|
|
5776
|
+
return false;
|
|
5777
|
+
}
|
|
5778
|
+
const list = unwrap(u.expression.expression);
|
|
5779
|
+
const arg = u.arguments[0];
|
|
5780
|
+
return ts11.isArrayLiteralExpression(list) && list.elements.length > 0 && list.elements.every((el) => ts11.isStringLiteralLike(el)) && u.arguments.length === 1 && arg !== void 0 && onCaller(arg);
|
|
5781
|
+
}
|
|
5782
|
+
function isDenyValue(e) {
|
|
5783
|
+
const u = unwrap(e);
|
|
5784
|
+
return u.kind === ts11.SyntaxKind.FalseKeyword || u.kind === ts11.SyntaxKind.NullKeyword || ts11.isIdentifier(u) && u.text === "undefined";
|
|
5785
|
+
}
|
|
5786
|
+
function operandsOf(e, op) {
|
|
5787
|
+
const u = unwrap(e);
|
|
5788
|
+
return ts11.isBinaryExpression(u) && u.operatorToken.kind === op ? [...operandsOf(u.left, op), ...operandsOf(u.right, op)] : [u];
|
|
5789
|
+
}
|
|
5790
|
+
function thenBranchIf(ret) {
|
|
5791
|
+
let child = ret;
|
|
5792
|
+
for (let cur = ret.parent; cur && !isFunctionLikeNode(cur); cur = cur.parent) {
|
|
5793
|
+
if (ts11.isIfStatement(cur)) return cur.thenStatement === child ? cur : null;
|
|
5794
|
+
child = cur;
|
|
5795
|
+
}
|
|
5796
|
+
return null;
|
|
5797
|
+
}
|
|
5798
|
+
function onlyDenies(stmt) {
|
|
5799
|
+
let stops = false;
|
|
5800
|
+
let allows = false;
|
|
5801
|
+
walkOwn(stmt, (n) => {
|
|
5802
|
+
if (ts11.isThrowStatement(n)) stops = true;
|
|
5803
|
+
else if (ts11.isReturnStatement(n)) {
|
|
5804
|
+
if (!n.expression || isDenyValue(n.expression)) stops = true;
|
|
5805
|
+
else allows = true;
|
|
5806
|
+
}
|
|
5807
|
+
});
|
|
5808
|
+
return stops && !allows;
|
|
5809
|
+
}
|
|
5810
|
+
function helperComparison(fn2, rowIndex, callerIndexes, sf) {
|
|
5811
|
+
const roles = /* @__PURE__ */ new Map();
|
|
5812
|
+
fn2.parameters.forEach((param, i) => {
|
|
5813
|
+
if (!ts11.isIdentifier(param.name)) return;
|
|
5814
|
+
if (i === rowIndex) roles.set(param.name.text, "row");
|
|
5815
|
+
else if (callerIndexes.includes(i)) roles.set(param.name.text, "caller");
|
|
5816
|
+
});
|
|
5817
|
+
const kinds = new Set(roles.values());
|
|
5818
|
+
if (!kinds.has("row") || !kinds.has("caller") || !fn2.body) return null;
|
|
5819
|
+
let denied = false;
|
|
5820
|
+
if (ts11.isBlock(fn2.body)) {
|
|
5821
|
+
walkOwn(fn2.body, (n) => {
|
|
5822
|
+
if (ts11.isIfStatement(n) && operandsOf(n.expression, ts11.SyntaxKind.BarBarToken).some(
|
|
5823
|
+
(c) => comparesRowAndCaller(c, roles, NOT_EQUALS)
|
|
5824
|
+
) && onlyDenies(n.thenStatement)) {
|
|
5825
|
+
denied = true;
|
|
5826
|
+
}
|
|
5827
|
+
});
|
|
5828
|
+
}
|
|
5829
|
+
const bypasses = [];
|
|
5830
|
+
let compared = false;
|
|
5831
|
+
for (const ret of ownReturns(fn2)) {
|
|
5832
|
+
if (isDenyValue(ret)) continue;
|
|
5833
|
+
if (unwrap(ret).kind === ts11.SyntaxKind.TrueKeyword) {
|
|
5834
|
+
const gate = thenBranchIf(ret);
|
|
5835
|
+
if (gate && isCallerRoleTest(gate.expression, roles)) {
|
|
5836
|
+
bypasses.push(gate.expression.getText(sf).replace(/\s+/g, " "));
|
|
5837
|
+
continue;
|
|
5838
|
+
}
|
|
5839
|
+
if (gate === null && denied) {
|
|
5840
|
+
compared = true;
|
|
5841
|
+
continue;
|
|
5842
|
+
}
|
|
5843
|
+
return null;
|
|
5844
|
+
}
|
|
5845
|
+
for (const alt of operandsOf(ret, ts11.SyntaxKind.BarBarToken)) {
|
|
5846
|
+
const conjuncts = operandsOf(alt, ts11.SyntaxKind.AmpersandAmpersandToken);
|
|
5847
|
+
if (conjuncts.some((c) => comparesRowAndCaller(c, roles, EQUALS))) compared = true;
|
|
5848
|
+
else if (isCallerRoleTest(alt, roles)) bypasses.push(alt.getText(sf).replace(/\s+/g, " "));
|
|
5849
|
+
else return null;
|
|
5850
|
+
}
|
|
5851
|
+
}
|
|
5852
|
+
if (!compared) return null;
|
|
5853
|
+
return bypasses.length > 0 ? { bypass: bypasses.join(" || ") } : {};
|
|
5854
|
+
}
|
|
5855
|
+
function helperRowChecks(p, tail, frame, scope) {
|
|
5856
|
+
const rows = rowNamesOf(tail);
|
|
5857
|
+
const decl = outerOf(tail).parent;
|
|
5858
|
+
const fn2 = decl ? enclosingFunction(decl) : null;
|
|
5859
|
+
if (rows.size === 0 || !decl || !fn2?.body) return [];
|
|
5860
|
+
const out = [];
|
|
5861
|
+
walkOwn(fn2.body, (n) => {
|
|
5862
|
+
if (!ts11.isCallExpression(n) || n.pos < decl.end) return;
|
|
5863
|
+
const rowIndex = n.arguments.findIndex((a) => {
|
|
5864
|
+
const u = unwrap(a);
|
|
5865
|
+
if (!ts11.isPropertyAccessExpression(u)) return false;
|
|
5866
|
+
const base = unwrap(u.expression);
|
|
5867
|
+
return ts11.isIdentifier(base) && rows.has(base.text);
|
|
5868
|
+
});
|
|
5869
|
+
const rowArg = unwrap(n.arguments[rowIndex] ?? n);
|
|
5870
|
+
if (rowIndex < 0 || !ts11.isPropertyAccessExpression(rowArg)) return;
|
|
5871
|
+
const exit = checkedResultExit(n);
|
|
5872
|
+
if (exit === null || exit === "return" && !frame.exitPropagates) return;
|
|
5873
|
+
const callerIndexes = n.arguments.flatMap(
|
|
5874
|
+
(a, i) => i !== rowIndex && identityOf(frame, a) !== void 0 ? [i] : []
|
|
5875
|
+
);
|
|
5876
|
+
if (callerIndexes.length === 0) return;
|
|
5877
|
+
const target = callTarget(p, n, frame, scope);
|
|
5878
|
+
const targetSf = target ? p.sources.get(target.facts.file) : void 0;
|
|
5879
|
+
if (!target || !targetSf) return;
|
|
5880
|
+
const verdict = helperComparison(target.fn, rowIndex, callerIndexes, targetSf);
|
|
5881
|
+
if (!verdict) return;
|
|
5882
|
+
out.push({
|
|
5883
|
+
method: "compare",
|
|
5884
|
+
column: rowArg.name.text,
|
|
5885
|
+
valueText: n.getText(frame.sf).replace(/\s+/g, " ").slice(0, 160),
|
|
5886
|
+
inputDerived: false,
|
|
5887
|
+
identity: true,
|
|
5888
|
+
...verdict.bypass !== void 0 ? { bypass: verdict.bypass } : {}
|
|
5889
|
+
});
|
|
5890
|
+
});
|
|
5891
|
+
return out;
|
|
5892
|
+
}
|
|
5893
|
+
function membershipChecks(reads, query, values, frame) {
|
|
5894
|
+
if (!query.filters.some((f) => f.identity === true && !f.inputDerived && f.method === "eq")) {
|
|
5895
|
+
return;
|
|
5896
|
+
}
|
|
5897
|
+
for (const f of query.filters) {
|
|
5898
|
+
if (f.identity === true || f.inputDerived || f.method !== "eq") continue;
|
|
5899
|
+
const v = values.get(f);
|
|
5900
|
+
const u = v ? unwrap(v) : void 0;
|
|
5901
|
+
if (!u || !ts11.isPropertyAccessExpression(u)) continue;
|
|
5902
|
+
const base = unwrap(u.expression);
|
|
5903
|
+
if (!ts11.isIdentifier(base) || normalizeColumn(u.name.text) !== normalizeColumn(f.column)) {
|
|
5904
|
+
continue;
|
|
5905
|
+
}
|
|
5906
|
+
const row = [...reads].reverse().find((r) => r.frame === frame.key && r.rows.has(base.text));
|
|
5907
|
+
if (!row) continue;
|
|
5908
|
+
const check = {
|
|
5909
|
+
method: "compare",
|
|
5910
|
+
column: u.name.text,
|
|
5911
|
+
valueText: `a ${query.table} row of the caller (${query.location.file}:${query.location.line})`,
|
|
5912
|
+
inputDerived: false,
|
|
5913
|
+
identity: true
|
|
5914
|
+
};
|
|
5915
|
+
row.checks.push(check);
|
|
5916
|
+
row.query.ownerChecks = [...row.query.ownerChecks ?? [], check];
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5323
5919
|
function childFrame(p, call, target, frame) {
|
|
5324
5920
|
const tsf = p.sources.get(target.facts.file);
|
|
5325
5921
|
if (!tsf) return null;
|
|
@@ -5341,7 +5937,8 @@ function childFrame(p, call, target, frame) {
|
|
|
5341
5937
|
key: `${target.facts.file}#${target.name}@${call.getStart(frame.sf)}`,
|
|
5342
5938
|
aliases: /* @__PURE__ */ new Map(),
|
|
5343
5939
|
pathPos: [...frame.pathPos, call.getStart(frame.sf)],
|
|
5344
|
-
exitPropagates: frame.exitPropagates && callResultChecked(call)
|
|
5940
|
+
exitPropagates: frame.exitPropagates && callResultChecked(call),
|
|
5941
|
+
identities: /* @__PURE__ */ new Map()
|
|
5345
5942
|
};
|
|
5346
5943
|
const cx = wholeContext(p, frame);
|
|
5347
5944
|
target.fn.parameters.forEach((param, i) => {
|
|
@@ -5356,6 +5953,9 @@ function childFrame(p, call, target, frame) {
|
|
|
5356
5953
|
child.instances.set(head, ab.instance);
|
|
5357
5954
|
}
|
|
5358
5955
|
for (const nm of names) if (ab.isRequest) child.reqNames.add(nm);
|
|
5956
|
+
const who = arg ? identityOf(frame, arg) : void 0;
|
|
5957
|
+
if (who !== void 0 && ts11.isIdentifier(param.name))
|
|
5958
|
+
child.identities.set(param.name.text, who);
|
|
5359
5959
|
if (ab.tainted) {
|
|
5360
5960
|
bindParamTaint(child, param.name, arg, frame);
|
|
5361
5961
|
for (const nm of wholeParamNames(param.name, arg, cx)) {
|
|
@@ -5373,6 +5973,7 @@ function frameSignature(child) {
|
|
|
5373
5973
|
...[...child.inputNames].map((n) => `${n}!${child.wholeNames.has(n) ? "*" : ""}`),
|
|
5374
5974
|
...[...child.partialInputs].map(([n, s]) => `${n}.{${[...s].sort().join("|")}}`),
|
|
5375
5975
|
...[...child.reqNames].map((n) => `${n}?`),
|
|
5976
|
+
...[...child.identities].map(([n, t]) => `${n}@${t ?? ""}`),
|
|
5376
5977
|
...[...child.thisProps].map(([n, b]) => `this.${n}=${b.client?.kind ?? "-"}`),
|
|
5377
5978
|
...[...child.aliases].map(([n, k]) => `${n}~${k}`),
|
|
5378
5979
|
`exit=${child.exitPropagates}`
|
|
@@ -5628,6 +6229,111 @@ function returnsIdentity(p, call, scope) {
|
|
|
5628
6229
|
if (symOfCallee(p, call.expression, scope)?.kind === "auth") return true;
|
|
5629
6230
|
return IDENTITY_CALLEE.test(calleePath(call.expression));
|
|
5630
6231
|
}
|
|
6232
|
+
function callsSession(p, call, sf, scope) {
|
|
6233
|
+
return /\.auth\.(getUser|getSession|getClaims)$/.test(call.expression.getText(sf)) || symOfCallee(p, call.expression, scope)?.kind === "auth";
|
|
6234
|
+
}
|
|
6235
|
+
function identityOf(frame, e) {
|
|
6236
|
+
if (frame.identities.size === 0 || derivedIn(frame, e)) return void 0;
|
|
6237
|
+
let u = unwrap(e);
|
|
6238
|
+
let part = false;
|
|
6239
|
+
while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
|
|
6240
|
+
if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return void 0;
|
|
6241
|
+
u = unwrap(u.expression);
|
|
6242
|
+
part = true;
|
|
6243
|
+
}
|
|
6244
|
+
if (!ts11.isIdentifier(u) || !frame.identities.has(u.text)) return void 0;
|
|
6245
|
+
const who = frame.identities.get(u.text) ?? null;
|
|
6246
|
+
return part && who !== null ? "" : who;
|
|
6247
|
+
}
|
|
6248
|
+
function identityNamesOf(name) {
|
|
6249
|
+
if (ts11.isIdentifier(name)) return [name.text];
|
|
6250
|
+
if (ts11.isArrayBindingPattern(name)) {
|
|
6251
|
+
const first = name.elements[0];
|
|
6252
|
+
return first && !ts11.isOmittedExpression(first) ? boundNames(first.name) : [];
|
|
6253
|
+
}
|
|
6254
|
+
const data = name.elements.find((el) => propertyKeyOf(el.propertyName ?? el.name) === "data");
|
|
6255
|
+
if (data) return boundNames(data.name);
|
|
6256
|
+
return name.elements.filter((el) => propertyKeyOf(el.propertyName ?? el.name) !== "error").flatMap((el) => boundNames(el.name));
|
|
6257
|
+
}
|
|
6258
|
+
function identityRowOf(frame, call) {
|
|
6259
|
+
const { segments } = flattenChain(call);
|
|
6260
|
+
const table2 = stringLiteralValue(segments.find((s) => s.name === "from")?.args[0]);
|
|
6261
|
+
if (table2 === null) return void 0;
|
|
6262
|
+
const keyed = (column, value) => value !== void 0 && (identityOf(frame, value) !== void 0 || isCredentialColumn(column) && derivedIn(frame, value));
|
|
6263
|
+
for (const s of segments) {
|
|
6264
|
+
if (s.name === "eq" && keyed(stringLiteralValue(s.args[0]), s.args[1])) return table2;
|
|
6265
|
+
const obj = s.name === "match" ? s.args[0] : void 0;
|
|
6266
|
+
if (!obj || !ts11.isObjectLiteralExpression(obj)) continue;
|
|
6267
|
+
for (const pr of obj.properties) {
|
|
6268
|
+
if (ts11.isPropertyAssignment(pr) && keyed(propertyKeyOf(pr.name), pr.initializer))
|
|
6269
|
+
return table2;
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
6272
|
+
return void 0;
|
|
6273
|
+
}
|
|
6274
|
+
function identityBinding(p, frame, init, scope) {
|
|
6275
|
+
let out;
|
|
6276
|
+
for (const leaf of branchesOf(init)) {
|
|
6277
|
+
if (isLiteralValue(leaf)) continue;
|
|
6278
|
+
const who = identityLeaf(p, frame, leaf, scope);
|
|
6279
|
+
if (who === void 0) return void 0;
|
|
6280
|
+
if (out === void 0) out = who;
|
|
6281
|
+
}
|
|
6282
|
+
return out;
|
|
6283
|
+
}
|
|
6284
|
+
function identityLeaf(p, frame, leaf, scope) {
|
|
6285
|
+
const u = unwrap(leaf);
|
|
6286
|
+
if (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
|
|
6287
|
+
let base = u;
|
|
6288
|
+
while (ts11.isPropertyAccessExpression(base) || ts11.isElementAccessExpression(base)) {
|
|
6289
|
+
if (ts11.isPropertyAccessExpression(base) && base.name.text === "user_metadata")
|
|
6290
|
+
return void 0;
|
|
6291
|
+
base = unwrap(base.expression);
|
|
6292
|
+
}
|
|
6293
|
+
if (!ts11.isCallExpression(base)) return identityOf(frame, u);
|
|
6294
|
+
if (derivedIn(frame, u)) return void 0;
|
|
6295
|
+
const who = identityLeaf(p, frame, base, scope);
|
|
6296
|
+
return who === void 0 || who === null ? who : "";
|
|
6297
|
+
}
|
|
6298
|
+
if (ts11.isIdentifier(u)) return identityOf(frame, u);
|
|
6299
|
+
if (!ts11.isCallExpression(u)) return void 0;
|
|
6300
|
+
if (callsSession(p, u, frame.sf, scope)) return null;
|
|
6301
|
+
if (isChainWithQuery(u)) return identityRowOf(frame, u);
|
|
6302
|
+
if (isDbChain(u, frame)) return void 0;
|
|
6303
|
+
return returnIdentity(p, u, frame, scope);
|
|
6304
|
+
}
|
|
6305
|
+
function returnIdentity(p, call, frame, scope) {
|
|
6306
|
+
if (frame.depth >= MAX_DEPTH) return void 0;
|
|
6307
|
+
const target = callTarget(p, call, frame, scope);
|
|
6308
|
+
if (!target) return void 0;
|
|
6309
|
+
const child = childFrame(p, call, target, frame);
|
|
6310
|
+
if (!child) return void 0;
|
|
6311
|
+
const key = `${target.facts.file}#${target.name}#${frameSignature(child)}`;
|
|
6312
|
+
const cached = p.returnIdentities.get(key);
|
|
6313
|
+
if (cached !== void 0) return cached === false ? void 0 : cached.who;
|
|
6314
|
+
p.returnIdentities.set(key, false);
|
|
6315
|
+
bindDeclarations(p, child, {
|
|
6316
|
+
inputs: [],
|
|
6317
|
+
authChecks: [],
|
|
6318
|
+
roleChecks: [],
|
|
6319
|
+
queries: [],
|
|
6320
|
+
metadataAccesses: [],
|
|
6321
|
+
visited: /* @__PURE__ */ new Set(),
|
|
6322
|
+
reads: []
|
|
6323
|
+
});
|
|
6324
|
+
const childScope = scopeOf(p, child.facts);
|
|
6325
|
+
let out = false;
|
|
6326
|
+
for (const ret of ownReturns(target.fn)) {
|
|
6327
|
+
for (const leaf of branchesOf(ret)) {
|
|
6328
|
+
if (isLiteralValue(leaf)) continue;
|
|
6329
|
+
const who = identityLeaf(p, child, leaf, childScope);
|
|
6330
|
+
if (who === void 0) return void 0;
|
|
6331
|
+
if (out === false) out = { who };
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
p.returnIdentities.set(key, out);
|
|
6335
|
+
return out === false ? void 0 : out.who;
|
|
6336
|
+
}
|
|
5631
6337
|
function calleePath(e) {
|
|
5632
6338
|
const u = unwrap(e);
|
|
5633
6339
|
if (ts11.isIdentifier(u)) return u.text;
|
|
@@ -5713,7 +6419,8 @@ function analyzeHandler(p, h) {
|
|
|
5713
6419
|
key: "h",
|
|
5714
6420
|
aliases: /* @__PURE__ */ new Map(),
|
|
5715
6421
|
pathPos: [],
|
|
5716
|
-
exitPropagates: true
|
|
6422
|
+
exitPropagates: true,
|
|
6423
|
+
identities: /* @__PURE__ */ new Map()
|
|
5717
6424
|
};
|
|
5718
6425
|
const first = fn2.parameters[0];
|
|
5719
6426
|
if (h.kind === "route" && first) {
|
|
@@ -5822,7 +6529,7 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5822
6529
|
}
|
|
5823
6530
|
}
|
|
5824
6531
|
const tables = /* @__PURE__ */ new Map();
|
|
5825
|
-
for (const rel of sql) {
|
|
6532
|
+
for (const rel of appliedSqlFiles(sql)) {
|
|
5826
6533
|
try {
|
|
5827
6534
|
parseSqlForRls(rel, readFileSync2(resolve2(root, rel), "utf8"), tables);
|
|
5828
6535
|
} catch (e) {
|
|
@@ -5853,6 +6560,7 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5853
6560
|
drizzleTablesByExport,
|
|
5854
6561
|
prismaModels,
|
|
5855
6562
|
returnTaints: /* @__PURE__ */ new Map(),
|
|
6563
|
+
returnIdentities: /* @__PURE__ */ new Map(),
|
|
5856
6564
|
tables,
|
|
5857
6565
|
warnings
|
|
5858
6566
|
};
|
|
@@ -5886,7 +6594,8 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5886
6594
|
warnings,
|
|
5887
6595
|
enums: schema.enums,
|
|
5888
6596
|
sqlFunctions: schema.sqlFunctions,
|
|
5889
|
-
storageBuckets: schema.storageBuckets
|
|
6597
|
+
storageBuckets: schema.storageBuckets,
|
|
6598
|
+
...schema.triggers.length > 0 ? { sqlTriggers: schema.triggers } : {}
|
|
5890
6599
|
};
|
|
5891
6600
|
}
|
|
5892
6601
|
var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
|
|
@@ -6102,9 +6811,35 @@ function publicReadNote(p, table2) {
|
|
|
6102
6811
|
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.`;
|
|
6103
6812
|
}
|
|
6104
6813
|
function adminOnly(ctx, h, t) {
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6814
|
+
if (h.roleChecks.length === 0 || !t?.known || !singleTenantTable(ctx, t.table)) return {};
|
|
6815
|
+
let selfGrant;
|
|
6816
|
+
for (const check of h.roleChecks) {
|
|
6817
|
+
const open = selfGrantable(ctx, check);
|
|
6818
|
+
if (open === null) return { check };
|
|
6819
|
+
selfGrant ??= open;
|
|
6820
|
+
}
|
|
6821
|
+
return selfGrant === void 0 ? {} : { selfGrant };
|
|
6822
|
+
}
|
|
6823
|
+
function selfGrantable(ctx, check) {
|
|
6824
|
+
if (check.table === void 0 || check.column === void 0) return null;
|
|
6825
|
+
const column = check.column.toLowerCase();
|
|
6826
|
+
if (check.table === "")
|
|
6827
|
+
return `the role is read off a row whose table could not be told (${check.source}), so nothing shows users cannot change it`;
|
|
6828
|
+
const where2 = `public.${check.table}.${column}`;
|
|
6829
|
+
const info = ctx.model.tables.find((x) => x.table === check.table?.toLowerCase());
|
|
6830
|
+
if (!info)
|
|
6831
|
+
return `the role comes from ${where2}, which the migrations do not define, so nothing shows users cannot change it`;
|
|
6832
|
+
if (!info.rlsEnabled)
|
|
6833
|
+
return `the role comes from ${where2}, and RLS is off on public.${info.table}, so a signed-in user can rewrite it`;
|
|
6834
|
+
const writable = info.policyDetails.find(
|
|
6835
|
+
(pl) => (pl.command === "update" || pl.command === "all") && (pl.roles.length === 0 || pl.roles.some((r) => r === "authenticated" || r === "public")) && policyScopesToCaller(pl.using)
|
|
6836
|
+
);
|
|
6837
|
+
if (!writable) return null;
|
|
6838
|
+
const guarded = (ctx.model.sqlTriggers ?? []).some(
|
|
6839
|
+
(tr) => tr.table === info.table && tr.timing === "before" && tr.events.includes("update") && tr.checkedColumns.includes(column)
|
|
6840
|
+
);
|
|
6841
|
+
if (guarded) return null;
|
|
6842
|
+
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`;
|
|
6108
6843
|
}
|
|
6109
6844
|
function singleTenantTable(ctx, table2) {
|
|
6110
6845
|
const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
|
|
@@ -6123,10 +6858,15 @@ function tableDataOf(ctx, table2) {
|
|
|
6123
6858
|
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
6124
6859
|
}
|
|
6125
6860
|
function callerCheck(checks) {
|
|
6126
|
-
|
|
6861
|
+
const tying = (checks ?? []).filter(
|
|
6862
|
+
(c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column))
|
|
6863
|
+
);
|
|
6864
|
+
return tying.find((c) => c.bypass === void 0) ?? tying[0];
|
|
6127
6865
|
}
|
|
6128
6866
|
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
6129
|
-
const callerFilter = guard.filters.find(
|
|
6867
|
+
const callerFilter = guard.filters.find(
|
|
6868
|
+
(f) => !f.inputDerived && (f.identity === true || isScopeColumn(f.column))
|
|
6869
|
+
);
|
|
6130
6870
|
if (callerFilter) {
|
|
6131
6871
|
return {
|
|
6132
6872
|
tied: true,
|
|
@@ -6139,7 +6879,8 @@ function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
|
6139
6879
|
return {
|
|
6140
6880
|
tied: true,
|
|
6141
6881
|
how: `its ${compared.column} compared with ${compared.valueText} in code, stopping otherwise`,
|
|
6142
|
-
why: ""
|
|
6882
|
+
why: "",
|
|
6883
|
+
...compared.bypass !== void 0 ? { bypass: compared.bypass } : {}
|
|
6143
6884
|
};
|
|
6144
6885
|
}
|
|
6145
6886
|
if (guard.client !== "user_scoped") {
|
|
@@ -6181,20 +6922,27 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6181
6922
|
if (!["select", "update", "delete"].includes(q.operation)) continue;
|
|
6182
6923
|
const filters = q.filters;
|
|
6183
6924
|
const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
|
|
6184
|
-
|
|
6185
|
-
|
|
6925
|
+
const scoped = filters.some(
|
|
6926
|
+
(f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
|
|
6927
|
+
);
|
|
6928
|
+
if (!idFilter || scoped) continue;
|
|
6929
|
+
const own = q.operation === "select" ? callerCheck(q.ownerChecks) : void 0;
|
|
6930
|
+
if (own && own.bypass === void 0) continue;
|
|
6186
6931
|
const guard = q.guard;
|
|
6187
6932
|
const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
|
|
6188
6933
|
const tied = guard ? guardTiesRowToCaller(guard, guardTable, callerFns) : null;
|
|
6189
|
-
if (guard && tied?.tied && guard.exitsWhenMissing) continue;
|
|
6934
|
+
if (guard && tied?.tied && guard.exitsWhenMissing && tied.bypass === void 0) continue;
|
|
6935
|
+
const bypassed = own ?? (guard && tied?.tied && guard.exitsWhenMissing ? callerCheck(guard.checks) : void 0);
|
|
6936
|
+
const bypass = bypassed?.bypass;
|
|
6190
6937
|
const guardWhat = guard?.parent ? `the parent row ${guard.parent.table}.${guard.parent.column} (${q.table}.${guard.column} refers to it by ${guard.parent.how})` : `the same "${guard?.column}"`;
|
|
6191
|
-
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}.` : "";
|
|
6938
|
+
const guardNote = guard && tied && bypass === void 0 && (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}.` : "";
|
|
6192
6939
|
const tableName = v.tableData?.table ?? q.table;
|
|
6193
6940
|
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.";
|
|
6194
|
-
const
|
|
6941
|
+
const gate = adminOnly(ctx, h, v.tableData);
|
|
6942
|
+
const admin = gate.check;
|
|
6195
6943
|
const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
|
|
6196
|
-
const downgrade = admin || anonPolicy ? "medium" : void 0;
|
|
6197
|
-
const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
|
|
6944
|
+
const downgrade = admin || anonPolicy || bypass !== void 0 ? "medium" : void 0;
|
|
6945
|
+
const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "") + (bypassed && bypass !== void 0 ? ` The row's ${bypassed.column} is checked against the caller (${bypassed.valueText}), but ${bypass} lets the caller through without that check, so the finding is lowered rather than dropped: verify those roles belong to the service's staff and a user cannot grant them to themselves.` : "");
|
|
6198
6946
|
const path = [
|
|
6199
6947
|
h.data.kind === "server_action" ? "Server action call" : "HTTP request",
|
|
6200
6948
|
h.data.entry,
|
|
@@ -6220,18 +6968,20 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6220
6968
|
authenticated: h.authenticated,
|
|
6221
6969
|
query: q.text,
|
|
6222
6970
|
...admin ? { adminOnly: true, roleCheck: admin.source } : {},
|
|
6223
|
-
...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {}
|
|
6971
|
+
...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {},
|
|
6972
|
+
...bypass !== void 0 ? { roleBypass: bypass } : {}
|
|
6224
6973
|
}
|
|
6225
6974
|
},
|
|
6226
6975
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
6227
6976
|
];
|
|
6228
6977
|
const who = admin ? "Admin-only" : h.authenticated ? "Cross-tenant" : "Unauthenticated";
|
|
6978
|
+
const verify = admin ? " (verify the admin check)" : bypass !== void 0 ? " (verify the role exception)" : "";
|
|
6229
6979
|
out.push(
|
|
6230
6980
|
finding(
|
|
6231
6981
|
ctx,
|
|
6232
6982
|
this,
|
|
6233
6983
|
{
|
|
6234
|
-
title: `${who} ${q.operation} on "${tableName}" via ${clientNoun(v.clientData)}${
|
|
6984
|
+
title: `${who} ${q.operation} on "${tableName}" via ${clientNoun(v.clientData)}${verify}`,
|
|
6235
6985
|
entrypoints: [h.data.entry],
|
|
6236
6986
|
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
6237
6987
|
sinks: [`supabase.${q.operation}:public.${tableName}`],
|
|
@@ -6323,6 +7073,7 @@ function emitGroups(ctx, rule, groups) {
|
|
|
6323
7073
|
}
|
|
6324
7074
|
return out;
|
|
6325
7075
|
}
|
|
7076
|
+
var REQUEST_ROLES = /* @__PURE__ */ new Set(["public", "anon", "authenticated"]);
|
|
6326
7077
|
var rlsPolicyWithoutCallerPredicate = {
|
|
6327
7078
|
id: "supabase.rls-policy-without-caller-predicate",
|
|
6328
7079
|
title: "RLS policy grants rows without a caller predicate",
|
|
@@ -6343,6 +7094,7 @@ var rlsPolicyWithoutCallerPredicate = {
|
|
|
6343
7094
|
const op = v.data.operation === "unknown" ? "select" : v.data.operation;
|
|
6344
7095
|
for (const p of t.policyDetails) {
|
|
6345
7096
|
if (p.command !== "all" && p.command !== op) continue;
|
|
7097
|
+
if (p.roles.length > 0 && !p.roles.some((r) => REQUEST_ROLES.has(r))) continue;
|
|
6346
7098
|
const expr = op === "insert" ? p.check : p.using;
|
|
6347
7099
|
if (expr === null || policyScopesToCaller(expr) || callsFunctionIn(expr, callerFns)) {
|
|
6348
7100
|
continue;
|
|
@@ -6575,6 +7327,26 @@ var serviceRoleQueryWithoutAuthentication = {
|
|
|
6575
7327
|
return out;
|
|
6576
7328
|
}
|
|
6577
7329
|
};
|
|
7330
|
+
function rlsOnWrite(t, operation) {
|
|
7331
|
+
if (!t?.known || !t.rlsEnabled) return void 0;
|
|
7332
|
+
const commands = operation === "upsert" ? ["insert", "update"] : [operation];
|
|
7333
|
+
const covering = t.policyDetails.filter(
|
|
7334
|
+
(pl) => (pl.command === "all" || commands.includes(pl.command)) && !(pl.roles.length > 0 && pl.roles.every((r) => r === "service_role"))
|
|
7335
|
+
);
|
|
7336
|
+
if (covering.length === 0) return "refused";
|
|
7337
|
+
return covering.every((pl) => /auth\.uid\(\)|auth\.jwt\(\)/i.test(pinExpression(pl))) ? covering : void 0;
|
|
7338
|
+
}
|
|
7339
|
+
function pinExpression(pl) {
|
|
7340
|
+
return pl.check ?? (pl.command === "insert" ? "" : pl.using ?? "");
|
|
7341
|
+
}
|
|
7342
|
+
function mentionsColumn(expr, column) {
|
|
7343
|
+
const c = column.replace(/[^A-Za-z0-9_]/g, "");
|
|
7344
|
+
return c !== "" && new RegExp(`(?<![A-Za-z0-9_])"?${c}"?(?![A-Za-z0-9_])`, "i").test(expr);
|
|
7345
|
+
}
|
|
7346
|
+
function ownedWriteNote(policies, table2, pinned) {
|
|
7347
|
+
const names = policies.map((pl) => `"${pl.name}" (${pl.location.file}:${pl.location.line})`).join(", ");
|
|
7348
|
+
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.`;
|
|
7349
|
+
}
|
|
6578
7350
|
var massAssignmentFromRequestBody = {
|
|
6579
7351
|
id: "supabase.mass-assignment-from-request-body",
|
|
6580
7352
|
title: "Request body written to a table without an allow-list",
|
|
@@ -6593,6 +7365,11 @@ var massAssignmentFromRequestBody = {
|
|
|
6593
7365
|
const sensitive = cols.filter(
|
|
6594
7366
|
(c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
|
|
6595
7367
|
);
|
|
7368
|
+
const kind = v.clientData?.kind;
|
|
7369
|
+
const rls = kind === "anon" || kind === "user_scoped" ? rlsOnWrite(v.tableData, v.data.operation) : void 0;
|
|
7370
|
+
if (rls === "refused") continue;
|
|
7371
|
+
const pinned = rls ? cols.filter((c) => rls.some((pl) => mentionsColumn(pinExpression(pl), c))) : [];
|
|
7372
|
+
const ownedNote = rls && sensitive.every((c) => pinned.includes(c)) ? ownedWriteNote(rls, tableName, pinned) : "";
|
|
6596
7373
|
const path = [
|
|
6597
7374
|
"HTTP request",
|
|
6598
7375
|
h.data.entry,
|
|
@@ -6601,22 +7378,27 @@ var massAssignmentFromRequestBody = {
|
|
|
6601
7378
|
`public.${tableName}.${v.data.operation}`
|
|
6602
7379
|
];
|
|
6603
7380
|
out.push(
|
|
6604
|
-
finding(
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
{
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
7381
|
+
finding(
|
|
7382
|
+
ctx,
|
|
7383
|
+
this,
|
|
7384
|
+
{
|
|
7385
|
+
title: `Mass assignment into "${tableName}" from the request body`,
|
|
7386
|
+
entrypoints: [h.data.entry],
|
|
7387
|
+
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
7388
|
+
sinks: [`supabase.${v.data.operation}:public.${tableName}`],
|
|
7389
|
+
path,
|
|
7390
|
+
evidence: [
|
|
7391
|
+
{
|
|
7392
|
+
kind: "rule",
|
|
7393
|
+
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.`,
|
|
7394
|
+
locations: locations(h.handler.location, v.query.location),
|
|
7395
|
+
data: { deterministic: false, ruleId: this.id, payload: p.text }
|
|
7396
|
+
},
|
|
7397
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
7398
|
+
]
|
|
7399
|
+
},
|
|
7400
|
+
ownedNote ? "medium" : void 0
|
|
7401
|
+
)
|
|
6620
7402
|
);
|
|
6621
7403
|
}
|
|
6622
7404
|
}
|
|
@@ -7088,32 +7870,51 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
7088
7870
|
`every object in ${where2}`
|
|
7089
7871
|
];
|
|
7090
7872
|
const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
|
|
7873
|
+
const insertOnly = p.command === "insert";
|
|
7874
|
+
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." : "";
|
|
7091
7875
|
out.push(
|
|
7092
|
-
finding3(
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
{
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7876
|
+
finding3(
|
|
7877
|
+
ctx,
|
|
7878
|
+
this,
|
|
7879
|
+
{
|
|
7880
|
+
title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
|
|
7881
|
+
entrypoints: [...entries, storageEntry],
|
|
7882
|
+
sources: unique([
|
|
7883
|
+
...reached.flatMap((r) => r.inputs.map((i) => `${i.kind}:${i.name}`)),
|
|
7884
|
+
"storage API (caller's own session)"
|
|
7885
|
+
]),
|
|
7886
|
+
sinks: buckets.length > 0 ? buckets.map((b) => `storage.objects:${b}`) : ["storage.objects"],
|
|
7887
|
+
path,
|
|
7888
|
+
evidence: [
|
|
7889
|
+
{
|
|
7890
|
+
kind: "rule",
|
|
7891
|
+
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.`,
|
|
7892
|
+
locations: locations3(p.location, ...reached.map((r) => r.query.location)),
|
|
7893
|
+
data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
|
|
7894
|
+
},
|
|
7895
|
+
{ kind: "trace", summary: path.join(" -> ") }
|
|
7896
|
+
]
|
|
7897
|
+
},
|
|
7898
|
+
insertOnly ? "medium" : void 0
|
|
7899
|
+
)
|
|
7111
7900
|
);
|
|
7112
7901
|
}
|
|
7113
7902
|
}
|
|
7114
7903
|
return out;
|
|
7115
7904
|
}
|
|
7116
7905
|
};
|
|
7906
|
+
var EVERY_ROW = /^\(*\s*true\s*\)*$/i;
|
|
7907
|
+
var MIGRATION_SQL = /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i;
|
|
7908
|
+
function readsOnlyPublicRows(fn2, tables, fromMigrationsOnly) {
|
|
7909
|
+
if ((fn2.writes?.length ?? 0) > 0 || (fn2.calls?.length ?? 0) > 0) return false;
|
|
7910
|
+
const read = fn2.tables ?? [];
|
|
7911
|
+
return read.length > 0 && read.every((name) => {
|
|
7912
|
+
const t = tables.get(name);
|
|
7913
|
+
return t?.rlsEnabled === true && t.policyDetails.some(
|
|
7914
|
+
(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))
|
|
7915
|
+
);
|
|
7916
|
+
});
|
|
7917
|
+
}
|
|
7117
7918
|
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
7118
7919
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
7119
7920
|
id: "supabase.security-definer-function-without-caller-check",
|
|
@@ -7130,6 +7931,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7130
7931
|
const key = r.data.table.toLowerCase();
|
|
7131
7932
|
rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
|
|
7132
7933
|
}
|
|
7934
|
+
const tables = new Map(ctx.model.tables.map((t) => [t.table, t]));
|
|
7935
|
+
const hasMigrations = ctx.model.files.some((f) => MIGRATION_SQL.test(f));
|
|
7133
7936
|
const out = [];
|
|
7134
7937
|
for (const fn2 of fns) {
|
|
7135
7938
|
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
@@ -7137,6 +7940,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7137
7940
|
if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
|
|
7138
7941
|
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
7139
7942
|
if (roles.length === 0) continue;
|
|
7943
|
+
if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
|
|
7140
7944
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
7141
7945
|
const sites = rpcByName.get(fn2.name) ?? [];
|
|
7142
7946
|
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.4",
|
|
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",
|