auditai-scan 0.7.3 → 0.7.5
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 +301 -23
- package/package.json +1 -1
package/dist/auditai-scan.mjs
CHANGED
|
@@ -260,7 +260,7 @@ function policedTables(f, model) {
|
|
|
260
260
|
const out = [];
|
|
261
261
|
for (const n of names) {
|
|
262
262
|
const t = table(model, n);
|
|
263
|
-
if (!t
|
|
263
|
+
if (!t?.rlsEnabled || t.policies.length === 0)
|
|
264
264
|
return null;
|
|
265
265
|
out.push(qualified(t.table));
|
|
266
266
|
}
|
|
@@ -1568,6 +1568,45 @@ function callResultChecked(call) {
|
|
|
1568
1568
|
}
|
|
1569
1569
|
return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child && exitKind(cur.thenStatement) !== null;
|
|
1570
1570
|
}
|
|
1571
|
+
function rowNamesOf(tail) {
|
|
1572
|
+
const { parent } = outerOf(tail);
|
|
1573
|
+
if (!parent || !ts4.isVariableDeclaration(parent)) return /* @__PURE__ */ new Set();
|
|
1574
|
+
const rows = resultNames(parent.name).data;
|
|
1575
|
+
const fn2 = enclosingFunction(parent);
|
|
1576
|
+
const scope = fn2 ? fn2.body : parent.getSourceFile();
|
|
1577
|
+
if (scope) {
|
|
1578
|
+
walkOwn(scope, (n) => {
|
|
1579
|
+
if (!ts4.isVariableDeclaration(n) || !n.initializer || n.pos < parent.end) return;
|
|
1580
|
+
const init = unwrap(n.initializer);
|
|
1581
|
+
if (!ts4.isIdentifier(init) || !rows.has(init.text) || ts4.isIdentifier(n.name)) return;
|
|
1582
|
+
for (const name of resultNames(n.name).data) rows.add(name);
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
return rows;
|
|
1586
|
+
}
|
|
1587
|
+
function checkedResultExit(call) {
|
|
1588
|
+
const { node, parent } = outerOf(call);
|
|
1589
|
+
if (!parent) return null;
|
|
1590
|
+
if (ts4.isVariableDeclaration(parent) && parent.initializer === node) {
|
|
1591
|
+
const names = new Set(boundNames(parent.name));
|
|
1592
|
+
for (const s of ifsAfter(parent)) {
|
|
1593
|
+
if (!checksMissing(s.expression, names)) continue;
|
|
1594
|
+
const kind = exitKind(s.thenStatement);
|
|
1595
|
+
if (kind) return kind;
|
|
1596
|
+
}
|
|
1597
|
+
return null;
|
|
1598
|
+
}
|
|
1599
|
+
if (!ts4.isPrefixUnaryExpression(parent) || parent.operator !== ts4.SyntaxKind.ExclamationToken) {
|
|
1600
|
+
return null;
|
|
1601
|
+
}
|
|
1602
|
+
let cur = parent.parent;
|
|
1603
|
+
let child = parent;
|
|
1604
|
+
while (cur && ts4.isParenthesizedExpression(cur)) {
|
|
1605
|
+
child = cur;
|
|
1606
|
+
cur = cur.parent;
|
|
1607
|
+
}
|
|
1608
|
+
return cur !== void 0 && ts4.isIfStatement(cur) && cur.expression === child ? exitKind(cur.thenStatement) : null;
|
|
1609
|
+
}
|
|
1571
1610
|
function compareOrder(a, b) {
|
|
1572
1611
|
const n = Math.min(a.length, b.length);
|
|
1573
1612
|
for (let i = 0; i < n; i++) {
|
|
@@ -4072,6 +4111,8 @@ var REFERENCE_SQL_DIRS = /* @__PURE__ */ new Set([
|
|
|
4072
4111
|
"examples",
|
|
4073
4112
|
"old"
|
|
4074
4113
|
]);
|
|
4114
|
+
var CLI_MIGRATION = /(?:^|\/)supabase\/migrations\/[0-9]+_[^/]*\.sql$/;
|
|
4115
|
+
var CLI_SEED = /(?:^|\/)supabase\/seed\.sql$/;
|
|
4075
4116
|
function appliedSqlFiles(rels) {
|
|
4076
4117
|
const applied = rels.filter(isAppliedSqlFile);
|
|
4077
4118
|
const slashed = (rel) => rel.split("\\").join("/");
|
|
@@ -4079,9 +4120,16 @@ function appliedSqlFiles(rels) {
|
|
|
4079
4120
|
(rel) => /(?:^|\/)supabase\/migrations\/[^/]+\.sql$/i.test(slashed(rel))
|
|
4080
4121
|
);
|
|
4081
4122
|
if (!hasMigrations) return applied;
|
|
4082
|
-
|
|
4123
|
+
const kept = applied.filter(
|
|
4083
4124
|
(rel) => !slashed(rel).split("/").slice(0, -1).some((dir) => REFERENCE_SQL_DIRS.has(dir.toLowerCase()))
|
|
4084
4125
|
);
|
|
4126
|
+
const migrations = kept.filter((rel) => CLI_MIGRATION.test(slashed(rel)));
|
|
4127
|
+
if (migrations.length === 0) return kept;
|
|
4128
|
+
const seeds = kept.filter((rel) => CLI_SEED.test(slashed(rel)));
|
|
4129
|
+
const byHand = kept.filter(
|
|
4130
|
+
(rel) => !CLI_MIGRATION.test(slashed(rel)) && !CLI_SEED.test(slashed(rel))
|
|
4131
|
+
);
|
|
4132
|
+
return [...byHand, ...migrations, ...seeds];
|
|
4085
4133
|
}
|
|
4086
4134
|
function parseSqlForRls(rel, text, into) {
|
|
4087
4135
|
if (!isAppliedSqlFile(rel)) return;
|
|
@@ -5640,14 +5688,23 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5640
5688
|
call,
|
|
5641
5689
|
chain.segments.map((s) => s.name)
|
|
5642
5690
|
);
|
|
5643
|
-
const
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5691
|
+
const exits = rowExit === "throw" || rowExit === "return" && frame.exitPropagates;
|
|
5692
|
+
const checks = [
|
|
5693
|
+
// `if (!row || row.user_id !== user.id) return 404`: ownership checked in code after the read.
|
|
5694
|
+
...rowComparisons(call).filter((c) => c.exit === "throw" || frame.exitPropagates).map((c) => ({
|
|
5695
|
+
method: "compare",
|
|
5696
|
+
column: c.column,
|
|
5697
|
+
valueText: c.value.getText(sf).replace(/\s+/g, " "),
|
|
5698
|
+
inputDerived: derivedIn(frame, c.value),
|
|
5699
|
+
...identityOf(frame, c.value) !== void 0 ? { identity: true } : {}
|
|
5700
|
+
})),
|
|
5701
|
+
// `if (!(await canAccessRequest(user, request.org_id))) return null`: checked by a helper.
|
|
5702
|
+
...helperRowChecks(p, call, frame, scope)
|
|
5703
|
+
];
|
|
5650
5704
|
if (checks.length > 0) query.ownerChecks = checks;
|
|
5705
|
+
if (exits && query.operation === "select") {
|
|
5706
|
+
membershipChecks(acc.reads, query, filterValues, frame);
|
|
5707
|
+
}
|
|
5651
5708
|
acc.reads.push({
|
|
5652
5709
|
query,
|
|
5653
5710
|
keys: query.filters.map((f) => {
|
|
@@ -5655,8 +5712,10 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5655
5712
|
return v ? valueKey(frame, v) : null;
|
|
5656
5713
|
}),
|
|
5657
5714
|
order: [...frame.pathPos, parsed.anchor.getStart(sf)],
|
|
5658
|
-
exits
|
|
5659
|
-
checks
|
|
5715
|
+
exits,
|
|
5716
|
+
checks,
|
|
5717
|
+
frame: frame.key,
|
|
5718
|
+
rows: rowNamesOf(call)
|
|
5660
5719
|
});
|
|
5661
5720
|
bindBuilder(call, query);
|
|
5662
5721
|
}
|
|
@@ -5676,6 +5735,187 @@ function analyzeFrame(p, frame, acc) {
|
|
|
5676
5735
|
analyzeFrame(p, child, acc);
|
|
5677
5736
|
}
|
|
5678
5737
|
}
|
|
5738
|
+
function paramRole(e, roles) {
|
|
5739
|
+
let u = unwrap(e);
|
|
5740
|
+
while (ts11.isPropertyAccessExpression(u) || ts11.isElementAccessExpression(u)) {
|
|
5741
|
+
if (ts11.isPropertyAccessExpression(u) && u.name.text === "user_metadata") return null;
|
|
5742
|
+
u = unwrap(u.expression);
|
|
5743
|
+
}
|
|
5744
|
+
return ts11.isIdentifier(u) ? roles.get(u.text) ?? null : null;
|
|
5745
|
+
}
|
|
5746
|
+
var EQUALS = /* @__PURE__ */ new Set([
|
|
5747
|
+
ts11.SyntaxKind.EqualsEqualsEqualsToken,
|
|
5748
|
+
ts11.SyntaxKind.EqualsEqualsToken
|
|
5749
|
+
]);
|
|
5750
|
+
var NOT_EQUALS = /* @__PURE__ */ new Set([
|
|
5751
|
+
ts11.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
5752
|
+
ts11.SyntaxKind.ExclamationEqualsToken
|
|
5753
|
+
]);
|
|
5754
|
+
function comparesRowAndCaller(e, roles, ops) {
|
|
5755
|
+
const u = unwrap(e);
|
|
5756
|
+
if (!ts11.isBinaryExpression(u) || !ops.has(u.operatorToken.kind)) return false;
|
|
5757
|
+
const a = paramRole(u.left, roles);
|
|
5758
|
+
const b = paramRole(u.right, roles);
|
|
5759
|
+
return a === "row" && b === "caller" || a === "caller" && b === "row";
|
|
5760
|
+
}
|
|
5761
|
+
function isCallerRoleTest(e, roles) {
|
|
5762
|
+
const u = unwrap(e);
|
|
5763
|
+
const onCaller = (x) => {
|
|
5764
|
+
const ux = unwrap(x);
|
|
5765
|
+
return ts11.isPropertyAccessExpression(ux) && paramRole(ux, roles) === "caller";
|
|
5766
|
+
};
|
|
5767
|
+
if (ts11.isBinaryExpression(u)) {
|
|
5768
|
+
const op = u.operatorToken.kind;
|
|
5769
|
+
if (op === ts11.SyntaxKind.BarBarToken || op === ts11.SyntaxKind.AmpersandAmpersandToken) {
|
|
5770
|
+
return isCallerRoleTest(u.left, roles) && isCallerRoleTest(u.right, roles);
|
|
5771
|
+
}
|
|
5772
|
+
if (!EQUALS.has(op)) return false;
|
|
5773
|
+
return onCaller(u.left) && ts11.isStringLiteralLike(unwrap(u.right)) || onCaller(u.right) && ts11.isStringLiteralLike(unwrap(u.left));
|
|
5774
|
+
}
|
|
5775
|
+
if (!ts11.isCallExpression(u) || !ts11.isPropertyAccessExpression(u.expression) || u.expression.name.text !== "includes") {
|
|
5776
|
+
return false;
|
|
5777
|
+
}
|
|
5778
|
+
const list = unwrap(u.expression.expression);
|
|
5779
|
+
const arg = u.arguments[0];
|
|
5780
|
+
return ts11.isArrayLiteralExpression(list) && list.elements.length > 0 && list.elements.every((el) => ts11.isStringLiteralLike(el)) && u.arguments.length === 1 && arg !== void 0 && onCaller(arg);
|
|
5781
|
+
}
|
|
5782
|
+
function isDenyValue(e) {
|
|
5783
|
+
const u = unwrap(e);
|
|
5784
|
+
return u.kind === ts11.SyntaxKind.FalseKeyword || u.kind === ts11.SyntaxKind.NullKeyword || ts11.isIdentifier(u) && u.text === "undefined";
|
|
5785
|
+
}
|
|
5786
|
+
function operandsOf(e, op) {
|
|
5787
|
+
const u = unwrap(e);
|
|
5788
|
+
return ts11.isBinaryExpression(u) && u.operatorToken.kind === op ? [...operandsOf(u.left, op), ...operandsOf(u.right, op)] : [u];
|
|
5789
|
+
}
|
|
5790
|
+
function thenBranchIf(ret) {
|
|
5791
|
+
let child = ret;
|
|
5792
|
+
for (let cur = ret.parent; cur && !isFunctionLikeNode(cur); cur = cur.parent) {
|
|
5793
|
+
if (ts11.isIfStatement(cur)) return cur.thenStatement === child ? cur : null;
|
|
5794
|
+
child = cur;
|
|
5795
|
+
}
|
|
5796
|
+
return null;
|
|
5797
|
+
}
|
|
5798
|
+
function onlyDenies(stmt) {
|
|
5799
|
+
let stops = false;
|
|
5800
|
+
let allows = false;
|
|
5801
|
+
walkOwn(stmt, (n) => {
|
|
5802
|
+
if (ts11.isThrowStatement(n)) stops = true;
|
|
5803
|
+
else if (ts11.isReturnStatement(n)) {
|
|
5804
|
+
if (!n.expression || isDenyValue(n.expression)) stops = true;
|
|
5805
|
+
else allows = true;
|
|
5806
|
+
}
|
|
5807
|
+
});
|
|
5808
|
+
return stops && !allows;
|
|
5809
|
+
}
|
|
5810
|
+
function helperComparison(fn2, rowIndex, callerIndexes, sf) {
|
|
5811
|
+
const roles = /* @__PURE__ */ new Map();
|
|
5812
|
+
fn2.parameters.forEach((param, i) => {
|
|
5813
|
+
if (!ts11.isIdentifier(param.name)) return;
|
|
5814
|
+
if (i === rowIndex) roles.set(param.name.text, "row");
|
|
5815
|
+
else if (callerIndexes.includes(i)) roles.set(param.name.text, "caller");
|
|
5816
|
+
});
|
|
5817
|
+
const kinds = new Set(roles.values());
|
|
5818
|
+
if (!kinds.has("row") || !kinds.has("caller") || !fn2.body) return null;
|
|
5819
|
+
let denied = false;
|
|
5820
|
+
if (ts11.isBlock(fn2.body)) {
|
|
5821
|
+
walkOwn(fn2.body, (n) => {
|
|
5822
|
+
if (ts11.isIfStatement(n) && operandsOf(n.expression, ts11.SyntaxKind.BarBarToken).some(
|
|
5823
|
+
(c) => comparesRowAndCaller(c, roles, NOT_EQUALS)
|
|
5824
|
+
) && onlyDenies(n.thenStatement)) {
|
|
5825
|
+
denied = true;
|
|
5826
|
+
}
|
|
5827
|
+
});
|
|
5828
|
+
}
|
|
5829
|
+
const bypasses = [];
|
|
5830
|
+
let compared = false;
|
|
5831
|
+
for (const ret of ownReturns(fn2)) {
|
|
5832
|
+
if (isDenyValue(ret)) continue;
|
|
5833
|
+
if (unwrap(ret).kind === ts11.SyntaxKind.TrueKeyword) {
|
|
5834
|
+
const gate = thenBranchIf(ret);
|
|
5835
|
+
if (gate && isCallerRoleTest(gate.expression, roles)) {
|
|
5836
|
+
bypasses.push(gate.expression.getText(sf).replace(/\s+/g, " "));
|
|
5837
|
+
continue;
|
|
5838
|
+
}
|
|
5839
|
+
if (gate === null && denied) {
|
|
5840
|
+
compared = true;
|
|
5841
|
+
continue;
|
|
5842
|
+
}
|
|
5843
|
+
return null;
|
|
5844
|
+
}
|
|
5845
|
+
for (const alt of operandsOf(ret, ts11.SyntaxKind.BarBarToken)) {
|
|
5846
|
+
const conjuncts = operandsOf(alt, ts11.SyntaxKind.AmpersandAmpersandToken);
|
|
5847
|
+
if (conjuncts.some((c) => comparesRowAndCaller(c, roles, EQUALS))) compared = true;
|
|
5848
|
+
else if (isCallerRoleTest(alt, roles)) bypasses.push(alt.getText(sf).replace(/\s+/g, " "));
|
|
5849
|
+
else return null;
|
|
5850
|
+
}
|
|
5851
|
+
}
|
|
5852
|
+
if (!compared) return null;
|
|
5853
|
+
return bypasses.length > 0 ? { bypass: bypasses.join(" || ") } : {};
|
|
5854
|
+
}
|
|
5855
|
+
function helperRowChecks(p, tail, frame, scope) {
|
|
5856
|
+
const rows = rowNamesOf(tail);
|
|
5857
|
+
const decl = outerOf(tail).parent;
|
|
5858
|
+
const fn2 = decl ? enclosingFunction(decl) : null;
|
|
5859
|
+
if (rows.size === 0 || !decl || !fn2?.body) return [];
|
|
5860
|
+
const out = [];
|
|
5861
|
+
walkOwn(fn2.body, (n) => {
|
|
5862
|
+
if (!ts11.isCallExpression(n) || n.pos < decl.end) return;
|
|
5863
|
+
const rowIndex = n.arguments.findIndex((a) => {
|
|
5864
|
+
const u = unwrap(a);
|
|
5865
|
+
if (!ts11.isPropertyAccessExpression(u)) return false;
|
|
5866
|
+
const base = unwrap(u.expression);
|
|
5867
|
+
return ts11.isIdentifier(base) && rows.has(base.text);
|
|
5868
|
+
});
|
|
5869
|
+
const rowArg = unwrap(n.arguments[rowIndex] ?? n);
|
|
5870
|
+
if (rowIndex < 0 || !ts11.isPropertyAccessExpression(rowArg)) return;
|
|
5871
|
+
const exit = checkedResultExit(n);
|
|
5872
|
+
if (exit === null || exit === "return" && !frame.exitPropagates) return;
|
|
5873
|
+
const callerIndexes = n.arguments.flatMap(
|
|
5874
|
+
(a, i) => i !== rowIndex && identityOf(frame, a) !== void 0 ? [i] : []
|
|
5875
|
+
);
|
|
5876
|
+
if (callerIndexes.length === 0) return;
|
|
5877
|
+
const target = callTarget(p, n, frame, scope);
|
|
5878
|
+
const targetSf = target ? p.sources.get(target.facts.file) : void 0;
|
|
5879
|
+
if (!target || !targetSf) return;
|
|
5880
|
+
const verdict = helperComparison(target.fn, rowIndex, callerIndexes, targetSf);
|
|
5881
|
+
if (!verdict) return;
|
|
5882
|
+
out.push({
|
|
5883
|
+
method: "compare",
|
|
5884
|
+
column: rowArg.name.text,
|
|
5885
|
+
valueText: n.getText(frame.sf).replace(/\s+/g, " ").slice(0, 160),
|
|
5886
|
+
inputDerived: false,
|
|
5887
|
+
identity: true,
|
|
5888
|
+
...verdict.bypass !== void 0 ? { bypass: verdict.bypass } : {}
|
|
5889
|
+
});
|
|
5890
|
+
});
|
|
5891
|
+
return out;
|
|
5892
|
+
}
|
|
5893
|
+
function membershipChecks(reads, query, values, frame) {
|
|
5894
|
+
if (!query.filters.some((f) => f.identity === true && !f.inputDerived && f.method === "eq")) {
|
|
5895
|
+
return;
|
|
5896
|
+
}
|
|
5897
|
+
for (const f of query.filters) {
|
|
5898
|
+
if (f.identity === true || f.inputDerived || f.method !== "eq") continue;
|
|
5899
|
+
const v = values.get(f);
|
|
5900
|
+
const u = v ? unwrap(v) : void 0;
|
|
5901
|
+
if (!u || !ts11.isPropertyAccessExpression(u)) continue;
|
|
5902
|
+
const base = unwrap(u.expression);
|
|
5903
|
+
if (!ts11.isIdentifier(base) || normalizeColumn(u.name.text) !== normalizeColumn(f.column)) {
|
|
5904
|
+
continue;
|
|
5905
|
+
}
|
|
5906
|
+
const row = [...reads].reverse().find((r) => r.frame === frame.key && r.rows.has(base.text));
|
|
5907
|
+
if (!row) continue;
|
|
5908
|
+
const check = {
|
|
5909
|
+
method: "compare",
|
|
5910
|
+
column: u.name.text,
|
|
5911
|
+
valueText: `a ${query.table} row of the caller (${query.location.file}:${query.location.line})`,
|
|
5912
|
+
inputDerived: false,
|
|
5913
|
+
identity: true
|
|
5914
|
+
};
|
|
5915
|
+
row.checks.push(check);
|
|
5916
|
+
row.query.ownerChecks = [...row.query.ownerChecks ?? [], check];
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5679
5919
|
function childFrame(p, call, target, frame) {
|
|
5680
5920
|
const tsf = p.sources.get(target.facts.file);
|
|
5681
5921
|
if (!tsf) return null;
|
|
@@ -6618,7 +6858,10 @@ function tableDataOf(ctx, table2) {
|
|
|
6618
6858
|
return ctx.graph.nodes.get(`table:${table2}`)?.data;
|
|
6619
6859
|
}
|
|
6620
6860
|
function callerCheck(checks) {
|
|
6621
|
-
|
|
6861
|
+
const tying = (checks ?? []).filter(
|
|
6862
|
+
(c) => !c.inputDerived && (c.identity === true || isScopeColumn(c.column))
|
|
6863
|
+
);
|
|
6864
|
+
return tying.find((c) => c.bypass === void 0) ?? tying[0];
|
|
6622
6865
|
}
|
|
6623
6866
|
function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
6624
6867
|
const callerFilter = guard.filters.find(
|
|
@@ -6636,7 +6879,8 @@ function guardTiesRowToCaller(guard, table2, callerFns) {
|
|
|
6636
6879
|
return {
|
|
6637
6880
|
tied: true,
|
|
6638
6881
|
how: `its ${compared.column} compared with ${compared.valueText} in code, stopping otherwise`,
|
|
6639
|
-
why: ""
|
|
6882
|
+
why: "",
|
|
6883
|
+
...compared.bypass !== void 0 ? { bypass: compared.bypass } : {}
|
|
6640
6884
|
};
|
|
6641
6885
|
}
|
|
6642
6886
|
if (guard.client !== "user_scoped") {
|
|
@@ -6682,20 +6926,23 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6682
6926
|
(f) => isScopeColumn(f.column) || f.identity === true && !f.inputDerived
|
|
6683
6927
|
);
|
|
6684
6928
|
if (!idFilter || scoped) continue;
|
|
6685
|
-
|
|
6929
|
+
const own = q.operation === "select" ? callerCheck(q.ownerChecks) : void 0;
|
|
6930
|
+
if (own && own.bypass === void 0) continue;
|
|
6686
6931
|
const guard = q.guard;
|
|
6687
6932
|
const guardTable = guard?.parent ? tableDataOf(ctx, guard.table) : v.tableData;
|
|
6688
6933
|
const tied = guard ? guardTiesRowToCaller(guard, guardTable, callerFns) : null;
|
|
6689
|
-
if (guard && tied?.tied && guard.exitsWhenMissing) continue;
|
|
6934
|
+
if (guard && tied?.tied && guard.exitsWhenMissing && tied.bypass === void 0) continue;
|
|
6935
|
+
const bypassed = own ?? (guard && tied?.tied && guard.exitsWhenMissing ? callerCheck(guard.checks) : void 0);
|
|
6936
|
+
const bypass = bypassed?.bypass;
|
|
6690
6937
|
const guardWhat = guard?.parent ? `the parent row ${guard.parent.table}.${guard.parent.column} (${q.table}.${guard.column} refers to it by ${guard.parent.how})` : `the same "${guard?.column}"`;
|
|
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}.` : "";
|
|
6938
|
+
const guardNote = guard && tied && bypass === void 0 && (tied.tied || guard.client === "user_scoped") ? ` An earlier read of ${guardWhat} at ${guard.location.file}:${guard.location.line} (${tied.how}) could be an ownership check, but ${tied.tied ? "the entry point does not stop when it finds no row" : tied.why}.` : "";
|
|
6692
6939
|
const tableName = v.tableData?.table ?? q.table;
|
|
6693
6940
|
const authNote = h.authenticated ? "The handler authenticates the caller but never checks that the row belongs to them." : "The handler does not authenticate the caller at all.";
|
|
6694
6941
|
const gate = adminOnly(ctx, h, v.tableData);
|
|
6695
6942
|
const admin = gate.check;
|
|
6696
6943
|
const anonPolicy = q.operation === "select" ? anonReadPolicy(v.tableData) : void 0;
|
|
6697
|
-
const downgrade = admin || anonPolicy ? "medium" : void 0;
|
|
6698
|
-
const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "");
|
|
6944
|
+
const downgrade = admin || anonPolicy || bypass !== void 0 ? "medium" : void 0;
|
|
6945
|
+
const downgradeNote = (admin ? adminOnlyNote(admin, tableName) : "") + (gate.selfGrant ? ` The handler is behind a role check, but ${gate.selfGrant}.` : "") + (anonPolicy ? publicReadNote(anonPolicy, tableName) : "") + (bypassed && bypass !== void 0 ? ` The row's ${bypassed.column} is checked against the caller (${bypassed.valueText}), but ${bypass} lets the caller through without that check, so the finding is lowered rather than dropped: verify those roles belong to the service's staff and a user cannot grant them to themselves.` : "");
|
|
6699
6946
|
const path = [
|
|
6700
6947
|
h.data.kind === "server_action" ? "Server action call" : "HTTP request",
|
|
6701
6948
|
h.data.entry,
|
|
@@ -6721,18 +6968,20 @@ var serviceRoleObjectAccessWithoutTenantScope = {
|
|
|
6721
6968
|
authenticated: h.authenticated,
|
|
6722
6969
|
query: q.text,
|
|
6723
6970
|
...admin ? { adminOnly: true, roleCheck: admin.source } : {},
|
|
6724
|
-
...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {}
|
|
6971
|
+
...anonPolicy ? { anonReadPolicy: anonPolicy.name } : {},
|
|
6972
|
+
...bypass !== void 0 ? { roleBypass: bypass } : {}
|
|
6725
6973
|
}
|
|
6726
6974
|
},
|
|
6727
6975
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
6728
6976
|
];
|
|
6729
6977
|
const who = admin ? "Admin-only" : h.authenticated ? "Cross-tenant" : "Unauthenticated";
|
|
6978
|
+
const verify = admin ? " (verify the admin check)" : bypass !== void 0 ? " (verify the role exception)" : "";
|
|
6730
6979
|
out.push(
|
|
6731
6980
|
finding(
|
|
6732
6981
|
ctx,
|
|
6733
6982
|
this,
|
|
6734
6983
|
{
|
|
6735
|
-
title: `${who} ${q.operation} on "${tableName}" via ${clientNoun(v.clientData)}${
|
|
6984
|
+
title: `${who} ${q.operation} on "${tableName}" via ${clientNoun(v.clientData)}${verify}`,
|
|
6736
6985
|
entrypoints: [h.data.entry],
|
|
6737
6986
|
sources: h.inputs.map((i) => `${i.kind}:${i.name}`),
|
|
6738
6987
|
sinks: [`supabase.${q.operation}:public.${tableName}`],
|
|
@@ -7667,6 +7916,31 @@ function readsOnlyPublicRows(fn2, tables, fromMigrationsOnly) {
|
|
|
7667
7916
|
});
|
|
7668
7917
|
}
|
|
7669
7918
|
var API_ROLES2 = /* @__PURE__ */ new Set(["anon", "authenticated", "public"]);
|
|
7919
|
+
var NAMES_A_PERSON = /(^|_)(user|uid|profile|member|person|owner|customer|employee|student|patient|email)(_?id)?$/i;
|
|
7920
|
+
var CARRIES_CREDENTIAL = /(^|_)(pin|password|passwd|secret|token|otp|code|hash)$/i;
|
|
7921
|
+
function isCatalogRelation(name) {
|
|
7922
|
+
return name.startsWith("information_schema.") || name.startsWith("pg_catalog.") || /^pg_[a-z_]+$/.test(name);
|
|
7923
|
+
}
|
|
7924
|
+
function writesNothing(fn2, byName, seen = /* @__PURE__ */ new Set()) {
|
|
7925
|
+
if ((fn2.writes?.length ?? 0) > 0) return false;
|
|
7926
|
+
if (seen.has(fn2.name)) return true;
|
|
7927
|
+
seen.add(fn2.name);
|
|
7928
|
+
return (fn2.calls ?? []).every((c) => {
|
|
7929
|
+
const callee = byName.get(c);
|
|
7930
|
+
return callee !== void 0 && writesNothing(callee, byName, seen);
|
|
7931
|
+
});
|
|
7932
|
+
}
|
|
7933
|
+
function narrowResult(fn2, byName) {
|
|
7934
|
+
if (!writesNothing(fn2, byName)) return null;
|
|
7935
|
+
if (fn2.returns === "boolean") {
|
|
7936
|
+
if (fn2.params === void 0 && fn2.args !== "") return null;
|
|
7937
|
+
const about = (fn2.params ?? []).map((p) => p.name);
|
|
7938
|
+
return about.some((n) => NAMES_A_PERSON.test(n) || CARRIES_CREDENTIAL.test(n)) ? null : "boolean";
|
|
7939
|
+
}
|
|
7940
|
+
const read = fn2.tables ?? [];
|
|
7941
|
+
if (fn2.returns !== "void" && read.length > 0 && read.every(isCatalogRelation)) return "catalog";
|
|
7942
|
+
return null;
|
|
7943
|
+
}
|
|
7670
7944
|
var securityDefinerFunctionWithoutCallerCheck = {
|
|
7671
7945
|
id: "supabase.security-definer-function-without-caller-check",
|
|
7672
7946
|
title: "SECURITY DEFINER function without a caller check",
|
|
@@ -7684,6 +7958,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7684
7958
|
}
|
|
7685
7959
|
const tables = new Map(ctx.model.tables.map((t) => [t.table, t]));
|
|
7686
7960
|
const hasMigrations = ctx.model.files.some((f) => MIGRATION_SQL.test(f));
|
|
7961
|
+
const byName = new Map(fns.map((f) => [f.name, f]));
|
|
7687
7962
|
const out = [];
|
|
7688
7963
|
for (const fn2 of fns) {
|
|
7689
7964
|
if (!fn2.securityDefiner || fn2.checksCaller) continue;
|
|
@@ -7692,6 +7967,8 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7692
7967
|
const roles = fn2.grantedTo.filter((r) => API_ROLES2.has(r));
|
|
7693
7968
|
if (roles.length === 0) continue;
|
|
7694
7969
|
if (readsOnlyPublicRows(fn2, tables, hasMigrations)) continue;
|
|
7970
|
+
const narrow = narrowResult(fn2, byName);
|
|
7971
|
+
const narrowNote = narrow === "boolean" ? " It changes nothing and returns only true or false, so it is reported as medium: a yes/no answer about ids the caller supplies can still confirm that a record exists or that a user holds a role." : narrow === "catalog" ? " It changes nothing and reads only the system catalog, so it is reported as medium: it describes the schema, not anyone's rows." : "";
|
|
7695
7972
|
const anonymous = roles.includes("anon") || roles.includes("public");
|
|
7696
7973
|
const sites = rpcByName.get(fn2.name) ?? [];
|
|
7697
7974
|
const entries = unique(sites.map((s) => s.handlerData.entry));
|
|
@@ -7720,7 +7997,7 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7720
7997
|
evidence: [
|
|
7721
7998
|
{
|
|
7722
7999
|
kind: "rule",
|
|
7723
|
-
summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
|
|
8000
|
+
summary: `public.${fn2.name}() is SECURITY DEFINER: it runs with the rights of its owner and Row Level Security does not apply inside it. Its body never reads the caller's identity (auth.uid(), auth.jwt(), auth.email() or the request JWT), so whatever it returns or changes is available to every role that can execute it: ${roles.join(", ")}. ${who} at ${endpoint}.${callNote}${narrowNote} Filter by auth.uid() inside the function, make it SECURITY INVOKER, or revoke EXECUTE from public, anon and authenticated.`,
|
|
7724
8001
|
locations: locations3(
|
|
7725
8002
|
fn2.location,
|
|
7726
8003
|
...sites.flatMap((s) => [s.handler.location, s.query.location])
|
|
@@ -7729,13 +8006,14 @@ var securityDefinerFunctionWithoutCallerCheck = {
|
|
|
7729
8006
|
deterministic: false,
|
|
7730
8007
|
ruleId: this.id,
|
|
7731
8008
|
function: fn2.name,
|
|
7732
|
-
grantedTo: [...fn2.grantedTo]
|
|
8009
|
+
grantedTo: [...fn2.grantedTo],
|
|
8010
|
+
...narrow ? { narrowResult: narrow } : {}
|
|
7733
8011
|
}
|
|
7734
8012
|
},
|
|
7735
8013
|
{ kind: "trace", summary: path.join(" -> ") }
|
|
7736
8014
|
]
|
|
7737
8015
|
},
|
|
7738
|
-
anonymous ? "critical" : "high"
|
|
8016
|
+
narrow ? "medium" : anonymous ? "critical" : "high"
|
|
7739
8017
|
)
|
|
7740
8018
|
);
|
|
7741
8019
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "auditai-scan",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
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",
|