@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
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { itemCount, siteCount, withCount } from "../../violation-count.js";
|
|
2
|
+
import { parseFile } from "./_ast.js";
|
|
3
|
+
import { conditionalBoundaries, findHelperDefinitions, judgePredicateFunction, judgeResolverFunction, readHandlerGuards, SCOPE_REFINERS, summarizeValidator, topLevelFunctions, topLevelHelperCalls, } from "./guard-helper-flow.js";
|
|
4
|
+
import { IDENTITY_PACKAGE } from "./identity-by-id.js";
|
|
5
|
+
import { packagePrincipals, } from "./package-principal.js";
|
|
6
|
+
import { masterOnlyWrapperCredit, possessionGatedCredit } from "./r6-shapes.js";
|
|
1
7
|
/** The standard both are pinned against (short hash a9d7877b), §15/§18. */
|
|
2
8
|
export const IDENTITY_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15/§18";
|
|
3
9
|
function emptyScan() {
|
|
@@ -17,8 +23,32 @@ function isJudgeable(f) {
|
|
|
17
23
|
function code(l) {
|
|
18
24
|
return !l.comment;
|
|
19
25
|
}
|
|
20
|
-
/**
|
|
21
|
-
|
|
26
|
+
/**
|
|
27
|
+
* True when the ENGINE's scan root is itself a convex directory. loadTree sets
|
|
28
|
+
* `tree.root` to the target the CLI was pointed at, and computes every file's
|
|
29
|
+
* `rel` relative to that root. So the SAME convex file yields two different rels
|
|
30
|
+
* under the two documented invocations:
|
|
31
|
+
* - pointed at the repo ROOT → rel = `convex/messages.ts` (carries `convex/`)
|
|
32
|
+
* - pointed at the CONVEX dir → rel = `messages.ts` (bare, no segment)
|
|
33
|
+
* The bare-rel case is the normal, documented invocation, and it is precisely
|
|
34
|
+
* where a `rel`-only `isConvex` guard (see below) returned FALSE for 100% of
|
|
35
|
+
* files — silently skipping R-6/R-16 (Path-B C1 blind spot 1). When the root IS
|
|
36
|
+
* a convex dir, EVERY collected file is by construction under that convex tree,
|
|
37
|
+
* so no non-convex file (mcp-server/app/src) can be pulled in by trusting it.
|
|
38
|
+
*/
|
|
39
|
+
function rootIsConvexDir(root) {
|
|
40
|
+
// Any `convex` path SEGMENT of the scan root (`.../convex` or `.../convex/…`).
|
|
41
|
+
// A repo root (`/…/vantage-memory`) has no such segment, so this stays FALSE
|
|
42
|
+
// there and the per-file `convex/` rel prefix does the scoping instead.
|
|
43
|
+
return /(^|\/)convex(\/|$)/.test(root.replace(/\/+$/, ""));
|
|
44
|
+
}
|
|
45
|
+
/** convex source only — R-6/R-16 govern the data layer's write handlers. When
|
|
46
|
+
* the scan root is itself a convex dir every file is convex source (bare rels);
|
|
47
|
+
* otherwise a file qualifies only by a `convex/` rel prefix — so a repo-root
|
|
48
|
+
* scan still EXCLUDES non-convex source (mcp-server/src/*, app/*, src/*). */
|
|
49
|
+
function isConvex(f, rootIsConvex) {
|
|
50
|
+
if (rootIsConvex)
|
|
51
|
+
return true;
|
|
22
52
|
return /(^|\/)convex\//.test(f.rel);
|
|
23
53
|
}
|
|
24
54
|
/**
|
|
@@ -38,12 +68,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
38
68
|
.map((x) => `${x.rel}:${x.n} (${x.note})`)
|
|
39
69
|
.join("; ");
|
|
40
70
|
if (nV > 0) {
|
|
41
|
-
return {
|
|
71
|
+
return withCount({
|
|
42
72
|
id,
|
|
43
73
|
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
44
74
|
state: "non-conforming",
|
|
45
75
|
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
|
|
46
|
-
};
|
|
76
|
+
}, siteCount(scan.violate));
|
|
47
77
|
}
|
|
48
78
|
if (nJ > 0) {
|
|
49
79
|
const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
|
|
@@ -73,10 +103,11 @@ function decide(ruleId, id, check, scan, emptyReason) {
|
|
|
73
103
|
}
|
|
74
104
|
/** Split a convex file into its top-level mutation/internalMutation handler
|
|
75
105
|
* 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
|
-
|
|
106
|
+
* top-level `export ` (or EOF). Queries/actions are not R-6 write handlers.
|
|
107
|
+
* Each block records whether it is an `internalMutation` registration. */
|
|
108
|
+
export function splitMutationHandlers(f) {
|
|
78
109
|
const out = [];
|
|
79
|
-
const open = /export\s+const\s+(\w+)\s*=\s*(
|
|
110
|
+
const open = /export\s+const\s+(\w+)\s*=\s*(internalMutation|mutation)\s*\(/;
|
|
80
111
|
const topExport = /^export\s+/;
|
|
81
112
|
let cur = null;
|
|
82
113
|
for (const l of f.lines) {
|
|
@@ -84,7 +115,12 @@ function splitMutationHandlers(f) {
|
|
|
84
115
|
if (m) {
|
|
85
116
|
if (cur)
|
|
86
117
|
out.push(cur);
|
|
87
|
-
cur = {
|
|
118
|
+
cur = {
|
|
119
|
+
name: m[1],
|
|
120
|
+
startN: l.n,
|
|
121
|
+
lines: [l],
|
|
122
|
+
internal: m[2] === "internalMutation",
|
|
123
|
+
};
|
|
88
124
|
continue;
|
|
89
125
|
}
|
|
90
126
|
if (cur) {
|
|
@@ -116,16 +152,832 @@ function splitMutationHandlers(f) {
|
|
|
116
152
|
const R6_WRITE = /ctx\.db\.(?:insert|patch|replace|delete)\s*\(/;
|
|
117
153
|
const R6_IDENTITY_GUARD = /getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|expectedOrgIdForNamespace|tenantSlug|organizationId|organizationSlug|AUTH_NO_ORG|AUTH_NAMESPACE_DENIED/;
|
|
118
154
|
const R6_RESOURCE_GUARD = /assertTaskCallerAuthorized|RBAC_DENIED|\.createdBy\s*===|\.assignedTo\s*===|\.orchestratorId\s*===|callerOrchestrator\s*===\s*["']system["']|note\.createdBy/;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
155
|
+
// ---- WRITE-SITE provenance (blind spot 2) ---------------------------------
|
|
156
|
+
//
|
|
157
|
+
// Guard-PRESENCE alone cannot distinguish the historical tenant-write defect
|
|
158
|
+
// from its fix: BOTH `messages.sendMessage` versions call `withOrgScope(ctx)`.
|
|
159
|
+
// The real discriminator is the tenant-scoping boundary field's RHS AT THE
|
|
160
|
+
// WRITE SITE. `tenantId: args.tenantId` (raw caller arg, written directly) is
|
|
161
|
+
// the defect; `tenantId: derivedTenantId` (a scope-derived local) is the fix —
|
|
162
|
+
// even though a `derivedTenantId = args.tenantId` ASSIGNMENT exists inside the
|
|
163
|
+
// master carve-out branch, that assignment is not a ctx.db write and is never
|
|
164
|
+
// flagged. So a direct-arg tenant write is decisive VIOLATE regardless of any
|
|
165
|
+
// guard, and a derived-local tenant write (with scope resolved) is CONFORM.
|
|
166
|
+
// `args.x`, and a COMPUTED subscript `args[k]` (whatever the key, the value is
|
|
167
|
+
// read off the caller's arguments). A literal-keyed subscript (`args["x"]`) never
|
|
168
|
+
// reaches this regex: normalizeArgsAccess rewrites it to `args.x` first.
|
|
169
|
+
const R6_TENANT_FIELDS = new Set([
|
|
170
|
+
"tenantId",
|
|
171
|
+
"orgId",
|
|
172
|
+
"orgSlug",
|
|
173
|
+
]);
|
|
174
|
+
const R6_TENANT_FROM_ARG = /\b(?:tenantId|orgId|orgSlug)\s*:\s*args(?:\.\w+|\s*\[)/;
|
|
175
|
+
// ---- literal-keyed subscript (task k179nydvbdxm40ps7g0y18mwhs8dje91) -------
|
|
176
|
+
//
|
|
177
|
+
// `args["orgSlug"]`, `args['orgSlug']` and args[`orgSlug`] are the SAME property
|
|
178
|
+
// read as `args.orgSlug`. Every R-6 regex below was written against the dotted
|
|
179
|
+
// form, so the subscript form was invisible: `const t = args["orgSlug"]` kept
|
|
180
|
+
// its `\borgSlug\b` token after the `args.<field>` strip and read SCOPE-derived,
|
|
181
|
+
// and `tenantId: args["orgSlug"]` matched no direct-arg regex at all. Rewriting
|
|
182
|
+
// the literal subscript to the dotted form BEFORE any regex runs makes every
|
|
183
|
+
// existing rule see one shape. A template literal is rewritten only when it has
|
|
184
|
+
// no `\${…}` interpolation (the key must be a plain identifier).
|
|
185
|
+
export function normalizeArgsAccess(raw) {
|
|
186
|
+
return raw.replace(/\bargs\s*\[\s*(["'`])([A-Za-z_$][\w$]*)\1\s*\]/g, "args.$2");
|
|
187
|
+
}
|
|
188
|
+
function normalizeLines(lines) {
|
|
189
|
+
return lines.map((l) => {
|
|
190
|
+
const raw = normalizeArgsAccess(l.raw);
|
|
191
|
+
return raw === l.raw ? l : { ...l, raw };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const R6_SCOPE_RESOLUTION = /withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|assertNamespaceAllowed|RBAC_DENIED/;
|
|
195
|
+
// ---- follow-the-local taint (blind spot 2b — the ONE-HOP rename) ----------
|
|
196
|
+
//
|
|
197
|
+
// Write-site RHS provenance alone is defeated by a single rename:
|
|
198
|
+
// const t = args.tenantId; ... ctx.db.insert(..., { tenantId: t })
|
|
199
|
+
// The boundary field is now a LOCAL, so R6_TENANT_FROM_ARG does not match and
|
|
200
|
+
// R6_TENANT_FROM_LOCAL wrongly reads CONFORM — the caller's argument reaches the
|
|
201
|
+
// write untouched, wearing another name. Same file, same defect, laundered.
|
|
202
|
+
//
|
|
203
|
+
// The fix follows the local ONE hop: collect every assignment/declaration of the
|
|
204
|
+
// local written as a boundary field, and discriminate on whether ANY of them is
|
|
205
|
+
// SCOPE-derived. A local fed ONLY from args (fromScope === 0) is the argument
|
|
206
|
+
// under an alias → TAINTED → VIOLATE. A local that receives at least one
|
|
207
|
+
// scope/identity-derived value (fromScope >= 1) is genuinely derived → CONFORM.
|
|
208
|
+
// This is exactly what keeps the master carve-out safe (its non-master branch
|
|
209
|
+
// assigns from scope.orgSlug → fromScope >= 1) WITHOUT rescuing the laundering
|
|
210
|
+
// (whose sole assignment is `= args.tenantId` → fromScope === 0).
|
|
211
|
+
// The verified-identity CLAIM names (`identity.organizationId`, `.organizationSlug`,
|
|
212
|
+
// `.tenantSlug`) are the same tokens R6_IDENTITY_GUARD already trusts. Without
|
|
213
|
+
// them a shorthand `{ orgId }` fed from `identity.organizationId` read TAINTED —
|
|
214
|
+
// the false positive the shorthand read (k176ewgwrbc6mg1y4h42rg25gn8dja1p)
|
|
215
|
+
// surfaced in tests/source-identity.test.ts "neuter-proof". An argument whose
|
|
216
|
+
// field carries such a name is stripped below before this test, as for orgSlug.
|
|
217
|
+
const R6_SCOPE_RHS = /\bscope\b|withOrgScope|getUserIdentity|resolveOrgId|resolveOrgContext|\borgSlug\b|assertNamespaceAllowed|\b(?:organizationId|organizationSlug|tenantSlug)\b/;
|
|
218
|
+
/** The non-`args` LOCAL identifiers written to a tenant-scoping boundary field
|
|
219
|
+
* at this write-site text (`tenantId: <local>`). `tenantId: args.tenantId` is
|
|
220
|
+
* excluded here — it is the DIRECT-arg case handled by R6_TENANT_FROM_ARG. */
|
|
221
|
+
function boundaryLocalsInWrite(text) {
|
|
222
|
+
const out = [];
|
|
223
|
+
const re = /\b(?:tenantId|orgId|orgSlug)\s*:\s*([A-Za-z_$][\w$]*)/g;
|
|
224
|
+
let m = re.exec(text);
|
|
225
|
+
while (m !== null) {
|
|
226
|
+
if (m[1] !== "args")
|
|
227
|
+
out.push(m[1]);
|
|
228
|
+
m = re.exec(text);
|
|
229
|
+
}
|
|
230
|
+
// ES6 SHORTHAND (task k176ewgwrbc6mg1y4h42rg25gn8dja1p): `{ tenantId }` is
|
|
231
|
+
// `{ tenantId: tenantId }` — the boundary field written from a LOCAL of the
|
|
232
|
+
// same name. The keyed regex above needs a `:` and never saw it, so
|
|
233
|
+
// `const tenantId = args.namespace; insert({ tenantId })` read CONFORM. The
|
|
234
|
+
// shorthand member sits after `{` or `,` and before `,` or `}` (or a line
|
|
235
|
+
// end, for a multi-line literal); its local then goes through the SAME
|
|
236
|
+
// provenance analysis as a keyed local.
|
|
237
|
+
const short = /(?:^|[{,])\s*(tenantId|orgId|orgSlug)\s*(?=[,}]|$)/gm;
|
|
238
|
+
let s = short.exec(text);
|
|
239
|
+
while (s !== null) {
|
|
240
|
+
out.push(s[1]);
|
|
241
|
+
s = short.exec(text);
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/** If `local` is bound by destructuring the HANDLER'S OWN PARAMETER —
|
|
246
|
+
* `handler: async (ctx, { tenantId }) =>` / `async handler(ctx, { tenantId })`
|
|
247
|
+
* — it IS a caller argument under its own name. Returns true when this line is
|
|
248
|
+
* such a binding of `local`. */
|
|
249
|
+
function boundFromHandlerParam(raw, local) {
|
|
250
|
+
const m = /\bhandler\b[^(\n]*\(\s*[A-Za-z_$][\w$]*\s*(?::\s*[\w.<>]+\s*)?,\s*\{([^}]*)\}/.exec(raw);
|
|
251
|
+
if (!m)
|
|
252
|
+
return false;
|
|
253
|
+
for (const rawPart of m[1].split(",")) {
|
|
254
|
+
const part = rawPart.trim().split("=")[0].trim();
|
|
255
|
+
if (!part)
|
|
256
|
+
continue;
|
|
257
|
+
const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
|
|
258
|
+
if (bound === local)
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
/** Max bare-identifier hops chased before exhaustion is declared could-not-judge
|
|
264
|
+
* (defect 3): `const t = orgSlug; const orgSlug = args.x` resolves in one extra
|
|
265
|
+
* hop; deeper indirection or a cycle stops and is reported cnj, never guessed. */
|
|
266
|
+
const R6_MAX_HOP = 2;
|
|
267
|
+
/** If `local` is bound by an object-destructuring declaration on `raw`, return
|
|
268
|
+
* the destructuring RHS (the expression after `} =`) so it can be tested for
|
|
269
|
+
* scope-ness EXACTLY as a plain assignment RHS is (Eta REVISE #3 defect 1).
|
|
270
|
+
* Recognises the shorthand (`const { orgSlug } = ...`), the keyed/renamed form
|
|
271
|
+
* (`const { orgSlug: t } = ...`), and a defaulted binding (`const { x = d } = `).
|
|
272
|
+
* Returns null when this line does not destructure `local`. */
|
|
273
|
+
function destructuredRhs(raw, local) {
|
|
274
|
+
const m = /(?:const|let|var)\s*\{([^}]*)\}\s*=(?!=)\s*([\s\S]+)$/.exec(raw);
|
|
275
|
+
if (!m)
|
|
276
|
+
return null;
|
|
277
|
+
for (const rawPart of m[1].split(",")) {
|
|
278
|
+
const part = rawPart.trim().split("=")[0].trim(); // drop any default value
|
|
279
|
+
if (!part)
|
|
280
|
+
continue;
|
|
281
|
+
const bound = part.includes(":") ? (part.split(":")[1] ?? "").trim() : part;
|
|
282
|
+
if (bound === local)
|
|
283
|
+
return m[2];
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
/** Follow ONE hop (plus one bounded bare-identifier hop): classify every
|
|
288
|
+
* assignment/declaration of `local` in the handler as scope-derived, arg-derived,
|
|
289
|
+
* or could-not-judge. Reads a PLAIN assignment RHS and a DESTRUCTURING RHS with
|
|
290
|
+
* the same scope test (defect 1). Exposes `matched` so a local with NO resolvable
|
|
291
|
+
* assignment is a third state, not silently tainted (defect 2). A bare-identifier
|
|
292
|
+
* RHS (`const t = orgSlug`) is NOT scope evidence on its own — the identifier's
|
|
293
|
+
* own provenance is resolved one further, bounded, cycle-guarded hop (defect 3);
|
|
294
|
+
* exhaustion is `cnj`, never a guess. */
|
|
295
|
+
export function analyzeLocalTaint(h, local, isResolver, principalOf) {
|
|
296
|
+
return analyzeLocalTaintInner({ ...h, lines: normalizeLines(h.lines) }, local, 0, new Set(), isResolver, principalOf);
|
|
297
|
+
}
|
|
298
|
+
/** The RHS starting on `codeLines[i]`, extended over the following lines until
|
|
299
|
+
* an opening `await <name>(` is balanced (bounded; unbalanced -> the line alone).
|
|
300
|
+
* Used ONLY to recognise a resolver call that spans lines — never to widen the
|
|
301
|
+
* scope test of an ordinary RHS. */
|
|
302
|
+
function joinedAwaitedCall(rhs, codeLines, i) {
|
|
303
|
+
if (!/^\s*await\s+[A-Za-z_$][\w$]*\s*\(/.test(rhs))
|
|
304
|
+
return rhs;
|
|
305
|
+
let text = rhs;
|
|
306
|
+
const depthOf = (t) => (t.match(/\(/g) ?? []).length - (t.match(/\)/g) ?? []).length;
|
|
307
|
+
for (let j = i + 1; depthOf(text) > 0 && j < codeLines.length && j <= i + 40; j++)
|
|
308
|
+
text += `\n${codeLines[j].raw}`;
|
|
309
|
+
return depthOf(text) > 0 ? rhs : text;
|
|
310
|
+
}
|
|
311
|
+
/** `rhs` is exactly `await <name>(…)` closed on this line (nothing after the
|
|
312
|
+
* call but `;`): the whole value is that function's return. */
|
|
313
|
+
function wholeAwaitedCall(rhs) {
|
|
314
|
+
const m = /^\s*await\s+([A-Za-z_$][\w$]*)\s*\(/.exec(rhs);
|
|
315
|
+
if (!m)
|
|
316
|
+
return null;
|
|
317
|
+
let depth = 0;
|
|
318
|
+
for (let i = m[0].length - 1; i < rhs.length; i++) {
|
|
319
|
+
const ch = rhs[i];
|
|
320
|
+
if (ch === "(")
|
|
321
|
+
depth++;
|
|
322
|
+
else if (ch === ")") {
|
|
323
|
+
depth--;
|
|
324
|
+
if (depth === 0)
|
|
325
|
+
return /^\s*;?\s*$/.test(rhs.slice(i + 1)) ? m[1] : null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
function analyzeLocalTaintInner(h, local, depth, visited, isResolver, principalOf) {
|
|
331
|
+
// `local` IS an alias of a checked package principal (`const { principal }
|
|
332
|
+
// = who` after `if (!who.ok) throw`): the verified identity under a name.
|
|
333
|
+
if (principalOf?.(local))
|
|
334
|
+
return { fromArgs: 0, fromScope: 1, matched: 1, cnj: 0, fromResolver: 1 };
|
|
335
|
+
let fromArgs = 0;
|
|
336
|
+
let fromScope = 0;
|
|
337
|
+
let matched = 0;
|
|
338
|
+
let cnj = 0;
|
|
339
|
+
let fromResolver = 0;
|
|
340
|
+
const esc = local.replace(/[$]/g, "\\$&");
|
|
341
|
+
const assign = new RegExp(`(?:^|[^\\w.$])${esc}\\s*(?::[^=\\n]*)?=(?!=)`);
|
|
342
|
+
const decl = new RegExp(`\\b(?:const|let|var)\\s+${esc}\\b`);
|
|
343
|
+
const classify = (rhs, call = rhs) => {
|
|
344
|
+
// A BARE IDENTIFIER RHS carries no scope evidence on its own — its NAME
|
|
345
|
+
// (even `orgSlug`) never counts as scope; the identifier's own provenance
|
|
346
|
+
// decides, or the scan refuses (defect 3, Eta pole 2).
|
|
347
|
+
const bare = rhs
|
|
348
|
+
.trim()
|
|
349
|
+
.replace(/;+\s*$/, "")
|
|
350
|
+
.trim();
|
|
351
|
+
if (/^[A-Za-z_$][\w$]*$/.test(bare) && bare !== "args") {
|
|
352
|
+
if (visited.has(bare) || depth >= R6_MAX_HOP) {
|
|
353
|
+
cnj++; // cycle or hop exhausted — could-not-judge, never a guess.
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const next = new Set(visited);
|
|
357
|
+
next.add(local);
|
|
358
|
+
const sub = analyzeLocalTaintInner(h, bare, depth + 1, next, isResolver, principalOf);
|
|
359
|
+
if (sub.matched === 0)
|
|
360
|
+
cnj++; // the referenced local has no resolvable assignment.
|
|
361
|
+
else if (sub.fromScope > 0 && sub.fromArgs === 0)
|
|
362
|
+
fromScope++;
|
|
363
|
+
else if (sub.fromArgs > 0)
|
|
364
|
+
fromArgs++;
|
|
365
|
+
else
|
|
366
|
+
cnj++;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// The whole value is the return of a function the tree shows to be an
|
|
370
|
+
// identity RESOLVER (it returns the verified scope itself and refuses an
|
|
371
|
+
// absent identity first): scope-derived, whatever arguments it was handed.
|
|
372
|
+
const called = isResolver ? wholeAwaitedCall(call) : null;
|
|
373
|
+
if (called && isResolver?.(called, call)) {
|
|
374
|
+
fromScope++;
|
|
375
|
+
fromResolver++;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
// A member read of a checked package principal (`who.principal.orgId`).
|
|
379
|
+
if (principalOf?.(rhs)) {
|
|
380
|
+
fromScope++;
|
|
381
|
+
fromResolver++;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// Strip `args.<field>` from the RHS BEFORE the scope test so an argument
|
|
385
|
+
// whose FIELD is named like a scope key cannot launder itself:
|
|
386
|
+
// `const t = args.orgSlug` must not read scope-derived just because
|
|
387
|
+
// R6_SCOPE_RHS carries `\borgSlug\b`. The value is still an argument, and
|
|
388
|
+
// the law above (a local fed only from args is tainted) governs. Genuine
|
|
389
|
+
// `scope.orgSlug` / `withOrgScope(...)` RHS has no `args.` token to strip
|
|
390
|
+
// and still matches → fromScope.
|
|
391
|
+
const rhsForScope = rhs.replace(/\bargs\.\w+/g, "");
|
|
392
|
+
if (R6_SCOPE_RHS.test(rhsForScope))
|
|
393
|
+
fromScope++;
|
|
394
|
+
else
|
|
395
|
+
fromArgs++;
|
|
396
|
+
};
|
|
397
|
+
const codeLines = h.lines.filter(code);
|
|
398
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
399
|
+
const raw = codeLines[i].raw;
|
|
400
|
+
// The handler's own parameter destructured — the binding IS the argument.
|
|
401
|
+
if (boundFromHandlerParam(raw, local)) {
|
|
402
|
+
matched++;
|
|
403
|
+
fromArgs++;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// A destructuring binding of `local` — test its RHS as a plain RHS.
|
|
407
|
+
const dr = destructuredRhs(raw, local);
|
|
408
|
+
if (dr !== null) {
|
|
409
|
+
matched++;
|
|
410
|
+
classify(dr);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const isAssign = assign.test(raw);
|
|
414
|
+
const isBareDecl = !isAssign && decl.test(raw) && !raw.includes("=");
|
|
415
|
+
if (!isAssign && !isBareDecl)
|
|
416
|
+
continue;
|
|
417
|
+
matched++;
|
|
418
|
+
const rhs = isAssign ? raw.slice(raw.indexOf("=") + 1) : "";
|
|
419
|
+
classify(rhs, joinedAwaitedCall(rhs, codeLines, i));
|
|
420
|
+
}
|
|
421
|
+
return fromResolver > 0
|
|
422
|
+
? { fromArgs, fromScope, matched, cnj, fromResolver }
|
|
423
|
+
: { fromArgs, fromScope, matched, cnj };
|
|
424
|
+
}
|
|
425
|
+
/** The full source text of every `ctx.db.insert|patch|replace(...)` call in a
|
|
426
|
+
* handler, each joined across the lines the call spans (parens balanced). The
|
|
427
|
+
* tenant-provenance regexes read THIS — never the args-declaration block or a
|
|
428
|
+
* plain local assignment — so `tenantId: args.tenantId` is only ever counted
|
|
429
|
+
* when it is the value actually handed to a write. */
|
|
430
|
+
function writeSiteTexts(h) {
|
|
431
|
+
const out = [];
|
|
432
|
+
const codeLines = h.lines.filter(code);
|
|
433
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
434
|
+
if (!/ctx\.db\.(?:insert|patch|replace)\s*\(/.test(codeLines[i].raw))
|
|
435
|
+
continue;
|
|
436
|
+
let depth = 0;
|
|
437
|
+
let started = false;
|
|
438
|
+
let text = "";
|
|
439
|
+
for (let j = i; j < codeLines.length; j++) {
|
|
440
|
+
const raw = codeLines[j].raw;
|
|
441
|
+
text += (text ? "\n" : "") + raw;
|
|
442
|
+
for (const ch of raw) {
|
|
443
|
+
if (ch === "(") {
|
|
444
|
+
depth++;
|
|
445
|
+
started = true;
|
|
446
|
+
}
|
|
447
|
+
else if (ch === ")")
|
|
448
|
+
depth--;
|
|
449
|
+
}
|
|
450
|
+
if (started && depth <= 0)
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
out.push(text);
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
// ---- named guard HELPERS defined outside the handler ----------------------
|
|
458
|
+
//
|
|
459
|
+
// A public mutation frequently enforces its boundary in ONE line that calls a
|
|
460
|
+
// helper defined elsewhere: `await requireOrgAdmin(ctx, args.orgSlug)` binds the
|
|
461
|
+
// org argument to the verified identity's own org and throws RBAC_DENIED
|
|
462
|
+
// otherwise; `requireFleetMaster` / `requireServiceAccount` refuse every caller
|
|
463
|
+
// that is not the fleet master. The in-handler regexes above cannot see the
|
|
464
|
+
// helper's body. The fix is a NAMED ALLOWLIST — never "any function whose name
|
|
465
|
+
// starts with require" — and each listed helper is credited only when its OWN
|
|
466
|
+
// DEFINITION is found in the scanned tree and its CONTROL FLOW refuses, on every
|
|
467
|
+
// path to a normal exit, through a branch conditioned on the verified identity
|
|
468
|
+
// (guard-helper-flow.ts). A helper the walk cannot decide is could-not-judge,
|
|
469
|
+
// never credited; so is a call to a listed helper with no readable definition.
|
|
470
|
+
//
|
|
471
|
+
// - "binds-arg": the helper compares its target parameter with the identity;
|
|
472
|
+
// it legitimises a direct-arg boundary write ONLY for an argument that was
|
|
473
|
+
// handed to it (`requireOrgAdmin(ctx, args.orgSlug)` then
|
|
474
|
+
// `orgSlug: args.orgSlug`). A different argument written as the boundary is
|
|
475
|
+
// still the argument.
|
|
476
|
+
// - "master-only": the helper admits the fleet master alone; the master's
|
|
477
|
+
// authority is fleet-wide by design (the documented carve-out), so a
|
|
478
|
+
// boundary argument it writes is the master's decision, not a forgery.
|
|
479
|
+
//
|
|
480
|
+
// The CALL is credited only when it is a direct `await` statement of the
|
|
481
|
+
// handler's body, before the write: a call inside a branch, a try or a closure
|
|
482
|
+
// of the handler is not run on every path.
|
|
483
|
+
export const R6_GUARD_HELPERS = {
|
|
484
|
+
requireOrgAdmin: "binds-arg",
|
|
485
|
+
requireFleetMaster: "master-only",
|
|
486
|
+
requireServiceAccount: "master-only",
|
|
487
|
+
};
|
|
488
|
+
// ---- SCOPE REFINERS at the handler (a local assigned from one) ------------
|
|
489
|
+
//
|
|
490
|
+
// `const scope = await resolveVerifiedPerson(ctx, transportScope, args.verifiedPerson, …)`
|
|
491
|
+
// read as caller-arg-derived (fromScope=0) because an argument is handed to the
|
|
492
|
+
// call — the false R-6 accusation at vantage-peers 45e40c8 missions.ts:232. A
|
|
493
|
+
// SCOPE_REFINERS call (guard-helper-flow.ts) is scope-derived when its
|
|
494
|
+
// definition is READABLE in the scanned tree and the scope it is handed, at
|
|
495
|
+
// `argIndex`, is itself a scope-derived local. Its other arguments never taint
|
|
496
|
+
// its return. (A resolver such as `requireAuthenticatedCaller` stays judged on
|
|
497
|
+
// its BODY by judgeResolverFunction, which now reads a refiner of a pure scope
|
|
498
|
+
// as pure — recurringTasks.ts:216.)
|
|
499
|
+
/** Top-level comma-separated arguments of the first `name(…)` call in `call`. */
|
|
500
|
+
function callArguments(call, name) {
|
|
501
|
+
const at = call.search(new RegExp(`\\b${name}\\s*\\(`));
|
|
502
|
+
if (at === -1)
|
|
503
|
+
return null;
|
|
504
|
+
const open = call.indexOf("(", at);
|
|
505
|
+
const out = [];
|
|
506
|
+
let depth = 0;
|
|
507
|
+
let cur = "";
|
|
508
|
+
for (let i = open; i < call.length; i++) {
|
|
509
|
+
const ch = call[i];
|
|
510
|
+
if ("([{".includes(ch)) {
|
|
511
|
+
depth++;
|
|
512
|
+
if (depth === 1)
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
else if (")]}".includes(ch)) {
|
|
516
|
+
depth--;
|
|
517
|
+
if (depth === 0) {
|
|
518
|
+
if (cur.trim())
|
|
519
|
+
out.push(cur.trim());
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
else if (ch === "," && depth === 1) {
|
|
524
|
+
out.push(cur.trim());
|
|
525
|
+
cur = "";
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
cur += ch;
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
const sourceOf = (f) => f.lines.map((l) => l.raw).join("\n");
|
|
533
|
+
/** Index every definition of an allowlisted helper in the tree. */
|
|
534
|
+
export function indexGuardHelpers(files) {
|
|
535
|
+
const out = new Map();
|
|
536
|
+
const names = Object.keys(R6_GUARD_HELPERS);
|
|
122
537
|
for (const f of files) {
|
|
123
|
-
if (!isJudgeable(f)
|
|
538
|
+
if (!isJudgeable(f))
|
|
539
|
+
continue;
|
|
540
|
+
const src = sourceOf(f);
|
|
541
|
+
if (!names.some((n) => src.includes(n)))
|
|
124
542
|
continue;
|
|
543
|
+
for (const d of findHelperDefinitions(f.rel, src, R6_GUARD_HELPERS)) {
|
|
544
|
+
const list = out.get(d.name) ?? [];
|
|
545
|
+
list.push({
|
|
546
|
+
rel: f.rel,
|
|
547
|
+
n: d.line,
|
|
548
|
+
exported: d.exported,
|
|
549
|
+
judgement: d.judgement,
|
|
550
|
+
});
|
|
551
|
+
out.set(d.name, list);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return out;
|
|
555
|
+
}
|
|
556
|
+
const HELPER_NAMES = new Set(Object.keys(R6_GUARD_HELPERS));
|
|
557
|
+
const HELPER_MENTION = new RegExp(`\\b(?:${Object.keys(R6_GUARD_HELPERS).join("|")})\\s*\\(`);
|
|
558
|
+
/** `null` when the handler mentions a listed helper but its body could not be
|
|
559
|
+
* located for the statement-level read (could-not-judge, never credit). */
|
|
560
|
+
function helperCalls(h, f, writeLine, index, src) {
|
|
561
|
+
if (!h.lines.some((l) => code(l) && HELPER_MENTION.test(l.raw)))
|
|
562
|
+
return [];
|
|
563
|
+
const calls = topLevelHelperCalls(f.rel, src(), h.name, HELPER_NAMES, writeLine);
|
|
564
|
+
if (calls === null)
|
|
565
|
+
return null;
|
|
566
|
+
return calls.map((c) => {
|
|
567
|
+
const defs = index.get(c.name) ?? [];
|
|
568
|
+
const def = defs.find((d) => d.rel === f.rel) ?? defs.find((d) => d.exported);
|
|
569
|
+
return { name: c.name, kind: R6_GUARD_HELPERS[c.name], args: c.args, def };
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/** Every `args.<field>` written DIRECTLY to a boundary field at a write site. */
|
|
573
|
+
function directBoundaryArgs(writes) {
|
|
574
|
+
const out = [];
|
|
575
|
+
const re = /\b(?:tenantId|orgId|orgSlug|createdBy|namespace|orchestratorId|ownerId)\s*:\s*(args\.\w+)/g;
|
|
576
|
+
for (const t of writes) {
|
|
577
|
+
re.lastIndex = 0;
|
|
578
|
+
let m = re.exec(t);
|
|
579
|
+
while (m !== null) {
|
|
580
|
+
out.push(m[1]);
|
|
581
|
+
m = re.exec(t);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
/** The honoured helper call that covers this handler's boundary, if any.
|
|
587
|
+
* "master-only" covers any boundary write; "binds-arg" covers it only when every
|
|
588
|
+
* direct-arg boundary value written is an argument handed to the helper. */
|
|
589
|
+
function coveringCall(calls, writes) {
|
|
590
|
+
const honoured = calls.filter((c) => c.def?.judgement.verdict === "honoured");
|
|
591
|
+
const master = honoured.find((c) => c.kind === "master-only");
|
|
592
|
+
if (master)
|
|
593
|
+
return master;
|
|
594
|
+
const bound = honoured.filter((c) => c.kind === "binds-arg");
|
|
595
|
+
if (bound.length === 0)
|
|
596
|
+
return undefined;
|
|
597
|
+
const direct = directBoundaryArgs(writes);
|
|
598
|
+
const all = direct.every((a) => bound.some((c) => new RegExp(`\\b${a.replace(".", "\\.")}\\b`).test(c.args)));
|
|
599
|
+
return all ? bound[0] : undefined;
|
|
600
|
+
}
|
|
601
|
+
class FunctionIndex {
|
|
602
|
+
files;
|
|
603
|
+
parsed = new Map();
|
|
604
|
+
judged = new Map();
|
|
605
|
+
resolvers = new Map();
|
|
606
|
+
constructor(files) {
|
|
607
|
+
this.files = files;
|
|
608
|
+
}
|
|
609
|
+
defsIn(f) {
|
|
610
|
+
let d = this.parsed.get(f.rel);
|
|
611
|
+
if (!d) {
|
|
612
|
+
d = topLevelFunctions(f.rel, sourceOf(f));
|
|
613
|
+
this.parsed.set(f.rel, d);
|
|
614
|
+
}
|
|
615
|
+
return d;
|
|
616
|
+
}
|
|
617
|
+
lookup(name, fromRel) {
|
|
618
|
+
if (!/^[A-Za-z_][\w]*$/.test(name))
|
|
619
|
+
return undefined;
|
|
620
|
+
const decl = new RegExp(`\\b(?:function\\s*\\*?\\s*${name}\\b|const\\s+${name}\\s*=)`);
|
|
621
|
+
const local = this.files.find((f) => f.rel === fromRel);
|
|
622
|
+
if (local) {
|
|
623
|
+
const own = this.defsIn(local).find((d) => d.name === name);
|
|
624
|
+
if (own)
|
|
625
|
+
return { rel: local.rel, f: own };
|
|
626
|
+
}
|
|
627
|
+
const found = [];
|
|
628
|
+
for (const f of this.files) {
|
|
629
|
+
if (f.rel === fromRel || !isJudgeable(f))
|
|
630
|
+
continue;
|
|
631
|
+
if (!f.lines.some((l) => decl.test(l.raw)))
|
|
632
|
+
continue;
|
|
633
|
+
for (const d of this.defsIn(f))
|
|
634
|
+
if (d.name === name && d.exported)
|
|
635
|
+
found.push({ rel: f.rel, f: d });
|
|
636
|
+
}
|
|
637
|
+
return found.length === 1 ? found[0] : undefined;
|
|
638
|
+
}
|
|
639
|
+
judge(key, run) {
|
|
640
|
+
let v = this.judged.get(key);
|
|
641
|
+
if (!v) {
|
|
642
|
+
v = run();
|
|
643
|
+
this.judged.set(key, v);
|
|
644
|
+
}
|
|
645
|
+
return v;
|
|
646
|
+
}
|
|
647
|
+
/** Names seen from `fromRel`, resolved by `lookup` (same file first, else
|
|
648
|
+
* the single exported definition). */
|
|
649
|
+
callResolver(fromRel) {
|
|
650
|
+
let r = this.resolvers.get(fromRel);
|
|
651
|
+
if (!r) {
|
|
652
|
+
r = (name) => {
|
|
653
|
+
const d = this.lookup(name, fromRel);
|
|
654
|
+
return d
|
|
655
|
+
? {
|
|
656
|
+
fn: d.f.fn,
|
|
657
|
+
key: `${d.rel}:${d.f.line}`,
|
|
658
|
+
resolve: this.callResolver(d.rel),
|
|
659
|
+
}
|
|
660
|
+
: undefined;
|
|
661
|
+
};
|
|
662
|
+
this.resolvers.set(fromRel, r);
|
|
663
|
+
}
|
|
664
|
+
return r;
|
|
665
|
+
}
|
|
666
|
+
resolver(name, fromRel) {
|
|
667
|
+
const d = this.lookup(name, fromRel);
|
|
668
|
+
if (!d)
|
|
669
|
+
return undefined;
|
|
670
|
+
const v = this.judge(`R:${d.rel}:${d.f.line}`, () => judgeResolverFunction(d.f.fn));
|
|
671
|
+
return v.verdict === "honoured" ? d : undefined;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/** The boundary RHS expressions (`mission.orgId`) that a refusing predicate
|
|
675
|
+
* bound to the caller's verified scope before the write, with the predicate
|
|
676
|
+
* that did it. */
|
|
677
|
+
function predicateBoundValues(h, f, writeLine, fns, src) {
|
|
678
|
+
const out = new Map();
|
|
679
|
+
if (!h.lines.some((l) => code(l) && /\bif\s*\(\s*!\s*[A-Za-z_$][\w$]*\s*\(/.test(l.raw)))
|
|
680
|
+
return out;
|
|
681
|
+
const g = readHandlerGuards(f.rel, src(), h.name, writeLine);
|
|
682
|
+
if (!g)
|
|
683
|
+
return out;
|
|
684
|
+
const isIdentityLocal = (n) => {
|
|
685
|
+
if (!n)
|
|
686
|
+
return false;
|
|
687
|
+
const c = g.consts.get(n);
|
|
688
|
+
if (!c)
|
|
689
|
+
return false;
|
|
690
|
+
if (c.identityRead)
|
|
691
|
+
return true;
|
|
692
|
+
return !!c.awaitedCallee && !!fns.resolver(c.awaitedCallee, f.rel);
|
|
693
|
+
};
|
|
694
|
+
for (const r of g.refusals) {
|
|
695
|
+
const i = r.args.findIndex((a) => isIdentityLocal(a.ident));
|
|
696
|
+
const j = r.args.findIndex((a) => a.memberBase !== undefined && g.consts.has(a.memberBase));
|
|
697
|
+
if (i < 0 || j < 0)
|
|
698
|
+
continue;
|
|
699
|
+
const value = r.args[j].text.replace(/\s+/g, "");
|
|
700
|
+
// The bound property must not be re-assigned in the handler.
|
|
701
|
+
const esc = value.replace(/[.$]/g, "\\$&");
|
|
702
|
+
if (h.lines.some((l) => code(l) && new RegExp(`${esc}\\s*=(?!=)`).test(l.raw)))
|
|
703
|
+
continue;
|
|
704
|
+
const d = fns.lookup(r.predicate, f.rel);
|
|
705
|
+
if (!d)
|
|
706
|
+
continue;
|
|
707
|
+
const v = fns.judge(`P:${d.rel}:${d.f.line}:${i}:${j}`, () => judgePredicateFunction(d.f.fn, i, j));
|
|
708
|
+
if (v.verdict === "honoured")
|
|
709
|
+
out.set(value, `${r.predicate}() (definition ${d.rel}:${d.f.line})`);
|
|
710
|
+
}
|
|
711
|
+
return out;
|
|
712
|
+
}
|
|
713
|
+
/** The caller arguments (`args.namespace`) a BINDING VALIDATOR bound to the
|
|
714
|
+
* caller's verified scope before the write: a direct `await V(…, args.x, …)`
|
|
715
|
+
* statement of the handler whose callee, read in the tree on its body
|
|
716
|
+
* (summarizeValidator), reaches a normal exit only when `args.x` passed a
|
|
717
|
+
* predicate over the verified scope (or for the fleet master). Name-free. */
|
|
718
|
+
function validatorBoundArgs(h, f, writeLine, fns, src) {
|
|
719
|
+
const out = new Map();
|
|
720
|
+
const calls = topLevelHelperCalls(f.rel, src(), h.name, null, writeLine);
|
|
721
|
+
if (!calls)
|
|
722
|
+
return out;
|
|
723
|
+
let g;
|
|
724
|
+
for (const c of calls) {
|
|
725
|
+
const targets = c.argNodes
|
|
726
|
+
.map((a, k) => ({ k, text: a.getText().replace(/\s+/g, "") }))
|
|
727
|
+
.filter((a) => /^args\.[A-Za-z_$][\w$]*$/.test(a.text));
|
|
728
|
+
if (targets.length === 0)
|
|
729
|
+
continue;
|
|
730
|
+
const d = fns.lookup(c.name, f.rel);
|
|
731
|
+
if (!d)
|
|
732
|
+
continue;
|
|
733
|
+
// An argument that is the handler's own verified scope (a const bound to
|
|
734
|
+
// an identity read, or to a function judged an identity resolver).
|
|
735
|
+
g ??= readHandlerGuards(f.rel, src(), h.name, writeLine);
|
|
736
|
+
const identityIdx = c.argNodes.findIndex((a) => {
|
|
737
|
+
const t = a.getText();
|
|
738
|
+
const k = g?.consts.get(t);
|
|
739
|
+
return (!!k &&
|
|
740
|
+
(k.identityRead ||
|
|
741
|
+
(!!k.awaitedCallee && !!fns.resolver(k.awaitedCallee, f.rel))));
|
|
742
|
+
});
|
|
743
|
+
for (const t of targets) {
|
|
744
|
+
const sum = summarizeValidator(d.f.fn, {
|
|
745
|
+
boundIdx: t.k,
|
|
746
|
+
identityIdx: identityIdx < 0 ? undefined : identityIdx,
|
|
747
|
+
callArgs: c.argNodes,
|
|
748
|
+
resolve: fns.callResolver(d.rel),
|
|
749
|
+
key: `${d.rel}:${d.f.line}`,
|
|
750
|
+
depth: 0,
|
|
751
|
+
});
|
|
752
|
+
if (sum.ok && sum.exitsBound && !sum.never)
|
|
753
|
+
out.set(t.text, `${c.name}() (definition ${d.rel}:${d.f.line})`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
/** The write texts with every boundary value that IS (completely) a validated
|
|
759
|
+
* argument replaced by a placeholder: what is left is what still reads args. */
|
|
760
|
+
function maskValidated(writes, validated) {
|
|
761
|
+
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));
|
|
762
|
+
}
|
|
763
|
+
/** Every RHS of the boundary local `loc` at the write sites, when EACH is a
|
|
764
|
+
* complete member expression on it (`orgId: mission.orgId,`); null when any
|
|
765
|
+
* occurrence is something else (`orgId: mission.orgId ?? args.x`, `{ orgId }`). */
|
|
766
|
+
function boundaryMemberRhs(writes, loc) {
|
|
767
|
+
const out = [];
|
|
768
|
+
const esc = loc.replace(/[$]/g, "\\$&");
|
|
769
|
+
const any = new RegExp(`\\b(?:tenantId|orgId|orgSlug)\\s*:\\s*${esc}\\b`, "g");
|
|
770
|
+
const full = new RegExp(`^\\s*(${esc}(?:\\.[A-Za-z_$][\\w$]*)+)\\s*(?:,|}|$)`);
|
|
771
|
+
for (const t of writes) {
|
|
772
|
+
any.lastIndex = 0;
|
|
773
|
+
let m = any.exec(t);
|
|
774
|
+
while (m !== null) {
|
|
775
|
+
const rest = t.slice(m.index + m[0].length - loc.length);
|
|
776
|
+
const fm = full.exec(rest.split("\n")[0]);
|
|
777
|
+
if (!fm)
|
|
778
|
+
return null;
|
|
779
|
+
out.push(fm[1]);
|
|
780
|
+
m = any.exec(t);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return out.length ? out : null;
|
|
784
|
+
}
|
|
785
|
+
// ---- WHICH boundary field reaches a write from an ARGUMENT -----------------
|
|
786
|
+
//
|
|
787
|
+
// The last-resort accusation ("a boundary field taken from a caller argument
|
|
788
|
+
// with no identity/resource guard") used to fire on ANY handler line carrying a
|
|
789
|
+
// boundary token. vantage-peers 45e40c8 tasks.complete was accused for
|
|
790
|
+
// `createdBy: fixPatternCreatedBy` where `fixPatternCreatedBy = task.assignedTo`
|
|
791
|
+
// and `task = await ctx.db.get(args.taskId)` — a value read off the TARGET
|
|
792
|
+
// resource, server-side, never the caller's argument. The accusation now needs
|
|
793
|
+
// the boundary field's value, AT A WRITE, to come from the caller's arguments:
|
|
794
|
+
// - `field: args.x` directly, or a local whose assignments read `args` (one
|
|
795
|
+
// hop through renames, member reads and destructuring, bounded);
|
|
796
|
+
// - a local object handed to the write (`patch(id, obj)`) carrying the field
|
|
797
|
+
// in its literal or through `obj.field = …`;
|
|
798
|
+
// - `args` (or a rest/alias of it) spread or passed whole, when the args
|
|
799
|
+
// validator declares a boundary field the write does not override.
|
|
800
|
+
// NOT the argument: a value read from the database (`ctx.db.get`/`.query`),
|
|
801
|
+
// the return of an identity resolver, or anything with no `args` in its
|
|
802
|
+
// provenance (`lastActedBy`, a server-computed actor). A local whose assignment
|
|
803
|
+
// cannot be found stays an argument (the old, conservative reading).
|
|
804
|
+
const R6_BOUNDARY_FIELD = "(?:createdBy|orgId|orgSlug|namespace|orchestratorId|ownerId|tenantId)";
|
|
805
|
+
function boundaryFromArgument(h, writes, isResolver, principalOf) {
|
|
806
|
+
const codeLines = h.lines.filter(code);
|
|
807
|
+
const handlerAt = codeLines.findIndex((l) => /\bhandler\b/.test(l.raw));
|
|
808
|
+
const validator = codeLines
|
|
809
|
+
.slice(0, handlerAt === -1 ? 0 : handlerAt)
|
|
810
|
+
.map((l) => l.raw)
|
|
811
|
+
.join("\n");
|
|
812
|
+
const validatorFields = new Set([
|
|
813
|
+
...validator.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*:`, "g")),
|
|
814
|
+
].map((m) => m[1]));
|
|
815
|
+
const body = codeLines.slice(Math.max(handlerAt, 0)).map((l) => l.raw);
|
|
816
|
+
const bodyText = body.join("\n");
|
|
817
|
+
const esc = (n) => n.replace(/[$]/g, "\\$&");
|
|
818
|
+
// assignments of `local` in the handler body: RHS texts (multi-line object
|
|
819
|
+
// literals and awaited calls joined), plus whether a handler param binds it.
|
|
820
|
+
const assignments = (local) => {
|
|
821
|
+
const out = [];
|
|
822
|
+
const assign = new RegExp(`(?:^|[^\\w.$])${esc(local)}\\s*(?::[^=\\n]*)?=(?!=)`);
|
|
823
|
+
for (let i = 0; i < body.length; i++) {
|
|
824
|
+
const raw = body[i];
|
|
825
|
+
if (boundFromHandlerParam(raw, local))
|
|
826
|
+
return "param";
|
|
827
|
+
const dr = destructuredRhs(raw, local);
|
|
828
|
+
if (dr !== null) {
|
|
829
|
+
out.push(dr);
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (!assign.test(raw))
|
|
833
|
+
continue;
|
|
834
|
+
let rhs = raw.slice(raw.indexOf("=") + 1);
|
|
835
|
+
const depth = (t) => (t.match(/[({[]/g) ?? []).length - (t.match(/[)}\]]/g) ?? []).length;
|
|
836
|
+
for (let j = i + 1; depth(rhs) > 0 && j < body.length && j <= i + 60; j++)
|
|
837
|
+
rhs += `\n${body[j]}`;
|
|
838
|
+
out.push(rhs);
|
|
839
|
+
}
|
|
840
|
+
return out;
|
|
841
|
+
};
|
|
842
|
+
const argFed = (local, seen) => {
|
|
843
|
+
if (local === "args")
|
|
844
|
+
return true;
|
|
845
|
+
if (principalOf?.(local))
|
|
846
|
+
return false; // an alias of a checked principal
|
|
847
|
+
if (seen.has(local) || seen.size > 6)
|
|
848
|
+
return false;
|
|
849
|
+
const next = new Set(seen).add(local);
|
|
850
|
+
const rhss = assignments(local);
|
|
851
|
+
if (rhss === "param")
|
|
852
|
+
return true;
|
|
853
|
+
if (rhss.length === 0)
|
|
854
|
+
return true; // not found: conservative, as before.
|
|
855
|
+
return rhss.some((rhs) => valueFromArgs(rhs, next));
|
|
856
|
+
};
|
|
857
|
+
const valueFromArgs = (expr, seen) => {
|
|
858
|
+
const e = expr
|
|
859
|
+
.trim()
|
|
860
|
+
.replace(/;+\s*$/, "")
|
|
861
|
+
.trim();
|
|
862
|
+
if (/^(?:await\s+)?ctx\.db\.(?:get|query)\b/.test(e))
|
|
863
|
+
return false; // a stored row
|
|
864
|
+
const called = wholeAwaitedCall(e);
|
|
865
|
+
if (called && isResolver(called, e))
|
|
866
|
+
return false; // the verified scope
|
|
867
|
+
if (principalOf?.(e))
|
|
868
|
+
return false; // a checked package principal
|
|
869
|
+
const root = /^([A-Za-z_$][\w$]*)(?:\s*(?:\.|\?\.|\[|$|\s+as\b|!))/.exec(e);
|
|
870
|
+
if (root &&
|
|
871
|
+
/^[A-Za-z_$][\w$]*(?:(?:\.|\?\.)[A-Za-z_$][\w$]*)*(?:\s+as\s+[\w<>[\]|. ]+|!)?$/.test(e))
|
|
872
|
+
return argFed(root[1], seen); // a local, or a member read of one
|
|
873
|
+
if (/\bargs\b/.test(e))
|
|
874
|
+
return true;
|
|
875
|
+
// any other expression: argument-fed only through a local it names.
|
|
876
|
+
for (const m of e.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*)\s*(?:\.|\?\.)/g))
|
|
877
|
+
if (m[1] !== "ctx" && argFed(m[1], seen))
|
|
878
|
+
return true;
|
|
879
|
+
return false;
|
|
880
|
+
};
|
|
881
|
+
const objectCarries = (text, seen) => {
|
|
882
|
+
// `field: <value>` up to the next top-level `,` / `}` / line end.
|
|
883
|
+
for (const m of text.matchAll(new RegExp(`(?:^|[{,\\s])(${R6_BOUNDARY_FIELD})\\s*:\\s*([^,}\\n]+)`, "gm")))
|
|
884
|
+
if (valueFromArgs(m[2], seen))
|
|
885
|
+
return true;
|
|
886
|
+
for (const m of text.matchAll(new RegExp(`(?:^|[{,])\\s*(${R6_BOUNDARY_FIELD})\\s*(?=[,}]|$)`, "gm")))
|
|
887
|
+
if (argFed(m[1], seen))
|
|
888
|
+
return true;
|
|
889
|
+
// spreads: `...args`, or `...x` with x an alias/rest of args.
|
|
890
|
+
for (const m of text.matchAll(/\.\.\.\s*([A-Za-z_$][\w$]*)/g)) {
|
|
891
|
+
const x = m[1];
|
|
892
|
+
if (x === "args" || restOfArgs(x)) {
|
|
893
|
+
const pulled = x === "args" ? new Set() : pulledFromArgs(x);
|
|
894
|
+
const overridden = new Set([
|
|
895
|
+
...text.matchAll(new RegExp(`\\b(${R6_BOUNDARY_FIELD})\\s*[:,}]`, "g")),
|
|
896
|
+
].map((k) => k[1]));
|
|
897
|
+
if ([...validatorFields].some((fld) => !pulled.has(fld) && !overridden.has(fld)))
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
else if (localObjectCarries(x, seen))
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
return false;
|
|
904
|
+
};
|
|
905
|
+
const restOfArgs = (x) => new RegExp(`\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).test(bodyText) ||
|
|
906
|
+
new RegExp(`\\b(?:const|let|var)\\s+${esc(x)}\\s*=\\s*args\\s*;?\\s*$`, "m").test(bodyText);
|
|
907
|
+
const pulledFromArgs = (x) => {
|
|
908
|
+
const m = new RegExp(`\\{([^}]*)\\.\\.\\.\\s*${esc(x)}\\s*,?\\s*\\}\\s*=\\s*args\\b`).exec(bodyText);
|
|
909
|
+
const out = new Set();
|
|
910
|
+
if (m)
|
|
911
|
+
for (const part of m[1].split(",")) {
|
|
912
|
+
const key = part.split(":")[0].split("=")[0].trim();
|
|
913
|
+
if (key)
|
|
914
|
+
out.add(key);
|
|
915
|
+
}
|
|
916
|
+
return out;
|
|
917
|
+
};
|
|
918
|
+
// A local object handed to a write: its literal, and every `obj.field = v`.
|
|
919
|
+
const localObjectCarries = (obj, seen) => {
|
|
920
|
+
if (seen.has(`obj:${obj}`))
|
|
921
|
+
return false;
|
|
922
|
+
const next = new Set(seen).add(`obj:${obj}`);
|
|
923
|
+
const rhss = assignments(obj);
|
|
924
|
+
if (rhss !== "param")
|
|
925
|
+
for (const rhs of rhss)
|
|
926
|
+
if (rhs.trim().startsWith("{") && objectCarries(rhs, next))
|
|
927
|
+
return true;
|
|
928
|
+
const memberSet = new RegExp(`\\b${esc(obj)}\\s*(?:\\.\\s*(${R6_BOUNDARY_FIELD})|\\[\\s*["'\`](${R6_BOUNDARY_FIELD})["'\`]\\s*\\])\\s*=(?!=)\\s*([^;\\n]+)`, "g");
|
|
929
|
+
for (const m of bodyText.matchAll(memberSet))
|
|
930
|
+
if (valueFromArgs(m[3], next))
|
|
931
|
+
return true;
|
|
932
|
+
return false;
|
|
933
|
+
};
|
|
934
|
+
for (const w of writes) {
|
|
935
|
+
if (new RegExp(`\\b${R6_BOUNDARY_FIELD}\\s*:\\s*args\\b`).test(w))
|
|
936
|
+
return true;
|
|
937
|
+
if (objectCarries(w, new Set()))
|
|
938
|
+
return true;
|
|
939
|
+
// the object handed to the write: its last top-level argument.
|
|
940
|
+
const m = /ctx\.db\.(?:insert|patch|replace)\s*\(([\s\S]*)\)\s*;?\s*$/.exec(w.trim());
|
|
941
|
+
const last = m ? m[1].split(",").pop()?.trim() : undefined;
|
|
942
|
+
if (last === "args") {
|
|
943
|
+
if (validatorFields.size > 0)
|
|
944
|
+
return true;
|
|
945
|
+
}
|
|
946
|
+
else if (last && /^[A-Za-z_$][\w$]*$/.test(last)) {
|
|
947
|
+
if (restOfArgs(last) && validatorFields.size > 0)
|
|
948
|
+
return true;
|
|
949
|
+
if (localObjectCarries(last, new Set()))
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
export function scanR6(files, rootIsConvex = false) {
|
|
956
|
+
const scan = emptyScan();
|
|
957
|
+
const helperIndex = indexGuardHelpers(files);
|
|
958
|
+
const fns = new FunctionIndex(files);
|
|
959
|
+
for (const raw of files) {
|
|
960
|
+
if (!isJudgeable(raw) || !isConvex(raw, rootIsConvex))
|
|
961
|
+
continue;
|
|
962
|
+
// One shape for every argument read: `args["x"]` → `args.x`.
|
|
963
|
+
const f = { ...raw, lines: normalizeLines(raw.lines) };
|
|
964
|
+
// Parsed once per file, and only when the file names the identity
|
|
965
|
+
// package: the checked-principal read (package-principal.ts) needs it.
|
|
966
|
+
const parsed = f.lines.some((l) => l.raw.includes(IDENTITY_PACKAGE))
|
|
967
|
+
? parseFile(f)
|
|
968
|
+
: undefined;
|
|
125
969
|
for (const h of splitMutationHandlers(f)) {
|
|
970
|
+
// An internalMutation has NO client caller: its arguments come from
|
|
971
|
+
// server code (a cron, a scheduler hop, another function), so "a
|
|
972
|
+
// caller-supplied argument" — the thing R-6 forbids (standard
|
|
973
|
+
// 4bcd605 :71, :252 "never accept a userId as a function argument")
|
|
974
|
+
// does not exist there. R-6 governs the client-callable write only.
|
|
975
|
+
if (h.internal)
|
|
976
|
+
continue;
|
|
126
977
|
let writeLine = 0;
|
|
127
978
|
let guard = false;
|
|
128
979
|
let boundaryArg = false;
|
|
980
|
+
let resolvesScope = false;
|
|
129
981
|
for (const l of h.lines) {
|
|
130
982
|
if (!code(l))
|
|
131
983
|
continue;
|
|
@@ -133,32 +985,310 @@ export function scanR6(files) {
|
|
|
133
985
|
writeLine = l.n;
|
|
134
986
|
if (R6_IDENTITY_GUARD.test(l.raw) || R6_RESOURCE_GUARD.test(l.raw))
|
|
135
987
|
guard = true;
|
|
136
|
-
if (
|
|
137
|
-
|
|
988
|
+
if (R6_SCOPE_RESOLUTION.test(l.raw))
|
|
989
|
+
resolvesScope = true;
|
|
138
990
|
}
|
|
139
991
|
if (!writeLine)
|
|
140
992
|
continue; // not a write handler — R-6 does not govern it.
|
|
141
|
-
|
|
993
|
+
const writes = writeSiteTexts(h);
|
|
994
|
+
let directTenant = writes.some((t) => R6_TENANT_FROM_ARG.test(t));
|
|
995
|
+
// Follow the local ONE hop: every non-`args` local written to a tenant
|
|
996
|
+
// boundary field, and its THREE-STATE provenance. A local fed from args
|
|
997
|
+
// (matched>0, fromScope===0, fromArgs>0) is TAINTED; a local with NO
|
|
998
|
+
// resolvable assignment (matched===0) or only unresolved indirections is
|
|
999
|
+
// COULD-NOT-JUDGE — never silently tainted (Eta REVISE #3 defect 2).
|
|
1000
|
+
const boundaryLocals = [
|
|
1001
|
+
...new Set(writes.flatMap(boundaryLocalsInWrite)),
|
|
1002
|
+
];
|
|
1003
|
+
let taintedLocal;
|
|
1004
|
+
let cnjLocal;
|
|
1005
|
+
let anyDerived = false;
|
|
1006
|
+
let fileSrc;
|
|
1007
|
+
const getSrc = () => {
|
|
1008
|
+
fileSrc ??= sourceOf(f);
|
|
1009
|
+
return fileSrc;
|
|
1010
|
+
};
|
|
1011
|
+
const resolvedBy = new Map();
|
|
1012
|
+
const isResolver = (name, call) => {
|
|
1013
|
+
const d = fns.resolver(name, f.rel);
|
|
1014
|
+
if (d) {
|
|
1015
|
+
resolvedBy.set(name, `identity resolver ${name}() (definition ${d.rel}:${d.f.line}), which returns the verified scope itself and refuses an absent identity on every path`);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
const spec = SCOPE_REFINERS[name];
|
|
1019
|
+
if (!spec)
|
|
1020
|
+
return false;
|
|
1021
|
+
const def = fns.lookup(name, f.rel);
|
|
1022
|
+
const arg = callArguments(call, name)?.[spec.argIndex];
|
|
1023
|
+
if (!def || !arg || !/^[A-Za-z_$][\w$]*$/.test(arg) || arg === "args")
|
|
1024
|
+
return false;
|
|
1025
|
+
const t = analyzeLocalTaint(h, arg, isResolver);
|
|
1026
|
+
if (!(t.fromScope > 0 && t.fromArgs === 0))
|
|
1027
|
+
return false;
|
|
1028
|
+
resolvedBy.set(name, `named scope refiner ${name}() (definition ${def.rel}:${def.f.line}) handed the scope-derived local "${arg}"`);
|
|
1029
|
+
return true;
|
|
1030
|
+
};
|
|
1031
|
+
// The principal @vantageos/cloud-identity resolved, refused unless ok
|
|
1032
|
+
// before the write (package-principal.ts): a verified-identity value.
|
|
1033
|
+
const principals = parsed
|
|
1034
|
+
? packagePrincipals(parsed, h.name, writeLine)
|
|
1035
|
+
: undefined;
|
|
1036
|
+
const principalOf = principals
|
|
1037
|
+
? (expr) => {
|
|
1038
|
+
const why = principals.principalOf(expr);
|
|
1039
|
+
if (why)
|
|
1040
|
+
resolvedBy.set(`principal:${why}`, why);
|
|
1041
|
+
return why;
|
|
1042
|
+
}
|
|
1043
|
+
: undefined;
|
|
1044
|
+
// The boundary-from-argument test reads the WRITE's provenance, never a
|
|
1045
|
+
// boundary token anywhere in the handler (see boundaryFromArgument).
|
|
1046
|
+
boundaryArg = boundaryFromArgument(h, writes, isResolver, principalOf);
|
|
1047
|
+
// An awaited call, before the first write, to a function judged an
|
|
1048
|
+
// identity resolver (it refuses an absent identity on every path and
|
|
1049
|
+
// returns the verified scope) IS an identity guard, whatever arguments
|
|
1050
|
+
// it is handed — vantage-peers 45e40c8 tasks.complete resolves its
|
|
1051
|
+
// caller through requireAuthenticatedCaller before writing.
|
|
1052
|
+
let resolverGuard;
|
|
1053
|
+
{
|
|
1054
|
+
const codeLines = h.lines.filter(code);
|
|
1055
|
+
for (let i = 0; i < codeLines.length && !resolverGuard; i++) {
|
|
1056
|
+
if (codeLines[i].n >= writeLine)
|
|
1057
|
+
break;
|
|
1058
|
+
const m = /\bawait\s+([A-Za-z_$][\w$]*)\s*\(/.exec(codeLines[i].raw);
|
|
1059
|
+
if (!m || m[1] === "ctx")
|
|
1060
|
+
continue;
|
|
1061
|
+
const from = codeLines[i].raw.slice(m.index);
|
|
1062
|
+
const call = joinedAwaitedCall(from, codeLines, i);
|
|
1063
|
+
resolvedBy.clear();
|
|
1064
|
+
if (isResolver(m[1], call))
|
|
1065
|
+
resolverGuard = `${[...resolvedBy.values()].join(", ")} called at line ${codeLines[i].n}`;
|
|
1066
|
+
}
|
|
1067
|
+
resolvedBy.clear();
|
|
1068
|
+
}
|
|
1069
|
+
// Resolving the caller proves WHO writes, not that a boundary ARGUMENT
|
|
1070
|
+
// is bound to them: the credit stands only when no argument reaches a
|
|
1071
|
+
// boundary field at a write (a hollow master-only helper that returns the
|
|
1072
|
+
// scope must not excuse `orchestratorId: args.orchestratorId`).
|
|
1073
|
+
if (resolverGuard && !boundaryArg)
|
|
1074
|
+
guard = true;
|
|
1075
|
+
else
|
|
1076
|
+
resolverGuard = undefined;
|
|
1077
|
+
const credits = [];
|
|
1078
|
+
// A boundary written straight from an argument a BINDING VALIDATOR
|
|
1079
|
+
// bound to the verified scope before the write: credited only when
|
|
1080
|
+
// EVERY argument written to a boundary field is so bound.
|
|
1081
|
+
if (directBoundaryArgs(writes).length > 0) {
|
|
1082
|
+
const validated = validatorBoundArgs(h, f, writeLine, fns, getSrc);
|
|
1083
|
+
const masked = maskValidated(writes, validated);
|
|
1084
|
+
if (validated.size > 0 &&
|
|
1085
|
+
directBoundaryArgs(masked).length === 0 &&
|
|
1086
|
+
!masked.some((t) => R6_TENANT_FROM_ARG.test(t))) {
|
|
1087
|
+
directTenant = false;
|
|
1088
|
+
boundaryArg = false;
|
|
1089
|
+
anyDerived = true;
|
|
1090
|
+
resolvesScope = true;
|
|
1091
|
+
const written = new Set(directBoundaryArgs(writes));
|
|
1092
|
+
for (const [arg, by] of validated)
|
|
1093
|
+
if (written.has(arg))
|
|
1094
|
+
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`);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
let bound;
|
|
1098
|
+
for (const loc of boundaryLocals) {
|
|
1099
|
+
const rhs = boundaryMemberRhs(writes, loc);
|
|
1100
|
+
// Every write of `loc` reads the checked package principal
|
|
1101
|
+
// (`orgId: who.principal.orgId`, `orgId: p.orgId` for an alias p).
|
|
1102
|
+
const viaPrincipal = rhs && principals ? rhs.map((x) => principals.principalOf(x)) : [];
|
|
1103
|
+
if (viaPrincipal.length > 0 && viaPrincipal.every((x) => x)) {
|
|
1104
|
+
anyDerived = true;
|
|
1105
|
+
resolvesScope = true;
|
|
1106
|
+
credits.push(`\`${rhs?.[0]}\` is ${viaPrincipal[0]}`);
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
// A value a refusing predicate bound to the caller's scope before
|
|
1110
|
+
// the write (`if (!P(scope, mission.orgId)) throw`), written
|
|
1111
|
+
// unchanged: derived from the verified identity through the target.
|
|
1112
|
+
if (rhs) {
|
|
1113
|
+
bound ??= predicateBoundValues(h, f, writeLine, fns, getSrc);
|
|
1114
|
+
const by = rhs.map((x) => bound?.get(x));
|
|
1115
|
+
if (by.every((x) => x !== undefined)) {
|
|
1116
|
+
anyDerived = true;
|
|
1117
|
+
resolvesScope = true;
|
|
1118
|
+
credits.push(`\`${rhs[0]}\` bound to the verified scope by refusing predicate ${by[0]}`);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
resolvedBy.clear();
|
|
1123
|
+
const a = analyzeLocalTaint(h, loc, isResolver, principalOf);
|
|
1124
|
+
if (a.fromResolver) {
|
|
1125
|
+
resolvesScope = true;
|
|
1126
|
+
const by = [...resolvedBy.entries()];
|
|
1127
|
+
const returned = by.filter(([k]) => !k.startsWith("principal:"));
|
|
1128
|
+
const read = by.filter(([k]) => k.startsWith("principal:"));
|
|
1129
|
+
if (returned.length > 0)
|
|
1130
|
+
credits.push(`local "${loc}" is the return of ${returned.map(([, v]) => v).join(", ")}`);
|
|
1131
|
+
if (read.length > 0)
|
|
1132
|
+
credits.push(`local "${loc}" reads ${read.map(([, v]) => v).join(", ")}`);
|
|
1133
|
+
}
|
|
1134
|
+
if (a.fromScope > 0) {
|
|
1135
|
+
anyDerived = true;
|
|
1136
|
+
}
|
|
1137
|
+
else if (a.matched === 0) {
|
|
1138
|
+
cnjLocal ??= loc; // no assignment/declaration found — cannot judge.
|
|
1139
|
+
}
|
|
1140
|
+
else if (a.fromArgs > 0) {
|
|
1141
|
+
taintedLocal ??= loc; // fed only from args — laundered argument.
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
cnjLocal ??= loc; // matched but only unresolved bare-id hops.
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// A CONDITIONAL boundary value (`orgId: c ? a : b`): an arm that reads
|
|
1148
|
+
// the caller's args is the argument, unless it is the TRUE arm of a
|
|
1149
|
+
// positive `<scope>.isMaster` test on a scope-derived local (the
|
|
1150
|
+
// fleet carve-out: the master's authority is fleet-wide by design).
|
|
1151
|
+
let conditionalArg;
|
|
1152
|
+
for (const cb of writes.flatMap((t) => conditionalBoundaries(t, R6_TENANT_FIELDS))) {
|
|
1153
|
+
const base = cb.masterBase;
|
|
1154
|
+
const masterArm = base !== undefined &&
|
|
1155
|
+
base !== "args" &&
|
|
1156
|
+
(() => {
|
|
1157
|
+
const a = analyzeLocalTaint(h, base, isResolver);
|
|
1158
|
+
return a.fromScope > 0 && a.fromArgs === 0;
|
|
1159
|
+
})();
|
|
1160
|
+
if (cb.falseReadsArgs || (cb.trueReadsArgs && !masterArm)) {
|
|
1161
|
+
conditionalArg ??= cb.field;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const derivedTenant = anyDerived;
|
|
1165
|
+
const accusable = directTenant ||
|
|
1166
|
+
conditionalArg ||
|
|
1167
|
+
taintedLocal ||
|
|
1168
|
+
(!guard && boundaryArg);
|
|
1169
|
+
const calls = helperCalls(h, f, writeLine, helperIndex, getSrc);
|
|
1170
|
+
const violateBefore = scan.violate.length;
|
|
1171
|
+
const cover = calls ? coveringCall(calls, writes) : undefined;
|
|
1172
|
+
if (cover?.def) {
|
|
142
1173
|
scan.conform.push({
|
|
143
1174
|
rel: f.rel,
|
|
144
1175
|
n: writeLine,
|
|
145
|
-
note: `${h.name}: boundary
|
|
1176
|
+
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)`,
|
|
146
1177
|
});
|
|
1178
|
+
continue;
|
|
147
1179
|
}
|
|
148
|
-
|
|
1180
|
+
// The handler leans on an allowlisted helper the doctor cannot judge:
|
|
1181
|
+
// its definition is not in the scanned tree, its control flow is not
|
|
1182
|
+
// modelled, or the handler body itself could not be located. Neither
|
|
1183
|
+
// credited nor accused.
|
|
1184
|
+
const undecided = calls === null
|
|
1185
|
+
? "its handler body could not be located for the statement-level read"
|
|
1186
|
+
: calls
|
|
1187
|
+
.map((c) => c.def === undefined
|
|
1188
|
+
? `${c.name}() has no readable definition in the scanned tree`
|
|
1189
|
+
: c.def.judgement.verdict === "could-not-judge"
|
|
1190
|
+
? `${c.name}() at ${c.def.rel}:${c.def.n} could not be judged — ${c.def.judgement.why}`
|
|
1191
|
+
: "")
|
|
1192
|
+
.find((x) => x !== "");
|
|
1193
|
+
if (undecided && accusable) {
|
|
1194
|
+
scan.cnj.push({
|
|
1195
|
+
rel: f.rel,
|
|
1196
|
+
n: writeLine,
|
|
1197
|
+
note: `${h.name}: relies on an allowlisted guard helper whose enforcement cannot be decided — ${undecided} (could-not-judge, not an accusation)`,
|
|
1198
|
+
});
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (directTenant) {
|
|
1202
|
+
// Decisive VIOLATE — the WRITE-SITE provenance, not guard presence,
|
|
1203
|
+
// is what R-6 governs. A tenant-scoping field written straight from a
|
|
1204
|
+
// caller arg is the boundary being the argument, even when the handler
|
|
1205
|
+
// resolves scope elsewhere (the historical sendMessage defect).
|
|
149
1206
|
scan.violate.push({
|
|
150
1207
|
rel: f.rel,
|
|
151
1208
|
n: writeLine,
|
|
152
|
-
note: `${h.name}: writes a boundary field (
|
|
1209
|
+
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`,
|
|
153
1210
|
});
|
|
154
1211
|
}
|
|
155
|
-
else {
|
|
1212
|
+
else if (conditionalArg) {
|
|
1213
|
+
scan.violate.push({
|
|
1214
|
+
rel: f.rel,
|
|
1215
|
+
n: writeLine,
|
|
1216
|
+
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`,
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
else if (taintedLocal) {
|
|
1220
|
+
// LAUNDERED VIOLATE — the boundary field is a local (`tenantId: t`)
|
|
1221
|
+
// but every assignment to that local is fed from args (fromScope===0):
|
|
1222
|
+
// the caller's argument reaching the write under another name. A rename
|
|
1223
|
+
// hop does not sanitise provenance; scope resolved elsewhere in the
|
|
1224
|
+
// handler does not rescue it.
|
|
1225
|
+
scan.violate.push({
|
|
1226
|
+
rel: f.rel,
|
|
1227
|
+
n: writeLine,
|
|
1228
|
+
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`,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
else if (cnjLocal && !anyDerived) {
|
|
1232
|
+
// THIRD STATE — a boundary local whose provenance could not be
|
|
1233
|
+
// resolved (no assignment/declaration found in-handler, or only
|
|
1234
|
+
// unresolved bare-identifier indirections). This is could-not-judge,
|
|
1235
|
+
// NEVER an accusation (Eta REVISE #3 defect 2).
|
|
156
1236
|
scan.cnj.push({
|
|
157
1237
|
rel: f.rel,
|
|
158
1238
|
n: writeLine,
|
|
159
|
-
note: `${h.name}: writes
|
|
1239
|
+
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`,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
else if (derivedTenant && resolvesScope) {
|
|
1243
|
+
scan.conform.push({
|
|
1244
|
+
rel: f.rel,
|
|
1245
|
+
n: writeLine,
|
|
1246
|
+
note: `${h.name}: tenant boundary written from a scope-derived local at the write site (scope/identity resolved in-handler)${credits.length ? ` — ${credits.join("; ")}` : ""}`,
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
else if (guard) {
|
|
1250
|
+
scan.conform.push({
|
|
1251
|
+
rel: f.rel,
|
|
1252
|
+
n: writeLine,
|
|
1253
|
+
note: `${h.name}: boundary derived from a verified identity / target-resource guard${resolverGuard ? ` — ${resolverGuard}` : ""}`,
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
else if (boundaryArg) {
|
|
1257
|
+
scan.violate.push({
|
|
1258
|
+
rel: f.rel,
|
|
1259
|
+
n: writeLine,
|
|
1260
|
+
note: `${h.name}: writes a boundary field (org/createdBy/namespace) taken from a caller argument with no identity/resource guard — the boundary is the argument`,
|
|
160
1261
|
});
|
|
161
1262
|
}
|
|
1263
|
+
else {
|
|
1264
|
+
// Two shapes read in full from the AST before deferring: a FILE-LOCAL
|
|
1265
|
+
// master-only wrapper dominating the write, and a possession-gated
|
|
1266
|
+
// write of the one row a hashed argument selects (r6-shapes.ts).
|
|
1267
|
+
const credit = masterOnlyWrapperCredit(raw, h.name) ??
|
|
1268
|
+
possessionGatedCredit(raw, h.name);
|
|
1269
|
+
if (credit)
|
|
1270
|
+
scan.conform.push({
|
|
1271
|
+
rel: f.rel,
|
|
1272
|
+
n: writeLine,
|
|
1273
|
+
note: `${h.name}: ${credit}`,
|
|
1274
|
+
});
|
|
1275
|
+
else
|
|
1276
|
+
scan.cnj.push({
|
|
1277
|
+
rel: f.rel,
|
|
1278
|
+
n: writeLine,
|
|
1279
|
+
note: `${h.name}: writes with no identity/resource guard and no clearly caller-supplied boundary field — deferred, not statically confirmed`,
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
// A violation at a site calling a listed helper whose definition was
|
|
1283
|
+
// read and NOT honoured says why, so the reader is not left believing
|
|
1284
|
+
// the helper was simply missed.
|
|
1285
|
+
const refused = calls?.find((c) => c.def?.judgement.verdict === "refused");
|
|
1286
|
+
if (refused?.def &&
|
|
1287
|
+
refused.def.judgement.verdict === "refused" &&
|
|
1288
|
+
scan.violate.length > violateBefore) {
|
|
1289
|
+
const v = scan.violate[scan.violate.length - 1];
|
|
1290
|
+
v.note += `; allowlisted helper ${refused.name}() at ${refused.def.rel}:${refused.def.n} NOT honoured — ${refused.def.judgement.why}`;
|
|
1291
|
+
}
|
|
162
1292
|
}
|
|
163
1293
|
}
|
|
164
1294
|
return scan;
|
|
@@ -199,10 +1329,10 @@ function collectConvexErrorThrows(f) {
|
|
|
199
1329
|
}
|
|
200
1330
|
return out;
|
|
201
1331
|
}
|
|
202
|
-
export function scanR16(files) {
|
|
1332
|
+
export function scanR16(files, rootIsConvex = false) {
|
|
203
1333
|
const scan = emptyScan();
|
|
204
1334
|
for (const f of files) {
|
|
205
|
-
if (!isJudgeable(f) || !isConvex(f))
|
|
1335
|
+
if (!isJudgeable(f) || !isConvex(f, rootIsConvex))
|
|
206
1336
|
continue;
|
|
207
1337
|
for (const t of collectConvexErrorThrows(f)) {
|
|
208
1338
|
if (R16_CODE_TOKEN.test(t.payload)) {
|
|
@@ -232,12 +1362,12 @@ const R16_CHECK = "a refusal throws a ConvexError with a stable UPPER_SNAKE_CASE
|
|
|
232
1362
|
/** Classify an R-6 live probe: a boundary-less write MUST be refused fail-closed. */
|
|
233
1363
|
export function classifyR6Live(id, tool, outcome) {
|
|
234
1364
|
if (outcome.kind === "accepted") {
|
|
235
|
-
return {
|
|
1365
|
+
return withCount({
|
|
236
1366
|
id,
|
|
237
1367
|
anchor: `live:${tool}`,
|
|
238
1368
|
state: "non-conforming",
|
|
239
1369
|
verdict: `R-6 (live): tool "${tool}" ACCEPTED a write with no verified identity/boundary — fail-open.`,
|
|
240
|
-
};
|
|
1370
|
+
}, itemCount(1));
|
|
241
1371
|
}
|
|
242
1372
|
if (/RBAC|AUTH|ORG|unauthori|denied|required|forbidden/i.test(outcome.message)) {
|
|
243
1373
|
return {
|
|
@@ -269,12 +1399,12 @@ export function classifyR16Live(id, payload) {
|
|
|
269
1399
|
};
|
|
270
1400
|
}
|
|
271
1401
|
if (R16_REFUSAL_KW.test(payload)) {
|
|
272
|
-
return {
|
|
1402
|
+
return withCount({
|
|
273
1403
|
id,
|
|
274
1404
|
anchor: "live:error-contract",
|
|
275
1405
|
state: "non-conforming",
|
|
276
1406
|
verdict: `R-16 (live): a triggered refusal carried NO structured code — opaque error contract (${payload.slice(0, 80)}).`,
|
|
277
|
-
};
|
|
1407
|
+
}, itemCount(1));
|
|
278
1408
|
}
|
|
279
1409
|
const reason = `R-16 (live): could not trigger a classifiable refusal — the served response is neither a coded refusal nor a recognisable one. NOT a clean bill.`;
|
|
280
1410
|
return {
|
|
@@ -394,13 +1524,13 @@ function stringifyResult(res) {
|
|
|
394
1524
|
/** R-6 — static primary + live secondary (LiveDetector). */
|
|
395
1525
|
export const R6 = {
|
|
396
1526
|
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."),
|
|
1527
|
+
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
1528
|
runLive: r6RunLive,
|
|
399
1529
|
};
|
|
400
1530
|
/** R-16 — static primary + live secondary (LiveDetector). */
|
|
401
1531
|
export const R16 = {
|
|
402
1532
|
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."),
|
|
1533
|
+
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
1534
|
runLive: r16RunLive,
|
|
405
1535
|
};
|
|
406
1536
|
/** The two, in a form the orchestrator spreads into REGISTRY. */
|