@vantageos/backend-doctor 0.3.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 +15 -6
- package/dist/build-stamp.json +2 -2
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +1 -1
- package/dist/detectors/source/credential-hygiene.d.ts +11 -0
- package/dist/detectors/source/credential-hygiene.js +619 -0
- package/dist/detectors/source/local-identity.js +6 -1
- 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/service-account-claim.d.ts +15 -0
- package/dist/detectors/source/service-account-claim.js +491 -0
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +2 -2
- package/dist/not-checkable.js +1 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +29 -1
- package/dist/rule-class.js +2 -2
- package/fixtures/backend-standard-bacc54a.md +785 -0
- package/fixtures/{rule-classes-2825122.json → rule-classes-bacc54a.json} +20 -4
- package/package.json +1 -1
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// R-55 — a read narrows by every predicate that decides which rows belong to the
|
|
2
|
+
// result BEFORE it bounds the result, and an aggregate is computed over the
|
|
3
|
+
// narrowed set.
|
|
4
|
+
//
|
|
5
|
+
// Standard bacc54a §4 R-55 and its Detector spec, read literally. A read that
|
|
6
|
+
// returns a window (`.take(n)`, `.paginate(...)`, a `.slice(0, n)` of the rows it
|
|
7
|
+
// read) must apply every caller-dependent predicate inside the index range or the
|
|
8
|
+
// query builder that feeds the bound. A predicate applied to the window AFTER it is
|
|
9
|
+
// cut makes the window hold rows the caller may not see and drop rows it may: a
|
|
10
|
+
// short page while more exist, a cursor that skips, a figure over someone else's
|
|
11
|
+
// slice.
|
|
12
|
+
//
|
|
13
|
+
// HOW IT READS. For every PUBLIC door in `convex/` (query, mutation, action) and
|
|
14
|
+
// every in-tree helper its handler reaches, in source order (an internal function
|
|
15
|
+
// and a migration return no result to a caller, so there is no page to starve):
|
|
16
|
+
// bound a variable assigned from an expression that holds a DB read
|
|
17
|
+
// (`.query(` / `.withIndex(` / `.withSearchIndex(`) bounded by
|
|
18
|
+
// `.take(` or `.paginate(` — through a callback handed to a helper
|
|
19
|
+
// too; or re-bounded by `V.slice(0, n)`. A variable derived from a
|
|
20
|
+
// bounded one (an alias, `capped.rows`, a map of it) carries its bound.
|
|
21
|
+
// narrowing on a bounded variable V (or `V.page` of a paginate result):
|
|
22
|
+
// `V.filter(cb)` whose callback reads a CALLER-DEPENDENT value: the
|
|
23
|
+
// door's arguments, the resolved caller scope, or a local derived
|
|
24
|
+
// from either (a cursor, a status, a roster). A filter on the row
|
|
25
|
+
// alone, or against a module-level literal, is not caller-dependent
|
|
26
|
+
// and is not read as a narrowing;
|
|
27
|
+
// `filterBy…(V, …)`, a scope-filter helper;
|
|
28
|
+
// a call to an in-tree function whose own body narrows that
|
|
29
|
+
// parameter the same way;
|
|
30
|
+
// a `for…of` over V that `continue`s on a caller-dependent test.
|
|
31
|
+
// exempt the narrowing sits in a FILL LOOP (a loop that holds both the bound
|
|
32
|
+
// and the narrowing, so it keeps reading until enough kept rows), or
|
|
33
|
+
// a REFUSAL ON A FULL WINDOW stands between the (latest) bound and the
|
|
34
|
+
// narrowing: a throw whose condition or message names the cap, the
|
|
35
|
+
// overflow or the truncation. Each exemption is listed in the
|
|
36
|
+
// site's note; none is a silent pass.
|
|
37
|
+
//
|
|
38
|
+
// OUT OF SCOPE, stated. An unbounded `.collect()` has no window of its own: a
|
|
39
|
+
// post-filter over it is R-11 (where the tenant predicate runs) and R-47 (the read
|
|
40
|
+
// cap), not this rule. A narrowing hidden behind a value the scan cannot trace
|
|
41
|
+
// (a map that computes a flag the filter reads) is not seen: the detector errs
|
|
42
|
+
// toward silence there and the standard says so (no verdict is invented).
|
|
43
|
+
import * as ts from "typescript";
|
|
44
|
+
import { calledNames, closeOverCalls, indexFunctions, lineOf, parseFile, registrations, unwrap, walk, } from "./_ast.js";
|
|
45
|
+
import { decide, emptyScan, isJudgeable, } from "./_scan.js";
|
|
46
|
+
export const R55_ID = "R-55 a read narrows by every predicate before it bounds the result; an aggregate is computed over the narrowed set";
|
|
47
|
+
const R55_CHECK = "every caller-dependent predicate (tenant, roster, channel, status, time cursor) sits in the index range or the query builder before .take/.paginate, or the narrowing sits in a fill loop, or a refusal on a full window stands between the bound and the narrowing";
|
|
48
|
+
export const R55_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa §4 R-55";
|
|
49
|
+
/** A throw whose condition or message says the window was full. */
|
|
50
|
+
const FULL_WINDOW = /overflow|truncat|exceed|SCAN_CAP|_CAP\b|\bcap\b|incomplete|isFull|hitCap/i;
|
|
51
|
+
const PUBLIC_BUILDERS = new Set([
|
|
52
|
+
"query",
|
|
53
|
+
"mutation",
|
|
54
|
+
"action",
|
|
55
|
+
]);
|
|
56
|
+
const isConvexFile = (f) => /(^|\/)convex\//.test(f.rel);
|
|
57
|
+
// ---- caller-dependent values ------------------------------------------------
|
|
58
|
+
function declaredNames(fn) {
|
|
59
|
+
const out = new Set();
|
|
60
|
+
const addBinding = (b) => {
|
|
61
|
+
if (ts.isIdentifier(b))
|
|
62
|
+
out.add(b.text);
|
|
63
|
+
else
|
|
64
|
+
for (const e of b.elements)
|
|
65
|
+
if (!ts.isOmittedExpression(e))
|
|
66
|
+
addBinding(e.name);
|
|
67
|
+
};
|
|
68
|
+
walk(fn, (x) => {
|
|
69
|
+
if (ts.isParameter(x))
|
|
70
|
+
addBinding(x.name);
|
|
71
|
+
else if (ts.isVariableDeclaration(x))
|
|
72
|
+
addBinding(x.name);
|
|
73
|
+
});
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Does `node` read a caller-dependent value (an identifier in `tainted`) from
|
|
77
|
+
* outside itself? Declarations inside `node` shadow the outer names. */
|
|
78
|
+
function readsTainted(node, tainted) {
|
|
79
|
+
const declared = declaredNames(node);
|
|
80
|
+
let hit = false;
|
|
81
|
+
walk(node, (x) => {
|
|
82
|
+
if (hit || !ts.isIdentifier(x))
|
|
83
|
+
return;
|
|
84
|
+
const p = x.parent;
|
|
85
|
+
if (p && ts.isPropertyAccessExpression(p) && p.name === x)
|
|
86
|
+
return;
|
|
87
|
+
if (p && ts.isPropertyAssignment(p) && p.name === x)
|
|
88
|
+
return;
|
|
89
|
+
if (p && ts.isTypeReferenceNode(p))
|
|
90
|
+
return;
|
|
91
|
+
if (declared.has(x.text))
|
|
92
|
+
return;
|
|
93
|
+
if (tainted.has(x.text))
|
|
94
|
+
hit = true;
|
|
95
|
+
});
|
|
96
|
+
return hit;
|
|
97
|
+
}
|
|
98
|
+
/** The caller-dependent names of a function: its `args` (or, in a helper, every
|
|
99
|
+
* parameter), the result of a scope-resolver call, and any local derived from
|
|
100
|
+
* either, to a fixpoint. */
|
|
101
|
+
function taintedNames(fn, isHandler, resolvers) {
|
|
102
|
+
const tainted = new Set();
|
|
103
|
+
const params = fn.parameters ?? [];
|
|
104
|
+
params.forEach((prm, i) => {
|
|
105
|
+
if (!ts.isIdentifier(prm.name))
|
|
106
|
+
return;
|
|
107
|
+
// a handler is (ctx, args): ctx carries the database, not the caller
|
|
108
|
+
if (!isHandler || i >= 1)
|
|
109
|
+
tainted.add(prm.name.text);
|
|
110
|
+
});
|
|
111
|
+
const mentions = (e) => {
|
|
112
|
+
let hit = false;
|
|
113
|
+
walk(e, (x) => {
|
|
114
|
+
if (hit)
|
|
115
|
+
return;
|
|
116
|
+
if (ts.isIdentifier(x) && tainted.has(x.text)) {
|
|
117
|
+
const p = x.parent;
|
|
118
|
+
if (!(p && ts.isPropertyAccessExpression(p) && p.name === x))
|
|
119
|
+
hit = true;
|
|
120
|
+
}
|
|
121
|
+
if (ts.isCallExpression(x)) {
|
|
122
|
+
const c = unwrap(x.expression);
|
|
123
|
+
if (ts.isIdentifier(c) && resolvers.has(c.text))
|
|
124
|
+
hit = true;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
return hit;
|
|
128
|
+
};
|
|
129
|
+
for (let pass = 0; pass < 5; pass++) {
|
|
130
|
+
let grew = false;
|
|
131
|
+
walk(fn, (x) => {
|
|
132
|
+
if (!ts.isVariableDeclaration(x) || !x.initializer)
|
|
133
|
+
return;
|
|
134
|
+
if (!mentions(x.initializer))
|
|
135
|
+
return;
|
|
136
|
+
const names = [];
|
|
137
|
+
const add = (b) => {
|
|
138
|
+
if (ts.isIdentifier(b))
|
|
139
|
+
names.push(b.text);
|
|
140
|
+
else
|
|
141
|
+
for (const e of b.elements)
|
|
142
|
+
if (!ts.isOmittedExpression(e))
|
|
143
|
+
add(e.name);
|
|
144
|
+
};
|
|
145
|
+
add(x.name);
|
|
146
|
+
for (const n of names)
|
|
147
|
+
if (!tainted.has(n)) {
|
|
148
|
+
tainted.add(n);
|
|
149
|
+
grew = true;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
if (!grew)
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return tainted;
|
|
156
|
+
}
|
|
157
|
+
function rootIdent(e) {
|
|
158
|
+
let x = unwrap(e);
|
|
159
|
+
for (;;) {
|
|
160
|
+
if (ts.isIdentifier(x))
|
|
161
|
+
return x.text;
|
|
162
|
+
if (ts.isPropertyAccessExpression(x) || ts.isElementAccessExpression(x)) {
|
|
163
|
+
x = unwrap(x.expression);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ts.isCallExpression(x)) {
|
|
167
|
+
x = unwrap(x.expression);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function holdsBoundedRead(e, queryVars = new Set()) {
|
|
174
|
+
let kind;
|
|
175
|
+
let reads = false;
|
|
176
|
+
walk(e, (x) => {
|
|
177
|
+
if (!ts.isCallExpression(x))
|
|
178
|
+
return;
|
|
179
|
+
const c = unwrap(x.expression);
|
|
180
|
+
if (!ts.isPropertyAccessExpression(c))
|
|
181
|
+
return;
|
|
182
|
+
const m = c.name.text;
|
|
183
|
+
if (m === "take" || m === "paginate") {
|
|
184
|
+
kind = kind ?? m;
|
|
185
|
+
// `baseQuery.paginate(opts)` — the query was built in pieces
|
|
186
|
+
const r = rootIdent(c.expression);
|
|
187
|
+
if (r !== undefined && queryVars.has(r))
|
|
188
|
+
reads = true;
|
|
189
|
+
}
|
|
190
|
+
if (m === "query" || m === "withIndex" || m === "withSearchIndex")
|
|
191
|
+
reads = true;
|
|
192
|
+
});
|
|
193
|
+
return kind !== undefined && reads ? { kind } : undefined;
|
|
194
|
+
}
|
|
195
|
+
/** Does `e` build a DB query (without bounding it yet)? */
|
|
196
|
+
function buildsQuery(e) {
|
|
197
|
+
let hit = false;
|
|
198
|
+
walk(e, (x) => {
|
|
199
|
+
if (hit || !ts.isCallExpression(x))
|
|
200
|
+
return;
|
|
201
|
+
const c = unwrap(x.expression);
|
|
202
|
+
if (ts.isPropertyAccessExpression(c) && c.name.text === "query")
|
|
203
|
+
hit = true;
|
|
204
|
+
});
|
|
205
|
+
return hit;
|
|
206
|
+
}
|
|
207
|
+
/** `X.slice(0, …)` where X's root is a bounded variable. */
|
|
208
|
+
function isRebound(e, bounded) {
|
|
209
|
+
const x = unwrap(e);
|
|
210
|
+
if (!ts.isCallExpression(x))
|
|
211
|
+
return false;
|
|
212
|
+
const c = unwrap(x.expression);
|
|
213
|
+
if (!ts.isPropertyAccessExpression(c) || c.name.text !== "slice")
|
|
214
|
+
return false;
|
|
215
|
+
const a0 = x.arguments[0];
|
|
216
|
+
if (!a0 || !ts.isNumericLiteral(a0) || a0.text !== "0")
|
|
217
|
+
return false;
|
|
218
|
+
const r = rootIdent(c.expression);
|
|
219
|
+
return r !== undefined && bounded.has(r);
|
|
220
|
+
}
|
|
221
|
+
function mentionedBounded(e, bounded) {
|
|
222
|
+
let found;
|
|
223
|
+
walk(e, (x) => {
|
|
224
|
+
if (found || !ts.isIdentifier(x))
|
|
225
|
+
return;
|
|
226
|
+
const p = x.parent;
|
|
227
|
+
if (p && ts.isPropertyAccessExpression(p) && p.name === x)
|
|
228
|
+
return;
|
|
229
|
+
const b = bounded.get(x.text);
|
|
230
|
+
if (b)
|
|
231
|
+
found = b;
|
|
232
|
+
});
|
|
233
|
+
return found;
|
|
234
|
+
}
|
|
235
|
+
function enclosingLoops(n, stop) {
|
|
236
|
+
const out = [];
|
|
237
|
+
for (let x = n.parent; x && x !== stop; x = x.parent)
|
|
238
|
+
if (ts.isWhileStatement(x) ||
|
|
239
|
+
ts.isDoStatement(x) ||
|
|
240
|
+
ts.isForStatement(x) ||
|
|
241
|
+
ts.isForOfStatement(x) ||
|
|
242
|
+
ts.isForInStatement(x))
|
|
243
|
+
out.push(x);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
function contains(outer, inner) {
|
|
247
|
+
return (inner.getStart() >= outer.getStart() && inner.getEnd() <= outer.getEnd());
|
|
248
|
+
}
|
|
249
|
+
function refusalBetween(fn, from, to) {
|
|
250
|
+
let found = false;
|
|
251
|
+
walk(fn, (x) => {
|
|
252
|
+
if (found || !ts.isThrowStatement(x))
|
|
253
|
+
return;
|
|
254
|
+
const at = x.getStart();
|
|
255
|
+
if (at <= from || at >= to)
|
|
256
|
+
return;
|
|
257
|
+
if (FULL_WINDOW.test(x.getText())) {
|
|
258
|
+
found = true;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (let up = x.parent; up && up !== fn; up = up.parent)
|
|
262
|
+
if (ts.isIfStatement(up) && FULL_WINDOW.test(up.expression.getText())) {
|
|
263
|
+
found = true;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
return found;
|
|
268
|
+
}
|
|
269
|
+
function readFunction(fn, isHandler, narrowParams, resolvers) {
|
|
270
|
+
const out = { bounds: [], events: [], exempt: [] };
|
|
271
|
+
const bounded = new Map();
|
|
272
|
+
const queryVars = new Set();
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const tainted = taintedNames(fn, isHandler, resolvers);
|
|
275
|
+
const callerDependent = (cb) => readsTainted(cb, tainted);
|
|
276
|
+
const record = (node, what, b) => {
|
|
277
|
+
const ev = { node, line: lineOf(node), what, bound: b };
|
|
278
|
+
const loops = enclosingLoops(node, fn);
|
|
279
|
+
if (loops.some((l) => contains(l, b.node))) {
|
|
280
|
+
out.exempt.push({ event: ev, why: "fill loop" });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (refusalBetween(fn, b.pos, node.getStart())) {
|
|
284
|
+
out.exempt.push({ event: ev, why: "refuses on a full window" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
out.events.push(ev);
|
|
288
|
+
};
|
|
289
|
+
const scanNarrowing = (e) => {
|
|
290
|
+
walk(e, (x) => {
|
|
291
|
+
if (seen.has(x))
|
|
292
|
+
return;
|
|
293
|
+
if (ts.isCallExpression(x)) {
|
|
294
|
+
const c = unwrap(x.expression);
|
|
295
|
+
if (ts.isPropertyAccessExpression(c) && c.name.text === "filter") {
|
|
296
|
+
const r = rootIdent(c.expression);
|
|
297
|
+
let b = r === undefined ? undefined : bounded.get(r);
|
|
298
|
+
const inline = b === undefined
|
|
299
|
+
? holdsBoundedRead(c.expression, queryVars)
|
|
300
|
+
: undefined;
|
|
301
|
+
if (b === undefined && inline !== undefined) {
|
|
302
|
+
// `(await ctx.db.query(…).take(n)).filter(cb)` — the bound is in the receiver.
|
|
303
|
+
b = {
|
|
304
|
+
pos: c.expression.getStart(),
|
|
305
|
+
node: c.expression,
|
|
306
|
+
kind: inline.kind,
|
|
307
|
+
line: lineOf(c.expression),
|
|
308
|
+
};
|
|
309
|
+
out.bounds.push(b);
|
|
310
|
+
}
|
|
311
|
+
const cb = x.arguments[0];
|
|
312
|
+
if (b &&
|
|
313
|
+
cb &&
|
|
314
|
+
(ts.isArrowFunction(unwrap(cb)) ||
|
|
315
|
+
ts.isFunctionExpression(unwrap(cb))) &&
|
|
316
|
+
callerDependent(unwrap(cb))) {
|
|
317
|
+
seen.add(x);
|
|
318
|
+
record(x, x.getText().replace(/\s+/g, " ").slice(0, 90), b);
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (ts.isIdentifier(c)) {
|
|
323
|
+
const first = x.arguments[0];
|
|
324
|
+
const r = first ? rootIdent(first) : undefined;
|
|
325
|
+
const b = r === undefined ? undefined : bounded.get(r);
|
|
326
|
+
if (b && /^filter[A-Z]/.test(c.text)) {
|
|
327
|
+
seen.add(x);
|
|
328
|
+
record(x, x.getText().replace(/\s+/g, " ").slice(0, 90), b);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const np = narrowParams.get(c.text);
|
|
332
|
+
if (np) {
|
|
333
|
+
x.arguments.forEach((a, i) => {
|
|
334
|
+
const ra = rootIdent(a);
|
|
335
|
+
const ba = ra === undefined ? undefined : bounded.get(ra);
|
|
336
|
+
if (ba && np.has(i) && !seen.has(x)) {
|
|
337
|
+
seen.add(x);
|
|
338
|
+
record(x, `${c.text}(…) narrows its parameter ${i + 1}`, ba);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
const assign = (name, init, at) => {
|
|
347
|
+
scanNarrowing(init);
|
|
348
|
+
const read = holdsBoundedRead(init, queryVars);
|
|
349
|
+
if (read) {
|
|
350
|
+
const b = {
|
|
351
|
+
pos: at.getStart(),
|
|
352
|
+
node: at,
|
|
353
|
+
kind: read.kind,
|
|
354
|
+
line: lineOf(at),
|
|
355
|
+
};
|
|
356
|
+
bounded.set(name, b);
|
|
357
|
+
out.bounds.push(b);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (buildsQuery(init))
|
|
361
|
+
queryVars.add(name);
|
|
362
|
+
if (isRebound(init, bounded)) {
|
|
363
|
+
const b = {
|
|
364
|
+
pos: at.getStart(),
|
|
365
|
+
node: at,
|
|
366
|
+
kind: "slice",
|
|
367
|
+
line: lineOf(at),
|
|
368
|
+
};
|
|
369
|
+
bounded.set(name, b);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const inherited = mentionedBounded(init, bounded);
|
|
373
|
+
if (inherited)
|
|
374
|
+
bounded.set(name, inherited);
|
|
375
|
+
else
|
|
376
|
+
bounded.delete(name);
|
|
377
|
+
};
|
|
378
|
+
const visit = (n) => {
|
|
379
|
+
if (ts.isVariableDeclaration(n) && n.initializer) {
|
|
380
|
+
if (ts.isIdentifier(n.name))
|
|
381
|
+
assign(n.name.text, n.initializer, n);
|
|
382
|
+
else if (ts.isObjectBindingPattern(n.name))
|
|
383
|
+
for (const el of n.name.elements)
|
|
384
|
+
if (ts.isIdentifier(el.name))
|
|
385
|
+
assign(el.name.text, n.initializer, n);
|
|
386
|
+
// children already scanned by assign(); still descend for nested functions
|
|
387
|
+
ts.forEachChild(n.initializer, visit);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (ts.isBinaryExpression(n) &&
|
|
391
|
+
n.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
392
|
+
ts.isIdentifier(n.left)) {
|
|
393
|
+
assign(n.left.text, n.right, n);
|
|
394
|
+
ts.forEachChild(n.right, visit);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (ts.isForOfStatement(n)) {
|
|
398
|
+
const r = rootIdent(n.expression);
|
|
399
|
+
const b = r === undefined ? undefined : bounded.get(r);
|
|
400
|
+
if (b) {
|
|
401
|
+
const loopVars = new Set();
|
|
402
|
+
if (ts.isVariableDeclarationList(n.initializer))
|
|
403
|
+
for (const d of n.initializer.declarations)
|
|
404
|
+
for (const nm of declaredNames(d))
|
|
405
|
+
loopVars.add(nm);
|
|
406
|
+
walk(n.statement, (x) => {
|
|
407
|
+
if (!ts.isIfStatement(x))
|
|
408
|
+
return;
|
|
409
|
+
const skips = ts.isContinueStatement(x.thenStatement) ||
|
|
410
|
+
(ts.isBlock(x.thenStatement) &&
|
|
411
|
+
x.thenStatement.statements.some((s) => ts.isContinueStatement(s)));
|
|
412
|
+
if (!skips)
|
|
413
|
+
return;
|
|
414
|
+
if (!readsTainted(x.expression, tainted))
|
|
415
|
+
return;
|
|
416
|
+
record(x, `a loop that skips rows on ${x.expression.getText().replace(/\s+/g, " ").slice(0, 70)}`, b);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (ts.isReturnStatement(n) && n.expression && !seen.has(n)) {
|
|
421
|
+
// `return await ctx.db.query(…).take(n)` — a bounded read nobody names.
|
|
422
|
+
const rd = holdsBoundedRead(n.expression, queryVars);
|
|
423
|
+
if (rd && !out.bounds.some((b) => contains(n, b.node)))
|
|
424
|
+
out.bounds.push({
|
|
425
|
+
pos: n.getStart(),
|
|
426
|
+
node: n,
|
|
427
|
+
kind: rd.kind,
|
|
428
|
+
line: lineOf(n),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (ts.isCallExpression(n) ||
|
|
432
|
+
ts.isReturnStatement(n) ||
|
|
433
|
+
ts.isExpressionStatement(n)) {
|
|
434
|
+
if (!seen.has(n))
|
|
435
|
+
scanNarrowing(n);
|
|
436
|
+
}
|
|
437
|
+
ts.forEachChild(n, visit);
|
|
438
|
+
};
|
|
439
|
+
const body = fn.body;
|
|
440
|
+
if (body)
|
|
441
|
+
visit(body);
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
/** top-level functions whose own body narrows parameter i by a caller-dependent filter. */
|
|
445
|
+
function narrowingParams(idx, resolvers) {
|
|
446
|
+
const out = new Map();
|
|
447
|
+
for (const [name, defs] of idx)
|
|
448
|
+
for (const d of defs) {
|
|
449
|
+
const params = d.fn.parameters.map((p) => ts.isIdentifier(p.name) ? p.name.text : undefined);
|
|
450
|
+
if (!d.fn.body)
|
|
451
|
+
continue;
|
|
452
|
+
const tainted = taintedNames(d.fn, false, resolvers);
|
|
453
|
+
walk(d.fn.body, (x) => {
|
|
454
|
+
if (!ts.isCallExpression(x))
|
|
455
|
+
return;
|
|
456
|
+
const c = unwrap(x.expression);
|
|
457
|
+
if (!ts.isPropertyAccessExpression(c) || c.name.text !== "filter")
|
|
458
|
+
return;
|
|
459
|
+
const r = rootIdent(c.expression);
|
|
460
|
+
const i = r === undefined ? -1 : params.indexOf(r);
|
|
461
|
+
if (i < 0)
|
|
462
|
+
return;
|
|
463
|
+
const cb = x.arguments[0] && unwrap(x.arguments[0]);
|
|
464
|
+
if (!cb || !(ts.isArrowFunction(cb) || ts.isFunctionExpression(cb)))
|
|
465
|
+
return;
|
|
466
|
+
if (!readsTainted(cb, tainted))
|
|
467
|
+
return;
|
|
468
|
+
const s = out.get(name) ?? new Set();
|
|
469
|
+
s.add(i);
|
|
470
|
+
out.set(name, s);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
export function scanR55(files) {
|
|
476
|
+
const scan = emptyScan();
|
|
477
|
+
scan.bounded = 0;
|
|
478
|
+
const ps = files
|
|
479
|
+
.filter((f) => isJudgeable(f) && isConvexFile(f))
|
|
480
|
+
.map(parseFile);
|
|
481
|
+
const idx = indexFunctions(ps);
|
|
482
|
+
// the functions that resolve the caller's scope: withOrgScope and whatever calls it
|
|
483
|
+
const resolvers = new Set(["withOrgScope"]);
|
|
484
|
+
for (const n of closeOverCalls(idx, (d) => calledNames(d.fn).has("withOrgScope")))
|
|
485
|
+
resolvers.add(n);
|
|
486
|
+
const np = narrowingParams(idx, resolvers);
|
|
487
|
+
const units = [];
|
|
488
|
+
const taken = new Set();
|
|
489
|
+
const queue = [];
|
|
490
|
+
for (const p of ps)
|
|
491
|
+
for (const r of registrations(p)) {
|
|
492
|
+
if (!PUBLIC_BUILDERS.has(r.builder))
|
|
493
|
+
continue;
|
|
494
|
+
units.push({
|
|
495
|
+
rel: r.rel,
|
|
496
|
+
line: r.line,
|
|
497
|
+
name: r.name,
|
|
498
|
+
fn: r.handler,
|
|
499
|
+
isHandler: true,
|
|
500
|
+
});
|
|
501
|
+
taken.add(r.handler);
|
|
502
|
+
queue.push(r.handler);
|
|
503
|
+
}
|
|
504
|
+
// the helpers a public door reaches, once each
|
|
505
|
+
const reached = new Set();
|
|
506
|
+
while (queue.length) {
|
|
507
|
+
const n = queue.pop();
|
|
508
|
+
for (const c of calledNames(n)) {
|
|
509
|
+
if (reached.has(c))
|
|
510
|
+
continue;
|
|
511
|
+
reached.add(c);
|
|
512
|
+
for (const d of idx.get(c) ?? []) {
|
|
513
|
+
if (taken.has(d.fn))
|
|
514
|
+
continue;
|
|
515
|
+
taken.add(d.fn);
|
|
516
|
+
units.push({
|
|
517
|
+
rel: d.rel,
|
|
518
|
+
line: d.line,
|
|
519
|
+
name: c,
|
|
520
|
+
fn: d.fn,
|
|
521
|
+
isHandler: false,
|
|
522
|
+
});
|
|
523
|
+
queue.push(d.fn);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const u of units) {
|
|
528
|
+
const read = readFunction(u.fn, u.isHandler, np, resolvers);
|
|
529
|
+
if (read.bounds.length === 0)
|
|
530
|
+
continue;
|
|
531
|
+
scan.bounded++;
|
|
532
|
+
const b0 = read.bounds[0];
|
|
533
|
+
const where = `bounded read (${b0.kind}) at line ${b0.line}`;
|
|
534
|
+
if (read.events.length > 0) {
|
|
535
|
+
const named = read.events
|
|
536
|
+
.slice(0, 3)
|
|
537
|
+
.map((e) => `line ${e.line}: ${e.what}`)
|
|
538
|
+
.join("; ");
|
|
539
|
+
const more = read.events.length > 3 ? ` (+${read.events.length - 3} more)` : "";
|
|
540
|
+
scan.violate.push({
|
|
541
|
+
rel: u.rel,
|
|
542
|
+
n: u.line,
|
|
543
|
+
note: `${u.name}: ${where}, then a caller-dependent narrowing after the bound — ${named}${more}; the window holds rows the caller may not see and drops rows it may`,
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const parts = read.exempt.length === 0
|
|
548
|
+
? "every caller-dependent predicate sits before the bound"
|
|
549
|
+
: read.exempt
|
|
550
|
+
.map((x) => `narrowing at line ${x.event.line} is covered (${x.why})`)
|
|
551
|
+
.join("; ");
|
|
552
|
+
scan.conform.push({
|
|
553
|
+
rel: u.rel,
|
|
554
|
+
n: u.line,
|
|
555
|
+
note: `${u.name}: ${where}; ${parts}`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
return scan;
|
|
559
|
+
}
|
|
560
|
+
export const R55 = {
|
|
561
|
+
id: R55_ID,
|
|
562
|
+
run: (tree) => {
|
|
563
|
+
const s = scanR55(tree.files);
|
|
564
|
+
const n = tree.files.filter((f) => isJudgeable(f) && isConvexFile(f)).length;
|
|
565
|
+
const empty = `R-55: no applicable site — ${n} convex file(s) read, none holds a bounded read (.take/.paginate over a DB query) in a function body. NOT a clean bill.`;
|
|
566
|
+
return decide("R-55", R55_ID, R55_CHECK, s, empty);
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
export const SOURCE_NARROW_THEN_BOUND_DETECTORS = [
|
|
570
|
+
{ detector: R55, ruleId: "R-55", standardPin: R55_STANDARD_PIN },
|
|
571
|
+
];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Detector } from "../../engine-types.js";
|
|
2
|
+
import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
3
|
+
export declare const R54_ID = "R-54 a door the service account can reach acts inside a transport-verified organisation; a claimless call is refused, never served as master";
|
|
4
|
+
export declare const R54_STANDARD_PIN = "vantagepeers/backend-standard/backend-standard.md @ bacc54a53d5f6f9f20e5254908e2b46807902faa \u00A74 R-54";
|
|
5
|
+
/** `fleet-wide: <reason>` — the reason must be a real sentence, not a token. */
|
|
6
|
+
export declare const FLEET_WIDE_MARKER: RegExp;
|
|
7
|
+
export interface R54Scan extends Scan {
|
|
8
|
+
/** doors that resolve no caller (CR-1 / R-6 govern them), by `rel:name`. */
|
|
9
|
+
notApplicable: string[];
|
|
10
|
+
/** public doors read. */
|
|
11
|
+
doors: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function scanR54(files: SrcFile[]): R54Scan;
|
|
14
|
+
export declare const R54: Detector;
|
|
15
|
+
export declare const SOURCE_SERVICE_ACCOUNT_CLAIM_DETECTORS: SourceDetectorEntry[];
|