@vantageos/backend-doctor 0.2.0 → 0.4.0
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/README.md +57 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +19 -1
- package/dist/build-stamp.json +2 -2
- package/dist/caller-resolution.js +11 -8
- package/dist/cli.js +164 -15
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +5 -4
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +32 -16
- package/dist/detectors/predicates.js +12 -3
- package/dist/detectors/shared.js +3 -2
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +62 -17
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/credential-hygiene.d.ts +11 -0
- package/dist/detectors/source/credential-hygiene.js +619 -0
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
- package/dist/detectors/source/guard-helper-flow.js +49 -6
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +8 -1
- package/dist/detectors/source/identity.js +364 -35
- package/dist/detectors/source/jobs.js +13 -2
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +666 -0
- package/dist/detectors/source/narrow-then-bound.d.ts +11 -0
- package/dist/detectors/source/narrow-then-bound.js +571 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.js +291 -0
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.js +455 -26
- package/dist/detectors/source/service-account-claim.d.ts +15 -0
- package/dist/detectors/source/service-account-claim.js +491 -0
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.js +3 -2
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +75 -43
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +12 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +55 -3
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +14 -6
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/backend-standard-bacc54a.md +785 -0
- package/fixtures/rule-classes-bacc54a.json +244 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
// R-52 — a write that reaches a row carrying no tenant value, through a value the
|
|
2
|
+
// caller can choose, checks the tenant on that target before it writes.
|
|
3
|
+
//
|
|
4
|
+
// Standard 2825122 §4 (R-52) and its Detector spec, read literally:
|
|
5
|
+
// reads schema.ts — per table, whether a tenant column (orgId/tenantId/
|
|
6
|
+
// workspaceId/orgSlug, or a namespace checked against the caller's
|
|
7
|
+
// allowed prefixes) exists and whether it is optional; per table, which
|
|
8
|
+
// fields a public write sets from its arguments (what makes a stored
|
|
9
|
+
// field caller-chosen); every public mutation/action handler; per write
|
|
10
|
+
// (ctx.db.patch/replace/delete/insert, a scheduled or run effect) the
|
|
11
|
+
// provenance of the id or key that selected its target.
|
|
12
|
+
// red a write whose target carries no tenant value, selected by an
|
|
13
|
+
// argument-derived value or a caller-chosen stored field, with no guard
|
|
14
|
+
// on its path that reads a server-stamped tenant and refuses or skips it.
|
|
15
|
+
// green such a guard dominates the write; or the selector is not
|
|
16
|
+
// caller-choosable; or the target has a REQUIRED tenant column.
|
|
17
|
+
// cnj a selector whose provenance the walk cannot resolve, or an
|
|
18
|
+
// unreadable schema — never a readable site.
|
|
19
|
+
// No justification-token exit. Coverage fails on itself.
|
|
20
|
+
//
|
|
21
|
+
// HOW THE WALK DECIDES (each piece is read from the delivered source):
|
|
22
|
+
// * the namespace column — a tenant column only where it is prefix-checked
|
|
23
|
+
// (ElPi-Corp #228). Per table carrying a `namespace` and no required tenant
|
|
24
|
+
// column, every public query/mutation/action that reads or writes it is
|
|
25
|
+
// read: it is checked when it calls a prefix test (a function taking a
|
|
26
|
+
// namespace parameter and testing it with `.startsWith`, closed over calls —
|
|
27
|
+
// vantage-peers `isNamespaceAllowedForScope`, convex/lib/auth.ts) or refuses
|
|
28
|
+
// every non-master. All doors checked -> tenant column; none -> untenanted;
|
|
29
|
+
// mixed, or no public door -> every site targeting it is could-not-judge.
|
|
30
|
+
// * provenance — every local of a handler is evaluated from its initializer:
|
|
31
|
+
// `args.x` is an argument; `row.f` of a table T is a caller-chosen stored field
|
|
32
|
+
// when some public write sets T.f from its arguments (insert literal, `...args`
|
|
33
|
+
// spread, patch literal through an `args.<id>`); a row fetched or queried by a
|
|
34
|
+
// caller-choosable key carries that provenance into every value read off it —
|
|
35
|
+
// `mapping.repo` of a `githubRepoMapping` row looked up by `task.project` is
|
|
36
|
+
// caller-choosable, and so is the `issues` row it then selects.
|
|
37
|
+
// * the target — a patch/replace/delete reaches the row its selector names; an
|
|
38
|
+
// insert or a scheduled/run effect is ATTRIBUTED to a selected row when its
|
|
39
|
+
// payload, or a condition enclosing it, reads that row.
|
|
40
|
+
// * the guard — it must DOMINATE the write: an enclosing `if`/`&&` gate, an
|
|
41
|
+
// earlier `if (…) throw|return|continue`, or an earlier call to a refusing
|
|
42
|
+
// helper. What it must read depends on the target:
|
|
43
|
+
// - optional tenant column (the row may be unstamped): the TARGET'S OWN
|
|
44
|
+
// tenant, directly (`row.orgId`) or through a helper handed the target row;
|
|
45
|
+
// - no tenant column: the caller's or the source row's server-stamped tenant
|
|
46
|
+
// AND a fleet-scope decision (the standard: "a row with no tenant value is
|
|
47
|
+
// reachable only by a tenant the code names as fleet-scoped") — read as an
|
|
48
|
+
// `isMaster`/`orgKind`/operator/fleet test, or an unconditional master-only
|
|
49
|
+
// refusal (`if (!scope.isMaster) throw`). A visibility check on a DIFFERENT
|
|
50
|
+
// row (the source task's own tenant gate) is not a check on the target.
|
|
51
|
+
import * as ts from "typescript";
|
|
52
|
+
import { calledNames, calleeName, closeOverCalls, dotted, indexFunctions, lineOf, NAMESPACE_COLUMN, parseFile, readSchema, registrations, TENANT_COLUMNS, unwrap, walk, } from "./_ast.js";
|
|
53
|
+
import { decide, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
|
|
54
|
+
import { possessionGate } from "./r6-shapes.js";
|
|
55
|
+
export const R52_ID = "R-52 a write reaching a row with no tenant value through a caller-choosable value checks the target's tenant first";
|
|
56
|
+
const R52_CHECK = "a patch/replace/delete, an attributed insert or a scheduled effect whose target carries no tenant value and is selected by an argument or a caller-chosen stored field is dominated by a guard reading a server-stamped tenant";
|
|
57
|
+
/** Array methods whose result holds the receiver's own elements. */
|
|
58
|
+
const PRESERVING = new Set([
|
|
59
|
+
"slice",
|
|
60
|
+
"concat",
|
|
61
|
+
"reverse",
|
|
62
|
+
"sort",
|
|
63
|
+
"toSorted",
|
|
64
|
+
"toReversed",
|
|
65
|
+
]);
|
|
66
|
+
const ITERATORS = new Set([
|
|
67
|
+
"map",
|
|
68
|
+
"forEach",
|
|
69
|
+
"filter",
|
|
70
|
+
"find",
|
|
71
|
+
"some",
|
|
72
|
+
"every",
|
|
73
|
+
"flatMap",
|
|
74
|
+
"findLast",
|
|
75
|
+
]);
|
|
76
|
+
const SAFE = { kind: "value", vias: [] };
|
|
77
|
+
function mergeVias(a, b) {
|
|
78
|
+
const out = [...a];
|
|
79
|
+
for (const v of b)
|
|
80
|
+
if (!out.some((x) => x.table === v.table && x.line === v.line))
|
|
81
|
+
out.push(v);
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** Both provenances, when the two halves name different ones (capped). */
|
|
85
|
+
function joinReasons(a, b) {
|
|
86
|
+
if (!a)
|
|
87
|
+
return b;
|
|
88
|
+
if (!b || a.includes(b))
|
|
89
|
+
return a;
|
|
90
|
+
if (a.split(" + ").length >= 3)
|
|
91
|
+
return a;
|
|
92
|
+
return `${a} + ${b}`;
|
|
93
|
+
}
|
|
94
|
+
function merge(a, b) {
|
|
95
|
+
return {
|
|
96
|
+
kind: "value",
|
|
97
|
+
choosable: joinReasons(a.choosable, b.choosable),
|
|
98
|
+
vias: mergeVias(a.vias, b.vias),
|
|
99
|
+
unresolved: a.unresolved ?? b.unresolved,
|
|
100
|
+
gate: a.gate || b.gate
|
|
101
|
+
? {
|
|
102
|
+
tables: new Set([
|
|
103
|
+
...(a.gate?.tables ?? []),
|
|
104
|
+
...(b.gate?.tables ?? []),
|
|
105
|
+
]),
|
|
106
|
+
fleet: Boolean(a.gate?.fleet || b.gate?.fleet),
|
|
107
|
+
}
|
|
108
|
+
: undefined,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// ---- fleet scope ----------------------------------------------------------
|
|
112
|
+
//
|
|
113
|
+
// "A row with no tenant value is reachable only by a tenant the code names as
|
|
114
|
+
// fleet-scoped." A condition is FLEET-ONLY when it can hold for no caller but a
|
|
115
|
+
// fleet-scoped one: a positive `isMaster` read, an `orgKind ===` test, a tenant
|
|
116
|
+
// column tested `=== undefined` (the unstamped, fleet-created row), a call to a
|
|
117
|
+
// fleet-only helper or a local bound to one; a `||` chain needs EVERY disjunct
|
|
118
|
+
// fleet-only, a `&&` chain needs ONE conjunct. A name allow-list
|
|
119
|
+
// (`allowedOrchestrators.includes(name)`) is not a fleet decision.
|
|
120
|
+
function isUndefinedLit(e) {
|
|
121
|
+
const u = unwrap(e);
|
|
122
|
+
return ts.isIdentifier(u) && u.text === "undefined";
|
|
123
|
+
}
|
|
124
|
+
export function fleetOnlyExpr(e0, isFleetHelper, isFleetLocal = () => false) {
|
|
125
|
+
const e = unwrap(e0);
|
|
126
|
+
const rec = (x) => fleetOnlyExpr(x, isFleetHelper, isFleetLocal);
|
|
127
|
+
if (e.kind === ts.SyntaxKind.FalseKeyword ||
|
|
128
|
+
e.kind === ts.SyntaxKind.NullKeyword ||
|
|
129
|
+
isUndefinedLit(e))
|
|
130
|
+
return true;
|
|
131
|
+
if (ts.isBinaryExpression(e)) {
|
|
132
|
+
const op = e.operatorToken.kind;
|
|
133
|
+
if (op === ts.SyntaxKind.BarBarToken)
|
|
134
|
+
return rec(e.left) && rec(e.right);
|
|
135
|
+
if (op === ts.SyntaxKind.AmpersandAmpersandToken)
|
|
136
|
+
return rec(e.left) || rec(e.right);
|
|
137
|
+
if (op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
138
|
+
op === ts.SyntaxKind.EqualsEqualsToken) {
|
|
139
|
+
const sides = [e.left, e.right].map(unwrap);
|
|
140
|
+
if (sides.some((x) => ts.isPropertyAccessExpression(x) && x.name.text === "orgKind"))
|
|
141
|
+
return true;
|
|
142
|
+
if (sides.some((x) => ts.isPropertyAccessExpression(x) && TENANT_COLUMNS.has(x.name.text)) &&
|
|
143
|
+
sides.some(isUndefinedLit))
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (ts.isPropertyAccessExpression(e))
|
|
149
|
+
return e.name.text === "isMaster";
|
|
150
|
+
if (ts.isIdentifier(e))
|
|
151
|
+
return e.text === "isMaster" || isFleetLocal(e.text);
|
|
152
|
+
if (ts.isCallExpression(e)) {
|
|
153
|
+
const f = unwrap(e.expression);
|
|
154
|
+
return ts.isIdentifier(f) && isFleetHelper(f.text);
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
/** A helper every truthy `return` of which is fleet-only: a fleet-only
|
|
159
|
+
* expression, or a bare `true` inside an `if` whose condition is fleet-only. */
|
|
160
|
+
function fleetOnlyFunction(fn, known) {
|
|
161
|
+
const body = fn.body;
|
|
162
|
+
if (!body || !ts.isBlock(body))
|
|
163
|
+
return false;
|
|
164
|
+
const isHelper = (n) => known.has(n);
|
|
165
|
+
let returns = 0;
|
|
166
|
+
let ok = true;
|
|
167
|
+
walk(body, (x) => {
|
|
168
|
+
if (!ts.isReturnStatement(x))
|
|
169
|
+
return;
|
|
170
|
+
returns++;
|
|
171
|
+
const r = x.expression;
|
|
172
|
+
if (!r) {
|
|
173
|
+
ok = false;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (fleetOnlyExpr(r, isHelper))
|
|
177
|
+
return;
|
|
178
|
+
if (unwrap(r).kind === ts.SyntaxKind.TrueKeyword) {
|
|
179
|
+
let c = x;
|
|
180
|
+
for (let p = x.parent; p && p !== body; c = p, p = p.parent)
|
|
181
|
+
if (ts.isIfStatement(p) && p.thenStatement === c) {
|
|
182
|
+
if (fleetOnlyExpr(p.expression, isHelper))
|
|
183
|
+
return;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
ok = false;
|
|
188
|
+
}, true);
|
|
189
|
+
return ok && returns > 0;
|
|
190
|
+
}
|
|
191
|
+
/** `if (C) refuse` admits only a fleet caller past it: C is `!F` with F
|
|
192
|
+
* fleet-only, or a `||` chain one of whose disjuncts is. */
|
|
193
|
+
function refusesNonFleet(c0, isFleetHelper, isFleetLocal) {
|
|
194
|
+
const c = unwrap(c0);
|
|
195
|
+
if (ts.isBinaryExpression(c) &&
|
|
196
|
+
c.operatorToken.kind === ts.SyntaxKind.BarBarToken)
|
|
197
|
+
return (refusesNonFleet(c.left, isFleetHelper, isFleetLocal) ||
|
|
198
|
+
refusesNonFleet(c.right, isFleetHelper, isFleetLocal));
|
|
199
|
+
if (ts.isPrefixUnaryExpression(c) &&
|
|
200
|
+
c.operator === ts.SyntaxKind.ExclamationToken)
|
|
201
|
+
return fleetOnlyExpr(c.operand, isFleetHelper, isFleetLocal);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
function isConvexFile(f) {
|
|
205
|
+
return /(^|\/)convex\//.test(f.rel) && f.rel.endsWith(".ts");
|
|
206
|
+
}
|
|
207
|
+
/** A property access reading a tenant column off anything but the arguments. */
|
|
208
|
+
function tenantReadsIn(n) {
|
|
209
|
+
const out = [];
|
|
210
|
+
walk(n, (x) => {
|
|
211
|
+
if (ts.isPropertyAccessExpression(x) &&
|
|
212
|
+
TENANT_COLUMNS.has(x.name.text) &&
|
|
213
|
+
!/^args\b/.test(dotted(x.expression) ?? ""))
|
|
214
|
+
out.push(x);
|
|
215
|
+
});
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function ownThrows(n) {
|
|
219
|
+
let t = false;
|
|
220
|
+
walk(n, (x) => {
|
|
221
|
+
if (ts.isThrowStatement(x))
|
|
222
|
+
t = true;
|
|
223
|
+
}, true);
|
|
224
|
+
return t;
|
|
225
|
+
}
|
|
226
|
+
function exitsIn(n) {
|
|
227
|
+
let t = false;
|
|
228
|
+
walk(n, (x) => {
|
|
229
|
+
if (ts.isThrowStatement(x) ||
|
|
230
|
+
ts.isReturnStatement(x) ||
|
|
231
|
+
ts.isContinueStatement(x))
|
|
232
|
+
t = true;
|
|
233
|
+
}, true);
|
|
234
|
+
return t;
|
|
235
|
+
}
|
|
236
|
+
/** True when the condition holds for EVERY non-master caller: `!x.isMaster`,
|
|
237
|
+
* `x.isMaster === false`, or a `||` chain one of whose disjuncts is that. A
|
|
238
|
+
* conjunction (`a && !x.isMaster`) refuses only some non-masters and is not it. */
|
|
239
|
+
function refusesNonMaster(c0) {
|
|
240
|
+
const c = unwrap(c0);
|
|
241
|
+
if (ts.isBinaryExpression(c) &&
|
|
242
|
+
c.operatorToken.kind === ts.SyntaxKind.BarBarToken)
|
|
243
|
+
return refusesNonMaster(c.left) || refusesNonMaster(c.right);
|
|
244
|
+
const isMasterRead = (e) => {
|
|
245
|
+
const u = unwrap(e);
|
|
246
|
+
return ((ts.isPropertyAccessExpression(u) && u.name.text === "isMaster") ||
|
|
247
|
+
(ts.isIdentifier(u) && u.text === "isMaster"));
|
|
248
|
+
};
|
|
249
|
+
if (ts.isPrefixUnaryExpression(c) &&
|
|
250
|
+
c.operator === ts.SyntaxKind.ExclamationToken)
|
|
251
|
+
return isMasterRead(c.operand);
|
|
252
|
+
if (ts.isBinaryExpression(c) &&
|
|
253
|
+
(c.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
254
|
+
c.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken) &&
|
|
255
|
+
c.right.kind === ts.SyntaxKind.FalseKeyword)
|
|
256
|
+
return isMasterRead(c.left);
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
/** `if (!x.isMaster) throw` (or `isMaster === false`) in the function's own body. */
|
|
260
|
+
function ownMasterRefusal(fn) {
|
|
261
|
+
let hit = false;
|
|
262
|
+
walk(fn, (x) => {
|
|
263
|
+
if (!ts.isIfStatement(x))
|
|
264
|
+
return;
|
|
265
|
+
if (refusesNonMaster(x.expression) && ownThrows(x.thenStatement))
|
|
266
|
+
hit = true;
|
|
267
|
+
}, true);
|
|
268
|
+
return hit;
|
|
269
|
+
}
|
|
270
|
+
/** Calls made UNCONDITIONALLY: in the function body's own top-level statements. */
|
|
271
|
+
function topLevelCalls(fn) {
|
|
272
|
+
const out = new Set();
|
|
273
|
+
const body = fn.body;
|
|
274
|
+
if (!body || !ts.isBlock(body))
|
|
275
|
+
return out;
|
|
276
|
+
for (const st of body.statements) {
|
|
277
|
+
if (ts.isIfStatement(st))
|
|
278
|
+
continue;
|
|
279
|
+
walk(st, (x) => {
|
|
280
|
+
if (ts.isCallExpression(x)) {
|
|
281
|
+
const e = unwrap(x.expression);
|
|
282
|
+
if (ts.isIdentifier(e))
|
|
283
|
+
out.add(e.text);
|
|
284
|
+
}
|
|
285
|
+
}, true);
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
/** A prefix check of its own: the function takes a namespace-named parameter
|
|
290
|
+
* and tests it with `.startsWith(…)` (vantage-peers `isNamespaceAllowedForScope`,
|
|
291
|
+
* convex/lib/auth.ts). Read from the shape, never from the helper's name. */
|
|
292
|
+
function ownPrefixTest(fn) {
|
|
293
|
+
const params = new Set(fn.parameters
|
|
294
|
+
.map((p) => (ts.isIdentifier(p.name) ? p.name.text : ""))
|
|
295
|
+
.filter((n) => /namespace/i.test(n)));
|
|
296
|
+
if (params.size === 0)
|
|
297
|
+
return false;
|
|
298
|
+
let hit = false;
|
|
299
|
+
walk(fn, (x) => {
|
|
300
|
+
if (ts.isCallExpression(x) &&
|
|
301
|
+
ts.isPropertyAccessExpression(x.expression) &&
|
|
302
|
+
x.expression.name.text === "startsWith" &&
|
|
303
|
+
ts.isIdentifier(unwrap(x.expression.expression)) &&
|
|
304
|
+
params.has(unwrap(x.expression.expression).text))
|
|
305
|
+
hit = true;
|
|
306
|
+
});
|
|
307
|
+
return hit;
|
|
308
|
+
}
|
|
309
|
+
/** Every `ctx.db.query("T")` / `ctx.db.insert("T", …)` in the node, plus, given
|
|
310
|
+
* the handler's `v.id` arguments, every `db.get/patch/replace/delete(args.k)`
|
|
311
|
+
* whose `k` is a `v.id("T")` — an id argument that is only STORED (a list of
|
|
312
|
+
* linked ids) reaches no row of T and is not a touch. */
|
|
313
|
+
function touchSites(n, table, argIdTable) {
|
|
314
|
+
const out = [];
|
|
315
|
+
walk(n, (x) => {
|
|
316
|
+
if (!ts.isCallExpression(x) || !x.arguments[0])
|
|
317
|
+
return;
|
|
318
|
+
const a0 = x.arguments[0];
|
|
319
|
+
const c = calleeName(x);
|
|
320
|
+
if ((c === "query" || c === "insert") &&
|
|
321
|
+
ts.isStringLiteralLike(a0) &&
|
|
322
|
+
a0.text === table)
|
|
323
|
+
out.push(x);
|
|
324
|
+
else if (argIdTable &&
|
|
325
|
+
/\bdb\.(get|patch|replace|delete)$/.test(dotted(x.expression) ?? "")) {
|
|
326
|
+
const m = /^args\.(\w+)$/.exec(dotted(a0) ?? "");
|
|
327
|
+
if (m && argIdTable.get(m[1]) === table)
|
|
328
|
+
out.push(x);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
const ownTouches = (n, table) => touchSites(n, table).length > 0;
|
|
334
|
+
/** The site runs only on the TRUE side of a fleet-only condition, up to the
|
|
335
|
+
* handler: `isMaster ? <site> : []`, `if (isMaster) { <site> }`, `isMaster && <site>`. */
|
|
336
|
+
function fleetGated(site, stop, isFleetHelper) {
|
|
337
|
+
let child = site;
|
|
338
|
+
for (let x = site.parent; x && x !== stop; child = x, x = x.parent) {
|
|
339
|
+
const c = ts.isIfStatement(x)
|
|
340
|
+
? x.thenStatement === child
|
|
341
|
+
? x.expression
|
|
342
|
+
: undefined
|
|
343
|
+
: ts.isConditionalExpression(x)
|
|
344
|
+
? x.whenTrue === child
|
|
345
|
+
? x.condition
|
|
346
|
+
: undefined
|
|
347
|
+
: ts.isBinaryExpression(x) &&
|
|
348
|
+
x.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
|
|
349
|
+
x.right === child
|
|
350
|
+
? x.left
|
|
351
|
+
: undefined;
|
|
352
|
+
if (c && fleetOnlyExpr(c, isFleetHelper))
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
/** Does an argument carry a NAMESPACE VALUE rather than a constant? It reads a
|
|
358
|
+
* namespace-named field (`args.namespace`, `row.namespace`, `e.namespace`), or
|
|
359
|
+
* is a local / destructured binding / callback parameter that does. A literal,
|
|
360
|
+
* a template with no substitution, or a local initialised to one, does not:
|
|
361
|
+
* a prefix test on a constant bounds nothing the caller reads or writes. */
|
|
362
|
+
function derivesNamespace(e0, scope, depth = 0) {
|
|
363
|
+
if (depth > 4)
|
|
364
|
+
return false;
|
|
365
|
+
const e = unwrap(e0);
|
|
366
|
+
if (ts.isPropertyAccessExpression(e))
|
|
367
|
+
return /namespace/i.test(e.name.text);
|
|
368
|
+
if (ts.isElementAccessExpression(e))
|
|
369
|
+
return (ts.isStringLiteralLike(e.argumentExpression) &&
|
|
370
|
+
/namespace/i.test(e.argumentExpression.text));
|
|
371
|
+
if (!ts.isIdentifier(e))
|
|
372
|
+
return false;
|
|
373
|
+
let found;
|
|
374
|
+
walk(scope, (x) => {
|
|
375
|
+
if (found !== undefined)
|
|
376
|
+
return;
|
|
377
|
+
if (ts.isVariableDeclaration(x) &&
|
|
378
|
+
ts.isIdentifier(x.name) &&
|
|
379
|
+
x.name.text === e.text)
|
|
380
|
+
found = x.initializer
|
|
381
|
+
? derivesNamespace(x.initializer, scope, depth + 1)
|
|
382
|
+
: false;
|
|
383
|
+
else if (ts.isBindingElement(x) &&
|
|
384
|
+
ts.isIdentifier(x.name) &&
|
|
385
|
+
x.name.text === e.text) {
|
|
386
|
+
const key = x.propertyName && ts.isIdentifier(x.propertyName)
|
|
387
|
+
? x.propertyName.text
|
|
388
|
+
: x.name.text;
|
|
389
|
+
found = /namespace/i.test(key);
|
|
390
|
+
}
|
|
391
|
+
else if (ts.isParameter(x) &&
|
|
392
|
+
ts.isIdentifier(x.name) &&
|
|
393
|
+
x.name.text === e.text)
|
|
394
|
+
found = /namespace/i.test(x.name.text);
|
|
395
|
+
});
|
|
396
|
+
return found ?? false;
|
|
397
|
+
}
|
|
398
|
+
/** The prefix checkers a handler calls ON A NAMESPACE VALUE: the argument at
|
|
399
|
+
* the checker's own namespace-named parameter derives from a namespace field
|
|
400
|
+
* (ElPi-Corp #228: "a `namespace` checked against the caller's allowed
|
|
401
|
+
* prefixes"; Argus, backend-doctor #49 issuecomment-6022240186 — a call on a
|
|
402
|
+
* constant used to pass). A checker with no namespace-named parameter (a
|
|
403
|
+
* helper that only closes over one) is not bound at its call and counts for
|
|
404
|
+
* nothing here. */
|
|
405
|
+
function boundPrefixChecks(handler, prefixCheckers, fns) {
|
|
406
|
+
const out = [];
|
|
407
|
+
walk(handler, (x) => {
|
|
408
|
+
if (!ts.isCallExpression(x))
|
|
409
|
+
return;
|
|
410
|
+
const e = unwrap(x.expression);
|
|
411
|
+
if (!ts.isIdentifier(e) || !prefixCheckers.has(e.text))
|
|
412
|
+
return;
|
|
413
|
+
const def = (fns.get(e.text) ?? []).find((d) => d.fn.parameters.some((q) => ts.isIdentifier(q.name) && /namespace/i.test(q.name.text)));
|
|
414
|
+
if (!def)
|
|
415
|
+
return;
|
|
416
|
+
const i = def.fn.parameters.findIndex((q) => ts.isIdentifier(q.name) && /namespace/i.test(q.name.text));
|
|
417
|
+
const a = x.arguments[i];
|
|
418
|
+
if (a && derivesNamespace(a, handler) && !out.includes(e.text))
|
|
419
|
+
out.push(e.text);
|
|
420
|
+
});
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
function namespaceDecisions(parsed, schema, fns, fleetRefusal, fleetOnly) {
|
|
424
|
+
const out = new Map();
|
|
425
|
+
const tables = [...schema.values()].filter((t) => t.namespace !== "none" && t.tenant !== "required");
|
|
426
|
+
if (tables.length === 0)
|
|
427
|
+
return out;
|
|
428
|
+
const prefixCheckers = closeOverCalls(fns, (d) => ownPrefixTest(d.fn));
|
|
429
|
+
const doors = parsed.flatMap((p) => registrations(p).filter((r) => r.builder === "query" ||
|
|
430
|
+
r.builder === "mutation" ||
|
|
431
|
+
r.builder === "action"));
|
|
432
|
+
for (const t of tables) {
|
|
433
|
+
const touchers = closeOverCalls(fns, (d) => ownTouches(d.fn, t.name));
|
|
434
|
+
const checked = [];
|
|
435
|
+
const unchecked = [];
|
|
436
|
+
for (const r of doors) {
|
|
437
|
+
const calls = calledNames(r.handler);
|
|
438
|
+
const own = touchSites(r.handler, t.name, r.argIdTable);
|
|
439
|
+
const viaHelper = [...calls].some((c) => touchers.has(c));
|
|
440
|
+
if (own.length === 0 && !viaHelper)
|
|
441
|
+
continue;
|
|
442
|
+
const label = `${r.rel.replace(/^.*convex\//, "")}:${r.name}`;
|
|
443
|
+
const checker = boundPrefixChecks(r.handler, prefixCheckers, fns)[0] ??
|
|
444
|
+
[...calls].find((c) => fleetRefusal.has(c));
|
|
445
|
+
if (checker || ownMasterRefusal(r.handler))
|
|
446
|
+
checked.push(`${label} (${checker ?? "master-only"})`);
|
|
447
|
+
else if (!viaHelper &&
|
|
448
|
+
own.every((x) => fleetGated(x, r.handler, (n) => fleetOnly.has(n))))
|
|
449
|
+
checked.push(`${label} (master-gated)`);
|
|
450
|
+
else
|
|
451
|
+
unchecked.push(label);
|
|
452
|
+
}
|
|
453
|
+
const list = (xs) => `${xs.slice(0, 3).join(", ")}${xs.length > 3 ? `, … ${xs.length} in all` : ""}`;
|
|
454
|
+
if (checked.length === 0 && unchecked.length === 0)
|
|
455
|
+
out.set(t.name, {
|
|
456
|
+
verdict: "undecided",
|
|
457
|
+
why: "no public query/mutation/action reads or writes it, so no prefix check can be read",
|
|
458
|
+
});
|
|
459
|
+
else if (unchecked.length === 0)
|
|
460
|
+
out.set(t.name, {
|
|
461
|
+
verdict: "checked",
|
|
462
|
+
why: `every public door runs a prefix check: ${list(checked)}`,
|
|
463
|
+
});
|
|
464
|
+
else if (checked.length === 0)
|
|
465
|
+
out.set(t.name, {
|
|
466
|
+
verdict: "unchecked",
|
|
467
|
+
why: `no public door runs a prefix check: ${list(unchecked)}`,
|
|
468
|
+
});
|
|
469
|
+
else
|
|
470
|
+
out.set(t.name, {
|
|
471
|
+
verdict: "undecided",
|
|
472
|
+
why: `prefix-checked at ${list(checked)} but not at ${list(unchecked)}`,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
return out;
|
|
476
|
+
}
|
|
477
|
+
/** The schema as R-52 reads it: a table whose namespace is prefix-checked
|
|
478
|
+
* carries that namespace as a tenant column. */
|
|
479
|
+
function effectiveSchema(raw, ns) {
|
|
480
|
+
const out = new Map();
|
|
481
|
+
for (const [k, t] of raw) {
|
|
482
|
+
if (ns.get(k)?.verdict !== "checked") {
|
|
483
|
+
out.set(k, t);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const tenant = t.namespace === "required"
|
|
487
|
+
? "required"
|
|
488
|
+
: t.tenant === "none"
|
|
489
|
+
? "optional"
|
|
490
|
+
: t.tenant;
|
|
491
|
+
out.set(k, { ...t, tenant });
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
export function r52Facts(files) {
|
|
496
|
+
const parsed = files
|
|
497
|
+
.filter((f) => isConvexFile(f) && isJudgeable(f))
|
|
498
|
+
.map(parseFile);
|
|
499
|
+
const schemaP = parsed.find((p) => /(^|\/)convex\/schema\.ts$/.test(p.rel));
|
|
500
|
+
const rawSchema = schemaP ? readSchema(schemaP) : undefined;
|
|
501
|
+
const fns = indexFunctions(parsed);
|
|
502
|
+
const readsTenant = closeOverCalls(fns, (d) => tenantReadsIn(d.fn).length > 0);
|
|
503
|
+
const refusing = closeOverCalls(fns, (d) => ownThrows(d.fn));
|
|
504
|
+
const fleetOnly = new Set();
|
|
505
|
+
for (let grew = true; grew;) {
|
|
506
|
+
grew = false;
|
|
507
|
+
for (const [n, ds] of fns) {
|
|
508
|
+
if (fleetOnly.has(n))
|
|
509
|
+
continue;
|
|
510
|
+
if (ds.length === 1 && fleetOnlyFunction(ds[0].fn, fleetOnly)) {
|
|
511
|
+
fleetOnly.add(n);
|
|
512
|
+
grew = true;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const fleetRefusal = new Set();
|
|
517
|
+
for (const [n, ds] of fns)
|
|
518
|
+
if (ds.some((d) => ownMasterRefusal(d.fn)))
|
|
519
|
+
fleetRefusal.add(n);
|
|
520
|
+
for (let grew = true; grew;) {
|
|
521
|
+
grew = false;
|
|
522
|
+
for (const [n, ds] of fns) {
|
|
523
|
+
if (fleetRefusal.has(n))
|
|
524
|
+
continue;
|
|
525
|
+
if (ds.some((d) => [...topLevelCalls(d.fn)].some((c) => fleetRefusal.has(c)))) {
|
|
526
|
+
fleetRefusal.add(n);
|
|
527
|
+
grew = true;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const namespace = rawSchema
|
|
532
|
+
? namespaceDecisions(parsed, rawSchema, fns, fleetRefusal, fleetOnly)
|
|
533
|
+
: new Map();
|
|
534
|
+
const schema = rawSchema ? effectiveSchema(rawSchema, namespace) : undefined;
|
|
535
|
+
// a helper whose body reads exactly one table returns a row of that table.
|
|
536
|
+
const singleTable = new Map();
|
|
537
|
+
for (const [n, ds] of fns) {
|
|
538
|
+
if (ds.length !== 1)
|
|
539
|
+
continue;
|
|
540
|
+
const tables = new Set();
|
|
541
|
+
walk(ds[0].fn, (x) => {
|
|
542
|
+
if (ts.isCallExpression(x) &&
|
|
543
|
+
calleeName(x) === "query" &&
|
|
544
|
+
x.arguments[0] &&
|
|
545
|
+
ts.isStringLiteralLike(x.arguments[0]))
|
|
546
|
+
tables.add(x.arguments[0].text);
|
|
547
|
+
});
|
|
548
|
+
if (tables.size === 1)
|
|
549
|
+
singleTable.set(n, [...tables][0]);
|
|
550
|
+
}
|
|
551
|
+
const callerChosen = new Map();
|
|
552
|
+
const mark = (t, f) => {
|
|
553
|
+
const s = callerChosen.get(t) ?? new Set();
|
|
554
|
+
s.add(f);
|
|
555
|
+
callerChosen.set(t, s);
|
|
556
|
+
};
|
|
557
|
+
for (const p of parsed) {
|
|
558
|
+
const internal = registrations(p)
|
|
559
|
+
.filter((r) => r.builder.startsWith("internal"))
|
|
560
|
+
.map((r) => r.handler);
|
|
561
|
+
const inInternal = (n) => {
|
|
562
|
+
for (let x = n; x; x = x.parent)
|
|
563
|
+
if (internal.includes(x))
|
|
564
|
+
return true;
|
|
565
|
+
return false;
|
|
566
|
+
};
|
|
567
|
+
const argIds = new Map();
|
|
568
|
+
for (const r of registrations(p))
|
|
569
|
+
for (const [k, t] of r.argIdTable)
|
|
570
|
+
argIds.set(k, t);
|
|
571
|
+
walk(p.sf, (x) => {
|
|
572
|
+
if (!ts.isCallExpression(x) || inInternal(x))
|
|
573
|
+
return;
|
|
574
|
+
const callee = dotted(x.expression) ?? "";
|
|
575
|
+
let table;
|
|
576
|
+
let obj;
|
|
577
|
+
if (/\.db\.insert$/.test(callee) &&
|
|
578
|
+
x.arguments[0] &&
|
|
579
|
+
ts.isStringLiteralLike(x.arguments[0])) {
|
|
580
|
+
table = x.arguments[0].text;
|
|
581
|
+
obj = x.arguments[1];
|
|
582
|
+
}
|
|
583
|
+
else if (/\.db\.(patch|replace)$/.test(callee) && x.arguments[0]) {
|
|
584
|
+
const sel = dotted(x.arguments[0]) ?? "";
|
|
585
|
+
const m = /^args\.(\w+)$/.exec(sel);
|
|
586
|
+
if (m)
|
|
587
|
+
table = argIds.get(m[1]);
|
|
588
|
+
obj = x.arguments[1];
|
|
589
|
+
}
|
|
590
|
+
if (!table || !obj)
|
|
591
|
+
return;
|
|
592
|
+
const o = unwrap(obj);
|
|
593
|
+
if (!ts.isObjectLiteralExpression(o))
|
|
594
|
+
return;
|
|
595
|
+
const explicit = new Set();
|
|
596
|
+
for (const prop of o.properties) {
|
|
597
|
+
if (ts.isPropertyAssignment(prop) ||
|
|
598
|
+
ts.isShorthandPropertyAssignment(prop)) {
|
|
599
|
+
const k = ts.isIdentifier(prop.name) ? prop.name.text : undefined;
|
|
600
|
+
if (!k)
|
|
601
|
+
continue;
|
|
602
|
+
explicit.add(k);
|
|
603
|
+
const valText = ts.isPropertyAssignment(prop)
|
|
604
|
+
? prop.initializer.getText()
|
|
605
|
+
: k;
|
|
606
|
+
if (/\bargs\b|\b\w*Args\b/.test(valText))
|
|
607
|
+
mark(table, k);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
for (const prop of o.properties) {
|
|
611
|
+
if (!ts.isSpreadAssignment(prop))
|
|
612
|
+
continue;
|
|
613
|
+
const t = prop.expression.getText();
|
|
614
|
+
if (/^(?:args|\w*Args|rest)$/.test(t)) {
|
|
615
|
+
for (const f of schema?.get(table)?.fields ?? [])
|
|
616
|
+
if (!explicit.has(f))
|
|
617
|
+
mark(table, f);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
parsed,
|
|
624
|
+
schema,
|
|
625
|
+
schemaRel: schemaP?.rel,
|
|
626
|
+
callerChosen,
|
|
627
|
+
fns,
|
|
628
|
+
readsTenant,
|
|
629
|
+
refusing,
|
|
630
|
+
fleetOnly,
|
|
631
|
+
normalizers: new Set([...fns]
|
|
632
|
+
.filter(([, ds]) => ds.some((d) => /\.normalizeId\(/.test(d.fn.getText())))
|
|
633
|
+
.map(([n]) => n)),
|
|
634
|
+
fleetRefusal,
|
|
635
|
+
singleTable,
|
|
636
|
+
namespace,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
// ---- per-handler evaluation -----------------------------------------------
|
|
640
|
+
/** The node a declaration is visible in: its block, loop, or function. */
|
|
641
|
+
function scopeOf(n) {
|
|
642
|
+
for (let x = n.parent; x; x = x.parent)
|
|
643
|
+
if (ts.isBlock(x) ||
|
|
644
|
+
ts.isForStatement(x) ||
|
|
645
|
+
ts.isForOfStatement(x) ||
|
|
646
|
+
ts.isForInStatement(x) ||
|
|
647
|
+
ts.isArrowFunction(x) ||
|
|
648
|
+
ts.isFunctionExpression(x) ||
|
|
649
|
+
ts.isFunctionDeclaration(x) ||
|
|
650
|
+
ts.isMethodDeclaration(x) ||
|
|
651
|
+
ts.isSourceFile(x))
|
|
652
|
+
return x;
|
|
653
|
+
return n.getSourceFile();
|
|
654
|
+
}
|
|
655
|
+
/** Bindings keyed by name AND the scope they are declared in: a reference
|
|
656
|
+
* resolves to the innermost declaration whose scope contains it, so two
|
|
657
|
+
* `const task` in sibling blocks never share a provenance. */
|
|
658
|
+
class ScopedEnv {
|
|
659
|
+
m = new Map();
|
|
660
|
+
entry(name, at) {
|
|
661
|
+
const xs = this.m.get(name);
|
|
662
|
+
if (!xs || xs.length === 0)
|
|
663
|
+
return undefined;
|
|
664
|
+
if (!at)
|
|
665
|
+
return xs[xs.length - 1];
|
|
666
|
+
let best;
|
|
667
|
+
for (const x of xs)
|
|
668
|
+
if (x.scope.pos <= at.pos && at.end <= x.scope.end)
|
|
669
|
+
if (!best ||
|
|
670
|
+
x.scope.end - x.scope.pos <= best.scope.end - best.scope.pos)
|
|
671
|
+
best = x;
|
|
672
|
+
return best;
|
|
673
|
+
}
|
|
674
|
+
get(name, at) {
|
|
675
|
+
return this.entry(name, at)?.val;
|
|
676
|
+
}
|
|
677
|
+
set(name, val, scope) {
|
|
678
|
+
const xs = this.m.get(name) ?? [];
|
|
679
|
+
const i = xs.findIndex((x) => x.scope === scope);
|
|
680
|
+
if (i >= 0)
|
|
681
|
+
xs[i] = { scope, val };
|
|
682
|
+
else
|
|
683
|
+
xs.push({ scope, val });
|
|
684
|
+
this.m.set(name, xs);
|
|
685
|
+
}
|
|
686
|
+
remove(name, scope) {
|
|
687
|
+
const xs = this.m.get(name);
|
|
688
|
+
if (xs)
|
|
689
|
+
this.m.set(name, xs.filter((x) => x.scope !== scope));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
class HandlerWalk {
|
|
693
|
+
reg;
|
|
694
|
+
facts;
|
|
695
|
+
env = new ScopedEnv();
|
|
696
|
+
declared = new Set();
|
|
697
|
+
constructor(reg, facts) {
|
|
698
|
+
this.reg = reg;
|
|
699
|
+
this.facts = facts;
|
|
700
|
+
}
|
|
701
|
+
selectedBy(v, table, n) {
|
|
702
|
+
if (v.choosable && table !== undefined)
|
|
703
|
+
return mergeVias(v.vias, [
|
|
704
|
+
{ table, reason: v.choosable, line: lineOf(n) },
|
|
705
|
+
]);
|
|
706
|
+
return v.vias;
|
|
707
|
+
}
|
|
708
|
+
/** Root of a `ctx.db.query("T")…` chain, with every call in the chain. */
|
|
709
|
+
queryChain(c) {
|
|
710
|
+
const calls = [];
|
|
711
|
+
let x = c;
|
|
712
|
+
for (;;) {
|
|
713
|
+
const u = unwrap(x);
|
|
714
|
+
if (!ts.isCallExpression(u))
|
|
715
|
+
return undefined;
|
|
716
|
+
calls.push(u);
|
|
717
|
+
const callee = unwrap(u.expression);
|
|
718
|
+
if (!ts.isPropertyAccessExpression(callee))
|
|
719
|
+
return undefined;
|
|
720
|
+
if (callee.name.text === "query" &&
|
|
721
|
+
/\bdb$/.test(dotted(callee.expression) ?? "") &&
|
|
722
|
+
u.arguments[0] &&
|
|
723
|
+
ts.isStringLiteralLike(u.arguments[0]))
|
|
724
|
+
return { table: u.arguments[0].text, calls };
|
|
725
|
+
x = callee.expression;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
evalCb(cb, param) {
|
|
729
|
+
let out = SAFE;
|
|
730
|
+
const f = cb;
|
|
731
|
+
const names = new Set();
|
|
732
|
+
for (const p of f.parameters ?? [])
|
|
733
|
+
if (ts.isIdentifier(p.name))
|
|
734
|
+
names.add(p.name.text);
|
|
735
|
+
for (const n of names)
|
|
736
|
+
this.env.set(n, param, cb);
|
|
737
|
+
walk(f.body, (x) => {
|
|
738
|
+
if (ts.isBinaryExpression(x)) {
|
|
739
|
+
for (const side of [x.left, x.right]) {
|
|
740
|
+
const root = dotted(side)?.split(".")[0];
|
|
741
|
+
if (root !== undefined && names.has(root))
|
|
742
|
+
continue;
|
|
743
|
+
out = merge(out, this.eval(side));
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
for (const n of names)
|
|
748
|
+
this.env.remove(n, cb);
|
|
749
|
+
return out;
|
|
750
|
+
}
|
|
751
|
+
eval(e0) {
|
|
752
|
+
const e = unwrap(e0);
|
|
753
|
+
if (ts.isStringLiteralLike(e) ||
|
|
754
|
+
ts.isNumericLiteral(e) ||
|
|
755
|
+
ts.isRegularExpressionLiteral(e) ||
|
|
756
|
+
e.kind === ts.SyntaxKind.TrueKeyword ||
|
|
757
|
+
e.kind === ts.SyntaxKind.FalseKeyword ||
|
|
758
|
+
e.kind === ts.SyntaxKind.NullKeyword ||
|
|
759
|
+
ts.isArrowFunction(e) ||
|
|
760
|
+
ts.isFunctionExpression(e))
|
|
761
|
+
return SAFE;
|
|
762
|
+
if (ts.isIdentifier(e)) {
|
|
763
|
+
if (e.text === "args")
|
|
764
|
+
return {
|
|
765
|
+
kind: "value",
|
|
766
|
+
choosable: "the handler's arguments",
|
|
767
|
+
vias: [],
|
|
768
|
+
};
|
|
769
|
+
const v = this.env.get(e.text, e);
|
|
770
|
+
if (v)
|
|
771
|
+
return v;
|
|
772
|
+
if (this.declared.has(e.text))
|
|
773
|
+
return {
|
|
774
|
+
kind: "value",
|
|
775
|
+
vias: [],
|
|
776
|
+
unresolved: `local \`${e.text}\` has no provenance the walk can read`,
|
|
777
|
+
};
|
|
778
|
+
return SAFE;
|
|
779
|
+
}
|
|
780
|
+
if (ts.isPropertyAccessExpression(e)) {
|
|
781
|
+
const name = e.name.text;
|
|
782
|
+
const head = unwrap(e.expression);
|
|
783
|
+
if (ts.isIdentifier(head) && head.text === "args") {
|
|
784
|
+
const t = this.reg.argIdTable.get(name);
|
|
785
|
+
return {
|
|
786
|
+
kind: t ? "id" : "value",
|
|
787
|
+
table: t,
|
|
788
|
+
choosable: `argument args.${name}`,
|
|
789
|
+
vias: [],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
const obj = this.eval(e.expression);
|
|
793
|
+
if (obj.kind === "row" && obj.table !== undefined) {
|
|
794
|
+
if (name === "_id")
|
|
795
|
+
return { ...obj, kind: "id", gate: undefined };
|
|
796
|
+
const st = this.facts.schema?.get(obj.table);
|
|
797
|
+
// `rows.length` and other non-column reads are aggregates, not a
|
|
798
|
+
// value read off a row: no attribution travels with them.
|
|
799
|
+
if (st !== undefined &&
|
|
800
|
+
!st.fields.includes(name) &&
|
|
801
|
+
name !== "_creationTime")
|
|
802
|
+
return { kind: "value", choosable: obj.choosable, vias: [] };
|
|
803
|
+
const chosen = this.facts.callerChosen.get(obj.table)?.has(name);
|
|
804
|
+
const ref = st?.idRef.get(name);
|
|
805
|
+
return {
|
|
806
|
+
kind: ref ? "id" : "value",
|
|
807
|
+
table: ref,
|
|
808
|
+
choosable: chosen
|
|
809
|
+
? `stored field ${obj.table}.${name}, which a public write sets from its arguments`
|
|
810
|
+
: obj.choosable,
|
|
811
|
+
vias: obj.vias,
|
|
812
|
+
unresolved: obj.unresolved,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
// a field of an argument whose validator names exactly one table
|
|
816
|
+
// (`args.relations[i].targetId: v.id("memories")`) is still that id.
|
|
817
|
+
if (obj.kind === "id" &&
|
|
818
|
+
obj.table !== undefined &&
|
|
819
|
+
obj.choosable?.startsWith("argument args."))
|
|
820
|
+
return { ...obj, gate: undefined };
|
|
821
|
+
return { ...obj, kind: "value", table: undefined, gate: undefined };
|
|
822
|
+
}
|
|
823
|
+
if (ts.isElementAccessExpression(e)) {
|
|
824
|
+
const recv = this.eval(e.expression);
|
|
825
|
+
const m = merge(recv, this.eval(e.argumentExpression));
|
|
826
|
+
if (recv.table !== undefined)
|
|
827
|
+
return {
|
|
828
|
+
...m,
|
|
829
|
+
kind: recv.kind,
|
|
830
|
+
table: recv.table,
|
|
831
|
+
tenantChecked: recv.tenantChecked,
|
|
832
|
+
};
|
|
833
|
+
return m;
|
|
834
|
+
}
|
|
835
|
+
if (ts.isCallExpression(e))
|
|
836
|
+
return this.evalCall(e);
|
|
837
|
+
if (ts.isBinaryExpression(e) &&
|
|
838
|
+
(e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ||
|
|
839
|
+
e.operatorToken.kind === ts.SyntaxKind.BarBarToken)) {
|
|
840
|
+
const a = this.eval(e.left);
|
|
841
|
+
const b = this.eval(e.right);
|
|
842
|
+
const m = merge(a, b);
|
|
843
|
+
if (a.table !== undefined &&
|
|
844
|
+
(b.table === undefined || b.table === a.table))
|
|
845
|
+
return {
|
|
846
|
+
...m,
|
|
847
|
+
kind: a.kind,
|
|
848
|
+
table: a.table,
|
|
849
|
+
tenantChecked: a.tenantChecked,
|
|
850
|
+
};
|
|
851
|
+
return m;
|
|
852
|
+
}
|
|
853
|
+
if (ts.isConditionalExpression(e)) {
|
|
854
|
+
const a = this.eval(e.whenTrue);
|
|
855
|
+
const b = this.eval(e.whenFalse);
|
|
856
|
+
const m = merge(merge(a, b), this.eval(e.condition));
|
|
857
|
+
if (a.table !== undefined && a.table === b.table && a.kind === b.kind)
|
|
858
|
+
return { ...m, kind: a.kind, table: a.table };
|
|
859
|
+
return m;
|
|
860
|
+
}
|
|
861
|
+
let out = SAFE;
|
|
862
|
+
ts.forEachChild(e, (c) => {
|
|
863
|
+
if (ts.isExpression(c))
|
|
864
|
+
out = merge(out, this.eval(c));
|
|
865
|
+
else if (ts.isPropertyAssignment(c) ||
|
|
866
|
+
ts.isShorthandPropertyAssignment(c) ||
|
|
867
|
+
ts.isSpreadAssignment(c) ||
|
|
868
|
+
ts.isSpreadElement(c) ||
|
|
869
|
+
ts.isTemplateSpan(c))
|
|
870
|
+
ts.forEachChild(c, (cc) => {
|
|
871
|
+
if (ts.isExpression(cc))
|
|
872
|
+
out = merge(out, this.eval(cc));
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
return out;
|
|
876
|
+
}
|
|
877
|
+
evalCall(c) {
|
|
878
|
+
const callee = dotted(c.expression) ?? "";
|
|
879
|
+
if (/\bdb\.get$/.test(callee) && c.arguments[0]) {
|
|
880
|
+
const s = this.eval(c.arguments[0]);
|
|
881
|
+
return {
|
|
882
|
+
kind: "row",
|
|
883
|
+
table: s.table,
|
|
884
|
+
choosable: s.choosable,
|
|
885
|
+
vias: this.selectedBy(s, s.table, c),
|
|
886
|
+
unresolved: s.unresolved,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
// ctx.db.normalizeId("T", x), or a helper that wraps it: an id of table T.
|
|
890
|
+
const lit = c.arguments.find((a) => ts.isStringLiteralLike(a) && this.facts.schema?.has(a.text) === true);
|
|
891
|
+
const fnName = calleeName(c);
|
|
892
|
+
if (lit &&
|
|
893
|
+
fnName !== undefined &&
|
|
894
|
+
(fnName === "normalizeId" || this.facts.normalizers.has(fnName))) {
|
|
895
|
+
let a = SAFE;
|
|
896
|
+
for (const arg of c.arguments)
|
|
897
|
+
if (arg !== lit)
|
|
898
|
+
a = merge(a, this.eval(arg));
|
|
899
|
+
return { ...a, kind: "id", table: lit.text, gate: undefined };
|
|
900
|
+
}
|
|
901
|
+
const q = this.queryChain(c);
|
|
902
|
+
if (q) {
|
|
903
|
+
let sel = SAFE;
|
|
904
|
+
for (const call of q.calls)
|
|
905
|
+
for (const a of call.arguments)
|
|
906
|
+
walk(a, (x) => {
|
|
907
|
+
if (ts.isCallExpression(x) &&
|
|
908
|
+
calleeName(x) === "eq" &&
|
|
909
|
+
x.arguments.length === 2)
|
|
910
|
+
sel = merge(sel, this.eval(x.arguments[1]));
|
|
911
|
+
});
|
|
912
|
+
return {
|
|
913
|
+
kind: "row",
|
|
914
|
+
table: q.table,
|
|
915
|
+
choosable: sel.choosable,
|
|
916
|
+
vias: this.selectedBy(sel, q.table, c),
|
|
917
|
+
unresolved: sel.unresolved,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const fnExpr = unwrap(c.expression);
|
|
921
|
+
if (ts.isPropertyAccessExpression(fnExpr) &&
|
|
922
|
+
ITERATORS.has(fnExpr.name.text)) {
|
|
923
|
+
const recv = this.eval(fnExpr.expression);
|
|
924
|
+
const cb = c.arguments[0];
|
|
925
|
+
const elem = { ...recv, gate: undefined };
|
|
926
|
+
if (fnExpr.name.text === "map" &&
|
|
927
|
+
cb &&
|
|
928
|
+
ts.isArrowFunction(cb) &&
|
|
929
|
+
!ts.isBlock(cb.body)) {
|
|
930
|
+
const p0 = cb.parameters[0];
|
|
931
|
+
if (p0 && ts.isIdentifier(p0.name))
|
|
932
|
+
this.env.set(p0.name.text, elem, cb);
|
|
933
|
+
const out = this.eval(cb.body);
|
|
934
|
+
if (p0 && ts.isIdentifier(p0.name))
|
|
935
|
+
this.env.remove(p0.name.text, cb);
|
|
936
|
+
return {
|
|
937
|
+
...out,
|
|
938
|
+
choosable: out.choosable ?? recv.choosable,
|
|
939
|
+
vias: mergeVias(out.vias, recv.vias),
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const byCb = cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))
|
|
943
|
+
? this.evalCb(cb, elem)
|
|
944
|
+
: SAFE;
|
|
945
|
+
if ((fnExpr.name.text === "find" ||
|
|
946
|
+
fnExpr.name.text === "filter" ||
|
|
947
|
+
fnExpr.name.text === "findLast") &&
|
|
948
|
+
recv.table !== undefined) {
|
|
949
|
+
const choosable = recv.choosable ?? byCb.choosable;
|
|
950
|
+
const v = {
|
|
951
|
+
kind: "row",
|
|
952
|
+
table: recv.table,
|
|
953
|
+
choosable,
|
|
954
|
+
vias: mergeVias(recv.vias, byCb.vias),
|
|
955
|
+
unresolved: recv.unresolved ?? byCb.unresolved,
|
|
956
|
+
};
|
|
957
|
+
return {
|
|
958
|
+
...v,
|
|
959
|
+
vias: this.selectedBy({ ...v, vias: v.vias }, recv.table, c),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
return merge(recv, byCb);
|
|
963
|
+
}
|
|
964
|
+
if (ts.isIdentifier(fnExpr) && this.facts.fns.has(fnExpr.text)) {
|
|
965
|
+
const name = fnExpr.text;
|
|
966
|
+
let a = SAFE;
|
|
967
|
+
const tables = new Set();
|
|
968
|
+
for (const arg of c.arguments) {
|
|
969
|
+
if (ts.isIdentifier(arg) && arg.text === "ctx")
|
|
970
|
+
continue;
|
|
971
|
+
const v = this.eval(arg);
|
|
972
|
+
if (v.table)
|
|
973
|
+
tables.add(v.table);
|
|
974
|
+
a = merge(a, v);
|
|
975
|
+
}
|
|
976
|
+
const table = this.facts.singleTable.get(name);
|
|
977
|
+
const gate = this.facts.readsTenant.has(name)
|
|
978
|
+
? { tables, fleet: this.facts.fleetOnly.has(name) }
|
|
979
|
+
: undefined;
|
|
980
|
+
const tenantChecked = this.facts.readsTenant.has(name) ? name : undefined;
|
|
981
|
+
if (table !== undefined)
|
|
982
|
+
return {
|
|
983
|
+
kind: "row",
|
|
984
|
+
table,
|
|
985
|
+
choosable: a.choosable,
|
|
986
|
+
// a helper that reads the tenant of what it returns does not
|
|
987
|
+
// hand back a row the caller chose across tenants.
|
|
988
|
+
vias: tenantChecked ? a.vias : this.selectedBy(a, table, c),
|
|
989
|
+
unresolved: a.unresolved,
|
|
990
|
+
gate,
|
|
991
|
+
tenantChecked,
|
|
992
|
+
};
|
|
993
|
+
return { ...a, gate, tenantChecked };
|
|
994
|
+
}
|
|
995
|
+
if (ts.isPropertyAccessExpression(fnExpr) &&
|
|
996
|
+
PRESERVING.has(fnExpr.name.text)) {
|
|
997
|
+
const recv = this.eval(fnExpr.expression);
|
|
998
|
+
let a = SAFE;
|
|
999
|
+
for (const arg of c.arguments)
|
|
1000
|
+
a = merge(a, this.eval(arg));
|
|
1001
|
+
return { ...recv, choosable: recv.choosable ?? a.choosable };
|
|
1002
|
+
}
|
|
1003
|
+
let out = SAFE;
|
|
1004
|
+
if (ts.isPropertyAccessExpression(fnExpr))
|
|
1005
|
+
out = merge(out, this.eval(fnExpr.expression));
|
|
1006
|
+
for (const arg of c.arguments)
|
|
1007
|
+
out = merge(out, this.eval(arg));
|
|
1008
|
+
return out;
|
|
1009
|
+
}
|
|
1010
|
+
bind(name, v, scope) {
|
|
1011
|
+
if (ts.isIdentifier(name)) {
|
|
1012
|
+
const prev = this.env.entry(name.text, name);
|
|
1013
|
+
const same = prev && prev.scope === scope ? prev.val : undefined;
|
|
1014
|
+
this.env.set(name.text, same
|
|
1015
|
+
? {
|
|
1016
|
+
...merge(same, v),
|
|
1017
|
+
kind: v.table !== undefined ? v.kind : same.kind,
|
|
1018
|
+
table: v.table ?? same.table,
|
|
1019
|
+
tenantChecked: v.tenantChecked ?? same.tenantChecked,
|
|
1020
|
+
}
|
|
1021
|
+
: v, scope);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
for (const el of name.elements) {
|
|
1025
|
+
if (ts.isOmittedExpression(el))
|
|
1026
|
+
continue;
|
|
1027
|
+
if (ts.isObjectBindingPattern(name) &&
|
|
1028
|
+
ts.isIdentifier(el.name) &&
|
|
1029
|
+
v.choosable === "the handler's arguments") {
|
|
1030
|
+
const key = el.propertyName && ts.isIdentifier(el.propertyName)
|
|
1031
|
+
? el.propertyName.text
|
|
1032
|
+
: el.name.text;
|
|
1033
|
+
const t = this.reg.argIdTable.get(key);
|
|
1034
|
+
this.env.set(el.name.text, {
|
|
1035
|
+
kind: t ? "id" : "value",
|
|
1036
|
+
table: t,
|
|
1037
|
+
choosable: `argument args.${key}`,
|
|
1038
|
+
vias: [],
|
|
1039
|
+
}, scope);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
this.bind(el.name, v, scope);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/** Pass 1: declared names; pass 2: bind every declaration in source order. */
|
|
1046
|
+
build() {
|
|
1047
|
+
const body = this.reg.handler.body;
|
|
1048
|
+
if (!body)
|
|
1049
|
+
return;
|
|
1050
|
+
walk(body, (x) => {
|
|
1051
|
+
if (ts.isVariableDeclaration(x) || ts.isParameter(x)) {
|
|
1052
|
+
const collect = (n) => {
|
|
1053
|
+
if (ts.isIdentifier(n))
|
|
1054
|
+
this.declared.add(n.text);
|
|
1055
|
+
else
|
|
1056
|
+
for (const el of n.elements)
|
|
1057
|
+
if (!ts.isOmittedExpression(el))
|
|
1058
|
+
collect(el.name);
|
|
1059
|
+
};
|
|
1060
|
+
collect(x.name);
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
walk(body, (x) => {
|
|
1064
|
+
if (ts.isVariableDeclaration(x) && x.initializer) {
|
|
1065
|
+
const forOf = x.parent?.parent;
|
|
1066
|
+
if (forOf &&
|
|
1067
|
+
ts.isForOfStatement(forOf) &&
|
|
1068
|
+
forOf.initializer === x.parent)
|
|
1069
|
+
return;
|
|
1070
|
+
this.bind(x.name, this.eval(x.initializer), scopeOf(x));
|
|
1071
|
+
}
|
|
1072
|
+
if (ts.isForOfStatement(x) &&
|
|
1073
|
+
ts.isVariableDeclarationList(x.initializer)) {
|
|
1074
|
+
const v = this.eval(x.expression);
|
|
1075
|
+
for (const d of x.initializer.declarations)
|
|
1076
|
+
this.bind(d.name, v, x);
|
|
1077
|
+
}
|
|
1078
|
+
if (ts.isCallExpression(x) &&
|
|
1079
|
+
ts.isPropertyAccessExpression(unwrap(x.expression)) &&
|
|
1080
|
+
ITERATORS.has(unwrap(x.expression).name.text)) {
|
|
1081
|
+
const recv = this.eval(unwrap(x.expression).expression);
|
|
1082
|
+
const cb = x.arguments[0];
|
|
1083
|
+
if (cb && (ts.isArrowFunction(cb) || ts.isFunctionExpression(cb))) {
|
|
1084
|
+
const p0 = cb.parameters[0];
|
|
1085
|
+
if (p0)
|
|
1086
|
+
this.bind(p0.name, { ...recv, gate: undefined }, cb);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// `rows.push(row)`: the array holds that row; a push gated by a
|
|
1090
|
+
// condition reading the row's tenant makes the array tenant-checked.
|
|
1091
|
+
if (ts.isCallExpression(x) &&
|
|
1092
|
+
ts.isPropertyAccessExpression(x.expression) &&
|
|
1093
|
+
x.expression.name.text === "push" &&
|
|
1094
|
+
ts.isIdentifier(x.expression.expression) &&
|
|
1095
|
+
x.arguments[0]) {
|
|
1096
|
+
const arr = x.expression.expression;
|
|
1097
|
+
const target = this.env.entry(arr.text, arr);
|
|
1098
|
+
const pv = this.eval(x.arguments[0]);
|
|
1099
|
+
if (target && pv.table !== undefined) {
|
|
1100
|
+
const gate = enclosingConditions(x, this.reg.handler).find((c) => tenantTablesRead(this, c).has(pv.table));
|
|
1101
|
+
this.env.set(arr.text, {
|
|
1102
|
+
...merge(target.val, pv),
|
|
1103
|
+
kind: pv.kind,
|
|
1104
|
+
table: pv.table,
|
|
1105
|
+
tenantChecked: gate
|
|
1106
|
+
? `the push gated by \`${gate.getText().slice(0, 60)}\` (line ${lineOf(gate)})`
|
|
1107
|
+
: target.val.tenantChecked,
|
|
1108
|
+
}, target.scope);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
if (ts.isBinaryExpression(x) &&
|
|
1112
|
+
x.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
1113
|
+
ts.isIdentifier(x.left)) {
|
|
1114
|
+
const at = this.env.entry(x.left.text, x.left);
|
|
1115
|
+
if (at)
|
|
1116
|
+
this.bind(x.left, this.eval(x.right), at.scope);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
// ---- guards ---------------------------------------------------------------
|
|
1122
|
+
/** The tables whose server-stamped tenant a condition reads. */
|
|
1123
|
+
function tenantTablesRead(w, cond) {
|
|
1124
|
+
const out = new Set();
|
|
1125
|
+
for (const pa of tenantReadsIn(cond)) {
|
|
1126
|
+
const v = w.eval(pa.expression);
|
|
1127
|
+
if (v.kind === "row" && v.table)
|
|
1128
|
+
out.add(v.table);
|
|
1129
|
+
}
|
|
1130
|
+
walk(cond, (x) => {
|
|
1131
|
+
const g = ts.isIdentifier(x)
|
|
1132
|
+
? w.env.get(x.text, x)?.gate
|
|
1133
|
+
: ts.isCallExpression(x)
|
|
1134
|
+
? w.eval(x).gate
|
|
1135
|
+
: undefined;
|
|
1136
|
+
for (const t of g?.tables ?? [])
|
|
1137
|
+
out.add(t);
|
|
1138
|
+
});
|
|
1139
|
+
return out;
|
|
1140
|
+
}
|
|
1141
|
+
/** Conditions that gate `node` (enclosing if/ternary/&&) up to the handler. */
|
|
1142
|
+
function enclosingConditions(node, stop) {
|
|
1143
|
+
const out = [];
|
|
1144
|
+
let child = node;
|
|
1145
|
+
for (let x = node.parent; x && x !== stop; child = x, x = x.parent) {
|
|
1146
|
+
if (ts.isIfStatement(x) && child !== x.expression)
|
|
1147
|
+
out.push(x.expression);
|
|
1148
|
+
if (ts.isConditionalExpression(x) && child !== x.condition)
|
|
1149
|
+
out.push(x.condition);
|
|
1150
|
+
if (ts.isBinaryExpression(x) &&
|
|
1151
|
+
x.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken &&
|
|
1152
|
+
child === x.right)
|
|
1153
|
+
out.push(x.left);
|
|
1154
|
+
}
|
|
1155
|
+
return out;
|
|
1156
|
+
}
|
|
1157
|
+
/** Statements that precede `node` in each enclosing block, up to the handler. */
|
|
1158
|
+
function precedingStatements(node, stop) {
|
|
1159
|
+
const out = [];
|
|
1160
|
+
let child = node;
|
|
1161
|
+
for (let x = node.parent; x && x !== stop.parent; child = x, x = x.parent) {
|
|
1162
|
+
if (ts.isBlock(x))
|
|
1163
|
+
for (const st of x.statements) {
|
|
1164
|
+
if (st === child)
|
|
1165
|
+
break;
|
|
1166
|
+
out.push(st);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return out;
|
|
1170
|
+
}
|
|
1171
|
+
function guarded(w, node, target, kind) {
|
|
1172
|
+
const stop = w.reg.handler;
|
|
1173
|
+
const isHelper = (n) => w.facts.fleetOnly.has(n);
|
|
1174
|
+
const isLocal = (n) => w.env.get(n, node)?.gate?.fleet === true;
|
|
1175
|
+
const gates = (c) => kind === "none"
|
|
1176
|
+
? fleetOnlyExpr(c, isHelper, isLocal)
|
|
1177
|
+
: tenantTablesRead(w, c).has(target);
|
|
1178
|
+
const refuses = (c) => kind === "none"
|
|
1179
|
+
? refusesNonFleet(c, isHelper, isLocal)
|
|
1180
|
+
: tenantTablesRead(w, c).has(target);
|
|
1181
|
+
for (const c of enclosingConditions(node, stop)) {
|
|
1182
|
+
if (gates(c))
|
|
1183
|
+
return `gated by \`${c.getText().slice(0, 80)}\` (line ${lineOf(c)})`;
|
|
1184
|
+
}
|
|
1185
|
+
for (const st of precedingStatements(node, stop)) {
|
|
1186
|
+
if (ts.isIfStatement(st) &&
|
|
1187
|
+
exitsIn(st.thenStatement) &&
|
|
1188
|
+
refuses(st.expression))
|
|
1189
|
+
return `early exit on \`${st.expression.getText().slice(0, 80)}\` (line ${lineOf(st)})`;
|
|
1190
|
+
let hit;
|
|
1191
|
+
walk(st, (x) => {
|
|
1192
|
+
if (hit || !ts.isCallExpression(x))
|
|
1193
|
+
return;
|
|
1194
|
+
const f = unwrap(x.expression);
|
|
1195
|
+
if (!ts.isIdentifier(f))
|
|
1196
|
+
return;
|
|
1197
|
+
const n = f.text;
|
|
1198
|
+
if (w.facts.fleetRefusal.has(n)) {
|
|
1199
|
+
hit = `master-only refusal \`${n}\` (line ${lineOf(x)})`;
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (kind === "optional" &&
|
|
1203
|
+
w.facts.refusing.has(n) &&
|
|
1204
|
+
w.facts.readsTenant.has(n) &&
|
|
1205
|
+
x.arguments.some((a) => {
|
|
1206
|
+
const v = w.eval(a);
|
|
1207
|
+
return v.kind === "row" && v.table === target;
|
|
1208
|
+
}))
|
|
1209
|
+
hit = `refusing tenant check \`${n}\` handed the ${target} row (line ${lineOf(x)})`;
|
|
1210
|
+
}, true);
|
|
1211
|
+
if (hit)
|
|
1212
|
+
return hit;
|
|
1213
|
+
}
|
|
1214
|
+
return undefined;
|
|
1215
|
+
}
|
|
1216
|
+
export function scanR52(files) {
|
|
1217
|
+
return scanR52Detailed(files).scan;
|
|
1218
|
+
}
|
|
1219
|
+
const WRITE_RE = /\bdb\.(patch|replace|delete|insert)$/;
|
|
1220
|
+
const EFFECT_RE = /\b(scheduler\.(runAfter|runAt)|runMutation|runAction)$/;
|
|
1221
|
+
export function scanR52Detailed(files) {
|
|
1222
|
+
const scan = emptyScan();
|
|
1223
|
+
const facts = r52Facts(files);
|
|
1224
|
+
if (facts.schema === undefined || facts.schema.size === 0) {
|
|
1225
|
+
scan.cnj.push({
|
|
1226
|
+
rel: facts.schemaRel ?? "convex/schema.ts",
|
|
1227
|
+
n: 0,
|
|
1228
|
+
note: "schema: convex/schema.ts absent or no defineTable read — the tenant-column test cannot be made",
|
|
1229
|
+
});
|
|
1230
|
+
return { scan, untenanted: [], schemaRel: facts.schemaRel };
|
|
1231
|
+
}
|
|
1232
|
+
const untenanted = [...facts.schema.values()]
|
|
1233
|
+
.filter((t) => t.tenant !== "required")
|
|
1234
|
+
.map((t) => t.name);
|
|
1235
|
+
for (const p of facts.parsed) {
|
|
1236
|
+
for (const reg of registrations(p)) {
|
|
1237
|
+
if (reg.builder !== "mutation" && reg.builder !== "action")
|
|
1238
|
+
continue;
|
|
1239
|
+
const gate = possessionGate(reg);
|
|
1240
|
+
const w = new HandlerWalk(reg, facts);
|
|
1241
|
+
w.build();
|
|
1242
|
+
const body = reg.handler.body;
|
|
1243
|
+
if (!body)
|
|
1244
|
+
continue;
|
|
1245
|
+
walk(body, (x) => {
|
|
1246
|
+
if (!ts.isCallExpression(x))
|
|
1247
|
+
return;
|
|
1248
|
+
const callee = dotted(x.expression) ?? "";
|
|
1249
|
+
const wm = WRITE_RE.exec(callee);
|
|
1250
|
+
const em = EFFECT_RE.exec(callee);
|
|
1251
|
+
if (!wm && !em)
|
|
1252
|
+
return;
|
|
1253
|
+
const n = lineOf(x);
|
|
1254
|
+
const site = (note) => ({
|
|
1255
|
+
rel: p.rel,
|
|
1256
|
+
n,
|
|
1257
|
+
note: `${reg.name}: ${note}`,
|
|
1258
|
+
});
|
|
1259
|
+
const conds = enclosingConditions(x, reg.handler);
|
|
1260
|
+
// the targets this write reaches, with the provenance of each.
|
|
1261
|
+
const targets = [];
|
|
1262
|
+
if (wm && wm[1] !== "insert") {
|
|
1263
|
+
const sel = x.arguments[0];
|
|
1264
|
+
if (!sel)
|
|
1265
|
+
return;
|
|
1266
|
+
const v = w.eval(sel);
|
|
1267
|
+
if (v.table === undefined) {
|
|
1268
|
+
if (v.unresolved)
|
|
1269
|
+
scan.cnj.push(site(`${wm[1]} — selector \`${sel.getText()}\`: ${v.unresolved}`));
|
|
1270
|
+
else if (v.choosable)
|
|
1271
|
+
scan.cnj.push(site(`${wm[1]} — selector \`${sel.getText()}\` is caller-choosable (${v.choosable}) but its table cannot be resolved`));
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (gate && dotted(sel) === `${gate.rowVar}._id`) {
|
|
1275
|
+
scan.conform.push(site(`${wm[1]} ${gate.table} — possession-gated: the row is selected by the hash of a presented secret through a unique index on ${gate.table}.${gate.field} (line ${gate.rowLine}), and the handler writes only that row`));
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (!v.choosable) {
|
|
1279
|
+
scan.conform.push(site(`${wm[1]} ${v.table} — selector \`${sel.getText()}\` is not caller-choosable`));
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (v.tenantChecked &&
|
|
1283
|
+
facts.schema?.get(v.table)?.tenant === "optional") {
|
|
1284
|
+
scan.conform.push(site(`${wm[1]} ${v.table} — selected through ${/^the push/.test(v.tenantChecked) ? v.tenantChecked : `${v.tenantChecked}(), whose body reads the tenant of the ${v.table} rows it returns`}`));
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
targets.push({
|
|
1288
|
+
table: v.table,
|
|
1289
|
+
reason: v.choosable,
|
|
1290
|
+
what: `${wm[1]} ${v.table}`,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
// insert / scheduled effect: attributed to a row it reads. An
|
|
1295
|
+
// insert into a table that carries a tenant value, or whose
|
|
1296
|
+
// literal stamps one, belongs to that tenant (R-6 governs the
|
|
1297
|
+
// stamp) — it is not a row left to every tenant.
|
|
1298
|
+
if (wm) {
|
|
1299
|
+
const t0 = x.arguments[0];
|
|
1300
|
+
const intoName = t0 && ts.isStringLiteralLike(t0) ? t0.text : undefined;
|
|
1301
|
+
const into = intoName !== undefined
|
|
1302
|
+
? facts.schema?.get(intoName)?.tenant
|
|
1303
|
+
: undefined;
|
|
1304
|
+
// a `namespace` stamps a tenant only where that table's
|
|
1305
|
+
// namespace is prefix-checked (standard 2825122 R-52).
|
|
1306
|
+
const nsOf = intoName !== undefined
|
|
1307
|
+
? facts.namespace.get(intoName)
|
|
1308
|
+
: undefined;
|
|
1309
|
+
const stampCol = (k) => TENANT_COLUMNS.has(k) ||
|
|
1310
|
+
(k === NAMESPACE_COLUMN && nsOf?.verdict === "checked");
|
|
1311
|
+
let nsUndecided = false;
|
|
1312
|
+
const lit = x.arguments[1] && unwrap(x.arguments[1]);
|
|
1313
|
+
const stamps = lit !== undefined &&
|
|
1314
|
+
ts.isObjectLiteralExpression(lit) &&
|
|
1315
|
+
lit.properties.some((pr) => {
|
|
1316
|
+
const col = (k) => {
|
|
1317
|
+
if (k === NAMESPACE_COLUMN && nsOf?.verdict === "undecided")
|
|
1318
|
+
nsUndecided = true;
|
|
1319
|
+
return stampCol(k);
|
|
1320
|
+
};
|
|
1321
|
+
if ((ts.isPropertyAssignment(pr) ||
|
|
1322
|
+
ts.isShorthandPropertyAssignment(pr)) &&
|
|
1323
|
+
ts.isIdentifier(pr.name))
|
|
1324
|
+
return col(pr.name.text);
|
|
1325
|
+
// `...(org ? { orgId: org } : {})` stamps when set.
|
|
1326
|
+
let hit = false;
|
|
1327
|
+
if (ts.isSpreadAssignment(pr))
|
|
1328
|
+
walk(pr.expression, (y) => {
|
|
1329
|
+
if ((ts.isPropertyAssignment(y) ||
|
|
1330
|
+
ts.isShorthandPropertyAssignment(y)) &&
|
|
1331
|
+
ts.isIdentifier(y.name) &&
|
|
1332
|
+
col(y.name.text))
|
|
1333
|
+
hit = true;
|
|
1334
|
+
});
|
|
1335
|
+
return hit;
|
|
1336
|
+
});
|
|
1337
|
+
if (into === "required" || stamps)
|
|
1338
|
+
return;
|
|
1339
|
+
if (nsUndecided && nsOf) {
|
|
1340
|
+
scan.cnj.push(site(`insert ${intoName} — stamps only a namespace column of ${intoName}: prefix check not statically decidable (${nsOf.why})`));
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
let payload = SAFE;
|
|
1345
|
+
const args = wm ? x.arguments.slice(1) : x.arguments.slice(1);
|
|
1346
|
+
for (const a of args)
|
|
1347
|
+
payload = merge(payload, w.eval(a));
|
|
1348
|
+
for (const c of conds)
|
|
1349
|
+
payload = merge(payload, w.eval(c));
|
|
1350
|
+
const label = wm
|
|
1351
|
+
? `insert ${x.arguments[0]?.text ?? "?"}`
|
|
1352
|
+
: `scheduled effect ${x.arguments[em && /scheduler/.test(em[1]) ? 1 : 0]?.getText() ?? "?"}`;
|
|
1353
|
+
for (const via of payload.vias)
|
|
1354
|
+
targets.push({
|
|
1355
|
+
table: via.table,
|
|
1356
|
+
reason: `${via.reason} (row selected at line ${via.line})`,
|
|
1357
|
+
what: `${label} attributed to a ${via.table} row`,
|
|
1358
|
+
});
|
|
1359
|
+
if (targets.length === 0)
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
const reds = [];
|
|
1363
|
+
const greens = [];
|
|
1364
|
+
for (const t of targets) {
|
|
1365
|
+
const kind = facts.schema?.get(t.table)?.tenant;
|
|
1366
|
+
if (kind === undefined) {
|
|
1367
|
+
scan.cnj.push(site(`${t.what} — table ${t.table} not in schema.ts`));
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
const ns = facts.namespace.get(t.table);
|
|
1371
|
+
if (ns?.verdict === "undecided") {
|
|
1372
|
+
scan.cnj.push(site(`${t.what} — namespace column of ${t.table}: prefix check not statically decidable (${ns.why})`));
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
if (kind === "required") {
|
|
1376
|
+
greens.push(ns?.verdict === "checked"
|
|
1377
|
+
? `${t.what} — ${t.table} carries a required tenant column (namespace, checked against the caller's allowed prefixes: ${ns.why}) (R-6/R-11 govern)`
|
|
1378
|
+
: `${t.what} — ${t.table} carries a required tenant column (R-6/R-11 govern)`);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
const g = guarded(w, x, t.table, kind);
|
|
1382
|
+
if (g)
|
|
1383
|
+
greens.push(`${t.what} — ${g}`);
|
|
1384
|
+
else
|
|
1385
|
+
reds.push(`${t.what} (${kind === "none" ? "no tenant column" : "optional tenant column"}), selected by ${t.reason}; no dominating guard reads a server-stamped tenant${kind === "none" ? " and names fleet scope" : ` of the ${t.table} row`}`);
|
|
1386
|
+
}
|
|
1387
|
+
if (reds.length)
|
|
1388
|
+
scan.violate.push(site(reds.join(" | ")));
|
|
1389
|
+
else if (greens.length)
|
|
1390
|
+
scan.conform.push(site(greens.join(" | ")));
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return { scan, untenanted, schemaRel: facts.schemaRel };
|
|
1395
|
+
}
|
|
1396
|
+
export const R52 = {
|
|
1397
|
+
id: R52_ID,
|
|
1398
|
+
run: (tree) => {
|
|
1399
|
+
const d = scanR52Detailed(tree.files);
|
|
1400
|
+
const empty = d.untenanted.length === 0 && d.schemaRel
|
|
1401
|
+
? `R-52: no applicable site — ${d.schemaRel} declares no table without a required tenant column, so no write can reach an untenanted row. NOT a clean bill.`
|
|
1402
|
+
: `R-52: no applicable site — no public mutation/action write reached a table without a tenant value (untenanted tables read: ${d.untenanted.slice(0, 20).join(", ")}${d.untenanted.length > 20 ? ", …" : ""}). NOT a clean bill.`;
|
|
1403
|
+
return decide("R-52", R52_ID, R52_CHECK, d.scan, empty);
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
export const SOURCE_CROSS_TENANT_WRITE_DETECTORS = [
|
|
1407
|
+
{ detector: R52, ruleId: "R-52", standardPin: SOURCE_STANDARD_PIN },
|
|
1408
|
+
];
|