@vantageos/backend-doctor 0.1.13 → 0.2.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 +19 -2
- package/dist/args.d.ts +16 -0
- package/dist/args.js +54 -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 +1195 -0
- package/dist/cli.js +217 -10
- package/dist/coverage-cli.js +8 -0
- package/dist/crud-coverage.d.ts +81 -0
- package/dist/crud-coverage.js +375 -0
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +24 -15
- package/dist/detectors/oracle-axis2.js +5 -6
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +81 -16
- package/dist/detectors/shared.js +5 -5
- package/dist/detectors/source/_scan.js +6 -1
- package/dist/detectors/source/backfill.js +9 -1
- package/dist/detectors/source/guard-helper-flow.d.ts +112 -0
- package/dist/detectors/source/guard-helper-flow.js +1173 -0
- package/dist/detectors/source/identity.d.ts +51 -2
- package/dist/detectors/source/identity.js +815 -14
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +165 -2
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +67 -4
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +225 -7
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +45 -14
- package/dist/detectors/structural.js +14 -10
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +7 -0
- package/dist/registry.js +48 -6
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +35 -0
- package/dist/tally.js +76 -8
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -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/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
|
|
1
2
|
/** The standard both are pinned against (short hash a9d7877b), §15/§18. */
|
|
2
3
|
export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
|
|
3
4
|
function emptyScan() {
|
|
@@ -17,8 +18,32 @@ function isJudgeable(f) {
|
|
|
17
18
|
function code(l) {
|
|
18
19
|
return !l.comment;
|
|
19
20
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* True when the ENGINE's scan root is itself a convex directory. loadTree sets
|
|
23
|
+
* `tree.root` to the target the CLI was pointed at, and computes every file's
|
|
24
|
+
* `rel` relative to that root. So the SAME convex file yields two different rels
|
|
25
|
+
* under the two documented invocations:
|
|
26
|
+
* - pointed at the repo ROOT → rel = `convex/messages.ts` (carries `convex/`)
|
|
27
|
+
* - pointed at the CONVEX dir → rel = `messages.ts` (bare, no segment)
|
|
28
|
+
* The bare-rel case is the normal, documented invocation, and it is precisely
|
|
29
|
+
* where a `rel`-only `isConvex` guard (see below) returned FALSE for 100% of
|
|
30
|
+
* files — silently skipping R-6/R-16 (Path-B C1 blind spot 1). When the root IS
|
|
31
|
+
* a convex dir, EVERY collected file is by construction under that convex tree,
|
|
32
|
+
* so no non-convex file (mcp-server/app/src) can be pulled in by trusting it.
|
|
33
|
+
*/
|
|
34
|
+
function rootIsConvexDir(root) {
|
|
35
|
+
// Any `convex` path SEGMENT of the scan root (`.../convex` or `.../convex/…`).
|
|
36
|
+
// A repo root (`/…/vantage-memory`) has no such segment, so this stays FALSE
|
|
37
|
+
// there and the per-file `convex/` rel prefix does the scoping instead.
|
|
38
|
+
return /(^|\/)convex(\/|$)/.test(root.replace(/\/+$/, ""));
|
|
39
|
+
}
|
|
40
|
+
/** convex source only — R-6/R-16 govern the data layer's write handlers. When
|
|
41
|
+
* the scan root is itself a convex dir every file is convex source (bare rels);
|
|
42
|
+
* otherwise a file qualifies only by a `convex/` rel prefix — so a repo-root
|
|
43
|
+
* scan still EXCLUDES non-convex source (mcp-server/src/*, app/*, src/*). */
|
|
44
|
+
function isConvex(f, rootIsConvex) {
|
|
45
|
+
if (rootIsConvex)
|
|
46
|
+
return true;
|
|
22
47
|
return /(^|\/)convex\//.test(f.rel);
|
|
23
48
|
}
|
|
24
49
|
/**
|
|
@@ -73,10 +98,11 @@ function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
73
98
|
}
|
|
74
99
|
/** Split a convex file into its top-level mutation/internalMutation handler
|
|
75
100
|
* blocks. A block runs from `export const <name> = mutation(` to the next
|
|
76
|
-
* top-level `export ` (or EOF). Queries/actions are not R-6 write handlers.
|
|
77
|
-
|
|
101
|
+
* top-level `export ` (or EOF). Queries/actions are not R-6 write handlers.
|
|
102
|
+
* Each block records whether it is an `internalMutation` registration. */
|
|
103
|
+
export function splitMutationHandlers(f) {
|
|
78
104
|
const out = [];
|
|
79
|
-
const open = /export\s+const\s+(\w+)\s*=\s*(
|
|
105
|
+
const open = /export\s+const\s+(\w+)\s*=\s*(internalMutation|mutation)\s*\(/;
|
|
80
106
|
const topExport = /^export\s+/;
|
|
81
107
|
let cur = null;
|
|
82
108
|
for (const l of f.lines) {
|
|
@@ -84,7 +110,12 @@ function splitMutationHandlers(f) {
|
|
|
84
110
|
if (m) {
|
|
85
111
|
if (cur)
|
|
86
112
|
out.push(cur);
|
|
87
|
-
cur = {
|
|
113
|
+
cur = {
|
|
114
|
+
name: m[1],
|
|
115
|
+
startN: l.n,
|
|
116
|
+
lines: [l],
|
|
117
|
+
internal: m[2] === "internalMutation",
|
|
118
|
+
};
|
|
88
119
|
continue;
|
|
89
120
|
}
|
|
90
121
|
if (cur) {
|
|
@@ -117,15 +148,591 @@ const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
|
|
|
117
148
|
const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
|
|
118
149
|
const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
|
|
119
150
|
const R6_BOUNDARY_ARG = /\b(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)\b/;
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
// ---- WRITE-SITE provenance (blind spot 2) ---------------------------------
|
|
152
|
+
//
|
|
153
|
+
// Guard-PRESENCE alone cannot distinguish the historical tenant-write defect
|
|
154
|
+
// from its fix: BOTH `messages.sendMessage` versions call `withOrgScope(ctx)`.
|
|
155
|
+
// The real discriminator is the tenant-scoping boundary field's RHS AT THE
|
|
156
|
+
// WRITE SITE. `tenantId: args.tenantId` (raw caller arg, written directly) is
|
|
157
|
+
// the defect; `tenantId: derivedTenantId` (a scope-derived local) is the fix —
|
|
158
|
+
// even though a `derivedTenantId = args.tenantId` ASSIGNMENT exists inside the
|
|
159
|
+
// master carve-out branch, that assignment is not a ctx.db write and is never
|
|
160
|
+
// flagged. So a direct-arg tenant write is decisive VIOLATE regardless of any
|
|
161
|
+
// guard, and a derived-local tenant write (with scope resolved) is CONFORM.
|
|
162
|
+
// `args.x`, and a COMPUTED subscript `args[k]` (whatever the key, the value is
|
|
163
|
+
// read off the caller's arguments). A literal-keyed subscript (`args["x"]`) never
|
|
164
|
+
// reaches this regex: normalizeArgsAccess rewrites it to `args.x` first.
|
|
165
|
+
const R6_TENANT_FIELDS = new Set([
|
|
166
|
+
"tenantId",
|
|
167
|
+
"orgId",
|
|
168
|
+
"orgSlug",
|
|
169
|
+
]);
|
|
170
|
+
const R6_TENANT_FROM_ARG = /\b(?:tenantId|orgId|orgSlug)\s*:\s*args(?:\.\w+|\s*\[)/;
|
|
171
|
+
// ---- literal-keyed subscript (task k179nydvbdxm40ps7g0y18mwhs8dje91) -------
|
|
172
|
+
//
|
|
173
|
+
// `args["orgSlug"]`, `args['orgSlug']` and args[`orgSlug`] are the SAME property
|
|
174
|
+
// read as `args.orgSlug`. Every R-6 regex below was written against the dotted
|
|
175
|
+
// form, so the subscript form was invisible: `const t = args["orgSlug"]` kept
|
|
176
|
+
// its `\borgSlug\b` token after the `args.<field>` strip and read SCOPE-derived,
|
|
177
|
+
// and `tenantId: args["orgSlug"]` matched no direct-arg regex at all. Rewriting
|
|
178
|
+
// the literal subscript to the dotted form BEFORE any regex runs makes every
|
|
179
|
+
// existing rule see one shape. A template literal is rewritten only when it has
|
|
180
|
+
// no `\${…}` interpolation (the key must be a plain identifier).
|
|
181
|
+
export function normalizeArgsAccess(raw) {
|
|
182
|
+
return raw.replace(/\bargs\s*\[\s*(["'`])([A-Za-z_$][\w$]*)\1\s*\]/g, "args.$2");
|
|
183
|
+
}
|
|
184
|
+
function normalizeLines(lines) {
|
|
185
|
+
return lines.map((l) => {
|
|
186
|
+
const raw = normalizeArgsAccess(l.raw);
|
|
187
|
+
return raw === l.raw ? l : { ...l, raw };
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const R6_SCOPE_RESOLUTION = /withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|RBAC_DENIED/;
|
|
191
|
+
// ---- follow-the-local taint (blind spot 2b — the ONE-HOP rename) ----------
|
|
192
|
+
//
|
|
193
|
+
// Write-site RHS provenance alone is defeated by a single rename:
|
|
194
|
+
// const t = args.tenantId; ... ctx.db.insert(..., { tenantId: t })
|
|
195
|
+
// The boundary field is now a LOCAL, so R6_TENANT_FROM_ARG does not match and
|
|
196
|
+
// R6_TENANT_FROM_LOCAL wrongly reads CONFORM — the caller's argument reaches the
|
|
197
|
+
// write untouched, wearing another name. Same file, same defect, laundered.
|
|
198
|
+
//
|
|
199
|
+
// The fix follows the local ONE hop: collect every assignment/declaration of the
|
|
200
|
+
// local written as a boundary field, and discriminate on whether ANY of them is
|
|
201
|
+
// SCOPE-derived. A local fed ONLY from args (fromScope === 0) is the argument
|
|
202
|
+
// under an alias → TAINTED → VIOLATE. A local that receives at least one
|
|
203
|
+
// scope/identity-derived value (fromScope >= 1) is genuinely derived → CONFORM.
|
|
204
|
+
// This is exactly what keeps the master carve-out safe (its non-master branch
|
|
205
|
+
// assigns from scope.orgSlug → fromScope >= 1) WITHOUT rescuing the laundering
|
|
206
|
+
// (whose sole assignment is `= args.tenantId` → fromScope === 0).
|
|
207
|
+
// The verified-identity CLAIM names (`identity.organizationId`, `.organizationSlug`,
|
|
208
|
+
// `.tenantSlug`) are the same tokens R6_IDENTITY_GUARD already trusts. Without
|
|
209
|
+
// them a shorthand `{ orgId }` fed from `identity.organizationId` read TAINTED —
|
|
210
|
+
// the false positive the shorthand read (k176ewgwrbc6mg1y4h42rg25gn8dja1p)
|
|
211
|
+
// surfaced in tests/source-identity.test.ts "neuter-proof". An argument whose
|
|
212
|
+
// field carries such a name is stripped below before this test, as for orgSlug.
|
|
213
|
+
const R6_SCOPE_RHS = /\bscope\b|withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|\borgSlug\b|assertNamespaceAllowed|\b(?:organizationId|organizationSlug|tenantSlug)\b/;
|
|
214
|
+
/** The non-`args` LOCAL identifiers written to a tenant-scoping boundary field
|
|
215
|
+
* at this write-site text (`tenantId: <local>`). `tenantId: args.tenantId` is
|
|
216
|
+
* excluded here — it is the DIRECT-arg case handled by R6_TENANT_FROM_ARG. */
|
|
217
|
+
function boundaryLocalsInWrite(text) {
|
|
218
|
+
const out = [];
|
|
219
|
+
const re = /\b(?:tenantId|orgId|orgSlug)\s*:\s*([A-Za-z_$][\w$]*)/g;
|
|
220
|
+
let m = re.exec(text);
|
|
221
|
+
while (m !== null) {
|
|
222
|
+
if (m[1] !== "args")
|
|
223
|
+
out.push(m[1]);
|
|
224
|
+
m = re.exec(text);
|
|
225
|
+
}
|
|
226
|
+
// ES6 SHORTHAND (task k176ewgwrbc6mg1y4h42rg25gn8dja1p): `{ tenantId }` is
|
|
227
|
+
// `{ tenantId: tenantId }` — the boundary field written from a LOCAL of the
|
|
228
|
+
// same name. The keyed regex above needs a `:` and never saw it, so
|
|
229
|
+
// `const tenantId = args.namespace; insert({ tenantId })` read CONFORM. The
|
|
230
|
+
// shorthand member sits after `{` or `,` and before `,` or `}` (or a line
|
|
231
|
+
// end, for a multi-line literal); its local then goes through the SAME
|
|
232
|
+
// provenance analysis as a keyed local.
|
|
233
|
+
const short = /(?:^|[{,])\s*(tenantId|orgId|orgSlug)\s*(?=[,}]|$)/gm;
|
|
234
|
+
let s = short.exec(text);
|
|
235
|
+
while (s !== null) {
|
|
236
|
+
out.push(s[1]);
|
|
237
|
+
s = short.exec(text);
|
|
238
|
+
}
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
/** If `local` is bound by destructuring the HANDLER'S OWN PARAMETER —
|
|
242
|
+
* `handler: async (ctx, { tenantId }) =>` / `async handler(ctx, { tenantId })`
|
|
243
|
+
* — it IS a caller argument under its own name. Returns true when this line is
|
|
244
|
+
* such a binding of `local`. */
|
|
245
|
+
function boundFromHandlerParam(raw, local) {
|
|
246
|
+
const m = /\bhandler\b[^(\n]*\(\s*[A-Za-z_$][\w$]*\s*(?::\s*[\w.<>]+\s*)?,\s*\{([^}]*)\}/.exec(raw);
|
|
247
|
+
if (!m)
|
|
248
|
+
return false;
|
|
249
|
+
for (const rawPart of m[1].split(",")) {
|
|
250
|
+
const part = rawPart.trim().split("=")[0].trim();
|
|
251
|
+
if (!part)
|
|
252
|
+
continue;
|
|
253
|
+
const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
|
|
254
|
+
if (bound === local)
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
/** Max bare-identifier hops chased before exhaustion is declared could-not-judge
|
|
260
|
+
* (defect 3): `const t = orgSlug; const orgSlug = args.x` resolves in one extra
|
|
261
|
+
* hop; deeper indirection or a cycle stops and is reported cnj, never guessed. */
|
|
262
|
+
const R6_MAX_HOP = 2;
|
|
263
|
+
/** If `local` is bound by an object-destructuring declaration on `raw`, return
|
|
264
|
+
* the destructuring RHS (the expression after `} =`) so it can be tested for
|
|
265
|
+
* scope-ness EXACTLY as a plain assignment RHS is (Eta REVISE #3 defect 1).
|
|
266
|
+
* Recognises the shorthand (`const { orgSlug } = ...`), the keyed/renamed form
|
|
267
|
+
* (`const { orgSlug: t } = ...`), and a defaulted binding (`const { x = d } = `).
|
|
268
|
+
* Returns null when this line does not destructure `local`. */
|
|
269
|
+
function destructuredRhs(raw, local) {
|
|
270
|
+
const m = /(?:const|let|var)\s*\{([^}]*)\}\s*=(?!=)\s*([\s\S]+)$/.exec(raw);
|
|
271
|
+
if (!m)
|
|
272
|
+
return null;
|
|
273
|
+
for (const rawPart of m[1].split(",")) {
|
|
274
|
+
const part = rawPart.trim().split("=")[0].trim(); // drop any default value
|
|
275
|
+
if (!part)
|
|
276
|
+
continue;
|
|
277
|
+
const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
|
|
278
|
+
if (bound === local)
|
|
279
|
+
return m[2];
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
/** Follow ONE hop (plus one bounded bare-identifier hop): classify every
|
|
284
|
+
* assignment/declaration of `local` in the handler as scope-derived, arg-derived,
|
|
285
|
+
* or could-not-judge. Reads a PLAIN assignment RHS and a DESTRUCTURING RHS with
|
|
286
|
+
* the same scope test (defect 1). Exposes `matched` so a local with NO resolvable
|
|
287
|
+
* assignment is a third state, not silently tainted (defect 2). A bare-identifier
|
|
288
|
+
* RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
|
|
289
|
+
* own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
|
|
290
|
+
* exhaustion is `cnj`, never a guess. */
|
|
291
|
+
export function analyzeLocalTaint(h, local, isResolver) {
|
|
292
|
+
return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver);
|
|
293
|
+
}
|
|
294
|
+
/** `rhs` is exactly `await <name>(…)` closed on this line (nothing after the
|
|
295
|
+
* call but `;`): the whole value is that function's return. */
|
|
296
|
+
function wholeAwaitedCall(rhs) {
|
|
297
|
+
const m = /^\s*await\s+([A-Za-z_$][\w$]*)\s*\(/.exec(rhs);
|
|
298
|
+
if (!m)
|
|
299
|
+
return null;
|
|
300
|
+
let depth = 0;
|
|
301
|
+
for (let i = m[0].length - 1; i < rhs.length; i++) {
|
|
302
|
+
const ch = rhs[i];
|
|
303
|
+
if (ch === "(")
|
|
304
|
+
depth++;
|
|
305
|
+
else if (ch === ")") {
|
|
306
|
+
depth--;
|
|
307
|
+
if (depth === 0)
|
|
308
|
+
return /^\s*;?\s*$/.test(rhs.slice(i + 1)) ? m[1] : null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function analyzeLocalTaintInner(h, local, depth, visited, isResolver) {
|
|
314
|
+
let fromArgs = 0;
|
|
315
|
+
let fromScope = 0;
|
|
316
|
+
let matched = 0;
|
|
317
|
+
let cnj = 0;
|
|
318
|
+
let fromResolver = 0;
|
|
319
|
+
const esc = local.replace(/[$]/g, "\\$&");
|
|
320
|
+
const assign = new RegExp(`(?:^|[^\\w.$])${esc}\\s*(?::[^=\\n]*)?=(?!=)`);
|
|
321
|
+
const decl = new RegExp(`\\b(?:const|let|var)\\s+${esc}\\b`);
|
|
322
|
+
const classify = (rhs) => {
|
|
323
|
+
// A BARE IDENTIFIER RHS carries no scope evidence on its own — its NAME
|
|
324
|
+
// (even `orgSlug`) never counts as scope; the identifier's own provenance
|
|
325
|
+
// decides, or the scan refuses (defect 3, Eta pole 2).
|
|
326
|
+
const bare = rhs
|
|
327
|
+
.trim()
|
|
328
|
+
.replace(/;+\s*$/, "")
|
|
329
|
+
.trim();
|
|
330
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare) && bare !== "args") {
|
|
331
|
+
if (visited.has(bare) || depth >= R6_MAX_HOP) {
|
|
332
|
+
cnj++; // cycle or hop exhausted — could-not-judge, never a guess.
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const next = new Set(visited);
|
|
336
|
+
next.add(local);
|
|
337
|
+
const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver);
|
|
338
|
+
if (sub.matched === 0)
|
|
339
|
+
cnj++; // the referenced local has no resolvable assignment.
|
|
340
|
+
else if (sub.fromScope > 0 && sub.fromArgs === 0)
|
|
341
|
+
fromScope++;
|
|
342
|
+
else if (sub.fromArgs > 0)
|
|
343
|
+
fromArgs++;
|
|
344
|
+
else
|
|
345
|
+
cnj++;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// The whole value is the return of a function the tree shows to be an
|
|
349
|
+
// identity RESOLVER (it returns the verified scope itself and refuses an
|
|
350
|
+
// absent identity first): scope-derived, whatever arguments it was handed.
|
|
351
|
+
const called = isResolver ? wholeAwaitedCall(rhs) : null;
|
|
352
|
+
if (called && isResolver?.(called)) {
|
|
353
|
+
fromScope++;
|
|
354
|
+
fromResolver++;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// Strip `args.<field>` from the RHS BEFORE the scope test so an argument
|
|
358
|
+
// whose FIELD is named like a scope key cannot launder itself:
|
|
359
|
+
// `const t = args.orgSlug` must not read scope-derived just because
|
|
360
|
+
// R6_SCOPE_RHS carries `\borgSlug\b`. The value is still an argument, and
|
|
361
|
+
// the law above (a local fed only from args is tainted) governs. Genuine
|
|
362
|
+
// `scope.orgSlug` / `withOrgScope(...)` RHS has no `args.` token to strip
|
|
363
|
+
// and still matches → fromScope.
|
|
364
|
+
const rhsForScope = rhs.replace(/\bargs\.\w+/g, "");
|
|
365
|
+
if (R6_SCOPE_RHS.test(rhsForScope))
|
|
366
|
+
fromScope++;
|
|
367
|
+
else
|
|
368
|
+
fromArgs++;
|
|
369
|
+
};
|
|
370
|
+
for (const l of h.lines) {
|
|
371
|
+
if (!code(l))
|
|
372
|
+
continue;
|
|
373
|
+
const raw = l.raw;
|
|
374
|
+
// The handler's own parameter destructured — the binding IS the argument.
|
|
375
|
+
if (boundFromHandlerParam(raw, local)) {
|
|
376
|
+
matched++;
|
|
377
|
+
fromArgs++;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
// A destructuring binding of `local` — test its RHS as a plain RHS.
|
|
381
|
+
const dr = destructuredRhs(raw, local);
|
|
382
|
+
if (dr !== null) {
|
|
383
|
+
matched++;
|
|
384
|
+
classify(dr);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const isAssign = assign.test(raw);
|
|
388
|
+
const isBareDecl = !isAssign && decl.test(raw) && !raw.includes("=");
|
|
389
|
+
if (!isAssign && !isBareDecl)
|
|
390
|
+
continue;
|
|
391
|
+
matched++;
|
|
392
|
+
const rhs = isAssign ? raw.slice(raw.indexOf("=") + 1) : "";
|
|
393
|
+
classify(rhs);
|
|
394
|
+
}
|
|
395
|
+
return fromResolver > 0
|
|
396
|
+
? { fromArgs, fromScope, matched, cnj, fromResolver }
|
|
397
|
+
: { fromArgs, fromScope, matched, cnj };
|
|
398
|
+
}
|
|
399
|
+
/** The full source text of every `ctx.db.insert|patch|replace(...)` call in a
|
|
400
|
+
* handler, each joined across the lines the call spans (parens balanced). The
|
|
401
|
+
* tenant-provenance regexes read THIS — never the args-declaration block or a
|
|
402
|
+
* plain local assignment — so `tenantId: args.tenantId` is only ever counted
|
|
403
|
+
* when it is the value actually handed to a write. */
|
|
404
|
+
function writeSiteTexts(h) {
|
|
405
|
+
const out = [];
|
|
406
|
+
const codeLines = h.lines.filter(code);
|
|
407
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
408
|
+
if (!/ctx\.db\.(?:insert|patch|replace)\s*\(/.test(codeLines[i].raw))
|
|
409
|
+
continue;
|
|
410
|
+
let depth = 0;
|
|
411
|
+
let started = false;
|
|
412
|
+
let text = "";
|
|
413
|
+
for (let j = i; j < codeLines.length; j++) {
|
|
414
|
+
const raw = codeLines[j].raw;
|
|
415
|
+
text += (text ? "\n" : "") + raw;
|
|
416
|
+
for (const ch of raw) {
|
|
417
|
+
if (ch === "(") {
|
|
418
|
+
depth++;
|
|
419
|
+
started = true;
|
|
420
|
+
}
|
|
421
|
+
else if (ch === ")")
|
|
422
|
+
depth--;
|
|
423
|
+
}
|
|
424
|
+
if (started && depth <= 0)
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
out.push(text);
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
// ---- named guard HELPERS defined outside the handler ----------------------
|
|
432
|
+
//
|
|
433
|
+
// A public mutation frequently enforces its boundary in ONE line that calls a
|
|
434
|
+
// helper defined elsewhere: `await requireOrgAdmin(ctx, args.orgSlug)` binds the
|
|
435
|
+
// org argument to the verified identity's own org and throws RBAC_DENIED
|
|
436
|
+
// otherwise; `requireFleetMaster` / `requireServiceAccount` refuse every caller
|
|
437
|
+
// that is not the fleet master. The in-handler regexes above cannot see the
|
|
438
|
+
// helper's body. The fix is a NAMED ALLOWLIST — never "any function whose name
|
|
439
|
+
// starts with require" — and each listed helper is credited only when its OWN
|
|
440
|
+
// DEFINITION is found in the scanned tree and its CONTROL FLOW refuses, on every
|
|
441
|
+
// path to a normal exit, through a branch conditioned on the verified identity
|
|
442
|
+
// (guard-helper-flow.ts). A helper the walk cannot decide is could-not-judge,
|
|
443
|
+
// never credited; so is a call to a listed helper with no readable definition.
|
|
444
|
+
//
|
|
445
|
+
// - "binds-arg": the helper compares its target parameter with the identity;
|
|
446
|
+
// it legitimises a direct-arg boundary write ONLY for an argument that was
|
|
447
|
+
// handed to it (`requireOrgAdmin(ctx, args.orgSlug)` then
|
|
448
|
+
// `orgSlug: args.orgSlug`). A different argument written as the boundary is
|
|
449
|
+
// still the argument.
|
|
450
|
+
// - "master-only": the helper admits the fleet master alone; the master's
|
|
451
|
+
// authority is fleet-wide by design (the documented carve-out), so a
|
|
452
|
+
// boundary argument it writes is the master's decision, not a forgery.
|
|
453
|
+
//
|
|
454
|
+
// The CALL is credited only when it is a direct `await` statement of the
|
|
455
|
+
// handler's body, before the write: a call inside a branch, a try or a closure
|
|
456
|
+
// of the handler is not run on every path.
|
|
457
|
+
export const R6_GUARD_HELPERS = {
|
|
458
|
+
requireOrgAdmin: "binds-arg",
|
|
459
|
+
requireFleetMaster: "master-only",
|
|
460
|
+
requireServiceAccount: "master-only",
|
|
461
|
+
};
|
|
462
|
+
const sourceOf = (f) => f.lines.map((l) => l.raw).join("\n");
|
|
463
|
+
/** Index every definition of an allowlisted helper in the tree. */
|
|
464
|
+
export function indexGuardHelpers(files) {
|
|
465
|
+
const out = new Map();
|
|
466
|
+
const names = Object.keys(R6_GUARD_HELPERS);
|
|
122
467
|
for (const f of files) {
|
|
123
|
-
if (!isJudgeable(f)
|
|
468
|
+
if (!isJudgeable(f))
|
|
469
|
+
continue;
|
|
470
|
+
const src = sourceOf(f);
|
|
471
|
+
if (!names.some((n) => src.includes(n)))
|
|
472
|
+
continue;
|
|
473
|
+
for (const d of findHelperDefinitions(f.rel, src, R6_GUARD_HELPERS)) {
|
|
474
|
+
const list = out.get(d.name) ?? [];
|
|
475
|
+
list.push({
|
|
476
|
+
rel: f.rel,
|
|
477
|
+
n: d.line,
|
|
478
|
+
exported: d.exported,
|
|
479
|
+
judgement: d.judgement,
|
|
480
|
+
});
|
|
481
|
+
out.set(d.name, list);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return out;
|
|
485
|
+
}
|
|
486
|
+
const HELPER_NAMES = new Set(Object.keys(R6_GUARD_HELPERS));
|
|
487
|
+
const HELPER_MENTION = new RegExp(`\\b(?:${Object.keys(R6_GUARD_HELPERS).join("|")})\\s*\\(`);
|
|
488
|
+
/** `null` when the handler mentions a listed helper but its body could not be
|
|
489
|
+
* located for the statement-level read (could-not-judge, never credit). */
|
|
490
|
+
function helperCalls(h, f, writeLine, index, src) {
|
|
491
|
+
if (!h.lines.some((l) => code(l) && HELPER_MENTION.test(l.raw)))
|
|
492
|
+
return [];
|
|
493
|
+
const calls = topLevelHelperCalls(f.rel, src(), h.name, HELPER_NAMES, writeLine);
|
|
494
|
+
if (calls === null)
|
|
495
|
+
return null;
|
|
496
|
+
return calls.map((c) => {
|
|
497
|
+
const defs = index.get(c.name) ?? [];
|
|
498
|
+
const def = defs.find((d) => d.rel === f.rel) ?? defs.find((d) => d.exported);
|
|
499
|
+
return { name: c.name, kind: R6_GUARD_HELPERS[c.name], args: c.args, def };
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/** Every `args.<field>` written DIRECTLY to a boundary field at a write site. */
|
|
503
|
+
function directBoundaryArgs(writes) {
|
|
504
|
+
const out = [];
|
|
505
|
+
const re = /\b(?:tenantId|orgId|orgSlug|createdBy|namespace|orchestratorId|ownerId)\s*:\s*(args\.\w+)/g;
|
|
506
|
+
for (const t of writes) {
|
|
507
|
+
re.lastIndex = 0;
|
|
508
|
+
let m = re.exec(t);
|
|
509
|
+
while (m !== null) {
|
|
510
|
+
out.push(m[1]);
|
|
511
|
+
m = re.exec(t);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
/** The honoured helper call that covers this handler's boundary, if any.
|
|
517
|
+
* "master-only" covers any boundary write; "binds-arg" covers it only when every
|
|
518
|
+
* direct-arg boundary value written is an argument handed to the helper. */
|
|
519
|
+
function coveringCall(calls, writes) {
|
|
520
|
+
const honoured = calls.filter((c) => c.def?.judgement.verdict === "honoured");
|
|
521
|
+
const master = honoured.find((c) => c.kind === "master-only");
|
|
522
|
+
if (master)
|
|
523
|
+
return master;
|
|
524
|
+
const bound = honoured.filter((c) => c.kind === "binds-arg");
|
|
525
|
+
if (bound.length === 0)
|
|
526
|
+
return undefined;
|
|
527
|
+
const direct = directBoundaryArgs(writes);
|
|
528
|
+
const all = direct.every((a) => bound.some((c) => new RegExp(`\\b${a.replace(".", "\\.")}\\b`).test(c.args)));
|
|
529
|
+
return all ? bound[0] : undefined;
|
|
530
|
+
}
|
|
531
|
+
class FunctionIndex {
|
|
532
|
+
files;
|
|
533
|
+
parsed = new Map();
|
|
534
|
+
judged = new Map();
|
|
535
|
+
resolvers = new Map();
|
|
536
|
+
constructor(files) {
|
|
537
|
+
this.files = files;
|
|
538
|
+
}
|
|
539
|
+
defsIn(f) {
|
|
540
|
+
let d = this.parsed.get(f.rel);
|
|
541
|
+
if (!d) {
|
|
542
|
+
d = topLevelFunctions(f.rel, sourceOf(f));
|
|
543
|
+
this.parsed.set(f.rel, d);
|
|
544
|
+
}
|
|
545
|
+
return d;
|
|
546
|
+
}
|
|
547
|
+
lookup(name, fromRel) {
|
|
548
|
+
if (!/^[A-Za-z_][\w]*$/.test(name))
|
|
549
|
+
return undefined;
|
|
550
|
+
const decl = new RegExp(`\\b(?:function\\s*\\*?\\s*${name}\\b|const\\s+${name}\\s*=)`);
|
|
551
|
+
const local = this.files.find((f) => f.rel === fromRel);
|
|
552
|
+
if (local) {
|
|
553
|
+
const own = this.defsIn(local).find((d) => d.name === name);
|
|
554
|
+
if (own)
|
|
555
|
+
return { rel: local.rel, f: own };
|
|
556
|
+
}
|
|
557
|
+
const found = [];
|
|
558
|
+
for (const f of this.files) {
|
|
559
|
+
if (f.rel === fromRel || !isJudgeable(f))
|
|
560
|
+
continue;
|
|
561
|
+
if (!f.lines.some((l) => decl.test(l.raw)))
|
|
562
|
+
continue;
|
|
563
|
+
for (const d of this.defsIn(f))
|
|
564
|
+
if (d.name === name && d.exported)
|
|
565
|
+
found.push({ rel: f.rel, f: d });
|
|
566
|
+
}
|
|
567
|
+
return found.length === 1 ? found[0] : undefined;
|
|
568
|
+
}
|
|
569
|
+
judge(key, run) {
|
|
570
|
+
let v = this.judged.get(key);
|
|
571
|
+
if (!v) {
|
|
572
|
+
v = run();
|
|
573
|
+
this.judged.set(key, v);
|
|
574
|
+
}
|
|
575
|
+
return v;
|
|
576
|
+
}
|
|
577
|
+
/** Names seen from `fromRel`, resolved by `lookup` (same file first, else
|
|
578
|
+
* the single exported definition). */
|
|
579
|
+
callResolver(fromRel) {
|
|
580
|
+
let r = this.resolvers.get(fromRel);
|
|
581
|
+
if (!r) {
|
|
582
|
+
r = (name) => {
|
|
583
|
+
const d = this.lookup(name, fromRel);
|
|
584
|
+
return d
|
|
585
|
+
? {
|
|
586
|
+
fn: d.f.fn,
|
|
587
|
+
key: `${d.rel}:${d.f.line}`,
|
|
588
|
+
resolve: this.callResolver(d.rel),
|
|
589
|
+
}
|
|
590
|
+
: undefined;
|
|
591
|
+
};
|
|
592
|
+
this.resolvers.set(fromRel, r);
|
|
593
|
+
}
|
|
594
|
+
return r;
|
|
595
|
+
}
|
|
596
|
+
resolver(name, fromRel) {
|
|
597
|
+
const d = this.lookup(name, fromRel);
|
|
598
|
+
if (!d)
|
|
599
|
+
return undefined;
|
|
600
|
+
const v = this.judge(`R:${d.rel}:${d.f.line}`, () => judgeResolverFunction(d.f.fn));
|
|
601
|
+
return v.verdict === "honoured" ? d : undefined;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** The boundary RHS expressions (`mission.orgId`) that a refusing predicate
|
|
605
|
+
* bound to the caller's verified scope before the write, with the predicate
|
|
606
|
+
* that did it. */
|
|
607
|
+
function predicateBoundValues(h, f, writeLine, fns, src) {
|
|
608
|
+
const out = new Map();
|
|
609
|
+
if (!h.lines.some((l) => code(l) && /\bif\s*\(\s*!\s*[A-Za-z_$][\w$]*\s*\(/.test(l.raw)))
|
|
610
|
+
return out;
|
|
611
|
+
const g = readHandlerGuards(f.rel, src(), h.name, writeLine);
|
|
612
|
+
if (!g)
|
|
613
|
+
return out;
|
|
614
|
+
const isIdentityLocal = (n) => {
|
|
615
|
+
if (!n)
|
|
616
|
+
return false;
|
|
617
|
+
const c = g.consts.get(n);
|
|
618
|
+
if (!c)
|
|
619
|
+
return false;
|
|
620
|
+
if (c.identityRead)
|
|
621
|
+
return true;
|
|
622
|
+
return !!c.awaitedCallee && !!fns.resolver(c.awaitedCallee, f.rel);
|
|
623
|
+
};
|
|
624
|
+
for (const r of g.refusals) {
|
|
625
|
+
const i = r.args.findIndex((a) => isIdentityLocal(a.ident));
|
|
626
|
+
const j = r.args.findIndex((a) => a.memberBase !== undefined && g.consts.has(a.memberBase));
|
|
627
|
+
if (i < 0 || j < 0)
|
|
628
|
+
continue;
|
|
629
|
+
const value = r.args[j].text.replace(/\s+/g, "");
|
|
630
|
+
// The bound property must not be re-assigned in the handler.
|
|
631
|
+
const esc = value.replace(/[.$]/g, "\\$&");
|
|
632
|
+
if (h.lines.some((l) => code(l) && new RegExp(`${esc}\\s*=(?!=)`).test(l.raw)))
|
|
633
|
+
continue;
|
|
634
|
+
const d = fns.lookup(r.predicate, f.rel);
|
|
635
|
+
if (!d)
|
|
124
636
|
continue;
|
|
637
|
+
const v = fns.judge(`P:${d.rel}:${d.f.line}:${i}:${j}`, () => judgePredicateFunction(d.f.fn, i, j));
|
|
638
|
+
if (v.verdict === "honoured")
|
|
639
|
+
out.set(value, `${r.predicate}() (definition ${d.rel}:${d.f.line})`);
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
/** The caller arguments (`args.namespace`) a BINDING VALIDATOR bound to the
|
|
644
|
+
* caller's verified scope before the write: a direct `await V(…, args.x, …)`
|
|
645
|
+
* statement of the handler whose callee, read in the tree on its body
|
|
646
|
+
* (summarizeValidator), reaches a normal exit only when `args.x` passed a
|
|
647
|
+
* predicate over the verified scope (or for the fleet master). Name-free. */
|
|
648
|
+
function validatorBoundArgs(h, f, writeLine, fns, src) {
|
|
649
|
+
const out = new Map();
|
|
650
|
+
const calls = topLevelHelperCalls(f.rel, src(), h.name, null, writeLine);
|
|
651
|
+
if (!calls)
|
|
652
|
+
return out;
|
|
653
|
+
let g;
|
|
654
|
+
for (const c of calls) {
|
|
655
|
+
const targets = c.argNodes
|
|
656
|
+
.map((a, k) => ({ k, text: a.getText().replace(/\s+/g, "") }))
|
|
657
|
+
.filter((a) => /^args\.[A-Za-z_$][\w$]*$/.test(a.text));
|
|
658
|
+
if (targets.length === 0)
|
|
659
|
+
continue;
|
|
660
|
+
const d = fns.lookup(c.name, f.rel);
|
|
661
|
+
if (!d)
|
|
662
|
+
continue;
|
|
663
|
+
// An argument that is the handler's own verified scope (a const bound to
|
|
664
|
+
// an identity read, or to a function judged an identity resolver).
|
|
665
|
+
g ??= readHandlerGuards(f.rel, src(), h.name, writeLine);
|
|
666
|
+
const identityIdx = c.argNodes.findIndex((a) => {
|
|
667
|
+
const t = a.getText();
|
|
668
|
+
const k = g?.consts.get(t);
|
|
669
|
+
return (!!k &&
|
|
670
|
+
(k.identityRead ||
|
|
671
|
+
(!!k.awaitedCallee && !!fns.resolver(k.awaitedCallee, f.rel))));
|
|
672
|
+
});
|
|
673
|
+
for (const t of targets) {
|
|
674
|
+
const sum = summarizeValidator(d.f.fn, {
|
|
675
|
+
boundIdx: t.k,
|
|
676
|
+
identityIdx: identityIdx < 0 ? undefined : identityIdx,
|
|
677
|
+
callArgs: c.argNodes,
|
|
678
|
+
resolve: fns.callResolver(d.rel),
|
|
679
|
+
key: `${d.rel}:${d.f.line}`,
|
|
680
|
+
depth: 0,
|
|
681
|
+
});
|
|
682
|
+
if (sum.ok && sum.exitsBound && !sum.never)
|
|
683
|
+
out.set(t.text, `${c.name}() (definition ${d.rel}:${d.f.line})`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
/** The write texts with every boundary value that IS (completely) a validated
|
|
689
|
+
* argument replaced by a placeholder: what is left is what still reads args. */
|
|
690
|
+
function maskValidated(writes, validated) {
|
|
691
|
+
return writes.map((t) => t.replace(/(\b(?:tenantId|orgId|orgSlug|createdBy|namespace|orchestratorId|ownerId)\s*:\s*)(args\.[A-Za-z_$][\w$]*)(?=\s*(?:,|\}|$))/gm, (m, field, arg) => validated.has(arg) ? `${field}__validated__` : m));
|
|
692
|
+
}
|
|
693
|
+
/** Every RHS of the boundary local `loc` at the write sites, when EACH is a
|
|
694
|
+
* complete member expression on it (`orgId: mission.orgId,`); null when any
|
|
695
|
+
* occurrence is something else (`orgId: mission.orgId ?? args.x`, `{ orgId }`). */
|
|
696
|
+
function boundaryMemberRhs(writes, loc) {
|
|
697
|
+
const out = [];
|
|
698
|
+
const esc = loc.replace(/[$]/g, "\\$&");
|
|
699
|
+
const any = new RegExp(`\\b(?:tenantId|orgId|orgSlug)\\s*:\\s*${esc}\\b`, "g");
|
|
700
|
+
const full = new RegExp(`^\\s*(${esc}(?:\\.[A-Za-z_$][\\w$]*)+)\\s*(?:,|}|$)`);
|
|
701
|
+
for (const t of writes) {
|
|
702
|
+
any.lastIndex = 0;
|
|
703
|
+
let m = any.exec(t);
|
|
704
|
+
while (m !== null) {
|
|
705
|
+
const rest = t.slice(m.index + m[0].length - loc.length);
|
|
706
|
+
const fm = full.exec(rest.split("\n")[0]);
|
|
707
|
+
if (!fm)
|
|
708
|
+
return null;
|
|
709
|
+
out.push(fm[1]);
|
|
710
|
+
m = any.exec(t);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return out.length ? out : null;
|
|
714
|
+
}
|
|
715
|
+
export function scanR6(files, rootIsConvex = false) {
|
|
716
|
+
const scan = emptyScan();
|
|
717
|
+
const helperIndex = indexGuardHelpers(files);
|
|
718
|
+
const fns = new FunctionIndex(files);
|
|
719
|
+
for (const raw of files) {
|
|
720
|
+
if (!isJudgeable(raw) || !isConvex(raw, rootIsConvex))
|
|
721
|
+
continue;
|
|
722
|
+
// One shape for every argument read: `args["x"]` → `args.x`.
|
|
723
|
+
const f = { ...raw, lines: normalizeLines(raw.lines) };
|
|
125
724
|
for (const h of splitMutationHandlers(f)) {
|
|
725
|
+
// An internalMutation has NO client caller: its arguments come from
|
|
726
|
+
// server code (a cron, a scheduler hop, another function), so "a
|
|
727
|
+
// caller-supplied argument" — the thing R-6 forbids (standard
|
|
728
|
+
// 4bcd605 :71, :252 "never accept a userId as a function argument")
|
|
729
|
+
// does not exist there. R-6 governs the client-callable write only.
|
|
730
|
+
if (h.internal)
|
|
731
|
+
continue;
|
|
126
732
|
let writeLine = 0;
|
|
127
733
|
let guard = false;
|
|
128
734
|
let boundaryArg = false;
|
|
735
|
+
let resolvesScope = false;
|
|
129
736
|
for (const l of h.lines) {
|
|
130
737
|
if (!code(l))
|
|
131
738
|
continue;
|
|
@@ -133,12 +740,196 @@ export function scanR6(files) {
|
|
|
133
740
|
writeLine = l.n;
|
|
134
741
|
if (R6_IDENTITY_GUARD.test(l.raw) || R6_RESOURCE_GUARD.test(l.raw))
|
|
135
742
|
guard = true;
|
|
743
|
+
if (R6_SCOPE_RESOLUTION.test(l.raw))
|
|
744
|
+
resolvesScope = true;
|
|
136
745
|
if (R6_BOUNDARY_ARG.test(l.raw))
|
|
137
746
|
boundaryArg = true;
|
|
138
747
|
}
|
|
139
748
|
if (!writeLine)
|
|
140
749
|
continue; // not a write handler — R-6 does not govern it.
|
|
141
|
-
|
|
750
|
+
const writes = writeSiteTexts(h);
|
|
751
|
+
let directTenant = writes.some((t) => R6_TENANT_FROM_ARG.test(t));
|
|
752
|
+
// Follow the local ONE hop: every non-`args` local written to a tenant
|
|
753
|
+
// boundary field, and its THREE-STATE provenance. A local fed from args
|
|
754
|
+
// (matched>0, fromScope===0, fromArgs>0) is TAINTED; a local with NO
|
|
755
|
+
// resolvable assignment (matched===0) or only unresolved indirections is
|
|
756
|
+
// COULD-NOT-JUDGE — never silently tainted (Eta REVISE #3 defect 2).
|
|
757
|
+
const boundaryLocals = [
|
|
758
|
+
...new Set(writes.flatMap(boundaryLocalsInWrite)),
|
|
759
|
+
];
|
|
760
|
+
let taintedLocal;
|
|
761
|
+
let cnjLocal;
|
|
762
|
+
let anyDerived = false;
|
|
763
|
+
let fileSrc;
|
|
764
|
+
const getSrc = () => {
|
|
765
|
+
fileSrc ??= sourceOf(f);
|
|
766
|
+
return fileSrc;
|
|
767
|
+
};
|
|
768
|
+
const resolvedBy = new Map();
|
|
769
|
+
const isResolver = (name) => {
|
|
770
|
+
const d = fns.resolver(name, f.rel);
|
|
771
|
+
if (d)
|
|
772
|
+
resolvedBy.set(name, `${d.rel}:${d.f.line}`);
|
|
773
|
+
return !!d;
|
|
774
|
+
};
|
|
775
|
+
const credits = [];
|
|
776
|
+
// A boundary written straight from an argument a BINDING VALIDATOR
|
|
777
|
+
// bound to the verified scope before the write: credited only when
|
|
778
|
+
// EVERY argument written to a boundary field is so bound.
|
|
779
|
+
if (directBoundaryArgs(writes).length > 0) {
|
|
780
|
+
const validated = validatorBoundArgs(h, f, writeLine, fns, getSrc);
|
|
781
|
+
const masked = maskValidated(writes, validated);
|
|
782
|
+
if (validated.size > 0 &&
|
|
783
|
+
directBoundaryArgs(masked).length === 0 &&
|
|
784
|
+
!masked.some((t) => R6_TENANT_FROM_ARG.test(t))) {
|
|
785
|
+
directTenant = false;
|
|
786
|
+
boundaryArg = false;
|
|
787
|
+
anyDerived = true;
|
|
788
|
+
resolvesScope = true;
|
|
789
|
+
const written = new Set(directBoundaryArgs(writes));
|
|
790
|
+
for (const [arg, by] of validated)
|
|
791
|
+
if (written.has(arg))
|
|
792
|
+
credits.push(`\`${arg}\` bound to the verified scope by validator ${by}, which returns only when it passes a predicate over the verified scope (or for the fleet master) and refuses otherwise`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
let bound;
|
|
796
|
+
for (const loc of boundaryLocals) {
|
|
797
|
+
// A value a refusing predicate bound to the caller's scope before
|
|
798
|
+
// the write (`if (!P(scope, mission.orgId)) throw`), written
|
|
799
|
+
// unchanged: derived from the verified identity through the target.
|
|
800
|
+
const rhs = boundaryMemberRhs(writes, loc);
|
|
801
|
+
if (rhs) {
|
|
802
|
+
bound ??= predicateBoundValues(h, f, writeLine, fns, getSrc);
|
|
803
|
+
const by = rhs.map((x) => bound?.get(x));
|
|
804
|
+
if (by.every((x) => x !== undefined)) {
|
|
805
|
+
anyDerived = true;
|
|
806
|
+
resolvesScope = true;
|
|
807
|
+
credits.push(`\`${rhs[0]}\` bound to the verified scope by refusing predicate ${by[0]}`);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
resolvedBy.clear();
|
|
812
|
+
const a = analyzeLocalTaint(h, loc, isResolver);
|
|
813
|
+
if (a.fromResolver) {
|
|
814
|
+
resolvesScope = true;
|
|
815
|
+
credits.push(`local "${loc}" is the return of identity resolver ${[...resolvedBy].map(([n, at]) => `${n}() (definition ${at})`).join(", ")}, which returns the verified scope itself and refuses an absent identity on every path`);
|
|
816
|
+
}
|
|
817
|
+
if (a.fromScope > 0) {
|
|
818
|
+
anyDerived = true;
|
|
819
|
+
}
|
|
820
|
+
else if (a.matched === 0) {
|
|
821
|
+
cnjLocal ??= loc; // no assignment/declaration found — cannot judge.
|
|
822
|
+
}
|
|
823
|
+
else if (a.fromArgs > 0) {
|
|
824
|
+
taintedLocal ??= loc; // fed only from args — laundered argument.
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
cnjLocal ??= loc; // matched but only unresolved bare-id hops.
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// A CONDITIONAL boundary value (`orgId: c ? a : b`): an arm that reads
|
|
831
|
+
// the caller's args is the argument, unless it is the TRUE arm of a
|
|
832
|
+
// positive `<scope>.isMaster` test on a scope-derived local (the
|
|
833
|
+
// fleet carve-out: the master's authority is fleet-wide by design).
|
|
834
|
+
let conditionalArg;
|
|
835
|
+
for (const cb of writes.flatMap((t) => conditionalBoundaries(t, R6_TENANT_FIELDS))) {
|
|
836
|
+
const base = cb.masterBase;
|
|
837
|
+
const masterArm = base !== undefined &&
|
|
838
|
+
base !== "args" &&
|
|
839
|
+
(() => {
|
|
840
|
+
const a = analyzeLocalTaint(h, base, isResolver);
|
|
841
|
+
return a.fromScope > 0 && a.fromArgs === 0;
|
|
842
|
+
})();
|
|
843
|
+
if (cb.falseReadsArgs || (cb.trueReadsArgs && !masterArm)) {
|
|
844
|
+
conditionalArg ??= cb.field;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const derivedTenant = anyDerived;
|
|
848
|
+
const accusable = directTenant ||
|
|
849
|
+
conditionalArg ||
|
|
850
|
+
taintedLocal ||
|
|
851
|
+
(!guard && boundaryArg);
|
|
852
|
+
const calls = helperCalls(h, f, writeLine, helperIndex, getSrc);
|
|
853
|
+
const violateBefore = scan.violate.length;
|
|
854
|
+
const cover = calls ? coveringCall(calls, writes) : undefined;
|
|
855
|
+
if (cover?.def) {
|
|
856
|
+
scan.conform.push({
|
|
857
|
+
rel: f.rel,
|
|
858
|
+
n: writeLine,
|
|
859
|
+
note: `${h.name}: boundary enforced by allowlisted guard helper ${cover.name}() (${cover.kind}; definition ${cover.def.rel}:${cover.def.n} refuses on every path through a branch conditioned on the verified identity)`,
|
|
860
|
+
});
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
// The handler leans on an allowlisted helper the doctor cannot judge:
|
|
864
|
+
// its definition is not in the scanned tree, its control flow is not
|
|
865
|
+
// modelled, or the handler body itself could not be located. Neither
|
|
866
|
+
// credited nor accused.
|
|
867
|
+
const undecided = calls === null
|
|
868
|
+
? "its handler body could not be located for the statement-level read"
|
|
869
|
+
: calls
|
|
870
|
+
.map((c) => c.def === undefined
|
|
871
|
+
? `${c.name}() has no readable definition in the scanned tree`
|
|
872
|
+
: c.def.judgement.verdict === "could-not-judge"
|
|
873
|
+
? `${c.name}() at ${c.def.rel}:${c.def.n} could not be judged — ${c.def.judgement.why}`
|
|
874
|
+
: "")
|
|
875
|
+
.find((x) => x !== "");
|
|
876
|
+
if (undecided && accusable) {
|
|
877
|
+
scan.cnj.push({
|
|
878
|
+
rel: f.rel,
|
|
879
|
+
n: writeLine,
|
|
880
|
+
note: `${h.name}: relies on an allowlisted guard helper whose enforcement cannot be decided — ${undecided} (could-not-judge, not an accusation)`,
|
|
881
|
+
});
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (directTenant) {
|
|
885
|
+
// Decisive VIOLATE — the WRITE-SITE provenance, not guard presence,
|
|
886
|
+
// is what R-6 governs. A tenant-scoping field written straight from a
|
|
887
|
+
// caller arg is the boundary being the argument, even when the handler
|
|
888
|
+
// resolves scope elsewhere (the historical sendMessage defect).
|
|
889
|
+
scan.violate.push({
|
|
890
|
+
rel: f.rel,
|
|
891
|
+
n: writeLine,
|
|
892
|
+
note: `${h.name}: writes a tenant-scoping boundary field (tenantId/orgId/orgSlug) directly from a caller argument AT THE WRITE SITE — provenance is the raw arg, not a resolved scope, even if a guard is present elsewhere`,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
else if (conditionalArg) {
|
|
896
|
+
scan.violate.push({
|
|
897
|
+
rel: f.rel,
|
|
898
|
+
n: writeLine,
|
|
899
|
+
note: `${h.name}: writes tenant-scoping boundary field "${conditionalArg}" from a conditional whose arm reads a caller argument outside a positive isMaster test on the resolved scope — that arm writes the argument`,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
else if (taintedLocal) {
|
|
903
|
+
// LAUNDERED VIOLATE — the boundary field is a local (`tenantId: t`)
|
|
904
|
+
// but every assignment to that local is fed from args (fromScope===0):
|
|
905
|
+
// the caller's argument reaching the write under another name. A rename
|
|
906
|
+
// hop does not sanitise provenance; scope resolved elsewhere in the
|
|
907
|
+
// handler does not rescue it.
|
|
908
|
+
scan.violate.push({
|
|
909
|
+
rel: f.rel,
|
|
910
|
+
n: writeLine,
|
|
911
|
+
note: `${h.name}: writes tenant-scoping boundary field from local "${taintedLocal}" whose every assignment is caller-arg-derived (fromScope=0) — the argument laundered through a rename, not a scope-derived value`,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
else if (cnjLocal && !anyDerived) {
|
|
915
|
+
// THIRD STATE — a boundary local whose provenance could not be
|
|
916
|
+
// resolved (no assignment/declaration found in-handler, or only
|
|
917
|
+
// unresolved bare-identifier indirections). This is could-not-judge,
|
|
918
|
+
// NEVER an accusation (Eta REVISE #3 defect 2).
|
|
919
|
+
scan.cnj.push({
|
|
920
|
+
rel: f.rel,
|
|
921
|
+
n: writeLine,
|
|
922
|
+
note: `${h.name}: writes tenant-scoping boundary field from local "${cnjLocal}" whose provenance could not be resolved in-handler (no assignment/declaration found, or only unresolved indirections) — could-not-judge, not an accusation`,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
else if (derivedTenant && resolvesScope) {
|
|
926
|
+
scan.conform.push({
|
|
927
|
+
rel: f.rel,
|
|
928
|
+
n: writeLine,
|
|
929
|
+
note: `${h.name}: tenant boundary written from a scope-derived local at the write site (scope/identity resolved in-handler)${credits.length ? ` — ${credits.join("; ")}` : ""}`,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
else if (guard) {
|
|
142
933
|
scan.conform.push({
|
|
143
934
|
rel: f.rel,
|
|
144
935
|
n: writeLine,
|
|
@@ -159,6 +950,16 @@ export function scanR6(files) {
|
|
|
159
950
|
note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
|
|
160
951
|
});
|
|
161
952
|
}
|
|
953
|
+
// A violation at a site calling a listed helper whose definition was
|
|
954
|
+
// read and NOT honoured says why, so the reader is not left believing
|
|
955
|
+
// the helper was simply missed.
|
|
956
|
+
const refused = calls?.find((c) => c.def?.judgement.verdict === "refused");
|
|
957
|
+
if (refused?.def &&
|
|
958
|
+
refused.def.judgement.verdict === "refused" &&
|
|
959
|
+
scan.violate.length > violateBefore) {
|
|
960
|
+
const v = scan.violate[scan.violate.length - 1];
|
|
961
|
+
v.note += `; allowlisted helper ${refused.name}() at ${refused.def.rel}:${refused.def.n} NOT honoured — ${refused.def.judgement.why}`;
|
|
962
|
+
}
|
|
162
963
|
}
|
|
163
964
|
}
|
|
164
965
|
return scan;
|
|
@@ -199,10 +1000,10 @@ function collectConvexErrorThrows(f) {
|
|
|
199
1000
|
}
|
|
200
1001
|
return out;
|
|
201
1002
|
}
|
|
202
|
-
export function scanR16(files) {
|
|
1003
|
+
export function scanR16(files, rootIsConvex = false) {
|
|
203
1004
|
const scan = emptyScan();
|
|
204
1005
|
for (const f of files) {
|
|
205
|
-
if (!isJudgeable(f) || !isConvex(f))
|
|
1006
|
+
if (!isJudgeable(f) || !isConvex(f, rootIsConvex))
|
|
206
1007
|
continue;
|
|
207
1008
|
for (const t of collectConvexErrorThrows(f)) {
|
|
208
1009
|
if (R16_CODE_TOKEN.test(t.payload)) {
|
|
@@ -394,13 +1195,13 @@ function stringifyResult(res) {
|
|
|
394
1195
|
/** R-6 — static primary + live secondary (LiveDetector). */
|
|
395
1196
|
export const R6 = {
|
|
396
1197
|
id: R6_ID,
|
|
397
|
-
run: (tree) => decide("R-6", R6_ID, R6_CHECK, scanR6(tree.files), "R-6: zero convex write handlers matched in the scanned tree — cannot certify boundary derivation. NOT a clean bill."),
|
|
1198
|
+
run: (tree) => decide("R-6", R6_ID, R6_CHECK, scanR6(tree.files, rootIsConvexDir(tree.root)), "R-6: zero convex write handlers matched in the scanned tree — cannot certify boundary derivation. NOT a clean bill."),
|
|
398
1199
|
runLive: r6RunLive,
|
|
399
1200
|
};
|
|
400
1201
|
/** R-16 — static primary + live secondary (LiveDetector). */
|
|
401
1202
|
export const R16 = {
|
|
402
1203
|
id: R16_ID,
|
|
403
|
-
run: (tree) => decide("R-16", R16_ID, R16_CHECK, scanR16(tree.files), "R-16: zero ConvexError throw sites matched in the scanned convex tree — cannot certify the error contract. NOT a clean bill."),
|
|
1204
|
+
run: (tree) => decide("R-16", R16_ID, R16_CHECK, scanR16(tree.files, rootIsConvexDir(tree.root)), "R-16: zero ConvexError throw sites matched in the scanned convex tree — cannot certify the error contract. NOT a clean bill."),
|
|
404
1205
|
runLive: r16RunLive,
|
|
405
1206
|
};
|
|
406
1207
|
/** The two, in a form the orchestrator spreads into REGISTRY. */
|