@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
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// abstains (never a silent pass). Every zero-count branch names the real site it
|
|
23
23
|
// matched so the branch is provably reachable.
|
|
24
24
|
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
-
import { join } from "node:path";
|
|
25
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
26
26
|
/** The standard all six are pinned against (short hash a9d7877b). */
|
|
27
27
|
export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
|
|
28
28
|
function emptyScan() {
|
|
@@ -251,6 +251,181 @@ export function scanR33(files) {
|
|
|
251
251
|
const INBOUND_JWT = /claims\.(aud|iss)\b|\.iss\b.*mismatch|verify\w*jwt/i;
|
|
252
252
|
const AUD_ASSERT = /VP_CLERK_EXPECTED_AUD|aud claim mismatch|audMatches|expectedAud/i;
|
|
253
253
|
const OUTBOUND_PASSTHROUGH = /["'`]?authorization["'`]?\s*[:=][^,\n]*(headers\.get\(\s*["'`]authorization|req(uest)?\.headers|callerToken|incomingToken|passthrough)/i;
|
|
254
|
+
// An `audience` KEY in the options object of a JWT-verify call is an audience
|
|
255
|
+
// assertion, judged on its VALUE, never on a name list (PR #44 follow-up: VP
|
|
256
|
+
// 30e821b mcp-server/src/auth.ts:882-885 `jwtVerify(token, jwks, { issuer,
|
|
257
|
+
// audience: CLERK_JWT_AUDIENCE })` with `CLERK_JWT_AUDIENCE =
|
|
258
|
+
// process.env.CLERK_JWT_AUDIENCE ?? "convex"` was reported as unasserted).
|
|
259
|
+
// counts: a non-empty string literal; a non-empty array literal; an identifier
|
|
260
|
+
// bound by a module-level `const` to either of those, or to
|
|
261
|
+
// `process.env.X ?? "<non-empty>"` / `|| "<non-empty>"`
|
|
262
|
+
// never: `undefined`, `""`, a const whose fallback is empty, a bare env read
|
|
263
|
+
// with no fallback (undefined at runtime disables the check in jose)
|
|
264
|
+
const VERIFY_CALL = /\b(?:jwtVerify|jwt\.verify|verifyToken|verify)\s*\(/;
|
|
265
|
+
const NON_EMPTY_LITERAL = /^(?:"[^"]+"|'[^']+'|`[^`$]+`|\[\s*["'`][^\]]*\])$/;
|
|
266
|
+
const ENV_WITH_FALLBACK = /^process\.env\.[A-Za-z0-9_]+\s*(?:\?\?|\|\|)\s*("[^"]+"|'[^']+'|`[^`$]+`)$/;
|
|
267
|
+
function audienceValueCounts(value, f) {
|
|
268
|
+
const v = value.trim();
|
|
269
|
+
if (NON_EMPTY_LITERAL.test(v))
|
|
270
|
+
return true;
|
|
271
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(v) || v === "undefined")
|
|
272
|
+
return false;
|
|
273
|
+
const decl = new RegExp(`^(?:export\\s+)?const\\s+${v}\\s*(?::[^=]+)?=\\s*(.+?);?\\s*$`);
|
|
274
|
+
for (const l of f.lines) {
|
|
275
|
+
if (!code(l))
|
|
276
|
+
continue;
|
|
277
|
+
const m = decl.exec(l.raw);
|
|
278
|
+
if (m) {
|
|
279
|
+
const init = m[1].trim();
|
|
280
|
+
return NON_EMPTY_LITERAL.test(init) || ENV_WITH_FALLBACK.test(init);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The source with comments blanked to spaces and string/template CONTENTS blanked
|
|
287
|
+
* to `_` (quotes kept, newlines kept), so offsets line up with the original. All
|
|
288
|
+
* structural matching runs on this mask; values are sliced from the original.
|
|
289
|
+
*/
|
|
290
|
+
function maskSource(src) {
|
|
291
|
+
const out = src.split("");
|
|
292
|
+
let i = 0;
|
|
293
|
+
while (i < src.length) {
|
|
294
|
+
const c = src[i];
|
|
295
|
+
const n = src[i + 1];
|
|
296
|
+
if (c === "/" && n === "/") {
|
|
297
|
+
while (i < src.length && src[i] !== "\n")
|
|
298
|
+
out[i++] = " ";
|
|
299
|
+
}
|
|
300
|
+
else if (c === "/" && n === "*") {
|
|
301
|
+
out[i++] = " ";
|
|
302
|
+
out[i++] = " ";
|
|
303
|
+
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
|
|
304
|
+
if (src[i] !== "\n")
|
|
305
|
+
out[i] = " ";
|
|
306
|
+
i++;
|
|
307
|
+
}
|
|
308
|
+
if (i < src.length) {
|
|
309
|
+
out[i++] = " ";
|
|
310
|
+
out[i++] = " ";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else if (c === '"' || c === "'" || c === "`") {
|
|
314
|
+
i++;
|
|
315
|
+
while (i < src.length && src[i] !== c) {
|
|
316
|
+
if (src[i] === "\\") {
|
|
317
|
+
out[i++] = "_";
|
|
318
|
+
if (i < src.length && src[i] !== "\n")
|
|
319
|
+
out[i] = "_";
|
|
320
|
+
i++;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (src[i] !== "\n")
|
|
324
|
+
out[i] = "_";
|
|
325
|
+
i++;
|
|
326
|
+
}
|
|
327
|
+
i++;
|
|
328
|
+
}
|
|
329
|
+
else
|
|
330
|
+
i++;
|
|
331
|
+
}
|
|
332
|
+
return out.join("");
|
|
333
|
+
}
|
|
334
|
+
/** Index of the bracket closing the one opened at `open` in `masked`, or -1. */
|
|
335
|
+
function closeOf(masked, open) {
|
|
336
|
+
let depth = 0;
|
|
337
|
+
for (let i = open; i < masked.length; i++) {
|
|
338
|
+
const c = masked[i];
|
|
339
|
+
if (c === "(" || c === "{" || c === "[")
|
|
340
|
+
depth += 1;
|
|
341
|
+
else if (c === ")" || c === "}" || c === "]") {
|
|
342
|
+
depth -= 1;
|
|
343
|
+
if (depth === 0)
|
|
344
|
+
return i;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return -1;
|
|
348
|
+
}
|
|
349
|
+
/** Split [from, to) of `masked` on commas at nesting depth 0 → [start, end) spans. */
|
|
350
|
+
function topLevelParts(masked, from, to) {
|
|
351
|
+
const parts = [];
|
|
352
|
+
let depth = 0;
|
|
353
|
+
let start = from;
|
|
354
|
+
for (let i = from; i < to; i++) {
|
|
355
|
+
const c = masked[i];
|
|
356
|
+
if (c === "(" || c === "{" || c === "[")
|
|
357
|
+
depth += 1;
|
|
358
|
+
else if (c === ")" || c === "}" || c === "]")
|
|
359
|
+
depth -= 1;
|
|
360
|
+
else if (c === "," && depth === 0) {
|
|
361
|
+
parts.push([start, i]);
|
|
362
|
+
start = i + 1;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
parts.push([start, to]);
|
|
366
|
+
return parts;
|
|
367
|
+
}
|
|
368
|
+
/** The `audience` property VALUE of the object literal whose `{` is at `open`
|
|
369
|
+
* (own properties only, never a nested object's), or undefined. A shorthand
|
|
370
|
+
* `{ audience }` yields the identifier `audience`. */
|
|
371
|
+
function audienceProp(src, masked, open) {
|
|
372
|
+
const close = closeOf(masked, open);
|
|
373
|
+
if (close === -1)
|
|
374
|
+
return undefined;
|
|
375
|
+
for (const [st, en] of topLevelParts(masked, open + 1, close)) {
|
|
376
|
+
const m = /^\s*audience\s*(?::|$)/.exec(masked.slice(st, en));
|
|
377
|
+
if (!m)
|
|
378
|
+
continue;
|
|
379
|
+
const colon = masked.indexOf(":", st);
|
|
380
|
+
const value = colon !== -1 && colon < en ? src.slice(colon + 1, en) : "audience";
|
|
381
|
+
return { value: value.trim(), at: st + m[0].indexOf("audience") };
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Line of an `audience` option that belongs to a JWT-verify call's OWN argument
|
|
387
|
+
* list and whose value counts, or 0. Bound to the call: the scan runs from the
|
|
388
|
+
* call's `(` to its balanced `)` (strings and comments masked), and the key is
|
|
389
|
+
* read only (a) as an own property of an object-literal argument, or (b) through
|
|
390
|
+
* an identifier argument bound by a module-level `const NAME = { … }` holding the
|
|
391
|
+
* key. An `audience` in a nested call, an unrelated object below the call, a
|
|
392
|
+
* different call on the same line, a string or a comment is never read
|
|
393
|
+
* (Eta REVISE #44, mutant A2).
|
|
394
|
+
*/
|
|
395
|
+
function audienceOptionLine(f) {
|
|
396
|
+
const src = f.lines.map((l) => l.raw).join("\n");
|
|
397
|
+
const masked = maskSource(src);
|
|
398
|
+
const lineAt = (idx) => src.slice(0, idx).split("\n").length;
|
|
399
|
+
const call = new RegExp(VERIFY_CALL.source, "g");
|
|
400
|
+
let m;
|
|
401
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
|
|
402
|
+
while ((m = call.exec(masked)) !== null) {
|
|
403
|
+
const open = m.index + m[0].length - 1;
|
|
404
|
+
const close = closeOf(masked, open);
|
|
405
|
+
if (close === -1)
|
|
406
|
+
continue;
|
|
407
|
+
for (const [st, en] of topLevelParts(masked, open + 1, close)) {
|
|
408
|
+
const arg = masked.slice(st, en);
|
|
409
|
+
const lead = st + (arg.length - arg.trimStart().length);
|
|
410
|
+
let prop;
|
|
411
|
+
if (masked[lead] === "{") {
|
|
412
|
+
prop = audienceProp(src, masked, lead);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
const id = /^([A-Za-z_$][\w$]*)\s*$/.exec(arg.trim());
|
|
416
|
+
if (!id)
|
|
417
|
+
continue;
|
|
418
|
+
const decl = new RegExp(`^(?:export\\s+)?const\\s+${id[1].replace(/\$/g, "\\$")}\\s*(?::[^=\\n]+)?=\\s*\\{`, "m");
|
|
419
|
+
const d = decl.exec(masked);
|
|
420
|
+
if (d)
|
|
421
|
+
prop = audienceProp(src, masked, d.index + d[0].length - 1);
|
|
422
|
+
}
|
|
423
|
+
if (prop && audienceValueCounts(prop.value, f))
|
|
424
|
+
return lineAt(prop.at);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
254
429
|
export function scanR34(files) {
|
|
255
430
|
const scan = emptyScan();
|
|
256
431
|
for (const f of files) {
|
|
@@ -269,9 +444,11 @@ export function scanR34(files) {
|
|
|
269
444
|
scan.violate.push({ rel: f.rel, n: l.n, note: "outbound Authorization header forwards a caller-supplied token verbatim (token passthrough)" });
|
|
270
445
|
}
|
|
271
446
|
}
|
|
447
|
+
if (!audAssert)
|
|
448
|
+
audAssert = audienceOptionLine(f);
|
|
272
449
|
if (inboundLine) {
|
|
273
450
|
if (audAssert) {
|
|
274
|
-
scan.conform.push({ rel: f.rel, n: audAssert, note: "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD / aud claim mismatch)" });
|
|
451
|
+
scan.conform.push({ rel: f.rel, n: audAssert, note: "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD / aud claim mismatch / a verify call's audience option with a non-empty value)" });
|
|
275
452
|
}
|
|
276
453
|
else {
|
|
277
454
|
scan.violate.push({ rel: f.rel, n: inboundLine, note: "inbound JWT verified with no audience assertion — accepts a token minted for another party" });
|
|
@@ -328,16 +505,57 @@ export function scanR35(files) {
|
|
|
328
505
|
const LOCKFILES = ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
|
|
329
506
|
const WORKFLOW_DIR = ".github/workflows";
|
|
330
507
|
const PUBLISH_HINT = /npm\s+publish|release|publish/i;
|
|
508
|
+
/**
|
|
509
|
+
* The directories R-36 searches, nearest first: the scanned root, then each
|
|
510
|
+
* ancestor up to and INCLUDING the git root (the first ancestor holding `.git`).
|
|
511
|
+
*
|
|
512
|
+
* The misread this closes: the documented invocation points the doctor at
|
|
513
|
+
* `<repo>/convex`, while the lockfile and `.github/workflows` are REPOSITORY
|
|
514
|
+
* artifacts living at the repo root. Reading only `tree.root` reported "no lockfile
|
|
515
|
+
* — dependencies are not pinned" on a repository with a committed `bun.lock`.
|
|
516
|
+
*
|
|
517
|
+
* The walk stops at the git root and never goes above it: a lockfile in some
|
|
518
|
+
* unrelated parent directory (a home directory, a monorepo host that is not this
|
|
519
|
+
* repository) does not pin this repository's dependencies. With no `.git` ancestor
|
|
520
|
+
* at all, only the scanned root itself is read (the pre-fix behaviour) — the walk
|
|
521
|
+
* is never widened on a guess.
|
|
522
|
+
*/
|
|
523
|
+
export function r36SearchDirs(root) {
|
|
524
|
+
const chain = [];
|
|
525
|
+
let dir = resolve(root);
|
|
526
|
+
for (;;) {
|
|
527
|
+
chain.push(dir);
|
|
528
|
+
if (existsSync(join(dir, ".git")))
|
|
529
|
+
return chain;
|
|
530
|
+
const up = dirname(dir);
|
|
531
|
+
if (up === dir)
|
|
532
|
+
return [resolve(root)];
|
|
533
|
+
dir = up;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
331
536
|
export function evalR36(root) {
|
|
332
537
|
const scan = emptyScan();
|
|
333
|
-
const
|
|
538
|
+
const dirs = r36SearchDirs(root);
|
|
539
|
+
const relTo = (dir, name) => {
|
|
540
|
+
const r = relative(resolve(root), join(dir, name));
|
|
541
|
+
return r === "" ? name : r;
|
|
542
|
+
};
|
|
543
|
+
let lock;
|
|
544
|
+
for (const dir of dirs) {
|
|
545
|
+
const name = LOCKFILES.find((n) => existsSync(join(dir, n)));
|
|
546
|
+
if (name) {
|
|
547
|
+
lock = { dir, name };
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
334
551
|
if (!lock) {
|
|
335
|
-
scan.violate.push({ rel: "(root)", n: 0, note:
|
|
552
|
+
scan.violate.push({ rel: "(root)", n: 0, note: `no lockfile present in the scanned root or any ancestor up to the git root (searched: ${dirs.join(", ")}) — dependencies are not pinned` });
|
|
336
553
|
}
|
|
337
554
|
else {
|
|
338
|
-
scan.conform.push({ rel: lock, n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
|
|
555
|
+
scan.conform.push({ rel: relTo(lock.dir, lock.name), n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
|
|
339
556
|
}
|
|
340
|
-
const
|
|
557
|
+
const wfBase = dirs.find((d) => existsSync(join(d, WORKFLOW_DIR))) ?? resolve(root);
|
|
558
|
+
const wfDir = join(wfBase, WORKFLOW_DIR);
|
|
341
559
|
let publishFile = "";
|
|
342
560
|
let provenance = false;
|
|
343
561
|
let sawPublish = false;
|
|
@@ -348,7 +566,7 @@ export function evalR36(root) {
|
|
|
348
566
|
const p = join(wfDir, name);
|
|
349
567
|
if (!existsSync(p))
|
|
350
568
|
continue;
|
|
351
|
-
publishFile = `${WORKFLOW_DIR}/${name}
|
|
569
|
+
publishFile = relTo(wfBase, `${WORKFLOW_DIR}/${name}`);
|
|
352
570
|
const body = readFileSync(p, "utf8");
|
|
353
571
|
if (PUBLISH_HINT.test(body))
|
|
354
572
|
sawPublish = true;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { Detector } from "../../engine-types.js";
|
|
2
|
-
import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
2
|
+
import { type Scan, type Site, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
3
|
+
/** R-51's scan plus the public writes found OUTSIDE its scope (no no-org throw,
|
|
4
|
+
* no typed-unauthorised return, no marker). The list never reaches a verdict. */
|
|
5
|
+
export interface R51Scan {
|
|
6
|
+
scan: Scan;
|
|
7
|
+
outOfScope: Site[];
|
|
8
|
+
}
|
|
3
9
|
export declare function scanR51(files: SrcFile[]): Scan;
|
|
10
|
+
export declare function scanR51Detailed(files: SrcFile[]): R51Scan;
|
|
4
11
|
export declare const R51: Detector;
|
|
5
12
|
export declare const SOURCE_WRITE_ISOLATION_DETECTORS: SourceDetectorEntry[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SCOPE_REFUSED_RETURN } from "./isolation.js";
|
|
2
|
+
import { code, decide, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, } from "./_scan.js";
|
|
2
3
|
const CUSTOM_MUTATION_OPEN = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customMutation\s*\(/;
|
|
3
4
|
const CUSTOM_QUERY_DEF = /(?:export\s+)?const\s+([A-Za-z0-9_]+)\s*=\s*customQuery\s*\(/;
|
|
4
5
|
const FIRST_IDENT = /([A-Za-z_][A-Za-z0-9_]*)/;
|
|
@@ -6,7 +7,26 @@ const BASE_PUBLIC_MUTATION = "mutation";
|
|
|
6
7
|
const BASE_INTERNAL_MUTATION = "internalMutation";
|
|
7
8
|
// A no-org throw a write inherits: an org-keyed ConvexError, or a write/org gate
|
|
8
9
|
// helper that throws when no org is resolved.
|
|
9
|
-
|
|
10
|
+
//
|
|
11
|
+
// `RBAC_DENIED` is NOT in this vocabulary, and that is the standard's own line, not
|
|
12
|
+
// a convenience. The pinned standard (backend-standard-4bcd605.md :652) says "a
|
|
13
|
+
// mutation that refuses an unauthorised write via `requireOrgAdmin` throwing
|
|
14
|
+
// `RBAC_DENIED` is **correct**", and R-51's own text (:655-656) keeps R-16's
|
|
15
|
+
// `{code,field/scope,message}` refusal of a chosen unauthorised action correct and
|
|
16
|
+
// names R-51's throw as NO_ORG (an org-keyed ConvexError or
|
|
17
|
+
// requireWriteAccess/requireWorkspaceId/getEffectiveWorkspaceId). Counting every
|
|
18
|
+
// RBAC_DENIED as a no-org throw contradicted both lines (isolation.ts header
|
|
19
|
+
// :16-19 said CORRECT while this regex said RED). An RBAC_DENIED raised BECAUSE the
|
|
20
|
+
// org is missing is still caught — by ORG_KEYED_GUARDED_THROW below, which reads
|
|
21
|
+
// the CONDITION, not the code word.
|
|
22
|
+
const ORG_THROW = /throw\b[^;]*\b(?:NO_ORG|ORG_REQUIRED|no org|missing org|organization is required|not in (?:an )?org)/i;
|
|
23
|
+
// A throw (any code) whose governing `if` is an ORG-keyed absence test — the
|
|
24
|
+
// no-org path itself, whatever the error string says:
|
|
25
|
+
// if (!scope.isMaster && scope.orgSlug === null) { throw new ConvexError("RBAC_DENIED…") }
|
|
26
|
+
// The condition must test an org/workspace field for absence; an identity-absence
|
|
27
|
+
// test (`if (!identity)`) is a caller with no credential at all, not the signed-in
|
|
28
|
+
// pre-org shell R-51 protects, and is R-16's refusal.
|
|
29
|
+
const ORG_KEYED_GUARDED_THROW = /if\s*\((?:[^()]|\([^()]*\))*?(?:!\s*(?:[A-Za-z_$][\w$]*\.)?(?:org|orgId|orgSlug|workspaceId|workspace)\b|\b(?:org|orgId|orgSlug|workspaceId)\s*(?:===|==)\s*(?:null|undefined))(?:[^()]|\([^()]*\))*\)\s*\{?\s*throw\b/i;
|
|
10
30
|
const WRITE_GATE_HELPER = /\b(?:requireWriteAccess|requireWorkspaceId|requireOrgId|getEffectiveWorkspaceId|getOrgIdOrThrow|ensureCurrentOrg)\s*\(/;
|
|
11
31
|
// a gate helper generally (any require*/ensure*/assert* access check in a wrapper).
|
|
12
32
|
const GATE_HELPER = /\b(?:require|ensure|assert)[A-Z][A-Za-z0-9_]*\s*\(/;
|
|
@@ -59,12 +79,17 @@ function namedValueDomination(before) {
|
|
|
59
79
|
function throwDomination(blockText) {
|
|
60
80
|
const t1 = ORG_THROW.exec(blockText);
|
|
61
81
|
const t2 = WRITE_GATE_HELPER.exec(blockText);
|
|
62
|
-
const
|
|
82
|
+
const t3 = ORG_KEYED_GUARDED_THROW.exec(blockText);
|
|
83
|
+
const throwIdx = Math.min(t1 ? t1.index : Number.POSITIVE_INFINITY, t2 ? t2.index : Number.POSITIVE_INFINITY, t3 ? t3.index : Number.POSITIVE_INFINITY);
|
|
63
84
|
if (!Number.isFinite(throwIdx))
|
|
64
85
|
return "none";
|
|
65
86
|
const before = blockText.slice(0, throwIdx);
|
|
66
87
|
if (namedValueDomination(before))
|
|
67
88
|
return "dominated";
|
|
89
|
+
// Form C — the VP pre-org `if (scope.refused) return …` typed value (shared
|
|
90
|
+
// definition with R-50, isolation.ts SCOPE_REFUSED_RETURN).
|
|
91
|
+
if (SCOPE_REFUSED_RETURN.test(before))
|
|
92
|
+
return "dominated";
|
|
68
93
|
if (!TYPED_EMPTY_RETURN.test(before))
|
|
69
94
|
return "none";
|
|
70
95
|
return NO_ORG_GUARD.test(before) ? "dominated" : "ambiguous";
|
|
@@ -169,6 +194,9 @@ function mutationBlock(lines, i) {
|
|
|
169
194
|
return { lines: body, unterminated: seenOpen };
|
|
170
195
|
}
|
|
171
196
|
export function scanR51(files) {
|
|
197
|
+
return scanR51Detailed(files).scan;
|
|
198
|
+
}
|
|
199
|
+
export function scanR51Detailed(files) {
|
|
172
200
|
const scan = emptyScan();
|
|
173
201
|
const builders = discoverWriteBuilders(files);
|
|
174
202
|
// Public writes that matched a definition opener but exhibit NO org-throw, NO
|
|
@@ -242,19 +270,28 @@ export function scanR51(files) {
|
|
|
242
270
|
.map((l) => l.raw)
|
|
243
271
|
.join("\n");
|
|
244
272
|
const blockAll = block.lines.map((l) => l.raw).join("\n");
|
|
245
|
-
const throwsOnNoOrg = ORG_THROW.test(blockText) ||
|
|
273
|
+
const throwsOnNoOrg = ORG_THROW.test(blockText) ||
|
|
274
|
+
WRITE_GATE_HELPER.test(blockText) ||
|
|
275
|
+
ORG_KEYED_GUARDED_THROW.test(blockText);
|
|
246
276
|
const typedUnauth = TYPED_UNAUTH_RETURN.test(blockText);
|
|
247
277
|
const hasMarker = CONTRACT_MARKER.test(blockAll);
|
|
248
278
|
if (!throwsOnNoOrg && !typedUnauth && !hasMarker) {
|
|
249
|
-
// A
|
|
250
|
-
//
|
|
251
|
-
//
|
|
279
|
+
// A public write that neither throws on the no-org path, nor returns a
|
|
280
|
+
// typed unauthorised result, nor carries a marker is NOT an R-51
|
|
281
|
+
// subject. The pinned standard's red (backend-standard-4bcd605.md :656,
|
|
282
|
+
// clause b) is "a bare public mutation whose no-org path THROWS" with
|
|
283
|
+
// the two readable absences; a write with no no-org throw cannot
|
|
284
|
+
// crash a pre-org shell, which is the whole of R-51. Whether such a
|
|
285
|
+
// write resolves its caller at all is CR-1's control
|
|
286
|
+
// (src/caller-resolution.ts), which drives the exit code — the
|
|
287
|
+
// instrument still goes red on an unguarded write, under the control
|
|
288
|
+
// whose question it is. Counted only to word the empty abstain.
|
|
252
289
|
ungated.push({
|
|
253
290
|
rel: f.rel,
|
|
254
291
|
n: block.lines[0].n,
|
|
255
|
-
note: `${fn}: public mutation with no org
|
|
292
|
+
note: `${fn}: public mutation with no no-org throw, no typed-unauthorized return, no write-contract marker — not an R-51 subject (standard 4bcd605 :656 red requires a no-org throw); caller resolution is CR-1's control`,
|
|
256
293
|
});
|
|
257
|
-
continue; // not a
|
|
294
|
+
continue; // not a subject
|
|
258
295
|
}
|
|
259
296
|
if (typedUnauth) {
|
|
260
297
|
// The write returns a shape on the no-org path — a pre-org client can render
|
|
@@ -295,7 +332,7 @@ export function scanR51(files) {
|
|
|
295
332
|
scan.cnj.push({
|
|
296
333
|
rel: f.rel,
|
|
297
334
|
n: block.lines[0].n,
|
|
298
|
-
note: `${fn}: public mutation throws on the missing-org path AND carries a write-contract marker — the marker records a claim that a client cannot reach this write before an org exists, which convex/ cannot verify. R-51 refuses (a reviewer checks the marker's claim) — never a silent green`,
|
|
335
|
+
note: `${fn}: public mutation throws on the missing-org path AND carries a write-contract marker — the marker records a claim that a client cannot reach this write before an org exists, which convex/ cannot verify. R-51 refuses (a reviewer checks the marker's claim) — never a silent green. Why R-50 accepts its marker and R-51 does not: the pinned standard decides it per rule — R-50's green includes a query that "documents a client contract" (4bcd605 :653), while R-51 lists "a write … that carries a write-contract marker whose unreachability claim the detector cannot verify" under could-not-judge, never green (4bcd605 :656)`,
|
|
299
336
|
});
|
|
300
337
|
}
|
|
301
338
|
else {
|
|
@@ -313,27 +350,24 @@ export function scanR51(files) {
|
|
|
313
350
|
}
|
|
314
351
|
}
|
|
315
352
|
}
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
// could-not-judge sites so the verdict is CNJ-with-sites, not a misleading empty
|
|
321
|
-
// abstain. Gated on "scan otherwise empty" so a tree that already produced
|
|
322
|
-
// subjects (VP: 7 marker CNJ) is byte-for-byte unchanged — the bare-write
|
|
323
|
-
// disposition in a mixed tree is out of this fix's scope.
|
|
324
|
-
// A bare ungated public write (no throw / typed-unauth / marker) is NEVER a silent
|
|
325
|
-
// clean bill — whether the scan is otherwise empty (a fully-ungated public-write
|
|
326
|
-
// surface) OR mixed (bare writes alongside gated/marker subjects). Surface EVERY
|
|
327
|
-
// such write as could-not-judge naming its site: never conformed, never dropped.
|
|
328
|
-
// (Eta mixed-tree follow-up: the prior "scan otherwise empty" guard silently
|
|
329
|
-
// dropped bare ungated writes whenever any gated/marker subject also existed — a
|
|
330
|
-
// bare write hidden behind a sibling that happened to produce a verdict. Removed:
|
|
331
|
-
// the disposition is per-def, not per-tree.)
|
|
332
|
-
for (const u of ungated)
|
|
333
|
-
scan.cnj.push(u);
|
|
334
|
-
return scan;
|
|
353
|
+
// Unmarked public writes are NOT pushed anywhere (standard 4bcd605 :656 — the red
|
|
354
|
+
// requires a no-org throw). Until 0.1.16 they were pushed to `violate`; that
|
|
355
|
+
// obligation is CR-1's now (a write resolving no caller is a CR-1 violation).
|
|
356
|
+
return { scan, outOfScope: ungated };
|
|
335
357
|
}
|
|
336
|
-
|
|
358
|
+
const R51_ID = "R-51 a public mutation returns a typed unauthorized result on the no-org path, never an uncaught throw a pre-org client shell cannot render";
|
|
359
|
+
const R51_CHECK = "a public write either returns a typed unauthorized result / documents its client contract, or a human confirms a pre-org client cannot reach it — it never throws NO_ORG on a path an ordinary render can hit";
|
|
360
|
+
export const R51 = {
|
|
361
|
+
id: R51_ID,
|
|
362
|
+
run: (tree) => {
|
|
363
|
+
const { scan, outOfScope } = scanR51Detailed(tree.files);
|
|
364
|
+
const n = outOfScope.length;
|
|
365
|
+
const empty = n > 0
|
|
366
|
+
? `R-51: no R-51 subject in the scanned tree — ${n} public mutation(s) matched, none throws on the no-org path, returns a typed unauthorized result, or carries a write-contract marker (out of scope per standard 4bcd605 :656; caller resolution is CR-1's control). NOT a clean bill.`
|
|
367
|
+
: "R-51: no public mutation matched in the scanned tree — no write to certify. NOT a clean bill.";
|
|
368
|
+
return decide("R-51", R51_ID, R51_CHECK, scan, empty);
|
|
369
|
+
},
|
|
370
|
+
};
|
|
337
371
|
export const SOURCE_WRITE_ISOLATION_DETECTORS = [
|
|
338
372
|
{ detector: R51, ruleId: "R-51", standardPin: SOURCE_STANDARD_PIN },
|
|
339
373
|
];
|
|
@@ -23,6 +23,14 @@ export interface DocValidatorMatch {
|
|
|
23
23
|
/** 1-based line span of the enclosing export — where a projection reason lives. */
|
|
24
24
|
exportStartLine: number;
|
|
25
25
|
exportEndLine: number;
|
|
26
|
+
/** 1-based line spans of every SHARED CONSTANT the export's `returns:` resolved
|
|
27
|
+
* through to reach this object (`returns: externalOpenReturns` →
|
|
28
|
+
* `const externalOpenReturns = v.object({…})`). A `returns-projection:` reason
|
|
29
|
+
* written on that constant governs every export that uses it. */
|
|
30
|
+
constSpans: {
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
}[];
|
|
26
34
|
}
|
|
27
35
|
export declare function extractDocMatches(name: string, text: string): DocValidatorMatch[];
|
|
28
36
|
export declare function projectionReason(rawLines: string[], startLine: number, endLine: number): string | undefined;
|
|
@@ -160,17 +160,35 @@ export function extractDocMatches(name, text) {
|
|
|
160
160
|
const matches = [];
|
|
161
161
|
const lineOf = (pos) => sf.getLineAndCharacterOfPosition(pos).line + 1;
|
|
162
162
|
const constMap = new Map();
|
|
163
|
+
const constSpan = new Map();
|
|
163
164
|
function collectConsts(node) {
|
|
164
165
|
if (ts.isVariableStatement(node)) {
|
|
165
166
|
for (const decl of node.declarationList.declarations) {
|
|
166
167
|
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
167
168
|
constMap.set(decl.name.text, decl.initializer);
|
|
169
|
+
constSpan.set(decl.name.text, {
|
|
170
|
+
start: lineOf(node.getStart(sf)),
|
|
171
|
+
end: lineOf(node.getEnd()),
|
|
172
|
+
});
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
ts.forEachChild(node, collectConsts);
|
|
172
177
|
}
|
|
173
178
|
collectConsts(sf);
|
|
179
|
+
/** The span of every const an expression resolves THROUGH (the chain a
|
|
180
|
+
* `returns: X` takes to reach its validator). */
|
|
181
|
+
function constChain(expr) {
|
|
182
|
+
const out = [];
|
|
183
|
+
let cur = expr;
|
|
184
|
+
for (let d = 0; d <= 10 && ts.isIdentifier(cur) && constMap.has(cur.text); d++) {
|
|
185
|
+
const span = constSpan.get(cur.text);
|
|
186
|
+
if (span)
|
|
187
|
+
out.push(span);
|
|
188
|
+
cur = constMap.get(cur.text);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
174
192
|
function resolve(expr, depth = 0) {
|
|
175
193
|
if (depth > 10)
|
|
176
194
|
return expr;
|
|
@@ -179,10 +197,11 @@ export function extractDocMatches(name, text) {
|
|
|
179
197
|
}
|
|
180
198
|
return expr;
|
|
181
199
|
}
|
|
182
|
-
function walkForDocObjects(expr, out, exportName, alias, span, depth = 0) {
|
|
200
|
+
function walkForDocObjects(expr, out, exportName, alias, span, depth = 0, consts = []) {
|
|
183
201
|
if (depth > 20)
|
|
184
202
|
return;
|
|
185
203
|
const nextAlias = ts.isIdentifier(expr) ? expr.text : alias;
|
|
204
|
+
const chain = [...consts, ...constChain(expr)];
|
|
186
205
|
const resolved = resolve(expr);
|
|
187
206
|
if (ts.isCallExpression(resolved) &&
|
|
188
207
|
ts.isPropertyAccessExpression(resolved.expression)) {
|
|
@@ -207,11 +226,12 @@ export function extractDocMatches(name, text) {
|
|
|
207
226
|
line: lineOf(objArg.getStart(sf)),
|
|
208
227
|
exportStartLine: span.start,
|
|
209
228
|
exportEndLine: span.end,
|
|
229
|
+
constSpans: chain,
|
|
210
230
|
});
|
|
211
231
|
}
|
|
212
232
|
for (const prop of objArg.properties) {
|
|
213
233
|
if (ts.isPropertyAssignment(prop)) {
|
|
214
|
-
walkForDocObjects(prop.initializer, out, exportName, undefined, span, depth + 1);
|
|
234
|
+
walkForDocObjects(prop.initializer, out, exportName, undefined, span, depth + 1, chain);
|
|
215
235
|
}
|
|
216
236
|
}
|
|
217
237
|
}
|
|
@@ -219,7 +239,7 @@ export function extractDocMatches(name, text) {
|
|
|
219
239
|
}
|
|
220
240
|
if (method === "union" || method === "array" || method === "optional") {
|
|
221
241
|
for (const arg of resolved.arguments) {
|
|
222
|
-
walkForDocObjects(arg, out, exportName, nextAlias, span, depth + 1);
|
|
242
|
+
walkForDocObjects(arg, out, exportName, nextAlias, span, depth + 1, chain);
|
|
223
243
|
}
|
|
224
244
|
return;
|
|
225
245
|
}
|
|
@@ -348,18 +368,29 @@ export function scanR46(files) {
|
|
|
348
368
|
}
|
|
349
369
|
// include the contiguous leading-comment block above the export — the
|
|
350
370
|
// natural place a `returns-projection:` reason is written for the read path.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
371
|
+
// The same holds for every SHARED CONSTANT the export's `returns:` resolves
|
|
372
|
+
// through: a reason written on `const externalOpenReturns = v.object(…)`
|
|
373
|
+
// is the author's written reason for every export that returns it. A
|
|
374
|
+
// reason on an UNRELATED const (one this export does not resolve through)
|
|
375
|
+
// governs nothing here.
|
|
376
|
+
const withLead = (start) => {
|
|
377
|
+
let lead = start;
|
|
378
|
+
while (lead > 1) {
|
|
379
|
+
const prev = (rawLines[lead - 2] ?? "").trim();
|
|
380
|
+
if (prev.startsWith("//") ||
|
|
381
|
+
prev.startsWith("*") ||
|
|
382
|
+
prev.startsWith("/*")) {
|
|
383
|
+
lead -= 1;
|
|
384
|
+
}
|
|
385
|
+
else
|
|
386
|
+
break;
|
|
358
387
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
388
|
+
return lead;
|
|
389
|
+
};
|
|
390
|
+
const reason = projectionReason(rawLines, withLead(m.exportStartLine), m.exportEndLine) ??
|
|
391
|
+
m.constSpans
|
|
392
|
+
.map((c) => projectionReason(rawLines, withLead(c.start), c.end))
|
|
393
|
+
.find((r) => r !== undefined);
|
|
363
394
|
if (reason) {
|
|
364
395
|
inventory.push({
|
|
365
396
|
rel: f.rel,
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// column. Agreeing with the oracle here would be certifying the defect the
|
|
6
6
|
// instrument exists to find — so these are NOT tuned to pass.
|
|
7
7
|
import { CONTRACT_FIELDS, READER_AUTHORITY_COLUMN, WRITER_AUTHORITY_COLUMN, authorityIsMerged } from "../contract.js";
|
|
8
|
-
import { ORACLE_COLUMNS, readOracle } from "../oracle.js";
|
|
9
|
-
const
|
|
8
|
+
import { ORACLE_COLUMNS, oracleLabel, readOracle, refuseWithoutOracle, requireOracle, } from "../oracle.js";
|
|
9
|
+
const csvAnchor = () => `${oracleLabel()} col#9 rbac_qui`;
|
|
10
10
|
/**
|
|
11
11
|
* R-14 — inventory-schema parity, contract → inventory, one direction.
|
|
12
12
|
* A merged reader/writer authority column is "non-conforming by construction"
|
|
@@ -15,12 +15,15 @@ const CSV_ANCHOR = "oracle:vp-by-tool-day158.csv col#9 rbac_qui";
|
|
|
15
15
|
export function makeR14(columns = ORACLE_COLUMNS) {
|
|
16
16
|
return {
|
|
17
17
|
id: "R-14 inventory-schema parity",
|
|
18
|
-
|
|
18
|
+
// The verdict is about the oracle's schema, so with no oracle bound there is
|
|
19
|
+
// no subject: refuse (could-not-judge), never a verdict on the tool's own constant.
|
|
20
|
+
run: () => refuseWithoutOracle("R-14 inventory-schema parity", () => {
|
|
21
|
+
requireOracle();
|
|
19
22
|
const authorityColumns = columns.filter((c) => c === "rbac_qui");
|
|
20
23
|
if (authorityIsMerged() || authorityColumns.length < 2) {
|
|
21
24
|
return {
|
|
22
25
|
id: "R-14 inventory-schema parity",
|
|
23
|
-
anchor:
|
|
26
|
+
anchor: csvAnchor(),
|
|
24
27
|
state: "non-conforming",
|
|
25
28
|
verdict: "R-14: the inventory holds a SINGLE merged authority column `rbac_qui` (col#9), " +
|
|
26
29
|
"but §2 splits authority into two distinct contract fields — reader authority (R-5) " +
|
|
@@ -30,11 +33,11 @@ export function makeR14(columns = ORACLE_COLUMNS) {
|
|
|
30
33
|
}
|
|
31
34
|
return {
|
|
32
35
|
id: "R-14 inventory-schema parity",
|
|
33
|
-
anchor:
|
|
36
|
+
anchor: oracleLabel(),
|
|
34
37
|
state: "present",
|
|
35
38
|
verdict: "R-14: every §2 contract field present; reader and writer authority are two separate columns.",
|
|
36
39
|
};
|
|
37
|
-
},
|
|
40
|
+
}),
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
43
|
/**
|
|
@@ -45,7 +48,8 @@ export function makeR14(columns = ORACLE_COLUMNS) {
|
|
|
45
48
|
export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
|
|
46
49
|
return {
|
|
47
50
|
id: "R-38 thirteen-field completeness",
|
|
48
|
-
run: () => {
|
|
51
|
+
run: () => refuseWithoutOracle("R-38 thirteen-field completeness", () => {
|
|
52
|
+
requireOracle();
|
|
49
53
|
const colSet = new Set(columns);
|
|
50
54
|
const missing = [];
|
|
51
55
|
for (const f of CONTRACT_FIELDS) {
|
|
@@ -64,7 +68,7 @@ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
|
|
|
64
68
|
.join("; ");
|
|
65
69
|
return {
|
|
66
70
|
id: "R-38 thirteen-field completeness",
|
|
67
|
-
anchor:
|
|
71
|
+
anchor: csvAnchor(),
|
|
68
72
|
state: "non-conforming",
|
|
69
73
|
verdict: `R-38: the oracle cannot satisfy the thirteen-field §2 contract — reader authority (R-5) and ` +
|
|
70
74
|
`writer authority (R-10) are not two separate fields; both map to the single column ` +
|
|
@@ -74,10 +78,10 @@ export function makeR38(columns = ORACLE_COLUMNS, rowsProvider = readOracle) {
|
|
|
74
78
|
}
|
|
75
79
|
return {
|
|
76
80
|
id: "R-38 thirteen-field completeness",
|
|
77
|
-
anchor:
|
|
81
|
+
anchor: oracleLabel(),
|
|
78
82
|
state: "present",
|
|
79
83
|
verdict: "R-38: all thirteen §2 contract fields present per table, each within its vocabulary.",
|
|
80
84
|
};
|
|
81
|
-
},
|
|
85
|
+
}),
|
|
82
86
|
};
|
|
83
87
|
}
|