@vantageos/backend-doctor 0.1.14 → 0.3.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 +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- 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 +71 -18
- package/dist/detectors/source/contract.js +21 -13
- 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 +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- 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 +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- 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 +661 -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.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- 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.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- 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.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- 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/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Shared TypeScript-AST primitives for the R-52 / R-53 source detectors.
|
|
2
|
+
//
|
|
3
|
+
// Both rules read the SAME delivered material — the schema's tenant columns, every
|
|
4
|
+
// public write handler, and the helper functions a handler calls — and both need
|
|
5
|
+
// the parser rather than a line regex: R-52 traces a selector's provenance across
|
|
6
|
+
// statements, R-53 reads the operands of an admission comparison. One parse per
|
|
7
|
+
// file, one function index per tree; nothing here decides a verdict.
|
|
8
|
+
import * as ts from "typescript";
|
|
9
|
+
export function sourceOf(f) {
|
|
10
|
+
return f.lines.map((l) => l.raw).join("\n");
|
|
11
|
+
}
|
|
12
|
+
export function parseFile(f) {
|
|
13
|
+
return {
|
|
14
|
+
rel: f.rel,
|
|
15
|
+
sf: ts.createSourceFile(f.rel, sourceOf(f), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function lineOf(n) {
|
|
19
|
+
const sf = n.getSourceFile();
|
|
20
|
+
return sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1;
|
|
21
|
+
}
|
|
22
|
+
/** Strip `await`, parentheses, `!` non-null, `as`/`satisfies` casts. */
|
|
23
|
+
export function unwrap(e) {
|
|
24
|
+
let x = e;
|
|
25
|
+
for (;;) {
|
|
26
|
+
if (ts.isAwaitExpression(x) ||
|
|
27
|
+
ts.isParenthesizedExpression(x) ||
|
|
28
|
+
ts.isNonNullExpression(x) ||
|
|
29
|
+
ts.isAsExpression(x) ||
|
|
30
|
+
ts.isSatisfiesExpression(x))
|
|
31
|
+
x = x.expression;
|
|
32
|
+
else
|
|
33
|
+
return x;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** A property read: `x.prop`, or `x["prop"]` (a string-literal element access
|
|
37
|
+
* is the same read, spelled differently). A computed key is not a read of a
|
|
38
|
+
* known property and gives undefined. */
|
|
39
|
+
export function propRead(e) {
|
|
40
|
+
if (ts.isPropertyAccessExpression(e))
|
|
41
|
+
return { obj: e.expression, name: e.name.text };
|
|
42
|
+
if (ts.isElementAccessExpression(e) &&
|
|
43
|
+
ts.isStringLiteralLike(e.argumentExpression))
|
|
44
|
+
return { obj: e.expression, name: e.argumentExpression.text };
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** The initializer of the NEAREST `const <id> = <init>` visible from `id`, or
|
|
48
|
+
* undefined (a parameter or a `let` in between shadows it, so does a
|
|
49
|
+
* destructure). Exactly one level: the caller never follows an alias of an
|
|
50
|
+
* alias. */
|
|
51
|
+
export function constInit(id) {
|
|
52
|
+
for (let x = id.parent; x; x = x.parent) {
|
|
53
|
+
if (ts.isFunctionLike(x) &&
|
|
54
|
+
x.parameters.some((q) => ts.isIdentifier(q.name) && q.name.text === id.text))
|
|
55
|
+
return undefined;
|
|
56
|
+
const stmts = ts.isBlock(x) || ts.isSourceFile(x) || ts.isModuleBlock(x)
|
|
57
|
+
? x.statements
|
|
58
|
+
: ts.isCaseClause(x) || ts.isDefaultClause(x)
|
|
59
|
+
? x.statements
|
|
60
|
+
: undefined;
|
|
61
|
+
if (!stmts)
|
|
62
|
+
continue;
|
|
63
|
+
for (const st of stmts) {
|
|
64
|
+
if (!ts.isVariableStatement(st))
|
|
65
|
+
continue;
|
|
66
|
+
for (const d of st.declarationList.declarations)
|
|
67
|
+
if (ts.isIdentifier(d.name) && d.name.text === id.text)
|
|
68
|
+
return st.declarationList.flags & ts.NodeFlags.Const
|
|
69
|
+
? d.initializer
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
/** `e` as a property read, one const alias deep: `x.p`, `x["p"]`, or an
|
|
76
|
+
* identifier declared `const a = x.p` / `const a = x["p"]` (read as `x.p`). */
|
|
77
|
+
export function propReadResolved(e) {
|
|
78
|
+
const u = unwrap(e);
|
|
79
|
+
const direct = propRead(u);
|
|
80
|
+
if (direct)
|
|
81
|
+
return direct;
|
|
82
|
+
if (!ts.isIdentifier(u))
|
|
83
|
+
return undefined;
|
|
84
|
+
const init = constInit(u);
|
|
85
|
+
return init ? propRead(unwrap(init)) : undefined;
|
|
86
|
+
}
|
|
87
|
+
/** Visit every descendant of `n` (pre-order), NOT descending into nested
|
|
88
|
+
* function bodies when `ownOnly` is set. */
|
|
89
|
+
export function walk(n, visit, ownOnly = false) {
|
|
90
|
+
const rec = (x) => {
|
|
91
|
+
visit(x);
|
|
92
|
+
if (ownOnly &&
|
|
93
|
+
x !== n &&
|
|
94
|
+
(ts.isFunctionDeclaration(x) ||
|
|
95
|
+
ts.isFunctionExpression(x) ||
|
|
96
|
+
ts.isArrowFunction(x)))
|
|
97
|
+
return;
|
|
98
|
+
ts.forEachChild(x, rec);
|
|
99
|
+
};
|
|
100
|
+
rec(n);
|
|
101
|
+
}
|
|
102
|
+
/** `a.b.c` as text, or undefined when the expression is not a dotted chain. */
|
|
103
|
+
export function dotted(e) {
|
|
104
|
+
const x = unwrap(e);
|
|
105
|
+
if (ts.isIdentifier(x))
|
|
106
|
+
return x.text;
|
|
107
|
+
if (ts.isPropertyAccessExpression(x)) {
|
|
108
|
+
const head = dotted(x.expression);
|
|
109
|
+
return head === undefined ? undefined : `${head}.${x.name.text}`;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/** The callee's last name: `ctx.db.patch` -> `patch`, `helper` -> `helper`. */
|
|
114
|
+
export function calleeName(c) {
|
|
115
|
+
const e = unwrap(c.expression);
|
|
116
|
+
if (ts.isIdentifier(e))
|
|
117
|
+
return e.text;
|
|
118
|
+
if (ts.isPropertyAccessExpression(e))
|
|
119
|
+
return e.name.text;
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const BUILDERS = new Set([
|
|
123
|
+
"mutation",
|
|
124
|
+
"action",
|
|
125
|
+
"query",
|
|
126
|
+
"internalMutation",
|
|
127
|
+
"internalAction",
|
|
128
|
+
"internalQuery",
|
|
129
|
+
]);
|
|
130
|
+
export function idTableOf(text) {
|
|
131
|
+
const m = /^\s*(?:v\.optional\(\s*)?v\.id\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/.exec(text);
|
|
132
|
+
return m?.[1];
|
|
133
|
+
}
|
|
134
|
+
/** The ONE table a validator's text references through `v.id("T")` — through
|
|
135
|
+
* `v.optional`, `v.array`, or a nested `v.object` — or undefined when it names
|
|
136
|
+
* none or several. */
|
|
137
|
+
export function singleIdTable(text) {
|
|
138
|
+
const ts_ = new Set([...text.matchAll(/v\.id\(\s*["'`]([A-Za-z0-9_]+)["'`]\s*\)/g)].map((m) => m[1]));
|
|
139
|
+
return ts_.size === 1 ? [...ts_][0] : undefined;
|
|
140
|
+
}
|
|
141
|
+
/** The object literal of an `args:` validator: inline, `v.object({…})`, or a
|
|
142
|
+
* same-file top-level const naming either. */
|
|
143
|
+
function argsObject(p, e0) {
|
|
144
|
+
const e = unwrap(e0);
|
|
145
|
+
if (ts.isObjectLiteralExpression(e))
|
|
146
|
+
return e;
|
|
147
|
+
if (ts.isCallExpression(e) &&
|
|
148
|
+
calleeName(e) === "object" &&
|
|
149
|
+
e.arguments[0] &&
|
|
150
|
+
ts.isObjectLiteralExpression(unwrap(e.arguments[0])))
|
|
151
|
+
return unwrap(e.arguments[0]);
|
|
152
|
+
if (ts.isIdentifier(e)) {
|
|
153
|
+
for (const st of p.sf.statements) {
|
|
154
|
+
if (!ts.isVariableStatement(st))
|
|
155
|
+
continue;
|
|
156
|
+
for (const d of st.declarationList.declarations)
|
|
157
|
+
if (ts.isIdentifier(d.name) && d.name.text === e.text && d.initializer)
|
|
158
|
+
return argsObject(p, d.initializer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
/** Every `export const NAME = <builder>({ … handler … })` of a file. */
|
|
164
|
+
export function registrations(p) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const st of p.sf.statements) {
|
|
167
|
+
if (!ts.isVariableStatement(st))
|
|
168
|
+
continue;
|
|
169
|
+
for (const d of st.declarationList.declarations) {
|
|
170
|
+
if (!ts.isIdentifier(d.name) || !d.initializer)
|
|
171
|
+
continue;
|
|
172
|
+
const init = unwrap(d.initializer);
|
|
173
|
+
if (!ts.isCallExpression(init))
|
|
174
|
+
continue;
|
|
175
|
+
const b = calleeName(init);
|
|
176
|
+
if (b === undefined || !BUILDERS.has(b))
|
|
177
|
+
continue;
|
|
178
|
+
const cfg = init.arguments[0];
|
|
179
|
+
if (!cfg || !ts.isObjectLiteralExpression(cfg))
|
|
180
|
+
continue;
|
|
181
|
+
let handler;
|
|
182
|
+
const argIdTable = new Map();
|
|
183
|
+
for (const prop of cfg.properties) {
|
|
184
|
+
const key = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
|
|
185
|
+
if (key === "handler") {
|
|
186
|
+
if (ts.isMethodDeclaration(prop))
|
|
187
|
+
handler = prop;
|
|
188
|
+
else if (ts.isPropertyAssignment(prop)) {
|
|
189
|
+
const h = unwrap(prop.initializer);
|
|
190
|
+
if (ts.isArrowFunction(h) || ts.isFunctionExpression(h))
|
|
191
|
+
handler = h;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (key === "args" && ts.isPropertyAssignment(prop)) {
|
|
195
|
+
const obj = argsObject(p, prop.initializer);
|
|
196
|
+
for (const a of obj?.properties ?? []) {
|
|
197
|
+
if (ts.isPropertyAssignment(a) && ts.isIdentifier(a.name)) {
|
|
198
|
+
const t = singleIdTable(a.initializer.getText(p.sf));
|
|
199
|
+
if (t)
|
|
200
|
+
argIdTable.set(a.name.text, t);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (handler === undefined)
|
|
206
|
+
continue;
|
|
207
|
+
out.push({
|
|
208
|
+
name: d.name.text,
|
|
209
|
+
builder: b,
|
|
210
|
+
rel: p.rel,
|
|
211
|
+
line: lineOf(d),
|
|
212
|
+
handler,
|
|
213
|
+
argIdTable,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/** Every top-level `function f` / `const f = (…) => …` across the parsed files. */
|
|
220
|
+
export function indexFunctions(ps) {
|
|
221
|
+
const idx = new Map();
|
|
222
|
+
const add = (d) => {
|
|
223
|
+
const xs = idx.get(d.name) ?? [];
|
|
224
|
+
xs.push(d);
|
|
225
|
+
idx.set(d.name, xs);
|
|
226
|
+
};
|
|
227
|
+
for (const p of ps) {
|
|
228
|
+
for (const st of p.sf.statements) {
|
|
229
|
+
if (ts.isFunctionDeclaration(st) && st.name && st.body)
|
|
230
|
+
add({ name: st.name.text, rel: p.rel, line: lineOf(st), fn: st });
|
|
231
|
+
else if (ts.isVariableStatement(st))
|
|
232
|
+
for (const d of st.declarationList.declarations) {
|
|
233
|
+
const init = d.initializer && unwrap(d.initializer);
|
|
234
|
+
if (ts.isIdentifier(d.name) &&
|
|
235
|
+
init &&
|
|
236
|
+
(ts.isArrowFunction(init) || ts.isFunctionExpression(init)))
|
|
237
|
+
add({ name: d.name.text, rel: p.rel, line: lineOf(d), fn: init });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return idx;
|
|
242
|
+
}
|
|
243
|
+
/** Names of the plain-identifier functions a body calls. */
|
|
244
|
+
export function calledNames(fn) {
|
|
245
|
+
const out = new Set();
|
|
246
|
+
walk(fn, (x) => {
|
|
247
|
+
if (ts.isCallExpression(x)) {
|
|
248
|
+
const e = unwrap(x.expression);
|
|
249
|
+
if (ts.isIdentifier(e))
|
|
250
|
+
out.add(e.text);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/** A per-function boolean property closed over the call graph: `own(fn)` holds,
|
|
256
|
+
* or the function calls (by name) another function for which it holds. */
|
|
257
|
+
export function closeOverCalls(idx, own) {
|
|
258
|
+
const holds = new Set();
|
|
259
|
+
for (const [n, ds] of idx)
|
|
260
|
+
if (ds.some(own))
|
|
261
|
+
holds.add(n);
|
|
262
|
+
let grew = true;
|
|
263
|
+
while (grew) {
|
|
264
|
+
grew = false;
|
|
265
|
+
for (const [n, ds] of idx) {
|
|
266
|
+
if (holds.has(n))
|
|
267
|
+
continue;
|
|
268
|
+
if (ds.some((d) => [...calledNames(d.fn)].some((c) => holds.has(c)))) {
|
|
269
|
+
holds.add(n);
|
|
270
|
+
grew = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return holds;
|
|
275
|
+
}
|
|
276
|
+
// ---- schema ---------------------------------------------------------------
|
|
277
|
+
/** The tenant columns, as standard 2825122 §4 R-52 lists them:
|
|
278
|
+
* `orgId`/`tenantId`/`workspaceId`/`orgSlug` (vantage-peers stamps its agents
|
|
279
|
+
* under `orgSlug: client_org_mapping.clerkOrgSlug`). A `namespace` is NOT in this
|
|
280
|
+
* set: the standard counts it only when it is "checked against the caller's
|
|
281
|
+
* allowed prefixes", which is a property of the code around the table, not of
|
|
282
|
+
* the schema. `readSchema` records it apart (`SchemaTable.namespace`) and R-52
|
|
283
|
+
* decides it per table (cross-tenant-write.ts `namespaceDecisions`). */
|
|
284
|
+
export const TENANT_COLUMNS = new Set([
|
|
285
|
+
"orgId",
|
|
286
|
+
"tenantId",
|
|
287
|
+
"workspaceId",
|
|
288
|
+
"orgSlug",
|
|
289
|
+
]);
|
|
290
|
+
/** The column that is a tenant column only where it is prefix-checked. */
|
|
291
|
+
export const NAMESPACE_COLUMN = "namespace";
|
|
292
|
+
/** Read every `name: defineTable({...})` of a schema file. */
|
|
293
|
+
export function readSchema(p) {
|
|
294
|
+
const out = new Map();
|
|
295
|
+
walk(p.sf, (x) => {
|
|
296
|
+
if (!ts.isPropertyAssignment(x) || !ts.isIdentifier(x.name))
|
|
297
|
+
return;
|
|
298
|
+
// the initializer is `defineTable({...})` possibly chained `.index(...)`.
|
|
299
|
+
let e = x.initializer;
|
|
300
|
+
for (;;) {
|
|
301
|
+
const u = unwrap(e);
|
|
302
|
+
if (ts.isCallExpression(u) &&
|
|
303
|
+
ts.isPropertyAccessExpression(u.expression)) {
|
|
304
|
+
e = u.expression.expression;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
e = u;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
if (!ts.isCallExpression(e) ||
|
|
311
|
+
calleeName(e) !== "defineTable" ||
|
|
312
|
+
!e.arguments[0] ||
|
|
313
|
+
!ts.isObjectLiteralExpression(e.arguments[0]))
|
|
314
|
+
return;
|
|
315
|
+
const fields = [];
|
|
316
|
+
const idRef = new Map();
|
|
317
|
+
let tenant = "none";
|
|
318
|
+
let namespace = "none";
|
|
319
|
+
for (const prop of e.arguments[0].properties) {
|
|
320
|
+
if (!ts.isPropertyAssignment(prop))
|
|
321
|
+
continue;
|
|
322
|
+
const k = ts.isIdentifier(prop.name)
|
|
323
|
+
? prop.name.text
|
|
324
|
+
: ts.isStringLiteral(prop.name)
|
|
325
|
+
? prop.name.text
|
|
326
|
+
: undefined;
|
|
327
|
+
if (k === undefined)
|
|
328
|
+
continue;
|
|
329
|
+
fields.push(k);
|
|
330
|
+
const text = prop.initializer.getText(p.sf);
|
|
331
|
+
const t = idTableOf(text);
|
|
332
|
+
if (t)
|
|
333
|
+
idRef.set(k, t);
|
|
334
|
+
const opt = /^\s*v\.optional\(/.test(text);
|
|
335
|
+
if (TENANT_COLUMNS.has(k)) {
|
|
336
|
+
if (!opt)
|
|
337
|
+
tenant = "required";
|
|
338
|
+
else if (tenant === "none")
|
|
339
|
+
tenant = "optional";
|
|
340
|
+
}
|
|
341
|
+
if (k === NAMESPACE_COLUMN)
|
|
342
|
+
namespace = opt ? "optional" : "required";
|
|
343
|
+
}
|
|
344
|
+
out.set(x.name.text, {
|
|
345
|
+
name: x.name.text,
|
|
346
|
+
tenant,
|
|
347
|
+
namespace,
|
|
348
|
+
fields,
|
|
349
|
+
idRef,
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { siteCount, withCount } from "../../violation-count.js";
|
|
1
2
|
/** The standard both wave-2 files are pinned against (short hash a9d7877b). */
|
|
2
3
|
export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
|
|
3
4
|
export function emptyScan() {
|
|
@@ -45,12 +46,12 @@ export function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
45
46
|
// verdict is otherwise the only one printed without an address (Eta REVISE
|
|
46
47
|
// blocker 1: R-51 CRM could-not-judge=1 was counted but never located). The
|
|
47
48
|
// data is already in scan.cnj; list it under its own label whenever non-empty.
|
|
48
|
-
return {
|
|
49
|
+
return withCount({
|
|
49
50
|
id,
|
|
50
51
|
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
51
52
|
state: "non-conforming",
|
|
52
|
-
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (
|
|
53
|
-
};
|
|
53
|
+
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}${nJ ? ` could-not-judge sites (not decided; each names why): ${fmt(scan.cnj)}.` : ""}`,
|
|
54
|
+
}, siteCount(scan.violate));
|
|
54
55
|
}
|
|
55
56
|
if (nJ > 0) {
|
|
56
57
|
const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasJustificationTokenInText } from "../oracle-axis2.js";
|
|
1
2
|
import { base, code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
|
|
2
3
|
// A schema definition file — where defineTable declarations live.
|
|
3
4
|
const SCHEMA_FILE = /schema\.ts$/;
|
|
@@ -73,7 +74,15 @@ export function derivedStructures(files) {
|
|
|
73
74
|
if (FIELD_LINE.test(l.raw))
|
|
74
75
|
fields += 1;
|
|
75
76
|
const id = ID_REF.exec(l.raw);
|
|
76
|
-
|
|
77
|
+
// A SYSTEM table (`_storage`, `_scheduled_functions` — Convex reserves the
|
|
78
|
+
// leading underscore) is not a source a migration can backfill FROM: its
|
|
79
|
+
// rows are platform objects, not domain rows, and there is no
|
|
80
|
+
// `ctx.db.query("_storage")` that enumerates what the write path would
|
|
81
|
+
// have bound. A table whose only foreign key points at a system table
|
|
82
|
+
// (a TOFU ownership binding of uploaded blobs, kbUploads) is therefore
|
|
83
|
+
// not a derived structure in R-44's sense. The first NON-system FK is
|
|
84
|
+
// the source; a table with none is skipped.
|
|
85
|
+
if (id && !fkTarget && !id[1].startsWith("_"))
|
|
77
86
|
fkTarget = id[1];
|
|
78
87
|
if (HAS_INDEX.test(l.raw))
|
|
79
88
|
hasIndex = true;
|
|
@@ -96,7 +105,9 @@ export function scanR44(files) {
|
|
|
96
105
|
let directInsert = false;
|
|
97
106
|
let loopsSource = false;
|
|
98
107
|
let referenced = false;
|
|
108
|
+
let migText = "";
|
|
99
109
|
for (const f of migFiles) {
|
|
110
|
+
migText += `${f.lines.map((l) => l.raw).join("\n")}\n`;
|
|
100
111
|
for (const l of f.lines) {
|
|
101
112
|
if (!code(l))
|
|
102
113
|
continue;
|
|
@@ -118,18 +129,39 @@ export function scanR44(files) {
|
|
|
118
129
|
});
|
|
119
130
|
}
|
|
120
131
|
else if (directInsert) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
// judgement retired (task k179jnhx, §23): flagged (no visible query/
|
|
133
|
+
// paginate of the source table); decides on the migration file's written
|
|
134
|
+
// JUSTIFIÉ:/JUSTIFIED: token, never a person's opinion of coverage.
|
|
135
|
+
if (hasJustificationTokenInText(migText)) {
|
|
136
|
+
scan.conform.push({
|
|
137
|
+
rel: d.rel,
|
|
138
|
+
n: d.n,
|
|
139
|
+
note: `derived structure ${d.table} has a migration inserting it; no query/paginate of ${d.source} is visible, but the migration file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
scan.violate.push({
|
|
144
|
+
rel: d.rel,
|
|
145
|
+
n: d.n,
|
|
146
|
+
note: `derived structure ${d.table} has a migration inserting it, but no query/paginate of the source table ${d.source} is visible, and no written JUSTIFIÉ:/JUSTIFIED: token in the migration file — coverage of existing source rows owes a fix or a recorded justification`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
126
149
|
}
|
|
127
150
|
else if (referenced) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
151
|
+
if (hasJustificationTokenInText(migText)) {
|
|
152
|
+
scan.conform.push({
|
|
153
|
+
rel: d.rel,
|
|
154
|
+
n: d.n,
|
|
155
|
+
note: `derived structure ${d.table} has a backfill migration NAMED for it, writing the structure via a helper; the migration file carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
scan.violate.push({
|
|
160
|
+
rel: d.rel,
|
|
161
|
+
n: d.n,
|
|
162
|
+
note: `derived structure ${d.table} has a backfill migration NAMED for it, but it writes the structure via a helper (no direct ctx.db.insert("${d.table}") in the migration), and no written JUSTIFIÉ:/JUSTIFIED: token in the migration file — idempotence/coverage owes a fix or a recorded justification`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
133
165
|
}
|
|
134
166
|
else {
|
|
135
167
|
scan.violate.push({
|
|
@@ -146,10 +178,12 @@ export const R44 = srcDetector("R-44", "R-44 a derived structure carries a same-
|
|
|
146
178
|
// R-45 — a derived structure carries a test whose fixture bypasses the write
|
|
147
179
|
// path and brackets the migration.
|
|
148
180
|
// ===========================================================================
|
|
181
|
+
const R45_TOKEN_WINDOW = 6;
|
|
149
182
|
export function scanR45(files) {
|
|
150
183
|
const scan = emptyScan();
|
|
151
184
|
const derived = derivedStructures(files);
|
|
152
185
|
const testFiles = files.filter(isTest);
|
|
186
|
+
const byRel = new Map(files.map((f) => [f.rel, f]));
|
|
153
187
|
for (const d of derived) {
|
|
154
188
|
const insertSourceRe = INSERT_OF(d.source);
|
|
155
189
|
const stem = d.table.toLowerCase();
|
|
@@ -192,13 +226,32 @@ export function scanR45(files) {
|
|
|
192
226
|
});
|
|
193
227
|
}
|
|
194
228
|
else {
|
|
195
|
-
// No test reaches the subject at all —
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
229
|
+
// No test reaches the subject at all — flagged. judgement retired
|
|
230
|
+
// (task k179jnhx, §23): the schema declaration's comment window is this
|
|
231
|
+
// site's remediation cell. No JUSTIFIÉ:/JUSTIFIED: token → non-conforming;
|
|
232
|
+
// token present → present, decided by the token's presence, never a
|
|
233
|
+
// person's opinion of whether the untested state is sound.
|
|
234
|
+
const declFile = byRel.get(d.rel);
|
|
235
|
+
const window = declFile
|
|
236
|
+
? declFile.lines
|
|
237
|
+
.slice(Math.max(0, d.n - 1 - R45_TOKEN_WINDOW), d.n - 1 + R45_TOKEN_WINDOW)
|
|
238
|
+
.map((l) => l.raw)
|
|
239
|
+
.join("\n")
|
|
240
|
+
: "";
|
|
241
|
+
if (hasJustificationTokenInText(window)) {
|
|
242
|
+
scan.conform.push({
|
|
243
|
+
rel: d.rel,
|
|
244
|
+
n: d.n,
|
|
245
|
+
note: `derived structure ${d.table}: no test constructs its source row ${d.source} without the write path, but the schema declaration's comment window carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
scan.violate.push({
|
|
250
|
+
rel: d.rel,
|
|
251
|
+
n: d.n,
|
|
252
|
+
note: `derived structure ${d.table}: no test constructs its source row ${d.source} without the write path (and no create-only fixture to mark red), and no written JUSTIFIÉ:/JUSTIFIED: token near its schema declaration — fixture-independence owes a test or a recorded justification`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
202
255
|
}
|
|
203
256
|
}
|
|
204
257
|
return scan;
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// zero-applicable-site abstain that resolves to could-not-judge WITH a reason.
|
|
19
19
|
import { existsSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
|
+
import { siteCount, withCount } from "../../violation-count.js";
|
|
22
|
+
import { hasJustificationTokenInText } from "../oracle-axis2.js";
|
|
21
23
|
/** The standard all four are pinned against (short hash a9d7877b). */
|
|
22
24
|
export const SOURCE_CONTRACT_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
|
|
23
25
|
function emptyScan() {
|
|
@@ -73,12 +75,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
73
75
|
const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
|
|
74
76
|
const fmt = (s) => s.slice(0, 3).map((x) => `${x.rel}:${x.n} (${x.note})`).join("; ");
|
|
75
77
|
if (nV > 0) {
|
|
76
|
-
return {
|
|
78
|
+
return withCount({
|
|
77
79
|
id,
|
|
78
80
|
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
79
81
|
state: "non-conforming",
|
|
80
82
|
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
|
|
81
|
-
};
|
|
83
|
+
}, siteCount(scan.violate));
|
|
82
84
|
}
|
|
83
85
|
if (nJ > 0) {
|
|
84
86
|
const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`;
|
|
@@ -320,15 +322,19 @@ export function scanR25(files) {
|
|
|
320
322
|
// authority-tier / boundary-key declaration: rag has only an import comment; the
|
|
321
323
|
// agentEngine block documents durability/versioning ("long-task survival",
|
|
322
324
|
// "Pinned exactly at 0.1.0-alpha.2"), not an isolation contract. Per §8 an
|
|
323
|
-
// undeclared seam is non-conforming as an app-owned table with a blank field
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
325
|
+
// undeclared seam is non-conforming as an app-owned table with a blank field.
|
|
326
|
+
// judgement retired (task k179jnhx, §23): whether the library's tables are in
|
|
327
|
+
// fact used fleet-internal/master only (which would justify the silence) is no
|
|
328
|
+
// longer a human disposition — it decides on the PRESENCE of a written
|
|
329
|
+
// JUSTIFIÉ:/JUSTIFIED: token in the seam's comment window (that window IS this
|
|
330
|
+
// site's remediation cell, the same mechanism R-7/R-8 use on an oracle row). No
|
|
331
|
+
// token → non-conforming; token present → present. could-not-judge is reserved
|
|
332
|
+
// for a seam the instrument could not read, never reached here (a parsed
|
|
333
|
+
// app.use() call always resolves to one of the two).
|
|
329
334
|
// PATTERN: each `app.use(<component>)` line in a *.config.ts seam is conform iff its
|
|
330
335
|
// surrounding window (comments included — the seam contract is documented prose)
|
|
331
|
-
// declares an authority tier + boundary key
|
|
336
|
+
// declares an authority tier + boundary key, OR carries a written justification
|
|
337
|
+
// token; else non-conforming.
|
|
332
338
|
// ---------------------------------------------------------------------------
|
|
333
339
|
const MOUNT_CALL = /app\.use\s*\(\s*(\w+)/;
|
|
334
340
|
const SEAM_CONTRACT = /authority tier|boundary key|isolation contract|org-scoped|fleet-internal|master-only|namespace-bound|tenant boundary/i;
|
|
@@ -350,14 +356,16 @@ export function scanR26(files) {
|
|
|
350
356
|
continue;
|
|
351
357
|
const comp = m[1];
|
|
352
358
|
// seam contract is documented as prose — scan ALL lines in the window.
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
.some((x) => SEAM_CONTRACT.test(x.raw));
|
|
359
|
+
const window = f.lines.slice(Math.max(0, i - R26_WINDOW), i + R26_WINDOW);
|
|
360
|
+
const declared = window.some((x) => SEAM_CONTRACT.test(x.raw));
|
|
356
361
|
if (declared) {
|
|
357
362
|
scan.conform.push({ rel: f.rel, n: l.n, note: `component "${comp}" mount declares an authority tier + boundary key at the seam` });
|
|
358
363
|
}
|
|
364
|
+
else if (hasJustificationTokenInText(window.map((x) => x.raw).join("\n"))) {
|
|
365
|
+
scan.conform.push({ rel: f.rel, n: l.n, note: `component "${comp}" mounted with no stated authority tier / boundary key, but the seam's comment window carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed` });
|
|
366
|
+
}
|
|
359
367
|
else {
|
|
360
|
-
scan.
|
|
368
|
+
scan.violate.push({ rel: f.rel, n: l.n, note: `component "${comp}" mounted with no stated authority tier / boundary key at the seam, and no written JUSTIFIÉ:/JUSTIFIED: token in the seam's comment window — usable fleet-internal/master only; §8 owes a fix or a recorded justification` });
|
|
361
369
|
}
|
|
362
370
|
}
|
|
363
371
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import type { Detector } from "../../engine-types.js";
|
|
3
|
+
import { type FnDef, type Parsed, type SchemaTable, type SrcFile } from "./_ast.js";
|
|
4
|
+
import { type Scan, type SourceDetectorEntry } from "./_scan.js";
|
|
5
|
+
export declare 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";
|
|
6
|
+
export declare function fleetOnlyExpr(e0: ts.Expression, isFleetHelper: (name: string) => boolean, isFleetLocal?: (name: string) => boolean): boolean;
|
|
7
|
+
export interface R52Facts {
|
|
8
|
+
parsed: Parsed[];
|
|
9
|
+
schema: Map<string, SchemaTable> | undefined;
|
|
10
|
+
schemaRel: string | undefined;
|
|
11
|
+
callerChosen: Map<string, Set<string>>;
|
|
12
|
+
fns: Map<string, FnDef[]>;
|
|
13
|
+
readsTenant: Set<string>;
|
|
14
|
+
refusing: Set<string>;
|
|
15
|
+
/** helpers every truthy return of which is a fleet-scope decision. */
|
|
16
|
+
fleetOnly: Set<string>;
|
|
17
|
+
fleetRefusal: Set<string>;
|
|
18
|
+
singleTable: Map<string, string>;
|
|
19
|
+
/** helpers that hand their table argument to `db.normalizeId`. */
|
|
20
|
+
normalizers: Set<string>;
|
|
21
|
+
/** per table carrying a `namespace` and no required tenant column: whether
|
|
22
|
+
* that namespace is checked against the caller's allowed prefixes. */
|
|
23
|
+
namespace: Map<string, NamespaceDecision>;
|
|
24
|
+
}
|
|
25
|
+
/** Standard 2825122 R-52 counts a `namespace` as a tenant column only when it is
|
|
26
|
+
* "checked against the caller's allowed prefixes". Decided per table from the
|
|
27
|
+
* public doors (query/mutation/action) that read or write it:
|
|
28
|
+
* checked every door runs a prefix check (or refuses every non-master);
|
|
29
|
+
* unchecked no door does — the namespace is a label, the table is untenanted;
|
|
30
|
+
* undecided some do and some do not, or no public door reaches the table —
|
|
31
|
+
* every site targeting it is could-not-judge, never green. */
|
|
32
|
+
export interface NamespaceDecision {
|
|
33
|
+
verdict: "checked" | "unchecked" | "undecided";
|
|
34
|
+
why: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function r52Facts(files: SrcFile[]): R52Facts;
|
|
37
|
+
export interface R52Detail {
|
|
38
|
+
scan: Scan;
|
|
39
|
+
/** tables carrying no tenant value (none, or optional), as read from schema. */
|
|
40
|
+
untenanted: string[];
|
|
41
|
+
schemaRel: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
export declare function scanR52(files: SrcFile[]): Scan;
|
|
44
|
+
export declare function scanR52Detailed(files: SrcFile[]): R52Detail;
|
|
45
|
+
export declare const R52: Detector;
|
|
46
|
+
export declare const SOURCE_CROSS_TENANT_WRITE_DETECTORS: SourceDetectorEntry[];
|