auditai-scan 0.5.0 → 0.6.1
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 +463 -57
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -78,20 +78,20 @@ var IllegalTransitionError = class extends Error {
|
|
|
78
78
|
to;
|
|
79
79
|
name = "IllegalTransitionError";
|
|
80
80
|
};
|
|
81
|
-
function transition(
|
|
82
|
-
if (!canTransition(
|
|
83
|
-
throw new IllegalTransitionError(
|
|
81
|
+
function transition(finding4, to, opts = {}) {
|
|
82
|
+
if (!canTransition(finding4.status, to)) {
|
|
83
|
+
throw new IllegalTransitionError(finding4.id, finding4.status, to);
|
|
84
84
|
}
|
|
85
85
|
const requiresEvidence = to === "confirmed" || to === "verified";
|
|
86
86
|
if (requiresEvidence && !opts.evidence) {
|
|
87
|
-
throw new Error(`Finding ${
|
|
87
|
+
throw new Error(`Finding ${finding4.id}: transition to ${to} requires evidence`);
|
|
88
88
|
}
|
|
89
|
-
if (to === "verified" && !isVerificationPassing(
|
|
90
|
-
throw new Error(`Finding ${
|
|
89
|
+
if (to === "verified" && !isVerificationPassing(finding4.verification)) {
|
|
90
|
+
throw new Error(`Finding ${finding4.id}: cannot mark verified without a passing verification`);
|
|
91
91
|
}
|
|
92
|
-
const evidence = opts.evidence ? [...
|
|
92
|
+
const evidence = opts.evidence ? [...finding4.evidence, opts.evidence] : finding4.evidence;
|
|
93
93
|
return {
|
|
94
|
-
...
|
|
94
|
+
...finding4,
|
|
95
95
|
status: to,
|
|
96
96
|
evidence,
|
|
97
97
|
updatedAt: opts.now ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -100,8 +100,8 @@ function transition(finding3, to, opts = {}) {
|
|
|
100
100
|
function isVerificationPassing(v) {
|
|
101
101
|
return v !== void 0 && v.securityTestBefore === "failed" && v.securityTestAfter === "passed" && (v.existingTests === "passed" || v.existingTests === "skipped") && v.rescan === "passed";
|
|
102
102
|
}
|
|
103
|
-
function isDeterministic(
|
|
104
|
-
return
|
|
103
|
+
function isDeterministic(finding4) {
|
|
104
|
+
return finding4.evidence.some((e) => e.kind === "rule" && e.data?.deterministic === true);
|
|
105
105
|
}
|
|
106
106
|
var DEFAULT_BLOCKING_POLICY = {
|
|
107
107
|
minConfidence: 0.8,
|
|
@@ -109,14 +109,14 @@ var DEFAULT_BLOCKING_POLICY = {
|
|
|
109
109
|
blockDeterministicCritical: true
|
|
110
110
|
};
|
|
111
111
|
var CONFIRMED_OR_LATER = ["confirmed", "fix_proposed", "fix_applied"];
|
|
112
|
-
function isBlocking(
|
|
113
|
-
if (
|
|
114
|
-
if (
|
|
115
|
-
if (
|
|
116
|
-
if (CONFIRMED_OR_LATER.includes(
|
|
112
|
+
function isBlocking(finding4, policy = DEFAULT_BLOCKING_POLICY) {
|
|
113
|
+
if (finding4.status === "suppressed") return false;
|
|
114
|
+
if (finding4.status === "verified") return true;
|
|
115
|
+
if (finding4.confidence < policy.minConfidence) return false;
|
|
116
|
+
if (CONFIRMED_OR_LATER.includes(finding4.status) && policy.blockOnConfirmedSeverities.includes(finding4.severity)) {
|
|
117
117
|
return true;
|
|
118
118
|
}
|
|
119
|
-
if (policy.blockDeterministicCritical &&
|
|
119
|
+
if (policy.blockDeterministicCritical && finding4.severity === "critical" && isDeterministic(finding4) && finding4.status !== "unverified") {
|
|
120
120
|
return true;
|
|
121
121
|
}
|
|
122
122
|
return false;
|
|
@@ -959,8 +959,23 @@ function secretScopeFor(fn, sf) {
|
|
|
959
959
|
growNames(ownDeclarations(fn), scope);
|
|
960
960
|
return scope;
|
|
961
961
|
}
|
|
962
|
+
var STORED_SECRET_FIELD = /(^|_)(secret|signing_key|api_key|key_hash|token_hash|hmac_key)$/;
|
|
963
|
+
function readsStoredSecret(e) {
|
|
964
|
+
let hit = false;
|
|
965
|
+
const visit = (n) => {
|
|
966
|
+
if (hit) return;
|
|
967
|
+
if (ts3.isPropertyAccessExpression(n) && STORED_SECRET_FIELD.test(n.name.text.toLowerCase())) {
|
|
968
|
+
hit = true;
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
n.forEachChild(visit);
|
|
972
|
+
};
|
|
973
|
+
visit(e);
|
|
974
|
+
return hit;
|
|
975
|
+
}
|
|
962
976
|
function isSecretish(e, scope) {
|
|
963
977
|
if (envNamesIn(e).some(isSecretEnvName)) return true;
|
|
978
|
+
if (readsStoredSecret(e)) return true;
|
|
964
979
|
for (const id of identifiersIn(e)) if (scope.names.has(id)) return true;
|
|
965
980
|
let calls = false;
|
|
966
981
|
const visit = (n) => {
|
|
@@ -984,7 +999,7 @@ var EQUALITY = /* @__PURE__ */ new Set([
|
|
|
984
999
|
ts3.SyntaxKind.EqualsEqualsToken,
|
|
985
1000
|
ts3.SyntaxKind.ExclamationEqualsToken
|
|
986
1001
|
]);
|
|
987
|
-
var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals
|
|
1002
|
+
var COMPARE_CALLEE = /^(timingSafeEqual|safeCompare|secureCompare|safeEqual|constantTimeEqual|constantTimeCompare|timingSafeCompare|compare|compareSync|isEqual|equals?|secretsMatch|secretMatch|matchesSecret|tokensMatch|tokenMatches|sameSecret|checkSecret|validSecret|isValidSecret)$/i;
|
|
988
1003
|
var VERIFY_CALLEE = /^(verify|verifySync|jwtVerify|constructEvent|constructEventAsync|verifySignature|verifyWebhook|validateSignature)$/i;
|
|
989
1004
|
var THROWING_VERIFIER = /^(jwtVerify|constructEvent|constructEventAsync)$/;
|
|
990
1005
|
var THROWING_VERIFY_RECEIVER = /jwt|jose|jsonwebtoken/i;
|
|
@@ -1035,11 +1050,39 @@ function gates(node, fnBody) {
|
|
|
1035
1050
|
}
|
|
1036
1051
|
return false;
|
|
1037
1052
|
}
|
|
1053
|
+
function receiverSecret(callee, secretish, builtFromSecret) {
|
|
1054
|
+
if (!ts3.isPropertyAccessExpression(callee)) return false;
|
|
1055
|
+
const recv = callee.expression;
|
|
1056
|
+
if (ts3.isNewExpression(recv)) return (recv.arguments ?? []).some(secretish);
|
|
1057
|
+
if (ts3.isIdentifier(recv) && builtFromSecret.has(recv.text)) return true;
|
|
1058
|
+
return secretish(recv);
|
|
1059
|
+
}
|
|
1060
|
+
function instancesBuiltFromSecret(body, secretish) {
|
|
1061
|
+
const out = /* @__PURE__ */ new Set();
|
|
1062
|
+
for (const d of collect(body, ts3.isVariableDeclaration)) {
|
|
1063
|
+
if (!d.initializer || !ts3.isIdentifier(d.name) || !ts3.isNewExpression(d.initializer)) continue;
|
|
1064
|
+
if ((d.initializer.arguments ?? []).some(secretish)) out.add(d.name.text);
|
|
1065
|
+
}
|
|
1066
|
+
return out;
|
|
1067
|
+
}
|
|
1068
|
+
function gatesByThrow(node, fnBody) {
|
|
1069
|
+
let child = node;
|
|
1070
|
+
let cur = node.parent;
|
|
1071
|
+
while (cur && cur !== fnBody && !isFunctionLikeNode(cur)) {
|
|
1072
|
+
if (ts3.isTryStatement(cur) && cur.tryBlock === child && cur.catchClause) {
|
|
1073
|
+
return exitKind(cur.catchClause.block) !== null;
|
|
1074
|
+
}
|
|
1075
|
+
child = cur;
|
|
1076
|
+
cur = cur.parent;
|
|
1077
|
+
}
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1038
1080
|
function secretChecksIn(fn, sf) {
|
|
1039
1081
|
if (!fn.body) return [];
|
|
1040
1082
|
const body = fn.body;
|
|
1041
1083
|
const secrets = secretScopeFor(fn, sf);
|
|
1042
1084
|
const secretish = (e) => isSecretish(e, secrets);
|
|
1085
|
+
const builtFromSecret = instancesBuiltFromSecret(body, secretish);
|
|
1043
1086
|
const out = [];
|
|
1044
1087
|
const push = (n) => {
|
|
1045
1088
|
out.push({ node: n, text: n.getText(sf).replace(/\s+/g, " ").slice(0, 160) });
|
|
@@ -1058,8 +1101,9 @@ function secretChecksIn(fn, sf) {
|
|
|
1058
1101
|
const verify = VERIFY_CALLEE.test(name);
|
|
1059
1102
|
if (!compare && !verify) return;
|
|
1060
1103
|
const secretArgs = n.arguments.filter(secretish).length;
|
|
1061
|
-
|
|
1062
|
-
if (
|
|
1104
|
+
const receiverHoldsSecret = verify && receiverSecret(n.expression, secretish, builtFromSecret);
|
|
1105
|
+
if (!receiverHoldsSecret && (secretArgs === 0 || secretArgs === n.arguments.length)) return;
|
|
1106
|
+
if (verify && throwsOnBadCredential(n) || gates(n, body) || gatesByThrow(n, body)) push(n);
|
|
1063
1107
|
});
|
|
1064
1108
|
return out;
|
|
1065
1109
|
}
|
|
@@ -1086,7 +1130,7 @@ function nextAuthSessionNames(stmt, nextAuthLocal) {
|
|
|
1086
1130
|
}
|
|
1087
1131
|
return out;
|
|
1088
1132
|
}
|
|
1089
|
-
var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret)$/;
|
|
1133
|
+
var CREDENTIAL_COLUMN = /(keyhash|tokenhash|hashedkey|hashedtoken|secrethash|apikey|apikeyhash|apitoken|accesstoken|sessiontoken|secret|token|sessioncode|invitecode|accesscode|sharecode)$/;
|
|
1090
1134
|
function isCredentialColumn(column) {
|
|
1091
1135
|
return column !== null && CREDENTIAL_COLUMN.test(column.toLowerCase().replace(/_/g, ""));
|
|
1092
1136
|
}
|
|
@@ -1604,7 +1648,7 @@ function isIdentPart(code) {
|
|
|
1604
1648
|
function isDigit(code) {
|
|
1605
1649
|
return code >= 48 && code <= 57;
|
|
1606
1650
|
}
|
|
1607
|
-
function readQuoted(text, from,
|
|
1651
|
+
function readQuoted(text, from, quote2, backslashEscapes) {
|
|
1608
1652
|
let value = "";
|
|
1609
1653
|
let j = from + 1;
|
|
1610
1654
|
while (j < text.length) {
|
|
@@ -1615,9 +1659,9 @@ function readQuoted(text, from, quote, backslashEscapes) {
|
|
|
1615
1659
|
j += 2;
|
|
1616
1660
|
continue;
|
|
1617
1661
|
}
|
|
1618
|
-
if (c ===
|
|
1619
|
-
if (text.charAt(j + 1) ===
|
|
1620
|
-
value +=
|
|
1662
|
+
if (c === quote2) {
|
|
1663
|
+
if (text.charAt(j + 1) === quote2) {
|
|
1664
|
+
value += quote2;
|
|
1621
1665
|
j += 2;
|
|
1622
1666
|
continue;
|
|
1623
1667
|
}
|
|
@@ -3302,6 +3346,10 @@ var CREATE_POLICY = new RegExp(
|
|
|
3302
3346
|
String.raw`^create\s+policy\s+("([^"]+)"|\S+)\s+on\s+${QUALIFIED}`,
|
|
3303
3347
|
"i"
|
|
3304
3348
|
);
|
|
3349
|
+
var DROP_POLICY = new RegExp(
|
|
3350
|
+
String.raw`^drop\s+policy\s+(?:if\s+exists\s+)?("([^"]+)"|\S+)\s+on\s+${QUALIFIED}`,
|
|
3351
|
+
"i"
|
|
3352
|
+
);
|
|
3305
3353
|
function isAppliedSqlFile(rel) {
|
|
3306
3354
|
return !/(?:^|\/)supabase\/migrations\/[^/]+\/.+\.sql$/i.test(rel.split("\\").join("/"));
|
|
3307
3355
|
}
|
|
@@ -3320,10 +3368,18 @@ function warnDynamicSql(state, rel) {
|
|
|
3320
3368
|
const w = `${rel}: a DO block runs dynamic SQL (a loop over a query, or an EXECUTE that is conditional or built from expressions); RLS, policies and privileges it sets are not seen`;
|
|
3321
3369
|
if (!state.warnings.includes(w)) state.warnings.push(w);
|
|
3322
3370
|
}
|
|
3371
|
+
var ROLE_NAME = '(?:"[A-Za-z_][A-Za-z0-9_]*"|[A-Za-z_][A-Za-z0-9_]*)';
|
|
3372
|
+
var ROLES = new RegExp(`\\bto\\s+(${ROLE_NAME}(?:\\s*,\\s*${ROLE_NAME})*)`, "i");
|
|
3373
|
+
function headEnd(rest) {
|
|
3374
|
+
const u = /\busing\s*\(/i.exec(rest);
|
|
3375
|
+
const c = /\bwith\s+check\s*\(/i.exec(rest);
|
|
3376
|
+
const ends = [u?.index, c?.index].filter((i) => i !== void 0);
|
|
3377
|
+
return ends.length === 0 ? rest.length : Math.min(...ends);
|
|
3378
|
+
}
|
|
3323
3379
|
function applyStatement(state, stmt, rel) {
|
|
3324
3380
|
const cp = CREATE_POLICY.exec(stmt.text);
|
|
3325
3381
|
if (!cp?.[1] || !cp[4]) {
|
|
3326
|
-
applySchemaStatement(state, stmt, rel);
|
|
3382
|
+
dropPolicy(state, stmt) || applySchemaStatement(state, stmt, rel);
|
|
3327
3383
|
return;
|
|
3328
3384
|
}
|
|
3329
3385
|
const t = ensureTable(
|
|
@@ -3336,10 +3392,9 @@ function applyStatement(state, stmt, rel) {
|
|
|
3336
3392
|
const rest = stmt.text.slice(cp[0].length);
|
|
3337
3393
|
const cmdMatch = /\bfor\s+(select|insert|update|delete|all)\b/i.exec(rest);
|
|
3338
3394
|
const command = cmdMatch?.[1]?.toLowerCase() ?? "all";
|
|
3339
|
-
const
|
|
3340
|
-
|
|
3341
|
-
);
|
|
3342
|
-
const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.toLowerCase()) : [];
|
|
3395
|
+
const head = rest.slice(0, headEnd(rest));
|
|
3396
|
+
const rolesMatch = ROLES.exec(head);
|
|
3397
|
+
const roles = rolesMatch?.[1] ? rolesMatch[1].split(/\s*,\s*/).map((r) => r.replace(/"/g, "").toLowerCase()) : [];
|
|
3343
3398
|
let using = null;
|
|
3344
3399
|
let check = null;
|
|
3345
3400
|
const u = /\busing\s*\(/i.exec(rest);
|
|
@@ -3356,6 +3411,17 @@ function applyStatement(state, stmt, rel) {
|
|
|
3356
3411
|
location: { file: rel, line: stmt.line }
|
|
3357
3412
|
});
|
|
3358
3413
|
}
|
|
3414
|
+
function dropPolicy(state, stmt) {
|
|
3415
|
+
const dp = DROP_POLICY.exec(stmt.text);
|
|
3416
|
+
if (!dp?.[1] || !dp[4]) return false;
|
|
3417
|
+
const name = dp[2] ?? dp[1].replace(/^"|"$/g, "");
|
|
3418
|
+
const t = state.tables.get(qualifiedKey({ schema: dp[3] ?? null, name: dp[4] }));
|
|
3419
|
+
if (t) {
|
|
3420
|
+
t.policies = t.policies.filter((p) => p !== name);
|
|
3421
|
+
t.policyDetails = t.policyDetails.filter((p) => p.name !== name);
|
|
3422
|
+
}
|
|
3423
|
+
return true;
|
|
3424
|
+
}
|
|
3359
3425
|
function sqlSchemaFor(into) {
|
|
3360
3426
|
return finishSchema(schemaStateFor(into));
|
|
3361
3427
|
}
|
|
@@ -3816,10 +3882,20 @@ var LOGICAL = /* @__PURE__ */ new Set([
|
|
|
3816
3882
|
ts10.SyntaxKind.BarBarToken,
|
|
3817
3883
|
ts10.SyntaxKind.AmpersandAmpersandToken
|
|
3818
3884
|
]);
|
|
3885
|
+
var SCHEMA_PARSE = /^(parse|safeParse|parseAsync|safeParseAsync)$/;
|
|
3886
|
+
function isSchemaParse(call, cx) {
|
|
3887
|
+
const callee = call.expression;
|
|
3888
|
+
if (!ts10.isPropertyAccessExpression(callee) || !SCHEMA_PARSE.test(callee.name.text)) return false;
|
|
3889
|
+
const recv = callee.expression;
|
|
3890
|
+
if (isWholeInput(recv, cx)) return false;
|
|
3891
|
+
return cx.strippingSchema(recv);
|
|
3892
|
+
}
|
|
3819
3893
|
function isWholeInput(e, cx) {
|
|
3820
3894
|
const u = unwrap(e);
|
|
3821
3895
|
if (ts10.isIdentifier(u)) return cx.wholeName(u.text);
|
|
3822
3896
|
if (ts10.isPropertyAccessExpression(u) || ts10.isElementAccessExpression(u)) {
|
|
3897
|
+
const inner = unwrap(u.expression);
|
|
3898
|
+
if (ts10.isCallExpression(inner) && isSchemaParse(inner, cx)) return false;
|
|
3823
3899
|
return isWholeInput(u.expression, cx);
|
|
3824
3900
|
}
|
|
3825
3901
|
if (ts10.isObjectLiteralExpression(u)) {
|
|
@@ -3839,6 +3915,7 @@ function isWholeInput(e, cx) {
|
|
|
3839
3915
|
}
|
|
3840
3916
|
function isWholeCall(call, cx) {
|
|
3841
3917
|
if (cx.requestBody(call)) return true;
|
|
3918
|
+
if (isSchemaParse(call, cx)) return false;
|
|
3842
3919
|
const callee = call.expression;
|
|
3843
3920
|
if (ts10.isPropertyAccessExpression(callee)) {
|
|
3844
3921
|
const method = callee.name.text;
|
|
@@ -3860,7 +3937,8 @@ function callbackKeepsWhole(cb, receiverWhole, cx) {
|
|
|
3860
3937
|
const inner = {
|
|
3861
3938
|
wholeName: (n) => elementNames.has(n) || cx.wholeName(n),
|
|
3862
3939
|
requestBody: cx.requestBody,
|
|
3863
|
-
requestName: cx.requestName
|
|
3940
|
+
requestName: cx.requestName,
|
|
3941
|
+
strippingSchema: cx.strippingSchema
|
|
3864
3942
|
};
|
|
3865
3943
|
return ownReturns(f).some((r) => isWholeInput(r, inner));
|
|
3866
3944
|
}
|
|
@@ -4185,6 +4263,7 @@ function usesInput(frame, e) {
|
|
|
4185
4263
|
});
|
|
4186
4264
|
return hit;
|
|
4187
4265
|
}
|
|
4266
|
+
var CALLER_READ = /\b(?:await\s+)?cookies\(\)\s*(?:\.|$)|cookieStore\s*\.\s*get\s*\(|\bcookies\s*\.\s*get\s*\(|\b(?:await\s+)?headers\(\)\s*\.\s*get\s*\(/;
|
|
4188
4267
|
var REQUEST_MEMBER = /^(json|formData|text|arrayBuffer|blob|body|headers|cookies|url|nextUrl|query|params|ip|geo)$/;
|
|
4189
4268
|
var REQUEST_CLIENT_CALLEE = /client|supabase|prisma|drizzle/i;
|
|
4190
4269
|
function handsOverRequest(frame, call) {
|
|
@@ -4253,13 +4332,28 @@ function isRequestBodyCall(frame, call) {
|
|
|
4253
4332
|
if (frame.reqNames.has(recv.text)) return true;
|
|
4254
4333
|
return frame.depth === 0 && frame.reqNames.size === 0 && REQUEST_NAME.test(recv.text);
|
|
4255
4334
|
}
|
|
4256
|
-
function wholeContext(frame) {
|
|
4335
|
+
function wholeContext(p, frame) {
|
|
4257
4336
|
return {
|
|
4258
4337
|
wholeName: (n) => frame.wholeNames.has(n),
|
|
4259
4338
|
requestBody: (c) => isRequestBodyCall(frame, c),
|
|
4260
|
-
requestName: (n) => frame.reqNames.has(n)
|
|
4339
|
+
requestName: (n) => frame.reqNames.has(n),
|
|
4340
|
+
strippingSchema: (e) => isStrippingSchema(p, frame, e)
|
|
4261
4341
|
};
|
|
4262
4342
|
}
|
|
4343
|
+
var OBJECT_SCHEMA = /\b(?:z|zod|v|valibot|yup)\s*\.\s*object\s*\(/;
|
|
4344
|
+
var SCHEMA_KEEPS_UNKNOWN = /\.\s*(?:passthrough|catchall|nonstrict|unknown)\s*\(/;
|
|
4345
|
+
function isStrippingSchema(p, frame, e) {
|
|
4346
|
+
const u = unwrap(e);
|
|
4347
|
+
if (ts11.isCallExpression(u) || ts11.isPropertyAccessExpression(u)) {
|
|
4348
|
+
const text2 = u.getText();
|
|
4349
|
+
return OBJECT_SCHEMA.test(text2) && !SCHEMA_KEEPS_UNKNOWN.test(text2);
|
|
4350
|
+
}
|
|
4351
|
+
if (!ts11.isIdentifier(u)) return false;
|
|
4352
|
+
const sym = scopeOf(p, frame.facts).get(u.text);
|
|
4353
|
+
if (sym?.kind !== "var") return false;
|
|
4354
|
+
const text = sym.init.getText();
|
|
4355
|
+
return OBJECT_SCHEMA.test(text) && !SCHEMA_KEEPS_UNKNOWN.test(text);
|
|
4356
|
+
}
|
|
4263
4357
|
function receiverTainted(frame, call) {
|
|
4264
4358
|
const callee = call.expression;
|
|
4265
4359
|
if (!ts11.isPropertyAccessExpression(callee) && !ts11.isElementAccessExpression(callee)) return false;
|
|
@@ -4314,7 +4408,7 @@ function argBinding(p, arg, frame) {
|
|
|
4314
4408
|
client,
|
|
4315
4409
|
instance,
|
|
4316
4410
|
tainted: derivedIn(frame, arg),
|
|
4317
|
-
whole: isWholeInput(arg, wholeContext(frame)),
|
|
4411
|
+
whole: isWholeInput(arg, wholeContext(p, frame)),
|
|
4318
4412
|
isRequest
|
|
4319
4413
|
};
|
|
4320
4414
|
}
|
|
@@ -4444,6 +4538,11 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4444
4538
|
continue;
|
|
4445
4539
|
}
|
|
4446
4540
|
}
|
|
4541
|
+
if (CALLER_READ.test(text)) {
|
|
4542
|
+
if (frame.depth === 0) handlerInput("header", names, decl);
|
|
4543
|
+
else bindInput(names, false);
|
|
4544
|
+
continue;
|
|
4545
|
+
}
|
|
4447
4546
|
if (ts11.isCallExpression(init)) {
|
|
4448
4547
|
const inst = instanceOfCall(p, init, frame, scope);
|
|
4449
4548
|
if (inst && ts11.isIdentifier(decl.name)) {
|
|
@@ -4456,7 +4555,7 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4456
4555
|
if (args.some((a) => a.tainted || a.isRequest) || receiverTainted(frame, init)) {
|
|
4457
4556
|
const rt = returnTaint(p, init, frame, scope, 0);
|
|
4458
4557
|
if (rt === null || rt.tainted && rt.props === null) {
|
|
4459
|
-
bindInput(names, isWholeInput(init, wholeContext(frame)));
|
|
4558
|
+
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4460
4559
|
} else if (rt.tainted && rt.props !== null) {
|
|
4461
4560
|
if (ts11.isIdentifier(decl.name))
|
|
4462
4561
|
frame.partialInputs.set(decl.name.text, new Set(rt.props));
|
|
@@ -4493,7 +4592,7 @@ function bindDeclarations(p, frame, acc) {
|
|
|
4493
4592
|
} else if (partial) bindPatternFrom(frame, decl.name, partial);
|
|
4494
4593
|
}
|
|
4495
4594
|
} else if (!isChainWithQuery(init) && derivedIn(frame, init)) {
|
|
4496
|
-
bindInput(names, isWholeInput(init, wholeContext(frame)));
|
|
4595
|
+
bindInput(names, isWholeInput(init, wholeContext(p, frame)));
|
|
4497
4596
|
}
|
|
4498
4597
|
}
|
|
4499
4598
|
if (frame.depth === 0) {
|
|
@@ -4597,7 +4696,7 @@ function analyzeFrame(p, frame, acc) {
|
|
|
4597
4696
|
const payloadOf = (arg) => arg ? {
|
|
4598
4697
|
text: arg.getText(sf).replace(/\s+/g, " ").slice(0, 200),
|
|
4599
4698
|
inputDerived: derivedIn(frame, arg),
|
|
4600
|
-
wholeInput: isWholeInput(arg, wholeContext(frame))
|
|
4699
|
+
wholeInput: isWholeInput(arg, wholeContext(p, frame))
|
|
4601
4700
|
} : null;
|
|
4602
4701
|
const storageHandles = storageBindingsIn(body);
|
|
4603
4702
|
let callerScope = null;
|
|
@@ -4842,7 +4941,7 @@ function childFrame(p, call, target, frame) {
|
|
|
4842
4941
|
pathPos: [...frame.pathPos, call.getStart(frame.sf)],
|
|
4843
4942
|
exitPropagates: frame.exitPropagates && callResultChecked(call)
|
|
4844
4943
|
};
|
|
4845
|
-
const cx = wholeContext(frame);
|
|
4944
|
+
const cx = wholeContext(p, frame);
|
|
4846
4945
|
target.fn.parameters.forEach((param, i) => {
|
|
4847
4946
|
const arg = call.arguments[i];
|
|
4848
4947
|
const ab = argBinding(p, arg, frame);
|
|
@@ -5135,6 +5234,51 @@ function calleePath(e) {
|
|
|
5135
5234
|
if (ts11.isElementAccessExpression(u)) return `${calleePath(u.expression)}[]`;
|
|
5136
5235
|
return "";
|
|
5137
5236
|
}
|
|
5237
|
+
var EMPTY_GUARDS = { authChecks: [], roleChecks: [] };
|
|
5238
|
+
function layoutGuards(p, pageRel, cache) {
|
|
5239
|
+
const parts = pageRel.split("/");
|
|
5240
|
+
parts.pop();
|
|
5241
|
+
const out = { authChecks: [], roleChecks: [] };
|
|
5242
|
+
for (let i = parts.length; i > 0; i--) {
|
|
5243
|
+
const g = layoutGuardsFor(p, parts.slice(0, i).join("/"), cache);
|
|
5244
|
+
out.authChecks.push(...g.authChecks);
|
|
5245
|
+
out.roleChecks.push(...g.roleChecks);
|
|
5246
|
+
}
|
|
5247
|
+
return out;
|
|
5248
|
+
}
|
|
5249
|
+
var LAYOUT_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
|
|
5250
|
+
function layoutGuardsFor(p, dir, cache) {
|
|
5251
|
+
const hit = cache.get(dir);
|
|
5252
|
+
if (hit) return hit;
|
|
5253
|
+
let guards = EMPTY_GUARDS;
|
|
5254
|
+
for (const ext of LAYOUT_EXTENSIONS) {
|
|
5255
|
+
const rel = `${dir}/layout.${ext}`;
|
|
5256
|
+
const sf = p.sources.get(rel);
|
|
5257
|
+
const facts = p.registry.get(rel);
|
|
5258
|
+
if (!sf || !facts || isClientComponentFile(sf)) continue;
|
|
5259
|
+
const fn = pageHandlerIn(sf);
|
|
5260
|
+
if (!fn) continue;
|
|
5261
|
+
try {
|
|
5262
|
+
const analysed = analyzeHandler(p, {
|
|
5263
|
+
rel,
|
|
5264
|
+
sf,
|
|
5265
|
+
facts,
|
|
5266
|
+
kind: "page",
|
|
5267
|
+
route: dir,
|
|
5268
|
+
method: "PAGE",
|
|
5269
|
+
fn: fn.fn,
|
|
5270
|
+
node: fn.node,
|
|
5271
|
+
wrapper: fn.wrapper
|
|
5272
|
+
});
|
|
5273
|
+
guards = { authChecks: analysed.authChecks, roleChecks: analysed.roleChecks ?? [] };
|
|
5274
|
+
} catch {
|
|
5275
|
+
guards = EMPTY_GUARDS;
|
|
5276
|
+
}
|
|
5277
|
+
break;
|
|
5278
|
+
}
|
|
5279
|
+
cache.set(dir, guards);
|
|
5280
|
+
return guards;
|
|
5281
|
+
}
|
|
5138
5282
|
function analyzeHandler(p, h) {
|
|
5139
5283
|
const { rel, sf, fn } = h;
|
|
5140
5284
|
const loc2 = (n) => ({ file: rel, line: lineOf(sf, n) });
|
|
@@ -5343,6 +5487,15 @@ function parseProject(rootInput, opts = {}) {
|
|
|
5343
5487
|
storageBuckets: schema.storageBuckets
|
|
5344
5488
|
};
|
|
5345
5489
|
}
|
|
5490
|
+
var LAYOUT_CACHE = /* @__PURE__ */ new WeakMap();
|
|
5491
|
+
function layoutCacheOf(p) {
|
|
5492
|
+
let m = LAYOUT_CACHE.get(p);
|
|
5493
|
+
if (!m) {
|
|
5494
|
+
m = /* @__PURE__ */ new Map();
|
|
5495
|
+
LAYOUT_CACHE.set(p, m);
|
|
5496
|
+
}
|
|
5497
|
+
return m;
|
|
5498
|
+
}
|
|
5346
5499
|
function analyzeFile(project, rel, sf, facts, out) {
|
|
5347
5500
|
const { routes, exposures, fileIgnores } = out;
|
|
5348
5501
|
{
|
|
@@ -5401,6 +5554,11 @@ function analyzeFile(project, rel, sf, facts, out) {
|
|
|
5401
5554
|
node: handler.node,
|
|
5402
5555
|
wrapper: handler.wrapper
|
|
5403
5556
|
});
|
|
5557
|
+
const guards = layoutGuards(project, rel, layoutCacheOf(project));
|
|
5558
|
+
analysed.authChecks.push(...guards.authChecks);
|
|
5559
|
+
if (guards.roleChecks.length > 0) {
|
|
5560
|
+
analysed.roleChecks = [...analysed.roleChecks ?? [], ...guards.roleChecks];
|
|
5561
|
+
}
|
|
5404
5562
|
if (analysed.queries.length > 0 || analysed.inputs.length > 0) routes.push(analysed);
|
|
5405
5563
|
}
|
|
5406
5564
|
}
|
|
@@ -6074,6 +6232,253 @@ var supabaseAuthorizationPack = [
|
|
|
6074
6232
|
rlsPolicyWithoutCallerPredicate
|
|
6075
6233
|
];
|
|
6076
6234
|
|
|
6235
|
+
// packages/rules/src/packs/supabase-sql-policies.ts
|
|
6236
|
+
var DATA_API = "Supabase Data API (PostgREST)";
|
|
6237
|
+
function locations2(...refs) {
|
|
6238
|
+
const out = [];
|
|
6239
|
+
for (const r of refs) {
|
|
6240
|
+
if (r && !out.some((o) => o.file === r.file && o.line === r.line)) out.push(r);
|
|
6241
|
+
}
|
|
6242
|
+
return out;
|
|
6243
|
+
}
|
|
6244
|
+
function finding2(ctx, rule, body, severity) {
|
|
6245
|
+
return {
|
|
6246
|
+
id: ctx.nextId(),
|
|
6247
|
+
ruleId: rule.id,
|
|
6248
|
+
status: "likely",
|
|
6249
|
+
severity: severity ?? rule.severity,
|
|
6250
|
+
confidence: rule.confidence,
|
|
6251
|
+
cwe: rule.cwe,
|
|
6252
|
+
createdAt: ctx.now,
|
|
6253
|
+
updatedAt: ctx.now,
|
|
6254
|
+
...body
|
|
6255
|
+
};
|
|
6256
|
+
}
|
|
6257
|
+
function publicTables(ctx) {
|
|
6258
|
+
return ctx.model.tables.filter((t) => !t.table.includes("."));
|
|
6259
|
+
}
|
|
6260
|
+
function commandLabel(p) {
|
|
6261
|
+
return p.command === "all" ? "for all commands" : `for ${p.command}`;
|
|
6262
|
+
}
|
|
6263
|
+
function roleLabel(p) {
|
|
6264
|
+
return p.roles.length === 0 ? "public (no TO clause)" : p.roles.join(", ");
|
|
6265
|
+
}
|
|
6266
|
+
function quote(expr, max = 200) {
|
|
6267
|
+
if (expr === null) return "(none)";
|
|
6268
|
+
const one = expr.replace(/\s+/g, " ").trim();
|
|
6269
|
+
return one.length > max ? `${one.slice(0, max - 1)}\u2026` : one;
|
|
6270
|
+
}
|
|
6271
|
+
var USER_METADATA = /\buser_metadata\b/i;
|
|
6272
|
+
var RAW_USER_META = /\braw_user_meta_data\b/i;
|
|
6273
|
+
var JWT_SOURCE = /auth\s*\.\s*jwt\s*\(|request\.jwt\.claim/i;
|
|
6274
|
+
var AUTH_USERS = /\bauth\s*\.\s*users\b/i;
|
|
6275
|
+
function readsSelfWrittenMetadata(expr) {
|
|
6276
|
+
if (USER_METADATA.test(expr) && JWT_SOURCE.test(expr)) return true;
|
|
6277
|
+
return RAW_USER_META.test(expr) && AUTH_USERS.test(expr);
|
|
6278
|
+
}
|
|
6279
|
+
var rlsPolicyTrustsUserMetadata = {
|
|
6280
|
+
id: "supabase.rls-policy-trusts-user-metadata",
|
|
6281
|
+
title: "RLS policy reads user_metadata from the JWT",
|
|
6282
|
+
description: "A policy decides access with a claim under user_metadata (or raw_user_meta_data). Any signed-in user can write user_metadata with updateUser({ data }), and the claim lands in their next JWT without validation, so the policy grants itself. Move the claim to app_metadata, which only the service role writes, or read a roles table joined on auth.uid().",
|
|
6283
|
+
severity: "critical",
|
|
6284
|
+
confidence: 0.9,
|
|
6285
|
+
cwe: ["CWE-602", "CWE-863"],
|
|
6286
|
+
evaluate(ctx) {
|
|
6287
|
+
const out = [];
|
|
6288
|
+
for (const t of publicTables(ctx)) {
|
|
6289
|
+
for (const p of t.policyDetails) {
|
|
6290
|
+
const where2 = [];
|
|
6291
|
+
if (p.using !== null && readsSelfWrittenMetadata(p.using)) where2.push(["USING", p.using]);
|
|
6292
|
+
if (p.check !== null && readsSelfWrittenMetadata(p.check))
|
|
6293
|
+
where2.push(["WITH CHECK", p.check]);
|
|
6294
|
+
if (where2.length === 0) continue;
|
|
6295
|
+
const clause = where2.map(([kind, expr]) => `${kind} ${quote(expr)}`).join(" / ");
|
|
6296
|
+
const evidence = [
|
|
6297
|
+
{
|
|
6298
|
+
kind: "rule",
|
|
6299
|
+
summary: `Policy "${p.name}" on public.${t.table} (${commandLabel(p)}, to ${roleLabel(p)}) decides access from user_metadata: ${clause}. A signed-in user sets user_metadata themselves with supabase.auth.updateUser({ data: { ... } }); the value is copied into their next access token without any check, so the caller can grant themselves whatever this predicate asks for. app_metadata cannot be written this way, and a roles table joined on auth.uid() cannot either.`,
|
|
6300
|
+
locations: locations2(p.location, t.location),
|
|
6301
|
+
// No `deterministic: true`: a critical deterministic finding blocks the GitHub
|
|
6302
|
+
// check, and this rule has not been seen on a real repository yet (zero hits on the
|
|
6303
|
+
// 26-repository corpus of 13 Sept 2026). It blocks once measured, not before.
|
|
6304
|
+
data: {
|
|
6305
|
+
ruleId: this.id,
|
|
6306
|
+
table: t.table,
|
|
6307
|
+
policy: p.name,
|
|
6308
|
+
command: p.command
|
|
6309
|
+
}
|
|
6310
|
+
},
|
|
6311
|
+
{
|
|
6312
|
+
kind: "trace",
|
|
6313
|
+
summary: [
|
|
6314
|
+
"Any signed-in user",
|
|
6315
|
+
'auth.updateUser({ data: { role: "admin" } })',
|
|
6316
|
+
"the claim is signed into the next JWT",
|
|
6317
|
+
`policy "${p.name}" reads it from user_metadata`,
|
|
6318
|
+
`public.${t.table} (${commandLabel(p)})`
|
|
6319
|
+
].join(" -> ")
|
|
6320
|
+
}
|
|
6321
|
+
];
|
|
6322
|
+
out.push(
|
|
6323
|
+
finding2(ctx, this, {
|
|
6324
|
+
title: `Policy "${p.name}" on "${t.table}" trusts user_metadata`,
|
|
6325
|
+
entrypoints: [DATA_API],
|
|
6326
|
+
sources: ["jwt:user_metadata"],
|
|
6327
|
+
sinks: [`supabase.policy:public.${t.table}`],
|
|
6328
|
+
path: [
|
|
6329
|
+
"Any signed-in user",
|
|
6330
|
+
"user_metadata written by the user",
|
|
6331
|
+
`policy "${p.name}"`,
|
|
6332
|
+
`public.${t.table}`
|
|
6333
|
+
],
|
|
6334
|
+
evidence
|
|
6335
|
+
})
|
|
6336
|
+
);
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
return out;
|
|
6340
|
+
}
|
|
6341
|
+
};
|
|
6342
|
+
var policiesWithoutRlsEnabled = {
|
|
6343
|
+
id: "supabase.policies-without-rls-enabled",
|
|
6344
|
+
title: "Policies exist but RLS is never enabled on the table",
|
|
6345
|
+
description: "A migration writes policies for a table but never runs `alter table ... enable row level security`, so the policies have no effect and the table stays fully readable and writable through the Data API. Every reviewer who sees the policies assumes the table is protected. Supabase's own lint (0007) reports the same shape.",
|
|
6346
|
+
severity: "high",
|
|
6347
|
+
confidence: 0.9,
|
|
6348
|
+
cwe: ["CWE-284"],
|
|
6349
|
+
evaluate(ctx) {
|
|
6350
|
+
const out = [];
|
|
6351
|
+
for (const t of publicTables(ctx)) {
|
|
6352
|
+
if (t.rlsEnabled || t.policyDetails.length === 0) continue;
|
|
6353
|
+
const names = t.policyDetails.map((p) => `"${p.name}" (${commandLabel(p)})`).join(", ");
|
|
6354
|
+
const evidence = [
|
|
6355
|
+
{
|
|
6356
|
+
kind: "rule",
|
|
6357
|
+
summary: `public.${t.table} has ${t.policyDetails.length} ${t.policyDetails.length === 1 ? "policy" : "policies"} \u2014 ${names} \u2014 and no "alter table public.${t.table} enable row level security" anywhere in the migrations. Policies only apply to a table with RLS on, so every one of them is dead and the table is fully readable and writable by anyone holding the public anon key. The policies say what the intent was, which is what makes this a defect rather than a choice.`,
|
|
6358
|
+
locations: locations2(t.location, t.policyDetails[0]?.location),
|
|
6359
|
+
data: {
|
|
6360
|
+
deterministic: true,
|
|
6361
|
+
ruleId: this.id,
|
|
6362
|
+
table: t.table,
|
|
6363
|
+
policies: t.policyDetails.map((p) => p.name)
|
|
6364
|
+
}
|
|
6365
|
+
},
|
|
6366
|
+
{
|
|
6367
|
+
kind: "trace",
|
|
6368
|
+
summary: [
|
|
6369
|
+
"Anyone with the public anon key",
|
|
6370
|
+
"PostgREST",
|
|
6371
|
+
`public.${t.table} (RLS never enabled)`,
|
|
6372
|
+
`${t.policyDetails.length} policies that never run`
|
|
6373
|
+
].join(" -> ")
|
|
6374
|
+
}
|
|
6375
|
+
];
|
|
6376
|
+
out.push(
|
|
6377
|
+
finding2(ctx, this, {
|
|
6378
|
+
title: `Table "${t.table}" has policies but RLS is off`,
|
|
6379
|
+
entrypoints: [DATA_API],
|
|
6380
|
+
sources: ["anon-key"],
|
|
6381
|
+
sinks: [`supabase.select:public.${t.table}`],
|
|
6382
|
+
path: [
|
|
6383
|
+
"Anyone with the public anon key",
|
|
6384
|
+
"PostgREST",
|
|
6385
|
+
`public.${t.table} (RLS off, policies inert)`
|
|
6386
|
+
],
|
|
6387
|
+
evidence
|
|
6388
|
+
})
|
|
6389
|
+
);
|
|
6390
|
+
}
|
|
6391
|
+
return out;
|
|
6392
|
+
}
|
|
6393
|
+
};
|
|
6394
|
+
function isTautology(expr) {
|
|
6395
|
+
if (expr === null) return false;
|
|
6396
|
+
return /^\(*\s*true\s*\)*$/i.test(expr.trim());
|
|
6397
|
+
}
|
|
6398
|
+
var ANON_ROLES = /* @__PURE__ */ new Set(["anon", "public"]);
|
|
6399
|
+
function reachableByAnon(p) {
|
|
6400
|
+
if (p.roles.length === 0) return true;
|
|
6401
|
+
return p.roles.some((r) => ANON_ROLES.has(r.toLowerCase().replace(/^"|"$/g, "")));
|
|
6402
|
+
}
|
|
6403
|
+
var WRITE_COMMANDS = /* @__PURE__ */ new Set(["insert", "update", "delete", "all"]);
|
|
6404
|
+
var anonWritePolicy = {
|
|
6405
|
+
id: "supabase.anon-write-policy",
|
|
6406
|
+
title: "Write policy open to anon or every role",
|
|
6407
|
+
description: "An insert, update or delete policy targets anon (or omits the TO clause, which means PUBLIC) and decides with `true`. Anyone holding the public anon key writes the table directly through PostgREST, including rows that belong to signed-in users, whether or not the application has a form for it.",
|
|
6408
|
+
severity: "high",
|
|
6409
|
+
confidence: 0.85,
|
|
6410
|
+
cwe: ["CWE-284", "CWE-862"],
|
|
6411
|
+
evaluate(ctx) {
|
|
6412
|
+
const out = [];
|
|
6413
|
+
for (const t of publicTables(ctx)) {
|
|
6414
|
+
if (!t.rlsEnabled) continue;
|
|
6415
|
+
for (const p of t.policyDetails) {
|
|
6416
|
+
if (!WRITE_COMMANDS.has(p.command) || !reachableByAnon(p)) continue;
|
|
6417
|
+
const decides = p.command === "insert" ? [["WITH CHECK", p.check]] : p.command === "all" ? [
|
|
6418
|
+
["USING", p.using],
|
|
6419
|
+
["WITH CHECK", p.check]
|
|
6420
|
+
] : [["USING", p.using]];
|
|
6421
|
+
const open = decides.filter(([, expr]) => isTautology(expr));
|
|
6422
|
+
if (open.length === 0) continue;
|
|
6423
|
+
const insertOnly = p.command === "insert";
|
|
6424
|
+
const severity = insertOnly ? "medium" : "high";
|
|
6425
|
+
const clause = open.map(([kind, expr]) => `${kind} ${quote(expr)}`).join(" / ");
|
|
6426
|
+
const verbs = p.command === "all" ? "insert, update and delete" : `${p.command} rows in`;
|
|
6427
|
+
const evidence = [
|
|
6428
|
+
{
|
|
6429
|
+
kind: "rule",
|
|
6430
|
+
summary: `Policy "${p.name}" on public.${t.table} is ${commandLabel(p)} to ${roleLabel(p)} and decides with a tautology: ${clause}. Anyone holding the public anon key can ${verbs} public.${t.table} straight through PostgREST, without going through this application.${insertOnly ? " An insert-only policy is how deliberate public forms are written, so this is reported as medium: check that the table is meant to accept rows from strangers and that a rate limit and a validation trigger exist." : " Rows that belong to signed-in users can be changed or deleted by a stranger."}`,
|
|
6431
|
+
locations: locations2(p.location, t.location),
|
|
6432
|
+
data: {
|
|
6433
|
+
deterministic: true,
|
|
6434
|
+
ruleId: this.id,
|
|
6435
|
+
table: t.table,
|
|
6436
|
+
policy: p.name,
|
|
6437
|
+
command: p.command,
|
|
6438
|
+
roles: p.roles
|
|
6439
|
+
}
|
|
6440
|
+
},
|
|
6441
|
+
{
|
|
6442
|
+
kind: "trace",
|
|
6443
|
+
summary: [
|
|
6444
|
+
"Anyone with the public anon key",
|
|
6445
|
+
"PostgREST",
|
|
6446
|
+
`policy "${p.name}" (${commandLabel(p)}, to ${roleLabel(p)}, ${clause})`,
|
|
6447
|
+
`public.${t.table}`
|
|
6448
|
+
].join(" -> ")
|
|
6449
|
+
}
|
|
6450
|
+
];
|
|
6451
|
+
out.push(
|
|
6452
|
+
finding2(
|
|
6453
|
+
ctx,
|
|
6454
|
+
this,
|
|
6455
|
+
{
|
|
6456
|
+
title: `Policy "${p.name}" lets anyone ${p.command === "all" ? "write" : p.command} "${t.table}"`,
|
|
6457
|
+
entrypoints: [DATA_API],
|
|
6458
|
+
sources: ["anon-key"],
|
|
6459
|
+
sinks: [`supabase.${p.command === "all" ? "insert" : p.command}:public.${t.table}`],
|
|
6460
|
+
path: [
|
|
6461
|
+
"Anyone with the public anon key",
|
|
6462
|
+
"PostgREST",
|
|
6463
|
+
`policy "${p.name}" (${commandLabel(p)}, to ${roleLabel(p)})`,
|
|
6464
|
+
`public.${t.table}`
|
|
6465
|
+
],
|
|
6466
|
+
evidence
|
|
6467
|
+
},
|
|
6468
|
+
severity
|
|
6469
|
+
)
|
|
6470
|
+
);
|
|
6471
|
+
}
|
|
6472
|
+
}
|
|
6473
|
+
return out;
|
|
6474
|
+
}
|
|
6475
|
+
};
|
|
6476
|
+
var supabaseSqlPoliciesPack = [
|
|
6477
|
+
rlsPolicyTrustsUserMetadata,
|
|
6478
|
+
policiesWithoutRlsEnabled,
|
|
6479
|
+
anonWritePolicy
|
|
6480
|
+
];
|
|
6481
|
+
|
|
6077
6482
|
// packages/rules/src/packs/supabase-storage-rpc.ts
|
|
6078
6483
|
function reaches(ctx) {
|
|
6079
6484
|
const out = [];
|
|
@@ -6096,7 +6501,7 @@ function reaches(ctx) {
|
|
|
6096
6501
|
}
|
|
6097
6502
|
return out;
|
|
6098
6503
|
}
|
|
6099
|
-
function
|
|
6504
|
+
function locations3(...refs) {
|
|
6100
6505
|
const out = [];
|
|
6101
6506
|
for (const r of refs) {
|
|
6102
6507
|
if (r && !out.some((o) => o.file === r.file && o.line === r.line)) out.push(r);
|
|
@@ -6106,7 +6511,7 @@ function locations2(...refs) {
|
|
|
6106
6511
|
function unique(values) {
|
|
6107
6512
|
return [...new Set(values)];
|
|
6108
6513
|
}
|
|
6109
|
-
function
|
|
6514
|
+
function finding3(ctx, rule, body, severity) {
|
|
6110
6515
|
return {
|
|
6111
6516
|
id: ctx.nextId(),
|
|
6112
6517
|
ruleId: rule.id,
|
|
@@ -6162,7 +6567,7 @@ var storageObjectAccessWithoutOwnerScope = {
|
|
|
6162
6567
|
{
|
|
6163
6568
|
kind: "rule",
|
|
6164
6569
|
summary: `storage.from(${s.bucket === null ? "\u2026" : `"${s.bucket}"`}).${s.op}(${s.pathText}) runs through ${client}, a service-role client, so storage policies on storage.objects do not apply. The object path comes from the request and is neither prefixed with nor checked against the caller's user id, so any signed-in user can ${verb} any object in ${bucketLabel(s.bucket)}, including other users' files. The handler authenticates the caller but never ties the path to them.${via}`,
|
|
6165
|
-
locations:
|
|
6570
|
+
locations: locations3(r.handler.location, r.query.location, r.client?.location),
|
|
6166
6571
|
data: {
|
|
6167
6572
|
deterministic: false,
|
|
6168
6573
|
ruleId: this.id,
|
|
@@ -6175,7 +6580,7 @@ var storageObjectAccessWithoutOwnerScope = {
|
|
|
6175
6580
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
6176
6581
|
];
|
|
6177
6582
|
out.push(
|
|
6178
|
-
|
|
6583
|
+
finding3(ctx, this, {
|
|
6179
6584
|
title: `Cross-user storage ${s.op} in ${bucketLabel(s.bucket)} via service-role client`,
|
|
6180
6585
|
entrypoints: [r.handlerData.entry],
|
|
6181
6586
|
sources: r.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
@@ -6282,7 +6687,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6282
6687
|
];
|
|
6283
6688
|
const reachNote = entries.length > 0 ? ` The app reaches the bucket from ${entries.join(", ")} with a client that relies on this policy.` : "";
|
|
6284
6689
|
out.push(
|
|
6285
|
-
|
|
6690
|
+
finding3(ctx, this, {
|
|
6286
6691
|
title: `Storage policy "${p.name}" lets ${who} ${verb} every object in ${buckets.length > 0 ? `bucket ${where2}` : "every bucket"}`,
|
|
6287
6692
|
entrypoints: [...entries, storageEntry],
|
|
6288
6693
|
sources: unique([
|
|
@@ -6295,7 +6700,7 @@ var storagePolicyWithoutOwnerCheck = {
|
|
|
6295
6700
|
{
|
|
6296
6701
|
kind: "rule",
|
|
6297
6702
|
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} Scope it to the owner, e.g. bucket_id = '${buckets[0] ?? "<bucket>"}' and (storage.foldername(name))[1] = (select auth.uid())::text.`,
|
|
6298
|
-
locations:
|
|
6703
|
+
locations: locations3(p.location, ...reached.map((r) => r.query.location)),
|
|
6299
6704
|
data: { deterministic: false, ruleId: this.id, policy: p.name, buckets }
|
|
6300
6705
|
},
|
|
6301
6706
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
@@ -6343,7 +6748,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6343
6748
|
"no auth.uid() / auth.jwt() check"
|
|
6344
6749
|
];
|
|
6345
6750
|
out.push(
|
|
6346
|
-
|
|
6751
|
+
finding3(
|
|
6347
6752
|
ctx,
|
|
6348
6753
|
this,
|
|
6349
6754
|
{
|
|
@@ -6359,7 +6764,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
6359
6764
|
{
|
|
6360
6765
|
kind: "rule",
|
|
6361
6766
|
summary: `public.${fn.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
|
|
6362
|
-
locations:
|
|
6767
|
+
locations: locations3(
|
|
6363
6768
|
fn.location,
|
|
6364
6769
|
...sites.flatMap((s) => [s.handler.location, s.query.location])
|
|
6365
6770
|
),
|
|
@@ -6412,9 +6817,9 @@ var PUBLIC_TABLE_RULES = /* @__PURE__ */ new Set([
|
|
|
6412
6817
|
"supabase.rls-policy-without-caller-predicate"
|
|
6413
6818
|
]);
|
|
6414
6819
|
var READ_SINK = /^supabase\.select:public\.(.+)$/;
|
|
6415
|
-
function applyPublicTables(findings,
|
|
6416
|
-
if (
|
|
6417
|
-
const declared = new Set(
|
|
6820
|
+
function applyPublicTables(findings, publicTables2, now) {
|
|
6821
|
+
if (publicTables2.length === 0) return findings;
|
|
6822
|
+
const declared = new Set(publicTables2.map((t) => t.toLowerCase()));
|
|
6418
6823
|
return findings.map((f) => {
|
|
6419
6824
|
if (f.status === "suppressed" || !PUBLIC_TABLE_RULES.has(f.ruleId)) return f;
|
|
6420
6825
|
if (f.sinks.length === 0) return f;
|
|
@@ -6462,7 +6867,8 @@ function applySuppressions(findings, model, now) {
|
|
|
6462
6867
|
// packages/rules/src/index.ts
|
|
6463
6868
|
var defaultRules = [
|
|
6464
6869
|
...supabaseAuthorizationPack,
|
|
6465
|
-
...supabaseStorageRpcPack
|
|
6870
|
+
...supabaseStorageRpcPack,
|
|
6871
|
+
...supabaseSqlPoliciesPack
|
|
6466
6872
|
];
|
|
6467
6873
|
|
|
6468
6874
|
// packages/scanner/src/config.ts
|
|
@@ -6495,14 +6901,14 @@ function loadAuditConfig(root) {
|
|
|
6495
6901
|
const obj = raw;
|
|
6496
6902
|
const ignore = strings(obj.ignore);
|
|
6497
6903
|
const migrations = strings(obj.migrations);
|
|
6498
|
-
const
|
|
6904
|
+
const publicTables2 = publicTableNames(obj.publicTables);
|
|
6499
6905
|
return {
|
|
6500
6906
|
config: {
|
|
6501
6907
|
...ignore ? { ignore } : {},
|
|
6502
6908
|
...migrations ? { migrations } : {},
|
|
6503
|
-
...
|
|
6909
|
+
...publicTables2.tables ? { publicTables: publicTables2.tables } : {}
|
|
6504
6910
|
},
|
|
6505
|
-
warnings:
|
|
6911
|
+
warnings: publicTables2.warnings
|
|
6506
6912
|
};
|
|
6507
6913
|
}
|
|
6508
6914
|
function publicTableNames(value) {
|
|
@@ -6617,7 +7023,7 @@ function ensureDirectory(path) {
|
|
|
6617
7023
|
}
|
|
6618
7024
|
if (!isDirectory) throw new ScanError("path_not_found", path, `${path} is not a directory`);
|
|
6619
7025
|
}
|
|
6620
|
-
function summarize(model, rules,
|
|
7026
|
+
function summarize(model, rules, publicTables2 = []) {
|
|
6621
7027
|
const apiTables = model.tables.filter((t) => !t.table.includes("."));
|
|
6622
7028
|
return {
|
|
6623
7029
|
root: model.root,
|
|
@@ -6628,7 +7034,7 @@ function summarize(model, rules, publicTables = []) {
|
|
|
6628
7034
|
tablesWithRls: apiTables.filter((t) => t.rlsEnabled).length,
|
|
6629
7035
|
rules,
|
|
6630
7036
|
warnings: model.warnings,
|
|
6631
|
-
publicTables: [...
|
|
7037
|
+
publicTables: [...publicTables2]
|
|
6632
7038
|
};
|
|
6633
7039
|
}
|
|
6634
7040
|
function runScan(path, opts = {}) {
|
|
@@ -6642,11 +7048,11 @@ function runScan(path, opts = {}) {
|
|
|
6642
7048
|
...ignore.globs.length > 0 ? { ignore: ignore.globs } : {}
|
|
6643
7049
|
});
|
|
6644
7050
|
const graph = buildGraph(model);
|
|
6645
|
-
const
|
|
6646
|
-
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables };
|
|
7051
|
+
const publicTables2 = cfg.config.publicTables ?? [];
|
|
7052
|
+
const runOpts = { ...opts.now === void 0 ? {} : { now: opts.now }, publicTables: publicTables2 };
|
|
6647
7053
|
const findings = runRules(defaultRules, model, graph, runOpts);
|
|
6648
7054
|
const coverage = summarizeCoverage(findings);
|
|
6649
|
-
const summary = summarize(model, defaultRules.length,
|
|
7055
|
+
const summary = summarize(model, defaultRules.length, publicTables2);
|
|
6650
7056
|
return {
|
|
6651
7057
|
summary: {
|
|
6652
7058
|
...summary,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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",
|