@vantageos/backend-doctor 0.4.0 → 0.4.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/build-stamp.json +2 -2
- package/dist/detectors/source/contract.js +10 -2
- package/dist/detectors/source/guard-helper-flow.d.ts +5 -1
- package/dist/detectors/source/guard-helper-flow.js +274 -10
- package/dist/detectors/source/identity.js +1 -1
- package/dist/detectors/source/isolation-read.d.ts +2 -0
- package/dist/detectors/source/isolation-read.js +557 -0
- package/dist/detectors/source/local-identity.d.ts +5 -0
- package/dist/detectors/source/local-identity.js +448 -6
- package/dist/trigger.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
// R-11, unbounded-read arm — the tenant boundary of an UNBOUNDED read is an
|
|
2
|
+
// index-range predicate inside the query, never a post-query handler filter.
|
|
3
|
+
//
|
|
4
|
+
// Standard bacc54a §4 R-11, read literally: "The boundary is an index-range
|
|
5
|
+
// predicate inside the query — `.withIndex("by_org", q => q.eq("orgId",
|
|
6
|
+
// identity.orgId))` — never a post-query handler filter … the query has already
|
|
7
|
+
// read rows the caller may not see, and correctness rests on a filter nothing
|
|
8
|
+
// enforces structurally — a leak and a cost at once." R-55's frontier hands this
|
|
9
|
+
// shape to R-11 by name: "an unbounded read has no window of its own, so a
|
|
10
|
+
// post-filter over it is R-11 and R-47".
|
|
11
|
+
//
|
|
12
|
+
// WHY A SECOND ARM. The line arm (contract.ts `scanR11`) judges a `.filter(` line
|
|
13
|
+
// whose predicate names a scope key. It cannot see the three cause-4 doors the
|
|
14
|
+
// door audit reproduced at vantage-peers 16f0907: the tenant predicate there is a
|
|
15
|
+
// helper call (`isNamespaceAllowedForScope`, `filterByOrgScope`) on a line that
|
|
16
|
+
// does not name a key, or there is no predicate at all (a streamed count).
|
|
17
|
+
//
|
|
18
|
+
// HOW IT READS. For every PUBLIC door in `convex/` (query, mutation, action) and
|
|
19
|
+
// every in-tree helper its handler reaches (call depth <= 4):
|
|
20
|
+
// read a `<…>.db.query(T)` chain that is UNBOUNDED: it ends in `.collect()`,
|
|
21
|
+
// or it is iterated by `for await (… of <chain>)` with no `.take`,
|
|
22
|
+
// `.paginate`, `.first` or `.unique` in it. A bounded read is R-55's.
|
|
23
|
+
// table T is a string literal, a const naming one, or a helper parameter
|
|
24
|
+
// bound at the call site to either. Anything else is could-not-judge.
|
|
25
|
+
// applies T carries a tenant column in `schema.ts` (orgId / tenantId /
|
|
26
|
+
// workspaceId / orgSlug, required or optional) or a `namespace`.
|
|
27
|
+
// conform an `.eq(K, …)` inside the chain's `.withIndex`/`.withSearchIndex`
|
|
28
|
+
// where K is a tenant column of T, or whose value reads the resolved
|
|
29
|
+
// caller (a value derived from `withOrgScope`/a resolver/an identity),
|
|
30
|
+
// or K is an id-reference column (`v.id("P")`): the read is then the
|
|
31
|
+
// children of ONE parent row, and whether the caller may reach that
|
|
32
|
+
// row is the door's by-id question (R-53, R-52), not R-11's locus;
|
|
33
|
+
// or the read is reached only by the fleet master: an earlier
|
|
34
|
+
// statement refuses every non-master caller (`requireResolvedCaller(…,
|
|
35
|
+
// { masterOnly: true })`, `if (!scope.isMaster) throw|return`, or an
|
|
36
|
+
// in-tree helper whose body does either first), or the read sits in
|
|
37
|
+
// the branch an `isMaster` test reserves for the master.
|
|
38
|
+
// red otherwise. The note says which harm: a post-query handler filter
|
|
39
|
+
// (a scope helper, or a test reading the caller or a tenant field,
|
|
40
|
+
// applied to the rows after the read) or no tenant predicate at all.
|
|
41
|
+
// cnj the table cannot be resolved, or there is no readable `schema.ts`.
|
|
42
|
+
//
|
|
43
|
+
// OUT OF SCOPE, stated. An internal function serves no caller; its reachability
|
|
44
|
+
// from a door through `ctx.runQuery` is not traced. A narrowing hidden behind a
|
|
45
|
+
// value the scan cannot trace is not seen. Where the value of an index `.eq` came
|
|
46
|
+
// from (an argument, a verified claim) is R-5 / R-6's question, not the locus.
|
|
47
|
+
import * as ts from "typescript";
|
|
48
|
+
import { indexFunctions, lineOf, NAMESPACE_COLUMN, parseFile, readSchema, registrations, TENANT_COLUMNS, unwrap, walk, } from "./_ast.js";
|
|
49
|
+
import { emptyScan, isJudgeable } from "./_scan.js";
|
|
50
|
+
const PUBLIC = new Set(["query", "mutation", "action"]);
|
|
51
|
+
const BOUNDING = new Set([
|
|
52
|
+
"take",
|
|
53
|
+
"paginate",
|
|
54
|
+
"first",
|
|
55
|
+
"unique",
|
|
56
|
+
]);
|
|
57
|
+
const INDEX_CALLS = new Set([
|
|
58
|
+
"withIndex",
|
|
59
|
+
"withSearchIndex",
|
|
60
|
+
]);
|
|
61
|
+
const MAX_DEPTH = 4;
|
|
62
|
+
/** A call whose result is the resolved caller (scope, principal, identity). */
|
|
63
|
+
const RESOLVER = /^(withOrgScope|getUserIdentity|resolve[A-Z]\w*|require[A-Z]\w*)$/;
|
|
64
|
+
/** A helper that narrows rows to the caller's scope after the read. */
|
|
65
|
+
const SCOPE_HELPER = /^(filterBy\w*|isRowVisibleToScope|isNamespaceAllowedForScope|\w*(ForScope|OnScope|ToScope|OnRoster|OnOrgRoster)\w*)$/;
|
|
66
|
+
const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
|
|
67
|
+
// ---- small readers -----------------------------------------------------------
|
|
68
|
+
function calleeIdent(c) {
|
|
69
|
+
const e = unwrap(c.expression);
|
|
70
|
+
if (ts.isIdentifier(e))
|
|
71
|
+
return e.text;
|
|
72
|
+
if (ts.isPropertyAccessExpression(e))
|
|
73
|
+
return e.name.text;
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
function plainCallee(c) {
|
|
77
|
+
const e = unwrap(c.expression);
|
|
78
|
+
return ts.isIdentifier(e) ? e.text : undefined;
|
|
79
|
+
}
|
|
80
|
+
/** `<x>.db.query(…)` / `db.query(…)`. */
|
|
81
|
+
function isDbQuery(c) {
|
|
82
|
+
const e = unwrap(c.expression);
|
|
83
|
+
if (!ts.isPropertyAccessExpression(e) || e.name.text !== "query")
|
|
84
|
+
return false;
|
|
85
|
+
const o = unwrap(e.expression);
|
|
86
|
+
return ((ts.isPropertyAccessExpression(o) && o.name.text === "db") ||
|
|
87
|
+
(ts.isIdentifier(o) && o.text === "db"));
|
|
88
|
+
}
|
|
89
|
+
function stringOf(e) {
|
|
90
|
+
const u = unwrap(e);
|
|
91
|
+
if (ts.isStringLiteralLike(u))
|
|
92
|
+
return u.text;
|
|
93
|
+
if (ts.isIdentifier(u)) {
|
|
94
|
+
const init = constInitOf(u);
|
|
95
|
+
if (init) {
|
|
96
|
+
const i = unwrap(init);
|
|
97
|
+
if (ts.isStringLiteralLike(i))
|
|
98
|
+
return i.text;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
/** nearest `const id = <init>` in an enclosing block or the file. */
|
|
104
|
+
function constInitOf(id) {
|
|
105
|
+
for (let x = id.parent; x; x = x.parent) {
|
|
106
|
+
const stmts = ts.isBlock(x) || ts.isSourceFile(x) ? x.statements : undefined;
|
|
107
|
+
if (!stmts)
|
|
108
|
+
continue;
|
|
109
|
+
for (const st of stmts) {
|
|
110
|
+
if (!ts.isVariableStatement(st))
|
|
111
|
+
continue;
|
|
112
|
+
if (!(st.declarationList.flags & ts.NodeFlags.Const))
|
|
113
|
+
continue;
|
|
114
|
+
for (const d of st.declarationList.declarations)
|
|
115
|
+
if (ts.isIdentifier(d.name) && d.name.text === id.text)
|
|
116
|
+
return d.initializer;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function identsIn(n) {
|
|
122
|
+
const out = new Set();
|
|
123
|
+
walk(n, (x) => {
|
|
124
|
+
if (!ts.isIdentifier(x))
|
|
125
|
+
return;
|
|
126
|
+
const p = x.parent;
|
|
127
|
+
if (p && ts.isPropertyAccessExpression(p) && p.name === x)
|
|
128
|
+
return;
|
|
129
|
+
if (p && ts.isPropertyAssignment(p) && p.name === x)
|
|
130
|
+
return;
|
|
131
|
+
out.add(x.text);
|
|
132
|
+
});
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
function readsAny(n, names) {
|
|
136
|
+
for (const i of identsIn(n))
|
|
137
|
+
if (names.has(i))
|
|
138
|
+
return true;
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
/** Names in `fn` holding the resolved caller: assigned from a resolver call, or
|
|
142
|
+
* derived from such a name (to a fixed point), or a loop variable over one. */
|
|
143
|
+
function callerNames(fn, seed) {
|
|
144
|
+
const out = new Set(seed);
|
|
145
|
+
const decls = [];
|
|
146
|
+
const bindingNames = (b) => ts.isIdentifier(b)
|
|
147
|
+
? [b.text]
|
|
148
|
+
: b.elements.flatMap((e) => ts.isOmittedExpression(e) ? [] : bindingNames(e.name));
|
|
149
|
+
walk(fn, (x) => {
|
|
150
|
+
if (ts.isVariableDeclaration(x) && x.initializer)
|
|
151
|
+
decls.push({ names: bindingNames(x.name), init: x.initializer });
|
|
152
|
+
else if ((ts.isForOfStatement(x) || ts.isForInStatement(x)) &&
|
|
153
|
+
ts.isVariableDeclarationList(x.initializer))
|
|
154
|
+
for (const d of x.initializer.declarations)
|
|
155
|
+
decls.push({ names: bindingNames(d.name), init: x.expression });
|
|
156
|
+
});
|
|
157
|
+
let grew = true;
|
|
158
|
+
while (grew) {
|
|
159
|
+
grew = false;
|
|
160
|
+
for (const d of decls) {
|
|
161
|
+
if (d.names.every((n) => out.has(n)))
|
|
162
|
+
continue;
|
|
163
|
+
let hit = readsAny(d.init, out);
|
|
164
|
+
if (!hit)
|
|
165
|
+
walk(d.init, (y) => {
|
|
166
|
+
if (ts.isCallExpression(y)) {
|
|
167
|
+
const c = calleeIdent(y);
|
|
168
|
+
if (c && RESOLVER.test(c))
|
|
169
|
+
hit = true;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (hit)
|
|
173
|
+
for (const n of d.names)
|
|
174
|
+
if (!out.has(n)) {
|
|
175
|
+
out.add(n);
|
|
176
|
+
grew = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
// ---- the fleet-master gate ---------------------------------------------------
|
|
183
|
+
/** `!x.isMaster` / `!isMaster`, or an `||` one of whose operands is one. */
|
|
184
|
+
function refusesNonMaster(cond) {
|
|
185
|
+
const c = unwrap(cond);
|
|
186
|
+
if (ts.isBinaryExpression(c) &&
|
|
187
|
+
c.operatorToken.kind === ts.SyntaxKind.BarBarToken)
|
|
188
|
+
return refusesNonMaster(c.left) || refusesNonMaster(c.right);
|
|
189
|
+
if (ts.isPrefixUnaryExpression(c) &&
|
|
190
|
+
c.operator === ts.SyntaxKind.ExclamationToken)
|
|
191
|
+
return isMasterRead(c.operand);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
function isMasterRead(e) {
|
|
195
|
+
const u = unwrap(e);
|
|
196
|
+
return ((ts.isIdentifier(u) && u.text === "isMaster") ||
|
|
197
|
+
(ts.isPropertyAccessExpression(u) && u.name.text === "isMaster"));
|
|
198
|
+
}
|
|
199
|
+
function exits(s) {
|
|
200
|
+
if (ts.isThrowStatement(s) || ts.isReturnStatement(s))
|
|
201
|
+
return true;
|
|
202
|
+
if (ts.isBlock(s)) {
|
|
203
|
+
const last = s.statements[s.statements.length - 1];
|
|
204
|
+
return last !== undefined && exits(last);
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
function hasMasterOnlyFlag(c) {
|
|
209
|
+
return c.arguments.some((a) => {
|
|
210
|
+
const u = unwrap(a);
|
|
211
|
+
return (ts.isObjectLiteralExpression(u) &&
|
|
212
|
+
u.properties.some((p) => ts.isPropertyAssignment(p) &&
|
|
213
|
+
ts.isIdentifier(p.name) &&
|
|
214
|
+
p.name.text === "masterOnly" &&
|
|
215
|
+
p.initializer.kind === ts.SyntaxKind.TrueKeyword));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Does this statement refuse every non-master caller before the next one runs? */
|
|
219
|
+
function isMasterGate(s, masterHelpers) {
|
|
220
|
+
if (ts.isIfStatement(s))
|
|
221
|
+
return refusesNonMaster(s.expression) && exits(s.thenStatement);
|
|
222
|
+
let call;
|
|
223
|
+
if (ts.isExpressionStatement(s))
|
|
224
|
+
call = s.expression;
|
|
225
|
+
else if (ts.isVariableStatement(s))
|
|
226
|
+
call = s.declarationList.declarations.find((d) => d.initializer)?.initializer;
|
|
227
|
+
if (!call)
|
|
228
|
+
return false;
|
|
229
|
+
const u = unwrap(call);
|
|
230
|
+
if (!ts.isCallExpression(u))
|
|
231
|
+
return false;
|
|
232
|
+
const name = plainCallee(u);
|
|
233
|
+
if (name === "requireResolvedCaller" && hasMasterOnlyFlag(u))
|
|
234
|
+
return true;
|
|
235
|
+
return name !== undefined && masterHelpers.has(name);
|
|
236
|
+
}
|
|
237
|
+
function bodyStatements(fn) {
|
|
238
|
+
return fn.body && ts.isBlock(fn.body) ? fn.body.statements : [];
|
|
239
|
+
}
|
|
240
|
+
/** In-tree functions whose body refuses every non-master caller (closed over calls). */
|
|
241
|
+
function masterOnlyHelpers(idx) {
|
|
242
|
+
const out = new Set();
|
|
243
|
+
let grew = true;
|
|
244
|
+
while (grew) {
|
|
245
|
+
grew = false;
|
|
246
|
+
for (const [n, ds] of idx) {
|
|
247
|
+
if (out.has(n))
|
|
248
|
+
continue;
|
|
249
|
+
if (ds.every((d) => bodyStatements(d.fn).some((s) => isMasterGate(s, out)))) {
|
|
250
|
+
out.add(n);
|
|
251
|
+
grew = true;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
/** Is `node` (inside `fn`) reached only by the fleet master within this frame? */
|
|
258
|
+
function masterOnlyAt(fn, node, masterHelpers) {
|
|
259
|
+
// an enclosing `isMaster` branch reserved for the master
|
|
260
|
+
for (let x = node; x && x !== fn; x = x.parent) {
|
|
261
|
+
const p = x.parent;
|
|
262
|
+
if (!p)
|
|
263
|
+
break;
|
|
264
|
+
if (ts.isIfStatement(p)) {
|
|
265
|
+
if (p.thenStatement === x && isMasterRead(p.expression))
|
|
266
|
+
return true;
|
|
267
|
+
if (p.elseStatement === x && refusesNonMaster(p.expression))
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (ts.isConditionalExpression(p)) {
|
|
271
|
+
if (p.whenTrue === x && isMasterRead(p.condition))
|
|
272
|
+
return true;
|
|
273
|
+
if (p.whenFalse === x && refusesNonMaster(p.condition))
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
// an earlier statement of any enclosing block that refuses non-masters
|
|
277
|
+
if (ts.isBlock(p) || ts.isSourceFile(p)) {
|
|
278
|
+
for (const s of p.statements) {
|
|
279
|
+
if (s === x)
|
|
280
|
+
break;
|
|
281
|
+
if (isMasterGate(s, masterHelpers))
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
function chainOf(q) {
|
|
289
|
+
const methods = [];
|
|
290
|
+
let top = q;
|
|
291
|
+
for (;;) {
|
|
292
|
+
const p = top.parent;
|
|
293
|
+
if (!p || !ts.isPropertyAccessExpression(p) || p.expression !== top)
|
|
294
|
+
break;
|
|
295
|
+
const call = p.parent;
|
|
296
|
+
if (!call || !ts.isCallExpression(call) || call.expression !== p)
|
|
297
|
+
break;
|
|
298
|
+
methods.push({ name: p.name.text, call });
|
|
299
|
+
top = call;
|
|
300
|
+
}
|
|
301
|
+
return { query: q, methods, top };
|
|
302
|
+
}
|
|
303
|
+
function unbounded(ch) {
|
|
304
|
+
if (ch.methods.some((m) => BOUNDING.has(m.name)))
|
|
305
|
+
return undefined;
|
|
306
|
+
if (ch.methods.some((m) => m.name === "collect"))
|
|
307
|
+
return "collect";
|
|
308
|
+
let x = ch.top;
|
|
309
|
+
while (x.parent &&
|
|
310
|
+
(ts.isParenthesizedExpression(x.parent) || ts.isAsExpression(x.parent)))
|
|
311
|
+
x = x.parent;
|
|
312
|
+
const p = x.parent;
|
|
313
|
+
if (p && ts.isForOfStatement(p) && p.expression === x && p.awaitModifier)
|
|
314
|
+
return "for await";
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
function tenantKeysOf(t) {
|
|
318
|
+
const out = new Set();
|
|
319
|
+
for (const f of t.fields)
|
|
320
|
+
if (TENANT_COLUMNS.has(f))
|
|
321
|
+
out.add(f);
|
|
322
|
+
if (t.namespace !== "none")
|
|
323
|
+
out.add(NAMESPACE_COLUMN);
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
/** An index `.eq` carrying a tenant key, a value read from the caller, or an
|
|
327
|
+
* id-reference column (the children of ONE parent row). */
|
|
328
|
+
function narrowsInQuery(ch, keys, caller, idRef) {
|
|
329
|
+
for (const m of ch.methods) {
|
|
330
|
+
if (!INDEX_CALLS.has(m.name))
|
|
331
|
+
continue;
|
|
332
|
+
let found;
|
|
333
|
+
walk(m.call, (x) => {
|
|
334
|
+
if (found || !ts.isCallExpression(x))
|
|
335
|
+
return;
|
|
336
|
+
if (calleeIdent(x) !== "eq" || x.arguments.length < 2)
|
|
337
|
+
return;
|
|
338
|
+
const k = stringOf(x.arguments[0]);
|
|
339
|
+
if (k !== undefined && keys.has(k))
|
|
340
|
+
found = `.${m.name} .eq("${k}")`;
|
|
341
|
+
else if (k !== undefined && idRef.has(k))
|
|
342
|
+
found = `.${m.name} .eq("${k}"), an id reference to ${idRef.get(k)}: the read is the children of one ${idRef.get(k)} row, and whether the caller may reach that row is the door's by-id question (R-53/R-52), not the locus`;
|
|
343
|
+
else if (readsAny(x.arguments[1], caller))
|
|
344
|
+
found = `.${m.name} .eq("${k ?? "?"}") on a value read from the resolved caller`;
|
|
345
|
+
});
|
|
346
|
+
if (found)
|
|
347
|
+
return found;
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
/** Is there a narrowing step applied to the rows after the read, in this frame? */
|
|
352
|
+
function postFilterAfter(fn, ch, keys, caller) {
|
|
353
|
+
const from = ch.top.getEnd();
|
|
354
|
+
const scope = unbounded(ch) === "for await"
|
|
355
|
+
? ch.top.parent
|
|
356
|
+
: (fn.body ?? fn);
|
|
357
|
+
let found;
|
|
358
|
+
walk(scope, (x) => {
|
|
359
|
+
if (found || x.getStart() < from)
|
|
360
|
+
return;
|
|
361
|
+
if (ts.isCallExpression(x)) {
|
|
362
|
+
const c = plainCallee(x);
|
|
363
|
+
if (c && SCOPE_HELPER.test(c)) {
|
|
364
|
+
found = `${c}(…)`;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (calleeIdent(x) === "filter" && x.arguments[0]) {
|
|
368
|
+
const cb = x.arguments[0];
|
|
369
|
+
let tenantField = false;
|
|
370
|
+
walk(cb, (y) => {
|
|
371
|
+
if (ts.isPropertyAccessExpression(y) && keys.has(y.name.text))
|
|
372
|
+
tenantField = true;
|
|
373
|
+
});
|
|
374
|
+
if (tenantField || readsAny(cb, caller))
|
|
375
|
+
found = ".filter(…) on the caller's scope";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
return found;
|
|
380
|
+
}
|
|
381
|
+
function resolveDef(idx, name, rel) {
|
|
382
|
+
const ds = idx.get(name);
|
|
383
|
+
if (!ds || ds.length === 0)
|
|
384
|
+
return undefined;
|
|
385
|
+
return ds.find((d) => d.rel === rel) ?? (ds.length === 1 ? ds[0] : undefined);
|
|
386
|
+
}
|
|
387
|
+
function tableRef(arg, frame) {
|
|
388
|
+
const s = stringOf(arg);
|
|
389
|
+
if (s !== undefined)
|
|
390
|
+
return { kind: "table", name: s };
|
|
391
|
+
const u = unwrap(arg);
|
|
392
|
+
if (ts.isIdentifier(u) && frame.tableOf.has(u.text))
|
|
393
|
+
return frame.tableOf.get(u.text);
|
|
394
|
+
return {
|
|
395
|
+
kind: "unknown",
|
|
396
|
+
why: `the table is \`${arg.getText()}\`, a value the scan cannot resolve to a table name`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function visitFrame(c, frame, depth) {
|
|
400
|
+
const caller = callerNames(frame.fn, frame.tainted);
|
|
401
|
+
walk(frame.fn, (x) => {
|
|
402
|
+
if (!ts.isCallExpression(x))
|
|
403
|
+
return;
|
|
404
|
+
if (isDbQuery(x)) {
|
|
405
|
+
judgeRead(c, frame, x, caller);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (depth >= MAX_DEPTH)
|
|
409
|
+
return;
|
|
410
|
+
const name = plainCallee(x);
|
|
411
|
+
if (!name)
|
|
412
|
+
return;
|
|
413
|
+
const def = resolveDef(c.idx, name, frame.rel);
|
|
414
|
+
if (!def || def.fn === frame.fn || frame.via.includes(name))
|
|
415
|
+
return;
|
|
416
|
+
const tableOf = new Map();
|
|
417
|
+
const tainted = new Set();
|
|
418
|
+
def.fn.parameters.forEach((p, i) => {
|
|
419
|
+
if (!ts.isIdentifier(p.name))
|
|
420
|
+
return;
|
|
421
|
+
const a = x.arguments[i];
|
|
422
|
+
if (!a)
|
|
423
|
+
return;
|
|
424
|
+
tableOf.set(p.name.text, tableRef(a, frame));
|
|
425
|
+
if (readsAny(a, caller))
|
|
426
|
+
tainted.add(p.name.text);
|
|
427
|
+
});
|
|
428
|
+
visitFrame(c, {
|
|
429
|
+
fn: def.fn,
|
|
430
|
+
rel: def.rel,
|
|
431
|
+
name,
|
|
432
|
+
tableOf,
|
|
433
|
+
tainted,
|
|
434
|
+
via: [...frame.via, name],
|
|
435
|
+
anchor: frame.anchor ?? x,
|
|
436
|
+
masterOnly: frame.masterOnly || masterOnlyAt(frame.fn, x, c.masterHelpers),
|
|
437
|
+
}, depth + 1);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function judgeRead(c, frame, q, caller) {
|
|
441
|
+
const ch = chainOf(q);
|
|
442
|
+
const kind = unbounded(ch);
|
|
443
|
+
if (!kind || !q.arguments[0])
|
|
444
|
+
return;
|
|
445
|
+
const anchor = frame.anchor ?? q;
|
|
446
|
+
const line = lineOf(anchor);
|
|
447
|
+
const rel = anchor.getSourceFile().fileName;
|
|
448
|
+
const via = frame.via.length
|
|
449
|
+
? ` via ${frame.via.join(" -> ")} (${frame.rel}:${lineOf(q)})`
|
|
450
|
+
: "";
|
|
451
|
+
const ref = tableRef(q.arguments[0], frame);
|
|
452
|
+
const key = `${c.door}|${line}|${ref.kind === "table" ? ref.name : "?"}`;
|
|
453
|
+
if (c.seen.has(key))
|
|
454
|
+
return;
|
|
455
|
+
c.seen.add(key);
|
|
456
|
+
const master = frame.masterOnly || masterOnlyAt(frame.fn, q, c.masterHelpers);
|
|
457
|
+
const at = `${c.door}: unbounded ${kind} read`;
|
|
458
|
+
if (ref.kind === "unknown") {
|
|
459
|
+
if (master)
|
|
460
|
+
return;
|
|
461
|
+
c.scan.cnj.push({
|
|
462
|
+
rel,
|
|
463
|
+
n: line,
|
|
464
|
+
note: `${at}${via} — ${ref.why}; whether it carries a tenant column is not decided`,
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (!c.schema) {
|
|
469
|
+
c.scan.cnj.push({
|
|
470
|
+
rel,
|
|
471
|
+
n: line,
|
|
472
|
+
note: `${at} of ${ref.name}${via} — no readable convex/schema.ts, so whether ${ref.name} carries a tenant column is not decided`,
|
|
473
|
+
});
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const t = c.schema.get(ref.name);
|
|
477
|
+
if (!t) {
|
|
478
|
+
c.scan.cnj.push({
|
|
479
|
+
rel,
|
|
480
|
+
n: line,
|
|
481
|
+
note: `${at} of ${ref.name}${via} — ${ref.name} is not a table of convex/schema.ts`,
|
|
482
|
+
});
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const keys = tenantKeysOf(t);
|
|
486
|
+
if (keys.size === 0)
|
|
487
|
+
return; // no tenant column: not an isolation site
|
|
488
|
+
if (master) {
|
|
489
|
+
c.scan.conform.push({
|
|
490
|
+
rel,
|
|
491
|
+
n: line,
|
|
492
|
+
note: `${at} of ${ref.name}${via} on a fleet-master-only path (every non-master caller is refused before the read)`,
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const inQuery = narrowsInQuery(ch, keys, caller, t.idRef);
|
|
497
|
+
if (inQuery) {
|
|
498
|
+
c.scan.conform.push({
|
|
499
|
+
rel,
|
|
500
|
+
n: line,
|
|
501
|
+
note: `${at} of ${ref.name}${via} — narrowed by an index range inside the query (${inQuery})`,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const post = postFilterAfter(frame.fn, ch, keys, caller);
|
|
506
|
+
const tenant = [...keys].join("/");
|
|
507
|
+
c.scan.violate.push({
|
|
508
|
+
rel,
|
|
509
|
+
n: line,
|
|
510
|
+
note: post
|
|
511
|
+
? `${at} of ${ref.name}${via} with no index range on ${tenant}; the tenant predicate is a post-query handler filter (${post}) — every row of ${ref.name} the index admits is read before the caller's scope is applied (R-11 defect)`
|
|
512
|
+
: `${at} of ${ref.name}${via} with no index range on ${tenant} and no tenant predicate at all, on a door a non-master caller is served — the result is computed over every tenant's rows (R-11 defect)`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
export function scanR11UnboundedReads(files) {
|
|
516
|
+
const scan = emptyScan();
|
|
517
|
+
const parsed = [];
|
|
518
|
+
for (const f of files) {
|
|
519
|
+
if (!isJudgeable(f) || !isConvexFile(f))
|
|
520
|
+
continue;
|
|
521
|
+
try {
|
|
522
|
+
parsed.push(parseFile(f));
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
// an unparsable file yields no site here; other rules report parse failures
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const schemaFile = parsed.find((p) => /(^|\/)convex\/schema\.ts$/.test(p.rel));
|
|
529
|
+
const schema = schemaFile ? readSchema(schemaFile) : undefined;
|
|
530
|
+
const idx = indexFunctions(parsed);
|
|
531
|
+
const masterHelpers = masterOnlyHelpers(idx);
|
|
532
|
+
for (const p of parsed) {
|
|
533
|
+
for (const r of registrations(p)) {
|
|
534
|
+
if (!PUBLIC.has(r.builder))
|
|
535
|
+
continue;
|
|
536
|
+
const door = r.name;
|
|
537
|
+
visitFrame({
|
|
538
|
+
schema: schema && schema.size > 0 ? schema : undefined,
|
|
539
|
+
idx,
|
|
540
|
+
masterHelpers,
|
|
541
|
+
scan,
|
|
542
|
+
door,
|
|
543
|
+
seen: new Set(),
|
|
544
|
+
}, {
|
|
545
|
+
fn: r.handler,
|
|
546
|
+
rel: p.rel,
|
|
547
|
+
name: door,
|
|
548
|
+
tableOf: new Map(),
|
|
549
|
+
tainted: new Set(),
|
|
550
|
+
via: [],
|
|
551
|
+
anchor: undefined,
|
|
552
|
+
masterOnly: false,
|
|
553
|
+
}, 0);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return scan;
|
|
557
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
1
2
|
import type { Detector, DetectorResult } from "../../engine-types.js";
|
|
2
3
|
import { type Site, type SrcFile } from "./_scan.js";
|
|
3
4
|
export declare const CR2_ID = "CR-2 no application defines its own identity primitive (tenant resolution, membership, scope filtering, credential validation, identification by name); identity lives only in @vantageos/cloud-identity";
|
|
@@ -18,6 +19,10 @@ export interface LocalIdentityScan {
|
|
|
18
19
|
exempt: Site[];
|
|
19
20
|
cnj: Site[];
|
|
20
21
|
}
|
|
22
|
+
export type DoorCheck = (call: ts.CallExpression) => boolean;
|
|
23
|
+
/** Every reference to `name` in the tree (outside its declaration, imports and
|
|
24
|
+
* exports) is a call whose result goes into a call argument; at least one. */
|
|
25
|
+
export type CallersForward = (name: string) => boolean;
|
|
21
26
|
export declare function scanLocalIdentity(files: SrcFile[]): LocalIdentityScan;
|
|
22
27
|
export declare function localIdentityResult(scan: LocalIdentityScan): DetectorResult;
|
|
23
28
|
/** The rendered section: EVERY violation, exemption and abstention is named. */
|