@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
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
// CR-1 — does this public registration RESOLVE ITS CALLER before serving or
|
|
2
|
+
// writing data?
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS CONTROL EXISTS, measured not supposed. At vantage-memory
|
|
5
|
+
// bd8c60e94b4cd9ffd579c9e4f84e670280ddebb4, fourteen public queries returned real
|
|
6
|
+
// rows to an ANONYMOUS POST against the live production deployment — no
|
|
7
|
+
// Authorization header, no bearer, no JWT — including `mandates:list` (spending
|
|
8
|
+
// authority) and `tasks:listUnlinkedBlocked` (159 rows). The instrument scored
|
|
9
|
+
// that exact tree `revise-mandatory` and its rendered verdict table NAMED 0 of the
|
|
10
|
+
// 14 (measured: 0/14 at 14f35d9). R-50 did classify 13 of them as violations, but
|
|
11
|
+
// its renderer prints three examples, so no reader of the report could see them;
|
|
12
|
+
// and the fourteenth — `messages:listByChannel` — R-50 called CONFORM, because
|
|
13
|
+
// R-50 asks whether a REFUSAL IS SHAPED CORRECTLY (a typed value versus a throw),
|
|
14
|
+
// and `listByChannel` does call the fail-closed resolver, then allows any caller
|
|
15
|
+
// through a hardcoded `if (ch === "broadcast") return true`. A fifteenth shape,
|
|
16
|
+
// `convex/search.ts::searchFixPatterns`, is an `action` with a hardcoded namespace
|
|
17
|
+
// and no identity at all — outside R-50's population entirely (R-50 scans queries).
|
|
18
|
+
//
|
|
19
|
+
// SO CR-1 ASKS A DIFFERENT QUESTION, and it is deliberately NOT R-50's:
|
|
20
|
+
// R-50 / R-51 : when this handler refuses, is the refusal SHAPED so a subscribing
|
|
21
|
+
// client can render it? (a rule about the shape of a refusal)
|
|
22
|
+
// CR-1 : is there a caller RESOLVED at all, and does the resolution decide
|
|
23
|
+
// what is served? (a rule about the presence of authority)
|
|
24
|
+
// A handler with no guard has nothing to throw, so it can never fail a rule about
|
|
25
|
+
// throwing. That is why CR-1 exists, and it is why a "does it refuse properly"
|
|
26
|
+
// check must never creep in here as the test — that would be R-50's blind spot
|
|
27
|
+
// under a new name.
|
|
28
|
+
//
|
|
29
|
+
// FOUR PROPERTIES THIS MODULE HOLDS ITSELF TO, each a defect it was built against:
|
|
30
|
+
//
|
|
31
|
+
// 1. POPULATION IS THE EXPOSED SURFACE ITSELF. It is derived from the TARGET tree's
|
|
32
|
+
// own `_generated/api.d.ts` (`declare const fullApi: ApiFromModules<{…}>` plus
|
|
33
|
+
// the `import type * as <key> from "…"` map that resolves each key to a file) —
|
|
34
|
+
// never a hand-written list, and never a pattern over ARGUMENT NAMES. Three
|
|
35
|
+
// side-scripts in the target repository were blind to all fourteen precisely
|
|
36
|
+
// because each defined its population by argument shape: not one of the fourteen
|
|
37
|
+
// takes a tenant-shaped argument and most take no arguments at all.
|
|
38
|
+
// 2. THE EXCLUSION OF `internal*` IS PROVEN PER SITE. Every registration is read
|
|
39
|
+
// with the BUILDER TOKEN observed at its own `export const NAME = <builder>(`,
|
|
40
|
+
// and an excluded site is REPORTED with that token. Nothing is excluded because
|
|
41
|
+
// of how it is named.
|
|
42
|
+
// 3. THE COUNT CLASSIFIED EQUALS THE COUNT EXPOSED. A discrepancy is CR-1's OWN
|
|
43
|
+
// BUG and is reported as could-not-judge naming the drift — never rounded away.
|
|
44
|
+
// 4. NO EXEMPTION LIST, NO BASELINE, NO TOLERATED COUNT. The only exit from a
|
|
45
|
+
// NO-DATA or NO-IDENTITY finding is a justification token written IN THE TARGET
|
|
46
|
+
// SOURCE at the registration, which this module reads from there. A list of
|
|
47
|
+
// names living beside the instrument that can hold a name and still pass IS the
|
|
48
|
+
// defect, so there is none. FAIL-OPEN has no token exit at all.
|
|
49
|
+
//
|
|
50
|
+
// CR-1 IS CRITICAL AND IT READS THE TREE. FAIL-OPEN and unjustified NO-IDENTITY are
|
|
51
|
+
// MECHANICAL violations, so the deploy gate refuses on them.
|
|
52
|
+
//
|
|
53
|
+
// STANDING GAP, NAMED: CR-1 is DOCTOR-OWNED. The pinned backend standard
|
|
54
|
+
// (fixtures/backend-standard-4bcd605.md) numbers R-1..R-51 and does not yet carry
|
|
55
|
+
// this obligation, so CR-1 is deliberately NOT in `registry.ts` — adding an id the
|
|
56
|
+
// vendored standard does not define would make `coverage.ts` report a phantom rule
|
|
57
|
+
// and would forge a pin. It is rendered in its own section, tallied on its own
|
|
58
|
+
// line, and it drives the exit code. Numbering it upstream (proposed R-52) is a
|
|
59
|
+
// separate change to the standard document in its own repository.
|
|
60
|
+
import { readFileSync } from "node:fs";
|
|
61
|
+
import { join } from "node:path";
|
|
62
|
+
import { convexRootOf } from "./target.js";
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// population — the exposed surface, from the TARGET tree's own api.d.ts
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
const API_IMPORT = /^import\s+type\s+\*\s+as\s+([A-Za-z0-9_$]+)\s+from\s+"(\.\.\/[^"]+)"/;
|
|
67
|
+
const FULL_API_OPEN = /declare\s+const\s+fullApi\s*:\s*ApiFromModules\s*<\s*\{/;
|
|
68
|
+
const FULL_API_KEY = /^\s*([A-Za-z0-9_$]+)\s*:\s*typeof\s+([A-Za-z0-9_$]+)\s*;?\s*$/;
|
|
69
|
+
/**
|
|
70
|
+
* Derive the exposed module surface from `<root>/_generated/api.d.ts`. The engine's
|
|
71
|
+
* loadTree excludes `_generated/`, so this reads the file from the target root —
|
|
72
|
+
* the same target the CLI was pointed at, never the tool's own install directory
|
|
73
|
+
* (the defect that let five oracle-scored rules present a frozen CSV count as a
|
|
74
|
+
* verdict on a tree they never opened).
|
|
75
|
+
*/
|
|
76
|
+
export function deriveApiSurface(root, read = (p) => readFileSync(p, "utf8")) {
|
|
77
|
+
const path = join(root, "_generated", "api.d.ts");
|
|
78
|
+
let blob;
|
|
79
|
+
try {
|
|
80
|
+
blob = read(path);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return {
|
|
84
|
+
modules: new Map(),
|
|
85
|
+
unreadable: `${path} could not be read (${e.message})`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const lines = blob.split("\n");
|
|
89
|
+
const imports = new Map();
|
|
90
|
+
for (const raw of lines) {
|
|
91
|
+
const m = API_IMPORT.exec(raw);
|
|
92
|
+
if (!m)
|
|
93
|
+
continue;
|
|
94
|
+
// "../lib/auth.js" -> "lib/auth.ts" (rel to the convex root the tree uses)
|
|
95
|
+
imports.set(m[1], m[2].replace(/^\.\.\//, "").replace(/\.js$/, ".ts"));
|
|
96
|
+
}
|
|
97
|
+
let inFullApi = false;
|
|
98
|
+
const modules = new Map();
|
|
99
|
+
const unmapped = [];
|
|
100
|
+
for (const raw of lines) {
|
|
101
|
+
if (!inFullApi) {
|
|
102
|
+
if (FULL_API_OPEN.test(raw))
|
|
103
|
+
inFullApi = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (/^\}>/.test(raw))
|
|
107
|
+
break;
|
|
108
|
+
const k = FULL_API_KEY.exec(raw);
|
|
109
|
+
if (!k)
|
|
110
|
+
continue;
|
|
111
|
+
const rel = imports.get(k[2]);
|
|
112
|
+
if (rel === undefined) {
|
|
113
|
+
unmapped.push(k[1]);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
modules.set(k[1], rel);
|
|
117
|
+
}
|
|
118
|
+
if (modules.size === 0) {
|
|
119
|
+
return {
|
|
120
|
+
modules,
|
|
121
|
+
unreadable: `${path} carries no resolvable \`declare const fullApi: ApiFromModules<{…}>\` member — the exposed surface cannot be derived from it`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (unmapped.length > 0) {
|
|
125
|
+
return {
|
|
126
|
+
modules,
|
|
127
|
+
unreadable: `${path}: fullApi member(s) ${unmapped.join(", ")} resolve to no \`import type * as\` line — CR-1's api.d.ts parse is incomplete (CR-1's own bug, not a tree finding)`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return { modules };
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// registrations — read with the builder token observed AT each site
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/** `export const NAME = <builder>(` — the only registration shape Convex exposes. */
|
|
136
|
+
const REGISTRATION = /^\s*export\s+const\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?([A-Za-z0-9_$]+)\s*\(/;
|
|
137
|
+
/** `export const NAME = customQuery(` and friends — a wrapper DEFINITION. */
|
|
138
|
+
const CUSTOM_BUILDER_DEF = /^\s*(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*=\s*(custom(?:Query|Mutation|Action))\s*\(/;
|
|
139
|
+
const FIRST_IDENT = /([A-Za-z_$][A-Za-z0-9_$]*)/;
|
|
140
|
+
const PUBLIC_BASES = new Set(["query", "mutation", "action"]);
|
|
141
|
+
const NON_PUBLIC_BASES = new Set([
|
|
142
|
+
"internalQuery",
|
|
143
|
+
"internalMutation",
|
|
144
|
+
"internalAction",
|
|
145
|
+
"httpAction",
|
|
146
|
+
]);
|
|
147
|
+
function base(rel) {
|
|
148
|
+
return (rel.split("/").pop() ?? rel).toLowerCase();
|
|
149
|
+
}
|
|
150
|
+
/** a file CR-1 judges — never a test, never generated. */
|
|
151
|
+
export function isJudgeable(f) {
|
|
152
|
+
const b = base(f.rel);
|
|
153
|
+
return (!b.endsWith(".test.ts") &&
|
|
154
|
+
!b.endsWith(".d.ts") &&
|
|
155
|
+
!f.rel.includes("__tests__") &&
|
|
156
|
+
!f.rel.includes("_generated/"));
|
|
157
|
+
}
|
|
158
|
+
function isCode(l) {
|
|
159
|
+
return !l.comment;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Discover every registration BUILDER the tree declares, so a `customQuery(query,…)`
|
|
163
|
+
* wrapper is recognised as public and a `customQuery(internalQuery,…)` wrapper as
|
|
164
|
+
* not. A wrapper whose base resolves to neither leaves the exposed surface
|
|
165
|
+
* unknowable → CR-1 refuses (never a silent green).
|
|
166
|
+
*/
|
|
167
|
+
export function discoverBuilders(files) {
|
|
168
|
+
const map = {
|
|
169
|
+
publicBuilders: new Map([...PUBLIC_BASES].map((b) => [b, b])),
|
|
170
|
+
nonPublicBuilders: new Map([...NON_PUBLIC_BASES].map((b) => [b, b])),
|
|
171
|
+
unresolvable: [],
|
|
172
|
+
};
|
|
173
|
+
for (const f of files) {
|
|
174
|
+
if (!isJudgeable(f))
|
|
175
|
+
continue;
|
|
176
|
+
const code = f.lines.filter(isCode);
|
|
177
|
+
for (let i = 0; i < code.length; i++) {
|
|
178
|
+
const m = CUSTOM_BUILDER_DEF.exec(code[i].raw);
|
|
179
|
+
if (!m)
|
|
180
|
+
continue;
|
|
181
|
+
const [, name, ,] = m;
|
|
182
|
+
const after = code[i].raw.slice((m.index ?? 0) + m[0].length);
|
|
183
|
+
let b = FIRST_IDENT.exec(after)?.[1];
|
|
184
|
+
for (let j = i + 1; !b && j < code.length && j <= i + 4; j++) {
|
|
185
|
+
b = FIRST_IDENT.exec(code[j].raw)?.[1];
|
|
186
|
+
}
|
|
187
|
+
if (b !== undefined && PUBLIC_BASES.has(b)) {
|
|
188
|
+
map.publicBuilders.set(name, b);
|
|
189
|
+
}
|
|
190
|
+
else if (b !== undefined && NON_PUBLIC_BASES.has(b)) {
|
|
191
|
+
map.nonPublicBuilders.set(name, b);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
map.unresolvable.push({
|
|
195
|
+
rel: f.rel,
|
|
196
|
+
n: code[i].n,
|
|
197
|
+
name,
|
|
198
|
+
base: b ?? "<none>",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return map;
|
|
204
|
+
}
|
|
205
|
+
/** delimit a body from its opener at index i by matching braces/parens. */
|
|
206
|
+
function blockAt(lines, i) {
|
|
207
|
+
let depth = 0;
|
|
208
|
+
let seenOpen = false;
|
|
209
|
+
const body = [];
|
|
210
|
+
for (let j = i; j < lines.length; j++) {
|
|
211
|
+
body.push(lines[j]);
|
|
212
|
+
for (const ch of lines[j].raw) {
|
|
213
|
+
if (ch === "(" || ch === "{") {
|
|
214
|
+
depth += 1;
|
|
215
|
+
seenOpen = true;
|
|
216
|
+
}
|
|
217
|
+
else if (ch === ")" || ch === "}") {
|
|
218
|
+
depth -= 1;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (seenOpen && depth <= 0)
|
|
222
|
+
return { lines: body, unterminated: false };
|
|
223
|
+
}
|
|
224
|
+
return { lines: body, unterminated: seenOpen };
|
|
225
|
+
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// identity helpers — DERIVED from the tree, transitively; never a name list
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
const GET_IDENTITY = /ctx\.auth\.getUserIdentity\s*\(/;
|
|
230
|
+
const FN_DECL = /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*[(<]/;
|
|
231
|
+
const FN_CONST = /^\s*(?:export\s+)?const\s+([A-Za-z0-9_$]+)\s*(?::[^=]*)?=\s*(?:async\s+)?(?:\(|function\b)/;
|
|
232
|
+
/** a privileged shape — what "an absent identity is treated as privileged" LOOKS
|
|
233
|
+
* like in a returned object: a master flag, or a wildcard authority sentinel. */
|
|
234
|
+
const PRIVILEGED_MARKER = /isMaster\s*:\s*true|"\*"|'\*'|isAdmin\s*:\s*true/;
|
|
235
|
+
/** `opts?.flag` / `options.flag` / `opts.flag` — an opt-in read inside a helper. */
|
|
236
|
+
const OPT_READ = /\b(?:opts|options|o)\s*\??\.\s*([A-Za-z0-9_$]+)/g;
|
|
237
|
+
/** the `name` half of a `rel::name` key. */
|
|
238
|
+
export function nameOf(key) {
|
|
239
|
+
return key.slice(key.indexOf("::") + 2);
|
|
240
|
+
}
|
|
241
|
+
/** the `rel` half of a `rel::name` key. */
|
|
242
|
+
export function relOf(key) {
|
|
243
|
+
return key.slice(0, key.indexOf("::"));
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The members of `set` that a handler in `rel` can actually reach: a plain function
|
|
247
|
+
* declared in `rel` or imported by `rel`, or a registration (reachable from anywhere,
|
|
248
|
+
* but only through its qualified `internal.…`/`api.…` name).
|
|
249
|
+
*/
|
|
250
|
+
export function reachable(model, set, rel) {
|
|
251
|
+
const out = new Map();
|
|
252
|
+
const scope = model.scopes.get(rel) ?? new Set();
|
|
253
|
+
for (const key of set) {
|
|
254
|
+
const name = nameOf(key);
|
|
255
|
+
const qualified = model.regNames.has(key);
|
|
256
|
+
if (qualified) {
|
|
257
|
+
out.set(`q:${name}`, { name, qualified: true });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (relOf(key) === rel || scope.has(name)) {
|
|
261
|
+
out.set(name, { name, qualified: false });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return [...out.values()];
|
|
265
|
+
}
|
|
266
|
+
/** the refs in `refs` that `text` actually references. */
|
|
267
|
+
export function referenced(text, refs) {
|
|
268
|
+
return refs.filter((r) => references(text, r.name, r.qualified));
|
|
269
|
+
}
|
|
270
|
+
/** `import { a, b as c } from "…"` — the local names a module brings into scope. The
|
|
271
|
+
* brace list is very often WRAPPED over several lines by the formatter, so a
|
|
272
|
+
* single-line regex misses it entirely: that is not a cosmetic miss, it empties the
|
|
273
|
+
* importing module's scope and files every handler that calls an imported resolver
|
|
274
|
+
* under NO-IDENTITY (measured on run 8: 83 NO-IDENTITY, `messages:listByChannel`
|
|
275
|
+
* among them, because `withOrgScope` is imported across four lines). */
|
|
276
|
+
const IMPORT_OPEN = /^\s*import\s+(?:type\s+)?\{(.*)$/;
|
|
277
|
+
/** collect (`rel::name` -> body) for every function-like declaration in the tree,
|
|
278
|
+
* plus, per file, the set of local names it declares or imports. */
|
|
279
|
+
function functionBodies(files) {
|
|
280
|
+
const bodies = new Map();
|
|
281
|
+
const scopes = new Map();
|
|
282
|
+
for (const f of files) {
|
|
283
|
+
if (!isJudgeable(f))
|
|
284
|
+
continue;
|
|
285
|
+
const scope = new Set();
|
|
286
|
+
scopes.set(f.rel, scope);
|
|
287
|
+
const lines = f.lines;
|
|
288
|
+
for (let i = 0; i < lines.length; i++) {
|
|
289
|
+
if (!isCode(lines[i]))
|
|
290
|
+
continue;
|
|
291
|
+
const imp = IMPORT_OPEN.exec(lines[i].raw);
|
|
292
|
+
if (imp) {
|
|
293
|
+
// accumulate the brace list, however many lines the formatter wrapped it over.
|
|
294
|
+
let spec = imp[1];
|
|
295
|
+
let j = i;
|
|
296
|
+
while (!spec.includes("}") && j + 1 < lines.length) {
|
|
297
|
+
j += 1;
|
|
298
|
+
spec += `\n${lines[j].raw}`;
|
|
299
|
+
}
|
|
300
|
+
for (const part of spec.slice(0, spec.indexOf("}")).split(",")) {
|
|
301
|
+
const local = part
|
|
302
|
+
.trim()
|
|
303
|
+
.split(/\s+as\s+/)
|
|
304
|
+
.pop()
|
|
305
|
+
?.trim();
|
|
306
|
+
if (local && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(local))
|
|
307
|
+
scope.add(local);
|
|
308
|
+
}
|
|
309
|
+
i = j;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// REGISTRATION bodies are indexed too: an ACTION resolves its principal by
|
|
313
|
+
// `ctx.runQuery(internal.lib.auth.resolveOrgScopeForAction, {})`, so the
|
|
314
|
+
// internalQuery it names must be modelled as an identity resolver or every
|
|
315
|
+
// action-side authority gate reads as "never consults the principal".
|
|
316
|
+
const fn = FN_DECL.exec(lines[i].raw) ?? FN_CONST.exec(lines[i].raw);
|
|
317
|
+
const reg = fn ? null : REGISTRATION.exec(lines[i].raw);
|
|
318
|
+
const m = fn ?? reg;
|
|
319
|
+
if (!m)
|
|
320
|
+
continue;
|
|
321
|
+
const blk = blockAt(lines, i);
|
|
322
|
+
scope.add(m[1]);
|
|
323
|
+
bodies.set(`${f.rel}::${m[1]}`, {
|
|
324
|
+
text: blk.lines
|
|
325
|
+
.filter(isCode)
|
|
326
|
+
.map((l) => l.raw)
|
|
327
|
+
.join("\n"),
|
|
328
|
+
at: `${f.rel}:${lines[i].n}`,
|
|
329
|
+
isRegistration: reg !== null,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { bodies, scopes };
|
|
334
|
+
}
|
|
335
|
+
export function deriveIdentityModel(files) {
|
|
336
|
+
const { bodies, scopes } = functionBodies(files);
|
|
337
|
+
const regNames = new Set([...bodies.entries()].filter(([, b]) => b.isRegistration).map(([k]) => k));
|
|
338
|
+
const base = {
|
|
339
|
+
helpers: new Set(),
|
|
340
|
+
gates: new Set(),
|
|
341
|
+
dataFns: new Set(),
|
|
342
|
+
permissiveOnAbsence: new Map(),
|
|
343
|
+
regNames,
|
|
344
|
+
scopes,
|
|
345
|
+
privilegeOnAbsenceFlags: new Map(),
|
|
346
|
+
refuserParams: new Map(),
|
|
347
|
+
};
|
|
348
|
+
/** grow `set` to every body that REACHES a member of `set` from its own scope. */
|
|
349
|
+
const close = (set, rounds) => {
|
|
350
|
+
for (let round = 0; round < rounds; round++) {
|
|
351
|
+
let grew = false;
|
|
352
|
+
for (const [key, b] of bodies) {
|
|
353
|
+
if (set.has(key))
|
|
354
|
+
continue;
|
|
355
|
+
if (referenced(b.text, reachable(base, set, relOf(key))).length > 0) {
|
|
356
|
+
set.add(key);
|
|
357
|
+
grew = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!grew)
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
for (const [key, b] of bodies) {
|
|
365
|
+
if (GET_IDENTITY.test(b.text))
|
|
366
|
+
base.helpers.add(key);
|
|
367
|
+
}
|
|
368
|
+
close(base.helpers, 4);
|
|
369
|
+
deriveValueRefusers(bodies, base);
|
|
370
|
+
// GATES — an identity helper that REFUSES on the absent-identity path, read from
|
|
371
|
+
// its own body; then transitively, a helper that reaches a gate refuses through it.
|
|
372
|
+
for (const [key, b] of bodies) {
|
|
373
|
+
if (!base.helpers.has(key))
|
|
374
|
+
continue;
|
|
375
|
+
if (absentIdentityBranchThrows(b.text) ||
|
|
376
|
+
refusesOnResolvedValue(b.text, base, relOf(key))) {
|
|
377
|
+
base.gates.add(key);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (let round = 0; round < 4; round++) {
|
|
381
|
+
let grew = false;
|
|
382
|
+
for (const [key, b] of bodies) {
|
|
383
|
+
if (!base.helpers.has(key) || base.gates.has(key))
|
|
384
|
+
continue;
|
|
385
|
+
if (referenced(b.text, reachable(base, base.gates, relOf(key))).length > 0) {
|
|
386
|
+
base.gates.add(key);
|
|
387
|
+
grew = true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!grew)
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
// DATA-TOUCHING functions, closed the same way.
|
|
394
|
+
for (const [key, b] of bodies) {
|
|
395
|
+
if (DATA_ACCESS.test(b.text))
|
|
396
|
+
base.dataFns.add(key);
|
|
397
|
+
}
|
|
398
|
+
close(base.dataFns, 5);
|
|
399
|
+
// PERMISSIVE-ON-ABSENCE: the absent-identity branch returns with no value, i.e.
|
|
400
|
+
// "proceed" — the opposite of a refusal, read from the helper's own body.
|
|
401
|
+
for (const [key, b] of bodies) {
|
|
402
|
+
if (!GET_IDENTITY.test(b.text))
|
|
403
|
+
continue;
|
|
404
|
+
const bind0 = /(?:const|let)\s+([A-Za-z0-9_$]+)\s*(?::[^=\n]*)?=\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(b.text);
|
|
405
|
+
if (!bind0)
|
|
406
|
+
continue;
|
|
407
|
+
const g0 = absentGuard(b.text, bind0[1]);
|
|
408
|
+
if (!g0)
|
|
409
|
+
continue;
|
|
410
|
+
// ALLOW-ON-ABSENCE, conditional or not. A helper is an assertion that the
|
|
411
|
+
// caller may proceed; a bare `return;` (or `return true`) ANYWHERE in its
|
|
412
|
+
// absent-identity branch is a path on which an anonymous caller proceeds.
|
|
413
|
+
// The pre-fix test required the branch to contain NO throw, so the shape
|
|
414
|
+
// if (identity === null) { if (isMasterNamespace) return; throw … }
|
|
415
|
+
// (vantage-peers 9e1413a okfBundleDurable.ts:124-128, okfBundleNode.ts:280-287)
|
|
416
|
+
// read as a refusing GATE — the throw was seen and the `return;` before it
|
|
417
|
+
// was not — and every export registration calling it was classed RESOLVES.
|
|
418
|
+
// A throw AFTER a conditional allow does not refuse the callers the allow
|
|
419
|
+
// already let through; the condition is on a caller-supplied value
|
|
420
|
+
// (`namespace`), never on a resolved principal.
|
|
421
|
+
const allow = PROCEED_RETURN.exec(g0.branch);
|
|
422
|
+
if (allow) {
|
|
423
|
+
const conditional = THROWS.test(g0.branch);
|
|
424
|
+
base.permissiveOnAbsence.set(nameOf(key), conditional
|
|
425
|
+
? `${b.at} (${nameOf(key)}: its absent-identity branch returns \`${allow[0].replace(/\s+/g, " ").trim()}\` on a condition BEFORE it throws — an anonymous caller meeting that condition is waved through; the throw refuses only the others)`
|
|
426
|
+
: `${b.at} (${nameOf(key)}: its absent-identity branch is a bare \`return;\` — an anonymous caller is waved through)`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const [key, b] of bodies) {
|
|
430
|
+
if (!GET_IDENTITY.test(b.text))
|
|
431
|
+
continue;
|
|
432
|
+
const bind = /(?:const|let)\s+([A-Za-z0-9_$]+)\s*(?::[^=\n]*)?=\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(b.text);
|
|
433
|
+
if (!bind)
|
|
434
|
+
continue;
|
|
435
|
+
const guard = absentGuard(b.text, bind[1]);
|
|
436
|
+
if (!guard)
|
|
437
|
+
continue;
|
|
438
|
+
const region = guard.branch;
|
|
439
|
+
OPT_READ.lastIndex = 0;
|
|
440
|
+
let m;
|
|
441
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
442
|
+
while ((m = OPT_READ.exec(region)) !== null) {
|
|
443
|
+
const after = region.slice(m.index, m.index + 900);
|
|
444
|
+
if (PRIVILEGED_MARKER.test(after)) {
|
|
445
|
+
base.privilegeOnAbsenceFlags.set(m[1], `${b.at} (${nameOf(key)}: an absent identity returns a privileged shape when \`${m[1]}\` is opted into)`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return base;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* `name` is REFERENCED in `text` — called (`name(`) or passed as a value
|
|
453
|
+
* (`internal.lib.auth.resolveOrgScopeForAction,`). The second form is how an action
|
|
454
|
+
* reaches a resolver, so a call-only test is blind to the whole action surface.
|
|
455
|
+
*/
|
|
456
|
+
export function references(text, name, qualified = false) {
|
|
457
|
+
if (qualified) {
|
|
458
|
+
// a registration is reached ONLY through the generated api/internal object —
|
|
459
|
+
// `internal.lib.auth.resolveOrgScopeForAction`. Nothing else may match it.
|
|
460
|
+
return new RegExp(`\\b(?:internal|api)(?:\\.[A-Za-z0-9_$]+)*\\.${name}\\b`).test(text);
|
|
461
|
+
}
|
|
462
|
+
return new RegExp(`(?:^|[^A-Za-z0-9_$.])${name}\\s*(?:\\(|,|\\)|;|$)`, "m").test(text);
|
|
463
|
+
}
|
|
464
|
+
/** Every `if (...)` in `text`, with its brace-balanced condition source. */
|
|
465
|
+
export function ifConditions(text) {
|
|
466
|
+
const out = [];
|
|
467
|
+
const open = /\bif\s*\(/g;
|
|
468
|
+
let m;
|
|
469
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
470
|
+
while ((m = open.exec(text)) !== null) {
|
|
471
|
+
let depth = 1;
|
|
472
|
+
let j = m.index + m[0].length;
|
|
473
|
+
for (; j < text.length && depth > 0; j++) {
|
|
474
|
+
if (text[j] === "(")
|
|
475
|
+
depth += 1;
|
|
476
|
+
else if (text[j] === ")")
|
|
477
|
+
depth -= 1;
|
|
478
|
+
}
|
|
479
|
+
out.push({
|
|
480
|
+
index: m.index,
|
|
481
|
+
cond: text.slice(m.index + m[0].length, j - 1),
|
|
482
|
+
end: j,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* ABSENT-IDENTITY GUARD — an `if` whose condition tests `v` ITSELF for absence:
|
|
489
|
+
* `!v`, `v === null`, `v == null`, `v === undefined`, including as one disjunct of a
|
|
490
|
+
* widened test (`if (identity === null || identity === undefined)`, the real
|
|
491
|
+
* `assertCanExportNamespace` shape). It must NOT match a test on a PROPERTY of `v`
|
|
492
|
+
* (`if (!scope.isMaster)` is an authority test on a RESOLVED principal, not a test
|
|
493
|
+
* for its absence) — reading those as absence accused every correctly-scoped
|
|
494
|
+
* dashboard query on the first run of this rule.
|
|
495
|
+
*/
|
|
496
|
+
export function absentGuard(text, v) {
|
|
497
|
+
const bare = new RegExp(`(?:^|[^.\\w$])!\\s*${v}\\s*(?![.\\w$[(])`);
|
|
498
|
+
const nullish = new RegExp(`\\b${v}\\s*===?\\s*(?:null|undefined)\\b`);
|
|
499
|
+
for (const c of ifConditions(text)) {
|
|
500
|
+
if (bare.test(c.cond) || nullish.test(c.cond)) {
|
|
501
|
+
return { index: c.index, branch: branchAfter(text, c.end) };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The GUARDED BRANCH itself, from just after an `if (…)`'s closing paren: a braced
|
|
508
|
+
* block to its matching brace, else the single statement up to its `;`. Reading a
|
|
509
|
+
* fixed-width window instead runs past the branch into the rest of the handler, and
|
|
510
|
+
* that is not a width error — it INVERTS the verdict: `if (ns === null) return [];`
|
|
511
|
+
* followed by the normal data path read as "the absent-identity branch serves data",
|
|
512
|
+
* which accused all three `search:*` actions that in fact refuse first (measured).
|
|
513
|
+
*/
|
|
514
|
+
export function branchAfter(text, from) {
|
|
515
|
+
let i = from;
|
|
516
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
517
|
+
i++;
|
|
518
|
+
if (text[i] === "{") {
|
|
519
|
+
let depth = 0;
|
|
520
|
+
for (let j = i; j < text.length; j++) {
|
|
521
|
+
if (text[j] === "{")
|
|
522
|
+
depth += 1;
|
|
523
|
+
else if (text[j] === "}") {
|
|
524
|
+
depth -= 1;
|
|
525
|
+
if (depth === 0)
|
|
526
|
+
return text.slice(i, j + 1);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return text.slice(i);
|
|
530
|
+
}
|
|
531
|
+
const semi = text.indexOf(";", i);
|
|
532
|
+
return text.slice(i, semi === -1 ? text.length : semi + 1);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* A helper that resolves the principal through ANOTHER resolver and then REFUSES on
|
|
536
|
+
* a test of the resolved value is a GATE too — the real `requireFleetMaster` shape:
|
|
537
|
+
* const scope = await withOrgScope(ctx);
|
|
538
|
+
* if (!scope.isMaster) throw new ConvexError("RBAC_DENIED: …");
|
|
539
|
+
* `withOrgScope` itself does not throw on an absent identity (it returns an
|
|
540
|
+
* anonymous, empty scope), so the refusal lives one level up. Missing this level
|
|
541
|
+
* made the rule accuse all four `mandates:*` writers, which are master-gated.
|
|
542
|
+
*/
|
|
543
|
+
function refusesOnResolvedValue(text, model, rel) {
|
|
544
|
+
const refs = referenced(text, reachable(model, model.helpers, rel)).filter((r) => !r.qualified);
|
|
545
|
+
const alts = refs.map((r) => r.name).join("|");
|
|
546
|
+
const ALT = alts || "\\0NEVER";
|
|
547
|
+
// SHAPE 1 — declaration and resolution in ONE statement:
|
|
548
|
+
// const scope = await withOrgScope(ctx);
|
|
549
|
+
const bind = new RegExp(`(?:const|let)\\s+([A-Za-z0-9_$]+)\\s*(?::[^=\\n]*)?=\\s*\\(?\\s*(?:await\\s+)?(?:${ALT})\\s*\\(`, "g");
|
|
550
|
+
// SHAPE 2 — DECLARE THEN ASSIGN, which shape 1 cannot see:
|
|
551
|
+
// let scope: Awaited<ReturnType<typeof withOrgScope>>;
|
|
552
|
+
// try { scope = await withOrgScope(ctx); } catch { throw … }
|
|
553
|
+
// if (!scope.isMaster) throw …
|
|
554
|
+
// This is the real `requireServiceAccount` of the audited tree (vantage-peers
|
|
555
|
+
// convex/oauth.ts @ 40c71cb), which declares the local on its own line
|
|
556
|
+
// PRECISELY so the resolution can sit inside a try and re-raise naming its
|
|
557
|
+
// own door. Shape 1 requires the `=` to follow the declarator, so every gate
|
|
558
|
+
// written this way read as ABSENT and SEVEN correctly service-account-gated
|
|
559
|
+
// oauth handlers were classed FAIL-OPEN. Measured on that tree: FAIL-OPEN
|
|
560
|
+
// 11 -> 4, RESOLVES 139 -> 146, classified total unchanged at 179.
|
|
561
|
+
//
|
|
562
|
+
// Why this is a detector defect rather than a style complaint: a gate that
|
|
563
|
+
// can only pass by being rewritten into the matcher's single accepted shape
|
|
564
|
+
// is a gate-satisfiability failure. The instrument reads the code the
|
|
565
|
+
// codebase writes; it does not demand the code the matcher finds convenient.
|
|
566
|
+
//
|
|
567
|
+
// A bare assignment counts only when the SAME identifier is also DECLARED
|
|
568
|
+
// with let/var in this body, so an assignment to a parameter, an imported
|
|
569
|
+
// binding or an object property is not mistaken for a local resolution.
|
|
570
|
+
const assign = new RegExp(`(?:^|[;{}\\n])\\s*([A-Za-z0-9_$]+)\\s*=\\s*\\(?\\s*(?:await\\s+)?(?:${ALT})\\s*\\(`, "g");
|
|
571
|
+
// SHAPE 3 — an ACTION resolves through a REGISTRATION it cannot call directly:
|
|
572
|
+
// const scope = await ctx.runQuery(internal.lib.auth.resolveOrgScopeForAction, {});
|
|
573
|
+
// (vantage-peers 58ed649 okfBundleNode.ts:271, `assertCanExportNamespace`). The
|
|
574
|
+
// registration must be a tree-derived identity helper reached by its QUALIFIED
|
|
575
|
+
// name — never a name list, and never any `ctx.runQuery` whatever it targets:
|
|
576
|
+
// a runQuery to a data read is not a resolution. Shapes 1-2 drop qualified refs
|
|
577
|
+
// (a registration is never called by bare name), so every action-side gate of
|
|
578
|
+
// this form read as "binds nothing" and three gated OKF doors were FAIL-OPEN.
|
|
579
|
+
const qualifiedAlts = referenced(text, reachable(model, model.helpers, rel))
|
|
580
|
+
.filter((r) => r.qualified)
|
|
581
|
+
.map((r) => r.name)
|
|
582
|
+
.join("|");
|
|
583
|
+
const runQueryBind = new RegExp(`(?:const|let)\\s+([A-Za-z0-9_$]+)\\s*(?::[^=\\n]*)?=\\s*\\(?\\s*(?:await\\s+)?ctx\\.runQuery\\s*\\(\\s*(?:internal|api)(?:\\.[A-Za-z0-9_$]+)*\\.(?:${qualifiedAlts || "\\0NEVER"})\\b`, "g");
|
|
584
|
+
const bound = new Set();
|
|
585
|
+
for (const re of [bind, assign, runQueryBind]) {
|
|
586
|
+
re.lastIndex = 0;
|
|
587
|
+
let m;
|
|
588
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
589
|
+
while ((m = re.exec(text)) !== null) {
|
|
590
|
+
const v = m[1];
|
|
591
|
+
if (re === assign && !new RegExp(`(?:let|var)\\s+${v}\\b`).test(text)) {
|
|
592
|
+
continue; // assigned but never declared here — not a local resolution.
|
|
593
|
+
}
|
|
594
|
+
bound.add(v);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
for (const v of bound) {
|
|
598
|
+
for (const c of ifConditions(text)) {
|
|
599
|
+
if (!new RegExp(`\\b${v}\\b`).test(c.cond))
|
|
600
|
+
continue;
|
|
601
|
+
if (THROWS.test(branchAfter(text, c.end)))
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
// The refusal may live in a function the RESOLVED value is handed to:
|
|
605
|
+
// requireTenantNamespace(scope, namespace, door);
|
|
606
|
+
// It counts only when `v` itself is the argument at a refused position of a
|
|
607
|
+
// reachable value refuser — `requireNonEmpty(namespace)` beside an unread
|
|
608
|
+
// `scope` refuses nothing about the principal.
|
|
609
|
+
if (passesToRefuser(text, v, model, rel))
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// value refusers — a refusal expressed as `fn(resolvedValue, …)`
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
/** split a parenthesised list's inner text on its TOP-LEVEL commas. */
|
|
618
|
+
function splitTopLevel(inner) {
|
|
619
|
+
const out = [];
|
|
620
|
+
let depth = 0;
|
|
621
|
+
let cur = "";
|
|
622
|
+
for (let i = 0; i < inner.length; i++) {
|
|
623
|
+
const ch = inner[i];
|
|
624
|
+
if (ch === "(" || ch === "[" || ch === "{" || ch === "<")
|
|
625
|
+
depth += 1;
|
|
626
|
+
else if (ch === ")" || ch === "]" || ch === "}")
|
|
627
|
+
depth -= 1;
|
|
628
|
+
// `>` closes a generic, but `=>` is an arrow and closes nothing.
|
|
629
|
+
else if (ch === ">" && inner[i - 1] !== "=")
|
|
630
|
+
depth -= 1;
|
|
631
|
+
if (ch === "," && depth === 0) {
|
|
632
|
+
out.push(cur);
|
|
633
|
+
cur = "";
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
cur += ch;
|
|
637
|
+
}
|
|
638
|
+
if (cur.trim() !== "")
|
|
639
|
+
out.push(cur);
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
/** the balanced `(…)` starting at `open` (an index of `(`): inner text and end. */
|
|
643
|
+
function parenAt(text, open) {
|
|
644
|
+
let depth = 0;
|
|
645
|
+
for (let j = open; j < text.length; j++) {
|
|
646
|
+
if (text[j] === "(")
|
|
647
|
+
depth += 1;
|
|
648
|
+
else if (text[j] === ")") {
|
|
649
|
+
depth -= 1;
|
|
650
|
+
if (depth === 0)
|
|
651
|
+
return { inner: text.slice(open + 1, j), end: j + 1 };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* `v` is passed, as itself, at a refused position of a value refuser REACHABLE from
|
|
658
|
+
* `rel` — declared in `rel`, or imported by it (the same scope rule `reachable`
|
|
659
|
+
* applies; a same-named function in a module `rel` does not import is not seen).
|
|
660
|
+
*/
|
|
661
|
+
function passesToRefuser(text, v, model, rel) {
|
|
662
|
+
const scope = model.scopes.get(rel) ?? new Set();
|
|
663
|
+
for (const [key, positions] of model.refuserParams) {
|
|
664
|
+
const name = nameOf(key);
|
|
665
|
+
if (relOf(key) !== rel && !scope.has(name))
|
|
666
|
+
continue;
|
|
667
|
+
const call = new RegExp(`(?:^|[^A-Za-z0-9_$.])${name}\\s*\\(`, "g");
|
|
668
|
+
let m;
|
|
669
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
670
|
+
while ((m = call.exec(text)) !== null) {
|
|
671
|
+
const p = parenAt(text, m.index + m[0].length - 1);
|
|
672
|
+
if (p === null)
|
|
673
|
+
continue;
|
|
674
|
+
const args = splitTopLevel(p.inner).map((a) => a.trim());
|
|
675
|
+
for (const i of positions) {
|
|
676
|
+
if (args[i] === v)
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
/** parameter names of a function body's FIRST parenthesised list, by position
|
|
684
|
+
* (`null` for a destructured or unreadable parameter), and where the list ends. */
|
|
685
|
+
function paramsOf(text) {
|
|
686
|
+
const open = text.indexOf("(");
|
|
687
|
+
if (open < 0)
|
|
688
|
+
return null;
|
|
689
|
+
const p = parenAt(text, open);
|
|
690
|
+
if (p === null)
|
|
691
|
+
return null;
|
|
692
|
+
const names = splitTopLevel(p.inner).map((raw) => {
|
|
693
|
+
const m = /^\s*(?:\.\.\.)?([A-Za-z_$][A-Za-z0-9_$]*)\s*[?:=,]?/.exec(raw);
|
|
694
|
+
return m ? m[1] : null;
|
|
695
|
+
});
|
|
696
|
+
return { names, end: p.end };
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Derive the VALUE REFUSERS from the tree, transitively. A plain (non-registration)
|
|
700
|
+
* function refuses on its parameter `p` when BOTH hold:
|
|
701
|
+
*
|
|
702
|
+
* REFUSES — an `if` whose condition reads `p` has a branch that THROWS
|
|
703
|
+
* (`if (scope.anonymous) throw …`), OR it hands `p` itself to another
|
|
704
|
+
* value refuser at a refused position (`requireTenantNamespace` ->
|
|
705
|
+
* `requireResolvedCaller(scope, …)`).
|
|
706
|
+
* NEVER PROCEEDS SILENTLY ON ANYTHING ELSE — every proceed (`return;` /
|
|
707
|
+
* `return true`) in the body sits in the branch of an `if` whose
|
|
708
|
+
* condition reads `p`. An allow decided on the RESOLVED value
|
|
709
|
+
* (`if (isNamespaceAllowedForScope(scope, ns)) return;`) is the
|
|
710
|
+
* authority deciding; an allow decided on a caller-supplied argument
|
|
711
|
+
* (`if (ns.startsWith("public/")) return;`) lets any principal —
|
|
712
|
+
* including none — through, so that function is NOT a refuser.
|
|
713
|
+
*
|
|
714
|
+
* What "refuses" means here is the bar CR-1 already applies to an inline gate
|
|
715
|
+
* (`refusesOnResolvedValue`: an `if` on the resolved value that throws), carried one
|
|
716
|
+
* call deeper. It is not a proof that every non-authorised input throws — that is
|
|
717
|
+
* not decidable from source; it is the absent-principal path CR-1 asks about.
|
|
718
|
+
*/
|
|
719
|
+
function deriveValueRefusers(bodies, model) {
|
|
720
|
+
const parsed = new Map();
|
|
721
|
+
for (const [key, b] of bodies) {
|
|
722
|
+
if (b.isRegistration)
|
|
723
|
+
continue;
|
|
724
|
+
const ps = paramsOf(b.text);
|
|
725
|
+
if (ps === null)
|
|
726
|
+
continue;
|
|
727
|
+
parsed.set(key, { names: ps.names, body: b.text.slice(ps.end) });
|
|
728
|
+
}
|
|
729
|
+
const silentlyProceeds = (body, p) => {
|
|
730
|
+
const reads = new RegExp(`\\b${p}\\b`);
|
|
731
|
+
const guarded = [];
|
|
732
|
+
for (const c of ifConditions(body)) {
|
|
733
|
+
if (!reads.test(c.cond))
|
|
734
|
+
continue;
|
|
735
|
+
let s = c.end;
|
|
736
|
+
while (s < body.length && /\s/.test(body[s]))
|
|
737
|
+
s++;
|
|
738
|
+
guarded.push([s, s + branchAfter(body, c.end).length]);
|
|
739
|
+
}
|
|
740
|
+
const proceed = new RegExp(PROCEED_RETURN.source, "g");
|
|
741
|
+
let m;
|
|
742
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
743
|
+
while ((m = proceed.exec(body)) !== null) {
|
|
744
|
+
const at = m.index;
|
|
745
|
+
if (!guarded.some(([s, e]) => at >= s && at < e))
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
};
|
|
750
|
+
for (let round = 0; round < 5; round++) {
|
|
751
|
+
let grew = false;
|
|
752
|
+
for (const [key, { names, body }] of parsed) {
|
|
753
|
+
names.forEach((p, i) => {
|
|
754
|
+
if (p === null || model.refuserParams.get(key)?.has(i))
|
|
755
|
+
return;
|
|
756
|
+
if (silentlyProceeds(body, p))
|
|
757
|
+
return;
|
|
758
|
+
const reads = new RegExp(`\\b${p}\\b`);
|
|
759
|
+
const direct = ifConditions(body).some((c) => reads.test(c.cond) && THROWS.test(branchAfter(body, c.end)));
|
|
760
|
+
if (!direct && !passesToRefuser(body, p, model, relOf(key)))
|
|
761
|
+
return;
|
|
762
|
+
const set = model.refuserParams.get(key) ?? new Set();
|
|
763
|
+
set.add(i);
|
|
764
|
+
model.refuserParams.set(key, set);
|
|
765
|
+
grew = true;
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (!grew)
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** true when a helper body resolves the identity and THROWS on its absent path. */
|
|
773
|
+
function absentIdentityBranchThrows(text) {
|
|
774
|
+
const bind = /(?:const|let)\s+([A-Za-z0-9_$]+)\s*(?::[^=\n]*)?=\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(text);
|
|
775
|
+
if (bind) {
|
|
776
|
+
const g = absentGuard(text, bind[1]);
|
|
777
|
+
if (g && THROWS.test(g.branch))
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
// `const identity = await ctx.auth.getUserIdentity(); if (!identity) throw` is the
|
|
781
|
+
// common form; a helper with no binding that throws on a falsy identity expression
|
|
782
|
+
// (`if (!(await ctx.auth.getUserIdentity())) throw`) refuses just as decisively.
|
|
783
|
+
const inline = /if\s*\(\s*!\s*\(?\s*await\s+ctx\.auth\.getUserIdentity/.exec(text);
|
|
784
|
+
return (inline !== null && THROWS.test(text.slice(inline.index, inline.index + 600)));
|
|
785
|
+
}
|
|
786
|
+
// ---------------------------------------------------------------------------
|
|
787
|
+
// the four classes
|
|
788
|
+
// ---------------------------------------------------------------------------
|
|
789
|
+
/** the handler TOUCHES DATA — reads, writes, schedules, or delegates to a function
|
|
790
|
+
* that does. A registration that touches none of these is the NO-DATA class. */
|
|
791
|
+
const DATA_ACCESS = /ctx\.db\b|ctx\.storage\b|ctx\.runQuery\b|ctx\.runMutation\b|ctx\.runAction\b|ctx\.scheduler\b|ctx\.vectorSearch\b|\bcomponents\./;
|
|
792
|
+
/** a refusal — an explicit throw. */
|
|
793
|
+
const THROWS = /\bthrow\b/;
|
|
794
|
+
/** a PROCEED inside an assertion helper: a bare `return;` / `return }` or a
|
|
795
|
+
* `return true` — the caller is let through. A `return null` / `return []` /
|
|
796
|
+
* `return {…}` is a value (a typed refusal shape in a resolver), not a proceed. */
|
|
797
|
+
const PROCEED_RETURN = /\breturn\s*(?:;|\}|true\b)/;
|
|
798
|
+
/**
|
|
799
|
+
* A CONSTANT in place of an authority: `if (x === "literal") return true` — an
|
|
800
|
+
* allow decision taken on a hardcoded string and nothing else. This is the shape
|
|
801
|
+
* that defeated every predecessor at `messages:listByChannel`
|
|
802
|
+
* (`if (ch === "broadcast") return true;`) and at `search.ts::searchFixPatterns`
|
|
803
|
+
* (a hardcoded namespace). A constant is not an authorisation.
|
|
804
|
+
*/
|
|
805
|
+
const CONSTANT_ALLOWANCE = /if\s*\(\s*[^)]*===\s*(['"`])[^'"`\n]*\1\s*\)\s*(?:\{\s*)?return\s+true\b/;
|
|
806
|
+
/** the justification token, read FROM THE TARGET SOURCE at the registration. */
|
|
807
|
+
const JUSTIFICATION = /allow-no-caller-resolution:\s*(\S[^\n]{7,})/;
|
|
808
|
+
/**
|
|
809
|
+
* Classify ONE registration from its own block text. `pre` is the comment context
|
|
810
|
+
* immediately above the opener (a justification token may sit there or inside).
|
|
811
|
+
*
|
|
812
|
+
* ORDER MATTERS and is the rule's logic:
|
|
813
|
+
* no data touched -> NO-DATA (token required)
|
|
814
|
+
* data touched, principal never consulted-> NO-IDENTITY (token required)
|
|
815
|
+
* principal consulted but not decisive -> FAIL-OPEN (NO token exit)
|
|
816
|
+
* otherwise -> RESOLVES
|
|
817
|
+
*/
|
|
818
|
+
export function classify(codeText, fullText, pre, model, rel) {
|
|
819
|
+
const token = JUSTIFICATION.exec(`${pre}\n${fullText}`);
|
|
820
|
+
const dataFnCalls = referenced(codeText, reachable(model, model.dataFns, rel));
|
|
821
|
+
const touchesData = DATA_ACCESS.test(codeText) || dataFnCalls.length > 0;
|
|
822
|
+
if (!touchesData) {
|
|
823
|
+
return token
|
|
824
|
+
? {
|
|
825
|
+
cls: "NO-DATA",
|
|
826
|
+
why: `touches no data — neither directly (ctx.db / ctx.storage / ctx.run* / ctx.scheduler / components.*) nor through any tree-derived data-touching helper — and carries a written justification at the registration: "${token[1].trim().slice(0, 120)}"`,
|
|
827
|
+
justified: true,
|
|
828
|
+
}
|
|
829
|
+
: {
|
|
830
|
+
cls: "NO-DATA",
|
|
831
|
+
why: "touches no data — neither directly (ctx.db / ctx.storage / ctx.run* / ctx.scheduler / components.*) nor through any tree-derived data-touching helper — and carries NO `allow-no-caller-resolution: <reason>` token at the registration. A public registration that resolves no caller is accepted only against a reason written in this source",
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
const helperCalls = referenced(codeText, reachable(model, model.helpers, rel));
|
|
835
|
+
const gateRefs = reachable(model, model.gates, rel);
|
|
836
|
+
const consults = GET_IDENTITY.test(codeText) || helperCalls.length > 0;
|
|
837
|
+
if (!consults) {
|
|
838
|
+
return token
|
|
839
|
+
? {
|
|
840
|
+
cls: "NO-IDENTITY",
|
|
841
|
+
why: `serves or writes data and never consults the verified principal, but carries a written justification at the registration: "${token[1].trim().slice(0, 120)}"`,
|
|
842
|
+
justified: true,
|
|
843
|
+
}
|
|
844
|
+
: {
|
|
845
|
+
cls: "NO-IDENTITY",
|
|
846
|
+
why: "serves or writes data and NEVER consults the verified principal — no `ctx.auth.getUserIdentity()` and no call to any tree-derived identity helper. An anonymous caller is served whatever this handler reads",
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
// --- FAIL-OPEN: the principal is resolved, then does not decide. ---
|
|
850
|
+
const reasons = [];
|
|
851
|
+
for (const [flag, where] of model.privilegeOnAbsenceFlags) {
|
|
852
|
+
if (new RegExp(`\\b${flag}\\s*:\\s*true\\b`).test(codeText)) {
|
|
853
|
+
reasons.push(`treats an ABSENT identity as privileged: it opts into \`${flag}: true\`, and that flag's own helper — ${where} — returns a privileged shape on a null identity`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// A GATE call refuses on the absent-identity path by throwing, so it decides even
|
|
857
|
+
// as a bare statement — `await requireOrgAdmin(ctx);` IS the fail-closed idiom and
|
|
858
|
+
// must never be read as a discarded principal. Only a RESOLVER's return value can
|
|
859
|
+
// be computed and discarded.
|
|
860
|
+
const isGate = (r) => gateRefs.some((g) => g.name === r.name && g.qualified === r.qualified);
|
|
861
|
+
const refusesInline = GET_IDENTITY.test(codeText) && absentIdentityBranchThrows(codeText);
|
|
862
|
+
for (const r of helperCalls) {
|
|
863
|
+
const where = model.permissiveOnAbsence.get(r.name);
|
|
864
|
+
if (where === undefined)
|
|
865
|
+
continue;
|
|
866
|
+
// The permissive helper only matters if an anonymous caller can REACH it. A
|
|
867
|
+
// handler that resolves the identity itself and throws on its absence BEFORE
|
|
868
|
+
// the helper call (`cancelOkfBundleExportDurable` at vantage-peers 9e1413a:
|
|
869
|
+
// `if (identity === null) throw …` then `assertCanExportNamespaceV8(…)`) has
|
|
870
|
+
// already refused every anonymous caller; the helper's allow-on-absence path
|
|
871
|
+
// is unreachable from here. The refusal must PRECEDE the first helper call —
|
|
872
|
+
// an inline refusal after the helper has run refuses nothing it let through.
|
|
873
|
+
const helperAt = codeText.search(new RegExp(`(?:^|[^A-Za-z0-9_$.])${r.name}\\s*\\(`));
|
|
874
|
+
if (refusesInline &&
|
|
875
|
+
helperAt >= 0 &&
|
|
876
|
+
absentIdentityBranchThrows(codeText.slice(0, helperAt))) {
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
reasons.push(`its only authority is \`${r.name}()\`, which GRANTS on an absent identity — ${where}. An anonymous caller passes the gate and reaches the data`);
|
|
880
|
+
}
|
|
881
|
+
const gateCalls = helperCalls.filter(isGate).map((r) => r.name);
|
|
882
|
+
const resolverCalls = helperCalls
|
|
883
|
+
.filter((r) => !isGate(r))
|
|
884
|
+
.map((r) => r.name);
|
|
885
|
+
// the principal is COMPUTED AND DISCARDED: every binding of an identity /
|
|
886
|
+
// resolver result occurs exactly once (its own declaration), or the call is a
|
|
887
|
+
// bare statement with no binding at all — and nothing in the handler refuses.
|
|
888
|
+
const bindPattern = new RegExp(`(?:const|let)\\s+(?:\\{[^}]*\\}|([A-Za-z0-9_$]+))\\s*(?::[^=\\n]*)?=\\s*\\(?\\s*(?:await\\s+)?(?:ctx\\.auth\\.getUserIdentity|${resolverCalls.length ? resolverCalls.join("|") : "\\0NEVER"})\\s*\\(`, "g");
|
|
889
|
+
const binds = [];
|
|
890
|
+
let destructured = false;
|
|
891
|
+
let bm;
|
|
892
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
893
|
+
while ((bm = bindPattern.exec(codeText)) !== null) {
|
|
894
|
+
if (bm[1] === undefined)
|
|
895
|
+
destructured = true;
|
|
896
|
+
else
|
|
897
|
+
binds.push(bm[1]);
|
|
898
|
+
}
|
|
899
|
+
if (gateCalls.length === 0 && !refusesInline && !destructured) {
|
|
900
|
+
if (binds.length === 0) {
|
|
901
|
+
reasons.push("resolves the principal and BINDS NOTHING — the identity/resolver call is a bare statement that does not refuse, so the verified principal is computed and discarded and cannot decide what is served");
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
const unused = binds.filter((b) => (codeText.match(new RegExp(`\\b${b}\\b`, "g")) ?? []).length <= 1);
|
|
905
|
+
if (unused.length === binds.length) {
|
|
906
|
+
reasons.push(`resolves the principal into ${unused.map((u) => `\`${u}\``).join(", ")} and never reads ${unused.length > 1 ? "any of them" : "it"} again — the verified principal is computed and discarded`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (CONSTANT_ALLOWANCE.test(codeText)) {
|
|
911
|
+
reasons.push('grants access on a HARDCODED CONSTANT — an `if (… === "<literal>") return true` allow decision that no resolved principal takes part in. A constant is not an authorisation');
|
|
912
|
+
}
|
|
913
|
+
// an absent-identity branch that SERVES DATA instead of refusing. The guard must
|
|
914
|
+
// test the binding ITSELF (`if (!scope)`), never a property of it
|
|
915
|
+
// (`if (!scope.isMaster)` is an authority test on a RESOLVED principal).
|
|
916
|
+
for (const b of binds) {
|
|
917
|
+
const g = absentGuard(codeText, b);
|
|
918
|
+
if (!g)
|
|
919
|
+
continue;
|
|
920
|
+
const region = g.branch;
|
|
921
|
+
const regionServes = DATA_ACCESS.test(region) || referenced(region, dataFnCalls).length > 0;
|
|
922
|
+
// a `return` in the branch IS a refusal shape (a typed empty / unauthorized
|
|
923
|
+
// result), exactly as a `throw` is — R-50 governs WHICH of the two a client can
|
|
924
|
+
// render; CR-1 only asks that the absent-identity path does not reach the data.
|
|
925
|
+
if (regionServes && !THROWS.test(region) && !/\breturn\b/.test(region)) {
|
|
926
|
+
reasons.push(`its absent-identity branch (\`if (!${b})\`) SERVES DATA and does not refuse — an unauthenticated caller reaches the data path`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (reasons.length > 0) {
|
|
930
|
+
return {
|
|
931
|
+
cls: "FAIL-OPEN",
|
|
932
|
+
why: `${reasons.join("; ")}. NO justification token clears FAIL-OPEN — a resolved principal that does not decide is the confirmed leak class, not a documented exception`,
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
cls: "RESOLVES",
|
|
937
|
+
why: `consults the verified principal (${[
|
|
938
|
+
GET_IDENTITY.test(codeText) ? "ctx.auth.getUserIdentity()" : "",
|
|
939
|
+
...gateCalls.map((h) => `${h}() [refusing gate]`),
|
|
940
|
+
...resolverCalls.map((h) => `${h}() [resolver]`),
|
|
941
|
+
]
|
|
942
|
+
.filter(Boolean)
|
|
943
|
+
.join(", ")}) and ${gateCalls.length > 0 || refusesInline ? "refuses on the absent-identity path" : "the resolved value decides what is served"}`,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
// ---------------------------------------------------------------------------
|
|
947
|
+
// the scan
|
|
948
|
+
// ---------------------------------------------------------------------------
|
|
949
|
+
/** up to 8 comment lines immediately above `i`, as text. */
|
|
950
|
+
function commentContext(lines, i) {
|
|
951
|
+
const out = [];
|
|
952
|
+
for (let j = i - 1; j >= 0 && i - j <= 8; j--) {
|
|
953
|
+
if (!lines[j].comment && lines[j].raw.trim() !== "")
|
|
954
|
+
break;
|
|
955
|
+
out.unshift(lines[j].raw);
|
|
956
|
+
}
|
|
957
|
+
return out.join("\n");
|
|
958
|
+
}
|
|
959
|
+
/** The exposed surface for a tree, with module rels in the TREE's rel space: a
|
|
960
|
+
* tree rooted at the repository (src/target.ts) carries `convex/` on every convex
|
|
961
|
+
* rel, while api.d.ts names modules relative to the convex dir. */
|
|
962
|
+
export function surfaceForTree(tree) {
|
|
963
|
+
const cr = convexRootOf(tree);
|
|
964
|
+
const s = deriveApiSurface(cr.dir);
|
|
965
|
+
if (cr.prefix === "")
|
|
966
|
+
return s;
|
|
967
|
+
return {
|
|
968
|
+
...s,
|
|
969
|
+
modules: new Map([...s.modules].map(([m, rel]) => [m, `${cr.prefix}${rel}`])),
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
export function scanCallerResolution(tree, surface = surfaceForTree(tree)) {
|
|
973
|
+
// CR-1's population is the CONVEX surface only: a repo-rooted tree also carries
|
|
974
|
+
// mcp-server/src, whose files api.d.ts never exposes and must not be read as drift.
|
|
975
|
+
const { dir: convexDir, prefix } = convexRootOf(tree);
|
|
976
|
+
const inConvex = (f) => f.rel.startsWith(prefix);
|
|
977
|
+
const files = tree.files.filter((f) => isJudgeable(f) && inConvex(f));
|
|
978
|
+
const byRel = new Map(files.map((f) => [f.rel, f]));
|
|
979
|
+
const builders = discoverBuilders(files);
|
|
980
|
+
const model = deriveIdentityModel(files);
|
|
981
|
+
const scan = {
|
|
982
|
+
exposedModules: surface.modules,
|
|
983
|
+
populationSource: surface.unreadable
|
|
984
|
+
? `DEGRADED — ${surface.unreadable}`
|
|
985
|
+
: `${convexDir}/_generated/api.d.ts (\`declare const fullApi: ApiFromModules<{…}>\`, ${surface.modules.size} exposed module(s))`,
|
|
986
|
+
findings: [],
|
|
987
|
+
excluded: [],
|
|
988
|
+
selfDefects: [],
|
|
989
|
+
exposedCount: 0,
|
|
990
|
+
classifiedCount: 0,
|
|
991
|
+
};
|
|
992
|
+
// An UNREADABLE api.d.ts is only a defect when there is a surface to miss. On a
|
|
993
|
+
// tree with ZERO public registrations the absent generated file is CONSISTENT with
|
|
994
|
+
// "nothing is exposed", and the honest report is NO APPLICABLE SITE — the
|
|
995
|
+
// empty-tree control (a tree whose only file declares `export const nothing = 1;`)
|
|
996
|
+
// must yield that, never a count and never a self-defect. On a tree that DOES
|
|
997
|
+
// register public functions, the same absence means the exposed surface cannot be
|
|
998
|
+
// derived, which is a refusal — never a silent pass.
|
|
999
|
+
const publicRegistrationsAnywhere = files.some((f) => f.lines.some((l) => {
|
|
1000
|
+
if (!isCode(l))
|
|
1001
|
+
return false;
|
|
1002
|
+
const m = REGISTRATION.exec(l.raw);
|
|
1003
|
+
return m !== null && builders.publicBuilders.has(m[2]);
|
|
1004
|
+
}));
|
|
1005
|
+
if (surface.unreadable && publicRegistrationsAnywhere) {
|
|
1006
|
+
scan.selfDefects.push(surface.unreadable);
|
|
1007
|
+
}
|
|
1008
|
+
if (surface.unreadable && !publicRegistrationsAnywhere) {
|
|
1009
|
+
scan.populationSource = `${convexDir}/_generated/api.d.ts is absent AND the tree registers no public query/mutation/action — nothing is exposed, so there is no applicable site (consistent, not a defect)`;
|
|
1010
|
+
}
|
|
1011
|
+
for (const u of builders.unresolvable) {
|
|
1012
|
+
scan.selfDefects.push(`${u.rel}:${u.n} — builder \`${u.name}\` = ${u.base === "<none>" ? "custom*(…)" : `custom*(${u.base}, …)`}: its base resolves to neither a public nor an internal Convex base, so whether registrations built from it are EXPOSED cannot be established. CR-1 refuses rather than certify a surface it cannot see`);
|
|
1013
|
+
}
|
|
1014
|
+
// The population is the exposed surface itself: only modules api.d.ts lists.
|
|
1015
|
+
for (const [mod, rel] of surface.modules) {
|
|
1016
|
+
const f = byRel.get(rel);
|
|
1017
|
+
if (f === undefined) {
|
|
1018
|
+
scan.selfDefects.push(`api.d.ts exposes module \`${mod}\` -> ${rel}, which is not in the loaded tree — the exposed surface and the scanned tree DISAGREE (CR-1's own population bug, never a rounding)`);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
const lines = f.lines;
|
|
1022
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1023
|
+
if (!isCode(lines[i]))
|
|
1024
|
+
continue;
|
|
1025
|
+
const m = REGISTRATION.exec(lines[i].raw);
|
|
1026
|
+
if (!m)
|
|
1027
|
+
continue;
|
|
1028
|
+
const [, name, builder] = m;
|
|
1029
|
+
const isPublic = builders.publicBuilders.has(builder);
|
|
1030
|
+
const isNonPublic = builders.nonPublicBuilders.has(builder);
|
|
1031
|
+
if (!isPublic && !isNonPublic)
|
|
1032
|
+
continue; // not a registration at all
|
|
1033
|
+
const reg = {
|
|
1034
|
+
module: mod,
|
|
1035
|
+
name,
|
|
1036
|
+
api: `${mod}:${name}`,
|
|
1037
|
+
rel: f.rel,
|
|
1038
|
+
n: lines[i].n,
|
|
1039
|
+
builder,
|
|
1040
|
+
};
|
|
1041
|
+
const blk = blockAt(lines, i);
|
|
1042
|
+
i += blk.lines.length - 1;
|
|
1043
|
+
if (isNonPublic) {
|
|
1044
|
+
// The exclusion is PROVEN PER SITE: the builder token observed at this
|
|
1045
|
+
// very line is what excludes it, never the shape of its name.
|
|
1046
|
+
scan.excluded.push(reg);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
scan.exposedCount += 1;
|
|
1050
|
+
if (blk.unterminated) {
|
|
1051
|
+
scan.findings.push({
|
|
1052
|
+
...reg,
|
|
1053
|
+
cls: "COULD-NOT-JUDGE",
|
|
1054
|
+
why: "its block could not be delimited (unbalanced braces/parens) — whether it resolves its caller is not readable; never a silent green",
|
|
1055
|
+
});
|
|
1056
|
+
continue;
|
|
1057
|
+
}
|
|
1058
|
+
const codeText = blk.lines
|
|
1059
|
+
.filter(isCode)
|
|
1060
|
+
.map((l) => l.raw)
|
|
1061
|
+
.join("\n");
|
|
1062
|
+
const fullText = blk.lines.map((l) => l.raw).join("\n");
|
|
1063
|
+
const c = classify(codeText, fullText, commentContext(lines, blk.lines[0].n - 1), model, f.rel);
|
|
1064
|
+
scan.findings.push({ ...reg, ...c });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// POPULATION DRIFT, both directions: a public registration in a judgeable file
|
|
1068
|
+
// that api.d.ts does NOT expose is either a stale api.d.ts or a CR-1 parse bug.
|
|
1069
|
+
// Either way it is named, never dropped.
|
|
1070
|
+
const exposedRels = new Set(surface.modules.values());
|
|
1071
|
+
for (const f of files) {
|
|
1072
|
+
if (exposedRels.has(f.rel))
|
|
1073
|
+
continue;
|
|
1074
|
+
for (const l of f.lines) {
|
|
1075
|
+
if (!isCode(l))
|
|
1076
|
+
continue;
|
|
1077
|
+
const m = REGISTRATION.exec(l.raw);
|
|
1078
|
+
if (!m)
|
|
1079
|
+
continue;
|
|
1080
|
+
if (!builders.publicBuilders.has(m[2]))
|
|
1081
|
+
continue;
|
|
1082
|
+
scan.selfDefects.push(`${f.rel}:${l.n} — public registration \`${m[1]}\` (builder \`${m[2]}\`) lives in a file the target's own api.d.ts does not expose. The exposed surface and the tree DISAGREE: either api.d.ts is stale or CR-1's parse is incomplete. Named, never rounded`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
scan.classifiedCount = scan.findings.length;
|
|
1086
|
+
if (scan.classifiedCount !== scan.exposedCount) {
|
|
1087
|
+
scan.selfDefects.push(`CR-1 OWN BUG: classified ${scan.classifiedCount} != exposed ${scan.exposedCount}. The count classified must EQUAL the count exposed`);
|
|
1088
|
+
}
|
|
1089
|
+
return scan;
|
|
1090
|
+
}
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
// verdict + rendering — EVERY member of EVERY class is named
|
|
1093
|
+
// ---------------------------------------------------------------------------
|
|
1094
|
+
export const CR1_ID = "CR-1 a public registration resolves its caller before serving or writing data";
|
|
1095
|
+
export function byClass(scan, cls) {
|
|
1096
|
+
return scan.findings.filter((f) => f.cls === cls);
|
|
1097
|
+
}
|
|
1098
|
+
/** the findings that are VIOLATIONS: fail-open, plus unjustified no-identity/no-data. */
|
|
1099
|
+
export function violations(scan) {
|
|
1100
|
+
return scan.findings.filter((f) => f.cls === "FAIL-OPEN" ||
|
|
1101
|
+
((f.cls === "NO-IDENTITY" || f.cls === "NO-DATA") && !f.justified));
|
|
1102
|
+
}
|
|
1103
|
+
export function abstentions(scan) {
|
|
1104
|
+
return scan.findings.filter((f) => f.cls === "COULD-NOT-JUDGE");
|
|
1105
|
+
}
|
|
1106
|
+
export function callerResolutionResult(scan) {
|
|
1107
|
+
const v = violations(scan);
|
|
1108
|
+
const a = abstentions(scan);
|
|
1109
|
+
const counts = `[exposed=${scan.exposedCount} classified=${scan.classifiedCount} RESOLVES=${byClass(scan, "RESOLVES").length} FAIL-OPEN=${byClass(scan, "FAIL-OPEN").length} NO-IDENTITY=${byClass(scan, "NO-IDENTITY").length} NO-DATA=${byClass(scan, "NO-DATA").length} could-not-judge=${a.length} internal-excluded=${scan.excluded.length}]`;
|
|
1110
|
+
if (scan.exposedCount === 0 && scan.selfDefects.length === 0) {
|
|
1111
|
+
// The EMPTY-TREE CONTROL. A tree that exposes nothing yields NO APPLICABLE
|
|
1112
|
+
// SITE — never a count. This is the control that exposed the frozen-CSV
|
|
1113
|
+
// defect in the oracle-scored rules, and CR-1 must pass it.
|
|
1114
|
+
const reason = "CR-1: no applicable site — the target exposes no public registration (no `declare const fullApi` member resolving to a file that registers a public query/mutation/action). NOT a clean bill: nothing was certified because there was nothing to certify.";
|
|
1115
|
+
return {
|
|
1116
|
+
id: CR1_ID,
|
|
1117
|
+
anchor: "caller-resolution:no-applicable-site",
|
|
1118
|
+
state: "could-not-judge",
|
|
1119
|
+
verdict: reason,
|
|
1120
|
+
reason,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (v.length > 0) {
|
|
1124
|
+
const named = v
|
|
1125
|
+
.map((f) => `${f.api} @ ${f.rel}:${f.n} [${f.cls}] — ${f.why}`)
|
|
1126
|
+
.join("\n ");
|
|
1127
|
+
return {
|
|
1128
|
+
id: CR1_ID,
|
|
1129
|
+
anchor: `${v[0].rel}:${v[0].n}`,
|
|
1130
|
+
state: "non-conforming",
|
|
1131
|
+
verdict: `CR-1: ${v.length} public registration(s) do NOT resolve their caller before serving or writing data. ${counts}\n ${named}${scan.selfDefects.length
|
|
1132
|
+
? `\n CR-1 SELF-DEFECTS (the instrument's own, never a tree finding): ${scan.selfDefects.join(" | ")}`
|
|
1133
|
+
: ""}`,
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
if (a.length > 0 || scan.selfDefects.length > 0) {
|
|
1137
|
+
const reason = `CR-1: 0 violations, but ${a.length} registration(s) could not be judged and ${scan.selfDefects.length} instrument self-defect(s) stand. ${counts} ${[
|
|
1138
|
+
...a.map((f) => `${f.api} @ ${f.rel}:${f.n} — ${f.why}`),
|
|
1139
|
+
...scan.selfDefects,
|
|
1140
|
+
].join(" | ")}. This is NOT a clean bill.`;
|
|
1141
|
+
return {
|
|
1142
|
+
id: CR1_ID,
|
|
1143
|
+
anchor: a.length
|
|
1144
|
+
? `${a[0].rel}:${a[0].n}`
|
|
1145
|
+
: "caller-resolution:self-defect",
|
|
1146
|
+
state: "could-not-judge",
|
|
1147
|
+
verdict: reason,
|
|
1148
|
+
reason,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
return {
|
|
1152
|
+
id: CR1_ID,
|
|
1153
|
+
anchor: `${scan.findings[0].rel}:${scan.findings[0].n}`,
|
|
1154
|
+
state: "present",
|
|
1155
|
+
verdict: `CR-1: all ${scan.exposedCount} exposed public registration(s) resolve their caller (or carry a written justification at the registration); 0 violations. ${counts}`,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
/** The rendered section. EVERY member of EVERY class is named — the truncation that
|
|
1159
|
+
* let R-50 classify thirteen of the fourteen and show a reader none of them is the
|
|
1160
|
+
* defect this renderer refuses to repeat. */
|
|
1161
|
+
export function callerResolutionSection(scan) {
|
|
1162
|
+
const out = [];
|
|
1163
|
+
out.push("CALLER RESOLUTION (CR-1) — DOCTOR-OWNED CRITICAL control, NOT one of the numbered rules in the pinned standard (see src/caller-resolution.ts: numbering it upstream is a separate change to the standard document). It drives the exit code.");
|
|
1164
|
+
out.push(` population source: ${scan.populationSource}`);
|
|
1165
|
+
out.push(` population cross-check: exposed=${scan.exposedCount} classified=${scan.classifiedCount} ${scan.exposedCount === scan.classifiedCount
|
|
1166
|
+
? "EQUAL (ok)"
|
|
1167
|
+
: "DISCREPANT — CR-1's own bug"}; internal/httpAction excluded=${scan.excluded.length}, each proven by the builder token read at its own site`);
|
|
1168
|
+
const order = [
|
|
1169
|
+
"FAIL-OPEN",
|
|
1170
|
+
"NO-IDENTITY",
|
|
1171
|
+
"NO-DATA",
|
|
1172
|
+
"RESOLVES",
|
|
1173
|
+
"COULD-NOT-JUDGE",
|
|
1174
|
+
];
|
|
1175
|
+
for (const cls of order) {
|
|
1176
|
+
const members = byClass(scan, cls);
|
|
1177
|
+
out.push(` ${cls} (${members.length}):`);
|
|
1178
|
+
for (const f of members) {
|
|
1179
|
+
out.push(` ${f.justified ? "JUSTIFIED " : ""}${f.api} @ ${f.rel}:${f.n} (${f.builder}) — ${f.why}`);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if (scan.selfDefects.length > 0) {
|
|
1183
|
+
out.push(` CR-1 SELF-DEFECTS (${scan.selfDefects.length}):`);
|
|
1184
|
+
for (const d of scan.selfDefects)
|
|
1185
|
+
out.push(` ${d}`);
|
|
1186
|
+
}
|
|
1187
|
+
const v = violations(scan);
|
|
1188
|
+
out.push(` CR-1 VERDICT: ${v.length} violation(s) — ${v.length > 0 ? "CRITICAL, the gate refuses" : "none"}; ${abstentions(scan).length} could-not-judge; ${scan.selfDefects.length} self-defect(s).`);
|
|
1189
|
+
return out.join("\n");
|
|
1190
|
+
}
|
|
1191
|
+
/** CR-1 as an engine Detector, for tests and for a future registry slot. */
|
|
1192
|
+
export const CR1 = {
|
|
1193
|
+
id: CR1_ID,
|
|
1194
|
+
run: (tree) => callerResolutionResult(scanCallerResolution(tree)),
|
|
1195
|
+
};
|