auditai-scan 0.7.2 → 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 +620 -65
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -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);
|
|
@@ -2675,6 +2687,14 @@ function newFunctionRegistry() {
|
|
|
2675
2687
|
publicSchemaPublic: false
|
|
2676
2688
|
};
|
|
2677
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
|
+
}
|
|
2678
2698
|
var CALLER_CHECKS = [
|
|
2679
2699
|
/"?\bauth"?\s*\.\s*"?(?:uid|jwt|email)"?\s*\(\s*\)/i,
|
|
2680
2700
|
/\bcurrent_setting\s*\(\s*'request\.jwt/i
|
|
@@ -2863,11 +2883,18 @@ function readParams(stmt, tokens, open, close) {
|
|
|
2863
2883
|
pieces.push([start, close]);
|
|
2864
2884
|
for (const [from, to] of pieces) {
|
|
2865
2885
|
const words = [];
|
|
2886
|
+
let hasDefault = false;
|
|
2887
|
+
let callerDefault = false;
|
|
2866
2888
|
for (let i = from; i < to; i++) {
|
|
2867
2889
|
const t = tokens[i];
|
|
2868
2890
|
if (!t) continue;
|
|
2869
|
-
if (isWord(t, "default"))
|
|
2870
|
-
|
|
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
|
+
}
|
|
2871
2898
|
words.push(t);
|
|
2872
2899
|
}
|
|
2873
2900
|
if (words.length === 0) return null;
|
|
@@ -2886,7 +2913,9 @@ function readParams(stmt, tokens, open, close) {
|
|
|
2886
2913
|
if (!first || !last) return null;
|
|
2887
2914
|
parts.push({
|
|
2888
2915
|
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()
|
|
2916
|
+
type: stmt.text.slice(first.start - stmt.start, last.end - stmt.start).replace(/\s+/g, " ").trim().toLowerCase(),
|
|
2917
|
+
hasDefault,
|
|
2918
|
+
callerDefault
|
|
2890
2919
|
});
|
|
2891
2920
|
}
|
|
2892
2921
|
return parts;
|
|
@@ -3064,16 +3093,63 @@ function applyDefaultPrivileges(reg, stmt) {
|
|
|
3064
3093
|
}
|
|
3065
3094
|
}
|
|
3066
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
|
+
}
|
|
3067
3101
|
function callees(reg, f, byName) {
|
|
3068
3102
|
const out = /* @__PURE__ */ new Set();
|
|
3069
3103
|
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);
|
|
3104
|
+
const g = calledFunction(reg, m, byName);
|
|
3073
3105
|
if (g && g !== f) out.add(g);
|
|
3074
3106
|
}
|
|
3075
3107
|
return out;
|
|
3076
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
|
+
}
|
|
3077
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;
|
|
3078
3154
|
function relationsOf(body) {
|
|
3079
3155
|
const out = [];
|
|
@@ -3086,6 +3162,118 @@ function relationsOf(body) {
|
|
|
3086
3162
|
}
|
|
3087
3163
|
return out;
|
|
3088
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
|
+
}
|
|
3089
3277
|
function effectiveRoles(acl) {
|
|
3090
3278
|
const out = [];
|
|
3091
3279
|
if (acl.publicExec || acl.roles.includes("anon")) out.push("anon");
|
|
@@ -3103,10 +3291,15 @@ function finishFunctions(reg) {
|
|
|
3103
3291
|
}
|
|
3104
3292
|
}
|
|
3105
3293
|
const callers = /* @__PURE__ */ new Map();
|
|
3294
|
+
const calls = /* @__PURE__ */ new Map();
|
|
3106
3295
|
for (const f of fns) {
|
|
3107
|
-
|
|
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]);
|
|
3108
3299
|
}
|
|
3109
|
-
const checks = new Set(
|
|
3300
|
+
const checks = new Set(
|
|
3301
|
+
fns.filter((f) => f.directCheck || callsWithCallerDefault(reg, f, byName))
|
|
3302
|
+
);
|
|
3110
3303
|
const queue = [...checks];
|
|
3111
3304
|
for (let g = queue.pop(); g !== void 0; g = queue.pop()) {
|
|
3112
3305
|
for (const f of callers.get(g) ?? []) {
|
|
@@ -3125,10 +3318,25 @@ function finishFunctions(reg) {
|
|
|
3125
3318
|
};
|
|
3126
3319
|
if (f.returns !== null) info.returns = f.returns;
|
|
3127
3320
|
if (f.args !== null) info.args = f.args;
|
|
3128
|
-
if (f.params?.every((x) => x.name !== null))
|
|
3129
|
-
|
|
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
|
+
}
|
|
3130
3334
|
const tables = relationsOf(f.body);
|
|
3131
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;
|
|
3132
3340
|
return info;
|
|
3133
3341
|
});
|
|
3134
3342
|
}
|
|
@@ -3250,6 +3458,7 @@ function schemaStateFor(tables) {
|
|
|
3250
3458
|
enums: /* @__PURE__ */ new Map(),
|
|
3251
3459
|
functions: newFunctionRegistry(),
|
|
3252
3460
|
buckets: newBucketRegistry(),
|
|
3461
|
+
triggers: /* @__PURE__ */ new Map(),
|
|
3253
3462
|
warnings: []
|
|
3254
3463
|
};
|
|
3255
3464
|
STATES.set(tables, state);
|
|
@@ -3692,6 +3901,96 @@ function doBlock(state, stmt, file) {
|
|
|
3692
3901
|
}
|
|
3693
3902
|
}
|
|
3694
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
|
+
}
|
|
3695
3994
|
function applySchemaStatement(state, stmt, file) {
|
|
3696
3995
|
const tk = stmt.tokens;
|
|
3697
3996
|
const w0 = tk[0]?.kind === "word" ? tk[0].value : "";
|
|
@@ -3702,6 +4001,7 @@ function applySchemaStatement(state, stmt, file) {
|
|
|
3702
4001
|
else if (kind === "type") createType(state, stmt);
|
|
3703
4002
|
else if (kind === "function") applyCreateFunction(state.functions, stmt, file);
|
|
3704
4003
|
else if (kind === "unique") createUniqueIndex(state, stmt);
|
|
4004
|
+
else if (kind === "trigger" || kind === "constraint") createTrigger(state, stmt, file);
|
|
3705
4005
|
} else if (w0 === "alter") {
|
|
3706
4006
|
if (w1 === "table") alterTable(state, stmt, file, false);
|
|
3707
4007
|
else if (w1 === "type") alterType(state, stmt);
|
|
@@ -3711,6 +4011,7 @@ function applySchemaStatement(state, stmt, file) {
|
|
|
3711
4011
|
if (w1 === "table") dropTable(state, stmt);
|
|
3712
4012
|
else if (w1 === "type") dropType(state, stmt);
|
|
3713
4013
|
else if (w1 === "function" || w1 === "routine") applyDropFunction(state.functions, stmt);
|
|
4014
|
+
else if (w1 === "trigger") dropTrigger(state, stmt);
|
|
3714
4015
|
} else if (w0 === "grant" || w0 === "revoke") {
|
|
3715
4016
|
applyGrantRevoke(state.functions, stmt);
|
|
3716
4017
|
} else if (w0 === "do") {
|
|
@@ -3725,6 +4026,7 @@ function finishSchema(state) {
|
|
|
3725
4026
|
enums: Object.fromEntries([...state.enums].map(([k, v]) => [k, [...v]])),
|
|
3726
4027
|
sqlFunctions: finishFunctions(state.functions),
|
|
3727
4028
|
storageBuckets: finishBuckets(state.buckets),
|
|
4029
|
+
triggers: [...state.triggers.values()].map((t) => finishTrigger(state, t)),
|
|
3728
4030
|
warnings: [...state.warnings]
|
|
3729
4031
|
};
|
|
3730
4032
|
}
|
|
@@ -3755,6 +4057,32 @@ var DROP_POLICY = new RegExp(
|
|
|
3755
4057
|
function isAppliedSqlFile(rel) {
|
|
3756
4058
|
return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
|
|
3757
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
|
+
}
|
|
3758
4086
|
function parseSqlForRls(rel, text, into) {
|
|
3759
4087
|
if (!isAppliedSqlFile(rel)) return;
|
|
3760
4088
|
const state = schemaStateFor(into);
|
|
@@ -3830,7 +4158,7 @@ function sqlSchemaFor(into) {
|
|
|
3830
4158
|
|
|
3831
4159
|
// packages/parser/src/role-gates.ts
|
|
3832
4160
|
import ts7 from "typescript";
|
|
3833
|
-
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;
|
|
3834
4162
|
var EMAIL_PROPERTY = /^email$/i;
|
|
3835
4163
|
function propertyPath(e) {
|
|
3836
4164
|
const u = unwrap(e);
|
|
@@ -4916,6 +5244,9 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4916
5244
|
const names = boundNames(decl.name);
|
|
4917
5245
|
const text = init.getText(sf);
|
|
4918
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);
|
|
4919
5250
|
const client = ts11.isCallExpression(init) ? classifyCall(p, init, sf, scope, frame, 0) : null;
|
|
4920
5251
|
if (client && ts11.isIdentifier(decl.name)) {
|
|
4921
5252
|
frame.clients.set(decl.name.text, client);
|
|
@@ -4997,6 +5328,21 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4997
5328
|
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4998
5329
|
}
|
|
4999
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
|
+
}
|
|
5000
5346
|
if (frame.depth === 0) {
|
|
5001
5347
|
walk(body, (n) => {
|
|
5002
5348
|
if (ts11.isPropertyAccessExpression(n) && ts11.isIdentifier(n.expression) && n.expression.text === "params") {
|
|
@@ -5022,16 +5368,22 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5022
5368
|
const param = fn3 && (ts11.isArrowFunction(fn3) || ts11.isFunctionExpression(fn3)) ? fn3.parameters[0] : void 0;
|
|
5023
5369
|
if (outer && param && ts11.isIdentifier(param.name)) frame.clients.set(param.name.text, outer);
|
|
5024
5370
|
}
|
|
5025
|
-
const isSessionCall = (call) =>
|
|
5371
|
+
const isSessionCall = (call) => callsSession(p, call, sf, scope);
|
|
5026
5372
|
for (const call of collect(body, ts11.isCallExpression)) {
|
|
5027
5373
|
if (isSessionCall(call)) acc.authChecks.push({ ...loc2(call), kind: "session" });
|
|
5028
5374
|
}
|
|
5029
|
-
|
|
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)) {
|
|
5030
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];
|
|
5031
5382
|
acc.roleChecks.push({
|
|
5032
5383
|
...loc2(gate.node),
|
|
5033
5384
|
source: gate.source,
|
|
5034
|
-
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 } : {}
|
|
5035
5387
|
});
|
|
5036
5388
|
}
|
|
5037
5389
|
for (const check of secretChecksIn(fn2, sf)) {
|
|
@@ -5091,7 +5443,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5091
5443
|
method: f.method,
|
|
5092
5444
|
column: f.column,
|
|
5093
5445
|
valueText: f.value ? f.value.getText(sf) : f.text,
|
|
5094
|
-
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 } : {}
|
|
5095
5448
|
},
|
|
5096
5449
|
f.value
|
|
5097
5450
|
);
|
|
@@ -5141,7 +5494,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5141
5494
|
method: "match",
|
|
5142
5495
|
column: pr.name.getText(sf).replace(/['"]/g, ""),
|
|
5143
5496
|
valueText: pr.initializer.getText(sf),
|
|
5144
|
-
inputDerived: derivedIn(frame, pr.initializer)
|
|
5497
|
+
inputDerived: derivedIn(frame, pr.initializer),
|
|
5498
|
+
...identityOf(frame, pr.initializer) !== void 0 ? { identity: true } : {}
|
|
5145
5499
|
};
|
|
5146
5500
|
filters.push(valued(f2, pr.initializer));
|
|
5147
5501
|
}
|
|
@@ -5153,7 +5507,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5153
5507
|
method: s.name,
|
|
5154
5508
|
column: stringLiteralValue(firstArg),
|
|
5155
5509
|
valueText: val ? val.getText(sf) : "",
|
|
5156
|
-
inputDerived: val ? derivedIn(frame, val) : false
|
|
5510
|
+
inputDerived: val ? derivedIn(frame, val) : false,
|
|
5511
|
+
...val && identityOf(frame, val) !== void 0 ? { identity: true } : {}
|
|
5157
5512
|
};
|
|
5158
5513
|
filters.push(valued(f, val));
|
|
5159
5514
|
}
|
|
@@ -5289,7 +5644,8 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5289
5644
|
method: "compare",
|
|
5290
5645
|
column: c.column,
|
|
5291
5646
|
valueText: c.value.getText(sf).replace(/\s+/g, " "),
|
|
5292
|
-
inputDerived: derivedIn(frame, c.value)
|
|
5647
|
+
inputDerived: derivedIn(frame, c.value),
|
|
5648
|
+
...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
|
|
5293
5649
|
}));
|
|
5294
5650
|
if (checks.length > 0) query.ownerChecks = checks;
|
|
5295
5651
|
acc.reads.push({
|
|
@@ -5341,7 +5697,8 @@ function childFrame(p, call, target, frame) {
|
|
|
5341
5697
|
key: `${target.facts.file}#${target.name}@${call.getStart(frame.sf)}`,
|
|
5342
5698
|
aliases: /* @__PURE__ */ new Map(),
|
|
5343
5699
|
pathPos: [...frame.pathPos, call.getStart(frame.sf)],
|
|
5344
|
-
exitPropagates: frame.exitPropagates && callResultChecked(call)
|
|
5700
|
+
exitPropagates: frame.exitPropagates && callResultChecked(call),
|
|
5701
|
+
identities: /* @__PURE__ */ new Map()
|
|
5345
5702
|
};
|
|
5346
5703
|
const cx = wholeContext(p, frame);
|
|
5347
5704
|
target.fn.parameters.forEach((param, i) => {
|
|
@@ -5356,6 +5713,9 @@ function childFrame(p, call, target, frame) {
|
|
|
5356
5713
|
child.instances.set(head, ab.instance);
|
|
5357
5714
|
}
|
|
5358
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);
|
|
5359
5719
|
if (ab.tainted) {
|
|
5360
5720
|
bindParamTaint(child, param.name, arg, frame);
|
|
5361
5721
|
for (const nm of wholeParamNames(param.name, arg, cx)) {
|
|
@@ -5373,6 +5733,7 @@ function frameSignature(child) {
|
|
|
5373
5733
|
...[...child.inputNames].map((n) => `${n}!${child.wholeNames.has(n) ? "*" : ""}`),
|
|
5374
5734
|
...[...child.partialInputs].map(([n, s]) => `${n}.{${[...s].sort().join("|")}}`),
|
|
5375
5735
|
...[...child.reqNames].map((n) => `${n}?`),
|
|
5736
|
+
...[...child.identities].map(([n, t]) => `${n}@${t ?? ""}`),
|
|
5376
5737
|
...[...child.thisProps].map(([n, b]) => `this.${n}=${b.client?.kind ?? "-"}`),
|
|
5377
5738
|
...[...child.aliases].map(([n, k]) => `${n}~${k}`),
|
|
5378
5739
|
`exit=${child.exitPropagates}`
|
|
@@ -5628,6 +5989,111 @@ function returnsIdentity(p, call, scope) {
|
|
|
5628
5989
|
if (symOfCallee(p, call.expression, scope)?.kind === "auth") return true;
|
|
5629
5990
|
return IDENTITY_CALLEE.test(calleePath(call.expression));
|
|
5630
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
|
+
}
|
|
5631
6097
|
function calleePath(e) {
|
|
5632
6098
|
const u = unwrap(e);
|
|
5633
6099
|
if (ts11.isIdentifier(u)) return u.text;
|
|
@@ -5713,7 +6179,8 @@ function analyzeHandler(p, h) {
|
|
|
5713
6179
|
key: "h",
|
|
5714
6180
|
aliases: /* @__PURE__ */ new Map(),
|
|
5715
6181
|
pathPos: [],
|
|
5716
|
-
exitPropagates: true
|
|
6182
|
+
exitPropagates: true,
|
|
6183
|
+
identities: /* @__PURE__ */ new Map()
|
|
5717
6184
|
};
|
|
5718
6185
|
const first = fn2.parameters[0];
|
|
5719
6186
|
if (h.kind === "route" && first) {
|
|
@@ -5822,7 +6289,7 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5822
6289
|
}
|
|
5823
6290
|
}
|
|
5824
6291
|
const tables = /* @__PURE__ */ new Map();
|
|
5825
|
-
for (const rel of sql) {
|
|
6292
|
+
for (const rel of appliedSqlFiles(sql)) {
|
|
5826
6293
|
try {
|
|
5827
6294
|
parseSqlForRls(rel, readFileSync2(resolve2(root, rel), "utf8"), tables);
|
|
5828
6295
|
} catch (e) {
|
|
@@ -5853,6 +6320,7 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5853
6320
|
drizzleTablesByExport,
|
|
5854
6321
|
prismaModels,
|
|
5855
6322
|
returnTaints: /* @__PURE__ */ new Map(),
|
|
6323
|
+
returnIdentities: /* @__PURE__ */ new Map(),
|
|
5856
6324
|
tables,
|
|
5857
6325
|
warnings
|
|
5858
6326
|
};
|
|
@@ -5886,7 +6354,8 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5886
6354
|
warnings,
|
|
5887
6355
|
enums: schema.enums,
|
|
5888
6356
|
sqlFunctions: schema.sqlFunctions,
|
|
5889
|
-
storageBuckets: schema.storageBuckets
|
|
6357
|
+
storageBuckets: schema.storageBuckets,
|
|
6358
|
+
...schema.triggers.length > 0 ? { sqlTriggers: schema.triggers } : {}
|
|
5890
6359
|
};
|
|
5891
6360
|
}
|
|
5892
6361
|
var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
|
|
@@ -6102,9 +6571,35 @@ function publicReadNote(p, table2) {
|
|
|
6102
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.`;
|
|
6103
6572
|
}
|
|
6104
6573
|
function adminOnly(ctx, h, t) {
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
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`;
|
|
6108
6603
|
}
|
|
6109
6604
|
function singleTenantTable(ctx, table2) {
|
|
6110
6605
|
const info = ctx.model.tables.find((x) => x.table === table2.toLowerCase());
|
|
@@ -6123,10 +6618,12 @@ function tableDataOf(ctx, table2) {
|
|
|
6123
6618
|
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
6124
6619
|
}
|
|
6125
6620
|
function callerCheck(checks) {
|
|
6126
|
-
return checks?.find((c) =>
|
|
6621
|
+
return checks?.find((c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column)));
|
|
6127
6622
|
}
|
|
6128
6623
|
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
6129
|
-
const callerFilter = guard.filters.find(
|
|
6624
|
+
const callerFilter = guard.filters.find(
|
|
6625
|
+
(f) => !f.inputDerived && (f.identity === true || isScopeColumn(f.column))
|
|
6626
|
+
);
|
|
6130
6627
|
if (callerFilter) {
|
|
6131
6628
|
return {
|
|
6132
6629
|
tied: true,
|
|
@@ -6181,7 +6678,10 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6181
6678
|
if (!["select", "update", "delete"].includes(q.operation)) continue;
|
|
6182
6679
|
const filters = q.filters;
|
|
6183
6680
|
const idFilter = filters.find((f) => f.inputDerived && isObjectIdColumn(f.column));
|
|
6184
|
-
|
|
6681
|
+
const scoped = filters.some(
|
|
6682
|
+
(f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
|
|
6683
|
+
);
|
|
6684
|
+
if (!idFilter || scoped) continue;
|
|
6185
6685
|
if (q.operation === "select" && callerCheck(q.ownerChecks)) continue;
|
|
6186
6686
|
const guard = q.guard;
|
|
6187
6687
|
const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
|
|
@@ -6191,10 +6691,11 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6191
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}.` : "";
|
|
6192
6692
|
const tableName = v.tableData?.table ?? q.table;
|
|
6193
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.";
|
|
6194
|
-
const
|
|
6694
|
+
const gate = adminOnly(ctx, h, v.tableData);
|
|
6695
|
+
const admin = gate.check;
|
|
6195
6696
|
const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
|
|
6196
6697
|
const downgrade = admin || anonPolicy ? "medium" : void 0;
|
|
6197
|
-
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) : "");
|
|
6198
6699
|
const path = [
|
|
6199
6700
|
h.data.kind === "server_action" ? "Server action call" : "HTTP request",
|
|
6200
6701
|
h.data.entry,
|
|
@@ -6323,6 +6824,7 @@ function emitGroups(ctx, rule, groups) {
|
|
|
6323
6824
|
}
|
|
6324
6825
|
return out;
|
|
6325
6826
|
}
|
|
6827
|
+
var REQUEST_ROLES = /* @__PURE__ */ new Set(["public", "anon", "authenticated"]);
|
|
6326
6828
|
var rlsPolicyWithoutCallerPredicate = {
|
|
6327
6829
|
id: "supabase.rls-policy-without-caller-predicate",
|
|
6328
6830
|
title: "RLS policy grants rows without a caller predicate",
|
|
@@ -6343,6 +6845,7 @@ var rlsPolicyWithoutCallerPredicate = {
|
|
|
6343
6845
|
const op = v.data.operation === "unknown" ? "select" : v.data.operation;
|
|
6344
6846
|
for (const p of t.policyDetails) {
|
|
6345
6847
|
if (p.command !== "all" && p.command !== op) continue;
|
|
6848
|
+
if (p.roles.length > 0 && !p.roles.some((r) => REQUEST_ROLES.has(r))) continue;
|
|
6346
6849
|
const expr = op === "insert" ? p.check : p.using;
|
|
6347
6850
|
if (expr === null || policyScopesToCaller(expr) || callsFunctionIn(expr, callerFns)) {
|
|
6348
6851
|
continue;
|
|
@@ -6575,6 +7078,26 @@ var serviceRoleQueryWithoutAuthentication = {
|
|
|
6575
7078
|
return out;
|
|
6576
7079
|
}
|
|
6577
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
|
+
}
|
|
6578
7101
|
var massAssignmentFromRequestBody = {
|
|
6579
7102
|
id: "supabase.mass-assignment-from-request-body",
|
|
6580
7103
|
title: "Request body written to a table without an allow-list",
|
|
@@ -6593,6 +7116,11 @@ var massAssignmentFromRequestBody = {
|
|
|
6593
7116
|
const sensitive = cols.filter(
|
|
6594
7117
|
(c) => isScopeColumn(c) || /role|admin|price|amount|status|plan|tier|balance/i.test(c)
|
|
6595
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) : "";
|
|
6596
7124
|
const path = [
|
|
6597
7125
|
"HTTP request",
|
|
6598
7126
|
h.data.entry,
|
|
@@ -6601,22 +7129,27 @@ var massAssignmentFromRequestBody = {
|
|
|
6601
7129
|
`public.${tableName}.${v.data.operation}`
|
|
6602
7130
|
];
|
|
6603
7131
|
out.push(
|
|
6604
|
-
finding(
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
{
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
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
|
+
)
|
|
6620
7153
|
);
|
|
6621
7154
|
}
|
|
6622
7155
|
}
|
|
@@ -7088,32 +7621,51 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
7088
7621
|
`every object in ${where2}`
|
|
7089
7622
|
];
|
|
7090
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." : "";
|
|
7091
7626
|
out.push(
|
|
7092
|
-
finding3(
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
{
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
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
|
+
)
|
|
7111
7651
|
);
|
|
7112
7652
|
}
|
|
7113
7653
|
}
|
|
7114
7654
|
return out;
|
|
7115
7655
|
}
|
|
7116
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
|
+
}
|
|
7117
7669
|
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
7118
7670
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
7119
7671
|
id: "supabase.security-definer-function-without-caller-check",
|
|
@@ -7130,6 +7682,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7130
7682
|
const key = r.data.table.toLowerCase();
|
|
7131
7683
|
rpcByName.set(key, [...rpcByName.get(key) ?? [], r]);
|
|
7132
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));
|
|
7133
7687
|
const out = [];
|
|
7134
7688
|
for (const fn2 of fns) {
|
|
7135
7689
|
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
@@ -7137,6 +7691,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7137
7691
|
if (fn2.returns === "trigger" || fn2.returns === "event_trigger") continue;
|
|
7138
7692
|
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
7139
7693
|
if (roles.length === 0) continue;
|
|
7694
|
+
if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
|
|
7140
7695
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
7141
7696
|
const sites = rpcByName.get(fn2.name) ?? [];
|
|
7142
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",
|