@vantageos/backend-doctor 0.1.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +66 -5
  2. package/dist/args.d.ts +16 -0
  3. package/dist/args.js +72 -0
  4. package/dist/build-stamp.d.ts +13 -0
  5. package/dist/build-stamp.js +76 -0
  6. package/dist/build-stamp.json +4 -0
  7. package/dist/caller-resolution.d.ts +222 -0
  8. package/dist/caller-resolution.js +1198 -0
  9. package/dist/cli.js +249 -17
  10. package/dist/contract.d.ts +25 -7
  11. package/dist/contract.js +52 -7
  12. package/dist/coverage-cli.js +8 -0
  13. package/dist/coverage.d.ts +1 -1
  14. package/dist/coverage.js +2 -2
  15. package/dist/detectors/oracle-axis1.d.ts +5 -3
  16. package/dist/detectors/oracle-axis1.js +28 -18
  17. package/dist/detectors/oracle-axis2.d.ts +6 -0
  18. package/dist/detectors/oracle-axis2.js +37 -22
  19. package/dist/detectors/predicates.d.ts +3 -0
  20. package/dist/detectors/predicates.js +93 -19
  21. package/dist/detectors/shared.js +8 -7
  22. package/dist/detectors/source/_ast.d.ts +92 -0
  23. package/dist/detectors/source/_ast.js +353 -0
  24. package/dist/detectors/source/_scan.js +4 -3
  25. package/dist/detectors/source/backfill.js +71 -18
  26. package/dist/detectors/source/contract.js +21 -13
  27. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  28. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  29. package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
  30. package/dist/detectors/source/guard-helper-flow.js +1216 -0
  31. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  32. package/dist/detectors/source/identity-by-id.js +1783 -0
  33. package/dist/detectors/source/identity.d.ts +58 -2
  34. package/dist/detectors/source/identity.js +1158 -28
  35. package/dist/detectors/source/isolation.d.ts +11 -1
  36. package/dist/detectors/source/isolation.js +106 -25
  37. package/dist/detectors/source/jobs.js +178 -4
  38. package/dist/detectors/source/lifecycle.js +14 -2
  39. package/dist/detectors/source/local-identity.d.ts +26 -0
  40. package/dist/detectors/source/local-identity.js +661 -0
  41. package/dist/detectors/source/package-principal.d.ts +9 -0
  42. package/dist/detectors/source/package-principal.js +177 -0
  43. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  44. package/dist/detectors/source/r6-shapes.js +285 -0
  45. package/dist/detectors/source/readbudget.d.ts +1 -0
  46. package/dist/detectors/source/readbudget.js +358 -4
  47. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  48. package/dist/detectors/source/recipient-by-id.js +1108 -0
  49. package/dist/detectors/source/refusal.d.ts +14 -0
  50. package/dist/detectors/source/refusal.js +75 -2
  51. package/dist/detectors/source/security.d.ts +16 -0
  52. package/dist/detectors/source/security.js +680 -33
  53. package/dist/detectors/source/write-isolation.d.ts +8 -1
  54. package/dist/detectors/source/write-isolation.js +64 -30
  55. package/dist/detectors/source/writesafety.js +27 -14
  56. package/dist/detectors/standard/_doc.d.ts +1 -1
  57. package/dist/detectors/standard/_doc.js +5 -4
  58. package/dist/detectors/standard/r41-frontier.js +3 -2
  59. package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
  60. package/dist/detectors/standard/r46-returns-validator.js +48 -16
  61. package/dist/detectors/structural.d.ts +6 -7
  62. package/dist/detectors/structural.js +84 -48
  63. package/dist/not-checkable.d.ts +1 -1
  64. package/dist/not-checkable.js +11 -0
  65. package/dist/oracle.d.ts +92 -3
  66. package/dist/oracle.js +229 -12
  67. package/dist/registry.d.ts +8 -1
  68. package/dist/registry.js +74 -8
  69. package/dist/report.d.ts +11 -1
  70. package/dist/report.js +15 -2
  71. package/dist/rule-class.d.ts +85 -0
  72. package/dist/rule-class.js +218 -0
  73. package/dist/state-rank.d.ts +5 -0
  74. package/dist/state-rank.js +9 -0
  75. package/dist/tally.d.ts +13 -5
  76. package/dist/tally.js +15 -13
  77. package/dist/target.d.ts +32 -0
  78. package/dist/target.js +173 -0
  79. package/dist/violation-count.d.ts +48 -0
  80. package/dist/violation-count.js +94 -0
  81. package/fixtures/backend-standard-2825122.md +722 -0
  82. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  83. package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
  84. package/fixtures/cr1-classes/surface.ts +206 -0
  85. package/fixtures/cr1-empty/nothing.ts +1 -0
  86. package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
  87. package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
  88. package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
  89. package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
  90. package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
  91. package/fixtures/rule-classes-2825122.json +228 -0
  92. package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
  93. package/package.json +2 -2
@@ -22,7 +22,8 @@
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
+ import { siteCount, withCount } from "../../violation-count.js";
26
27
  /** The standard all six are pinned against (short hash a9d7877b). */
27
28
  export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
28
29
  function emptyScan() {
@@ -60,12 +61,12 @@ function decide(ruleId, id, check, scan, emptyReason) {
60
61
  const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
61
62
  const fmt = (s) => s.slice(0, 3).map((x) => `${x.rel}:${x.n} (${x.note})`).join("; ");
62
63
  if (nV > 0) {
63
- return {
64
+ return withCount({
64
65
  id,
65
66
  anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
66
67
  state: "non-conforming",
67
68
  verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
68
- };
69
+ }, siteCount(scan.violate));
69
70
  }
70
71
  if (nJ > 0) {
71
72
  const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
@@ -107,42 +108,470 @@ function decide(ruleId, id, check, scan, emptyReason) {
107
108
  // a 401/403 reject, (c) key on a delivery id. A webhook file with no signature
108
109
  // verification is the violation.
109
110
  // ---------------------------------------------------------------------------
110
- const SIG_TOKEN = /createhmac|verify\w*signature|timingsafeequal|\bhmac\b|x-hub-signature|x-gumroad/i;
111
+ // VERIFICATION evidence — a computation over the signature, never the header
112
+ // NAME alone: a route that reads `x-hub-signature-256` and never checks it is
113
+ // unsigned. `crypto.subtle.sign/verify` is the Web Crypto HMAC form VP
114
+ // convex/http.ts `/github/webhook` uses (lines 46-61 at ec61e9d).
115
+ const SIG_TOKEN = /createhmac|verify\w*signature|timingsafeequal|\bhmac\b|crypto\.subtle\.(?:sign|verify)\b/i;
111
116
  const FAIL_CLOSED = /status:\s*40[13]\b|\b40[13]\b.*(reject|invalid|missing)|throw .*(unauthori|forbidden)/i;
112
- const DELIVERY_ID = /orderid|delivery|sale_id|order_id|idempot|event[_.]?id/i;
113
- function isWebhookFile(f) {
117
+ // A delivery-id KEY, never the bare word: VP http.ts:39 logs "refusing
118
+ // delivery", which is prose in a string, not an idempotency key.
119
+ const DELIVERY_ID = /orderid|delivery[_-]?id|x-github-delivery|sale_id|order_id|idempot|event[_.]?id/i;
120
+ //
121
+ // POPULATION — an INBOUND HTTP webhook entry point, never a file whose name
122
+ // merely contains "webhook". A webhook-named file is judged only when it is
123
+ // reachable from an HTTP request:
124
+ // (a) it defines an `httpAction(` / `http.route(` itself, OR
125
+ // (b) a file that defines an `httpAction(` references its module by function
126
+ // reference (`internal.<module>.<fn>` / `api.<module>.<fn>`) — the route
127
+ // delegating verification to an internal action (VP `http.ts:693-700` ->
128
+ // `internal.gumroadWebhook.handleGumroadWebhook`).
129
+ // A webhook-named internalMutation no route reaches (VP 45e40c8
130
+ // `convex/migrations/backfill_webhook_task_origin.ts`, a manual data repair) is
131
+ // NOT an inbound path and is not judged. Reachability is a property of the
132
+ // TREE: a webhook file scanned without the router that reaches it is not judged.
133
+ const HTTP_ENTRY = /\bhttpAction\s*\(|\bhttp\.route\s*\(/;
134
+ const FN_REF = /\b(?:internal|api)\.((?:[A-Za-z_$][\w$]*\.)+)[A-Za-z_$][\w$]*/g;
135
+ function isWebhookNamed(f) {
114
136
  return /webhook/i.test(base(f.rel));
115
137
  }
116
- export function scanR21(files) {
117
- const scan = emptyScan();
138
+ function definesHttpEntry(f) {
139
+ return f.lines.some((l) => code(l) && HTTP_ENTRY.test(l.raw));
140
+ }
141
+ /** The convex module path a file registers under (`convex/a/b.ts` -> `a/b`). */
142
+ function convexModuleOf(rel) {
143
+ const m = /(?:^|\/)convex\/(.+)\.ts$/.exec(rel);
144
+ return m ? m[1] : undefined;
145
+ }
146
+ /** Module paths (`a/b`) referenced by function reference from HTTP entry files. */
147
+ function httpReachedModules(files) {
148
+ const out = new Set();
118
149
  for (const f of files) {
119
- if (!isJudgeable(f) || !isWebhookFile(f))
150
+ if (!isJudgeable(f) || !definesHttpEntry(f))
120
151
  continue;
121
- let sigLine = 0;
122
- let failClosed = false;
123
- let delivery = false;
124
152
  for (const l of f.lines) {
125
153
  if (!code(l))
126
154
  continue;
127
- if (!sigLine && SIG_TOKEN.test(l.raw))
128
- sigLine = l.n;
129
- if (FAIL_CLOSED.test(l.raw))
130
- failClosed = true;
131
- if (DELIVERY_ID.test(l.raw))
132
- delivery = true;
155
+ for (const m of l.raw.matchAll(FN_REF))
156
+ out.add(m[1].slice(0, -1).replace(/\./g, "/"));
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ function locate(rel, lines) {
162
+ return lines.map((line) => ({ rel, line }));
163
+ }
164
+ /**
165
+ * Judge one webhook path (a whole file, or one route's span — possibly spread
166
+ * over the router and the module its handler is imported from) and push its
167
+ * site. Evidence sites are anchored in the file the evidence line lives in.
168
+ */
169
+ function judgeWebhookPath(scan, rel, anchor, locs, label) {
170
+ let sig;
171
+ let failClosed = false;
172
+ let delivery = false;
173
+ for (const x of locs) {
174
+ if (!code(x.line))
175
+ continue;
176
+ if (!sig && SIG_TOKEN.test(x.line.raw))
177
+ sig = x;
178
+ if (FAIL_CLOSED.test(x.line.raw))
179
+ failClosed = true;
180
+ if (DELIVERY_ID.test(x.line.raw))
181
+ delivery = true;
182
+ }
183
+ if (!sig) {
184
+ scan.violate.push({ rel, n: anchor, note: `${label}webhook path with no signature verification (no createHmac / verify*Signature) — fails open` });
185
+ }
186
+ else if (!failClosed) {
187
+ scan.violate.push({ rel: sig.rel, n: sig.line.n, note: `${label}signature verified but no fail-closed 401/403 reject before acting` });
188
+ }
189
+ else if (!delivery) {
190
+ scan.cnj.push({ rel: sig.rel, n: sig.line.n, note: `${label}signature verified + fail-closed, but no delivery-id idempotency key found on this path — deferred, not statically confirmed` });
191
+ }
192
+ else {
193
+ scan.conform.push({ rel: sig.rel, n: sig.line.n, note: `${label}signature verified (createHmac) + fail-closed 401 + delivery-id idempotency` });
194
+ }
195
+ }
196
+ // ROUTE POPULATION — an `http.route(...)` that receives a third-party webhook,
197
+ // in ANY file, whatever its name (VP convex/http.ts `/github/webhook` was never
198
+ // judged while population was "webhook-named file"). A route is a webhook route
199
+ // when its path names a webhook provider, or its handler reads a signature
200
+ // header / computes a signature. An ordinary JSON API route is not applicable.
201
+ //
202
+ // THE HANDLER IS READ WHEREVER IT LIVES. Inline `httpAction(`, a same-file
203
+ // `const h = httpAction(`, or a handler IMPORTED from another module (named,
204
+ // aliased, namespace or default import, relative specifier) is followed to its
205
+ // declaration; `h.bind(...)` is followed to `h`, and a one-argument wrapper
206
+ // `wrap(h)` to both `wrap` (whose body may verify) and `h`. A route whose
207
+ // handler cannot be read — imported from a module absent from the tree or
208
+ // from a package, an export the module does not declare (a re-export
209
+ // included), a name neither declared nor imported, any other expression
210
+ // (a multi-argument call, a conditional, ...) named verbatim, or no handler
211
+ // property at all — is could-not-judge: its
212
+ // applicability is unknown, and an unjudged route never stays silent (Argus,
213
+ // after #46: `/api/inbound` -> `inboundRelay.ts` reading `x-hub-signature`
214
+ // unverified produced no site at all).
215
+ const WEBHOOK_PATH = /webhook|github|gumroad|stripe/i;
216
+ const SIGNATURE_READ = /x-hub-signature|x-signature|stripe-signature|svix-signature|x-gumroad-signature|webhook-signature|createhmac|verify\w*signature/i;
217
+ const ROUTE_START = /^\s*http\.route\s*\(/;
218
+ const ROUTE_PATH = /\bpath(?:Prefix)?\s*:\s*["'`]([^"'`]+)["'`]/;
219
+ /** The `handler` PROPERTY of the route object (never the word in a string or a path). */
220
+ const HANDLER_KEY = /(?:^|[{,])\s*handler\s*(?:(:)|(?=[,}]|$))/m;
221
+ /** A reference `resolveHandler` can follow: `h` or `ns.member`. */
222
+ const REF = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/;
223
+ /** `h.bind(...)` on a reference. */
224
+ const BIND = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\.bind\s*\(.*\)$/s;
225
+ /** `wrap(arg)` — a wrapper reference called with exactly one argument. */
226
+ const WRAP = /^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\((.*)\)$/s;
227
+ /** A top-level statement start: column 0, an identifier/keyword (not `}`, `)`, a comment). */
228
+ const TOP_LEVEL = /^[A-Za-z_$@]/;
229
+ /** Lines from index `i` to (excluding) the next top-level statement. */
230
+ function spanFrom(lines, i) {
231
+ let j = i + 1;
232
+ while (j < lines.length && !(TOP_LEVEL.test(lines[j].raw) && code(lines[j])))
233
+ j++;
234
+ return lines.slice(i, j);
235
+ }
236
+ /**
237
+ * The source text of a route's `handler` value: from after `handler:` to the
238
+ * first `,` or closing `}`/`)` at depth 0, strings skipped. Shorthand
239
+ * `{ handler }` yields "handler". `undefined` when the route names no handler.
240
+ */
241
+ function handlerExpression(routeSpan) {
242
+ const text = routeSpan.filter((l) => code(l)).map((l) => l.raw).join("\n");
243
+ const m = HANDLER_KEY.exec(text);
244
+ if (!m)
245
+ return undefined;
246
+ if (!m[1])
247
+ return "handler";
248
+ let depth = 0;
249
+ let quote = "";
250
+ let out = "";
251
+ for (let i = m.index + m[0].length; i < text.length; i++) {
252
+ const c = text[i];
253
+ if (quote) {
254
+ out += c;
255
+ if (c === "\\") {
256
+ out += text[++i] ?? "";
257
+ }
258
+ else if (c === quote)
259
+ quote = "";
260
+ continue;
261
+ }
262
+ if (c === '"' || c === "'" || c === "`")
263
+ quote = c;
264
+ else if (c === "(" || c === "[" || c === "{")
265
+ depth++;
266
+ else if (c === ")" || c === "]" || c === "}") {
267
+ if (depth === 0)
268
+ break;
269
+ depth--;
270
+ }
271
+ else if (c === "," && depth === 0)
272
+ break;
273
+ out += c;
274
+ }
275
+ return out.replace(/\s+/g, " ").trim();
276
+ }
277
+ /** Split a call's argument text on top-level commas. */
278
+ function topLevelArgs(args) {
279
+ const out = [];
280
+ let depth = 0;
281
+ let cur = "";
282
+ for (const c of args) {
283
+ if (c === "(" || c === "[" || c === "{")
284
+ depth++;
285
+ else if (c === ")" || c === "]" || c === "}")
286
+ depth--;
287
+ if (c === "," && depth === 0) {
288
+ out.push(cur.trim());
289
+ cur = "";
290
+ }
291
+ else
292
+ cur += c;
293
+ }
294
+ if (cur.trim())
295
+ out.push(cur.trim());
296
+ return out;
297
+ }
298
+ function escapeRe(s) {
299
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
300
+ }
301
+ /** The top-level declaration span of `name` in `f` (`default` = `export default`). */
302
+ function declSpan(f, name) {
303
+ const decl = name === "default"
304
+ ? /^export\s+default\b/
305
+ : new RegExp(`^(?:export\\s+)?(?:const|let|var|function|async\\s+function)\\s+${escapeRe(name)}\\b`);
306
+ const k = f.lines.findIndex((x) => code(x) && decl.test(x.raw));
307
+ return k >= 0 ? spanFrom(f.lines, k) : undefined;
308
+ }
309
+ /** Normalise `a/./b/../c` (POSIX, relative). */
310
+ function normalisePath(p) {
311
+ const out = [];
312
+ for (const seg of p.split("/")) {
313
+ if (seg === "" || seg === ".")
314
+ continue;
315
+ if (seg === "..")
316
+ out.pop();
317
+ else
318
+ out.push(seg);
319
+ }
320
+ return out.join("/");
321
+ }
322
+ /** The tree file a relative import specifier in `fromRel` names, if scanned. */
323
+ function resolveSpecifier(fromRel, spec, byRel) {
324
+ if (!spec.startsWith("."))
325
+ return undefined;
326
+ const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
327
+ const bare = normalisePath(`${dir}/${spec}`).replace(/\.(?:js|mjs|cjs|ts|tsx)$/, "");
328
+ for (const cand of [`${bare}.ts`, `${bare}.tsx`, `${bare}/index.ts`]) {
329
+ const hit = byRel.get(cand);
330
+ if (hit)
331
+ return hit;
332
+ }
333
+ return undefined;
334
+ }
335
+ /** Follow a route's `handler: <ref>` to its declaration, in this file or an imported one. */
336
+ function resolveHandler(f, ref, byRel) {
337
+ const text = f.lines.filter((l) => code(l)).map((l) => l.raw).join("\n");
338
+ const dot = ref.indexOf(".");
339
+ let spec;
340
+ let exported;
341
+ if (dot >= 0) {
342
+ const ns = ref.slice(0, dot);
343
+ exported = ref.slice(dot + 1);
344
+ spec = new RegExp(`import\\s+\\*\\s+as\\s+${escapeRe(ns)}\\s+from\\s*["'\`]([^"'\`]+)["'\`]`).exec(text)?.[1];
345
+ if (spec === undefined)
346
+ return { kind: "unresolved", why: `handler ${ref}: namespace ${ns} is not an import in ${f.rel}` };
347
+ }
348
+ else {
349
+ const local = declSpan(f, ref);
350
+ if (local)
351
+ return { kind: "resolved", file: f, span: local };
352
+ for (const m of text.matchAll(/import\s+(?:type\s+)?([A-Za-z_$][\w$]*\s*,?\s*)?(?:\{([^}]*)\})?\s*from\s*["'`]([^"'`]+)["'`]/g)) {
353
+ const def = m[1]?.replace(/[\s,]/g, "");
354
+ if (def === ref) {
355
+ spec = m[3];
356
+ exported = "default";
357
+ break;
358
+ }
359
+ for (const entry of (m[2] ?? "").split(",")) {
360
+ const [orig, alias] = entry.trim().split(/\s+as\s+/);
361
+ if ((alias ?? orig) === ref && orig) {
362
+ spec = m[3];
363
+ exported = orig;
364
+ }
365
+ }
366
+ if (spec !== undefined)
367
+ break;
133
368
  }
134
- if (!sigLine) {
135
- const first = f.lines.find((l) => code(l)) ?? f.lines[0];
136
- scan.violate.push({ rel: f.rel, n: first?.n ?? 1, note: "webhook path with no signature verification (no createHmac / verify*Signature) — fails open" });
369
+ if (spec === undefined || exported === undefined) {
370
+ return { kind: "unresolved", why: `handler ${ref} is neither declared nor imported in ${f.rel}` };
137
371
  }
138
- else if (!failClosed) {
139
- scan.violate.push({ rel: f.rel, n: sigLine, note: "signature verified but no fail-closed 401/403 reject before acting" });
372
+ }
373
+ const target = resolveSpecifier(f.rel, spec, byRel);
374
+ if (!target)
375
+ return { kind: "unresolved", why: `handler ${ref} is imported from "${spec}", not in the scanned tree` };
376
+ // A default export that is an identifier (`export default h;` or
377
+ // `export { h as default }`) is read on h's own declaration in that module
378
+ // (Argus REVISE on #48 @77b6b59, blocker 2); the one-line export is not a body.
379
+ const name = exported === "default" ? (defaultExportIdentifier(target) ?? "default") : (exported ?? ref);
380
+ const span = declSpan(target, name);
381
+ if (!span)
382
+ return { kind: "unresolved", why: `handler ${ref}: ${target.rel} declares no ${name}` };
383
+ return { kind: "resolved", file: target, span };
384
+ }
385
+ /** The identifier a module default-exports by name, if it does. */
386
+ function defaultExportIdentifier(f) {
387
+ for (const l of f.lines) {
388
+ if (!code(l))
389
+ continue;
390
+ const bare = /^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?\s*$/.exec(l.raw);
391
+ if (bare)
392
+ return bare[1];
393
+ const named = /^export\s*\{[^}]*?\b([A-Za-z_$][\w$]*)\s+as\s+default\b/.exec(l.raw);
394
+ if (named)
395
+ return named[1];
396
+ }
397
+ return undefined;
398
+ }
399
+ /** Local binding names this file imports from a RELATIVE specifier (a module of the tree). */
400
+ function relativeImportBindings(f) {
401
+ const text = f.lines.filter((l) => code(l)).map((l) => l.raw).join("\n");
402
+ const out = new Set();
403
+ // One statement per match: the clause never crosses `from` or `;`, so a
404
+ // package import cannot be swallowed into the next relative one.
405
+ for (const m of text.matchAll(/\bimport\s+(?!type\b)((?:(?!\bfrom\b)[^;])*?)\s*\bfrom\s*["'`]([^"'`]*)["'`]/g)) {
406
+ if (!m[2].startsWith("."))
407
+ continue;
408
+ const clause = m[1];
409
+ const ns = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
410
+ if (ns)
411
+ out.add(ns[1]);
412
+ const def = /^\s*([A-Za-z_$][\w$]*)\s*(?:,|$)/.exec(clause);
413
+ if (def)
414
+ out.add(def[1]);
415
+ for (const entry of /\{([^}]*)\}/.exec(clause)?.[1]?.split(",") ?? []) {
416
+ const [orig, alias] = entry.trim().replace(/^type\s+/, "").split(/\s+as\s+/);
417
+ if (alias ?? orig)
418
+ out.add((alias ?? orig).trim());
140
419
  }
141
- else if (!delivery) {
142
- scan.cnj.push({ rel: f.rel, n: sigLine, note: "signature verified + fail-closed, but no delivery-id idempotency key found on this path — deferred, not statically confirmed" });
420
+ }
421
+ return out;
422
+ }
423
+ /**
424
+ * The tree-imported functions an inline `httpAction(` body calls (`h(…)` or
425
+ * `ns.h(…)` where `h`/`ns` is bound by a relative import). The inline span alone
426
+ * is not the handler when it delegates (Argus REVISE on #48 @77b6b59, blocker 1).
427
+ */
428
+ function inlineImportedCallees(f, expr) {
429
+ const bound = relativeImportBindings(f);
430
+ const out = [];
431
+ for (const m of expr.matchAll(/(?<![\w$.])([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\(/g)) {
432
+ const callee = m[1];
433
+ const head = callee.split(".")[0];
434
+ if (callee === "httpAction" || !bound.has(head) || out.includes(callee))
435
+ continue;
436
+ out.push(callee);
437
+ }
438
+ return out;
439
+ }
440
+ /** Every http.route in `f` that receives a webhook or whose handler cannot be read. */
441
+ function readRoutes(f, byRel, fileJudged) {
442
+ const out = [];
443
+ f.lines.forEach((l, i) => {
444
+ if (!code(l) || !ROUTE_START.test(l.raw))
445
+ return;
446
+ const routeSpan = spanFrom(f.lines, i);
447
+ const path = routeSpan.map((x) => (code(x) ? ROUTE_PATH.exec(x.raw)?.[1] : undefined)).find((p) => p !== undefined) ?? "";
448
+ let locs = locate(f.rel, routeSpan);
449
+ let handlerFile;
450
+ const expr = handlerExpression(routeSpan);
451
+ if (expr === undefined) {
452
+ out.push({ kind: "unresolved", n: l.n, path, why: "no handler property readable on the route" });
453
+ return;
143
454
  }
455
+ // Which references this expression's body is read through. An inline
456
+ // `httpAction(` is read on the route span itself PLUS every tree-imported
457
+ // function its body calls; a bare reference and `ref.bind(...)` are
458
+ // followed to `ref`; `wrap(ref)` is followed to BOTH the wrapper (which may
459
+ // verify) and `ref`. Anything else is unreadable.
460
+ let refs;
461
+ const inline = /^httpAction\s*\(/.test(expr);
462
+ if (inline)
463
+ refs = inlineImportedCallees(f, expr);
464
+ else if (REF.test(expr))
465
+ refs = [expr];
466
+ else if (BIND.test(expr))
467
+ refs = [BIND.exec(expr)?.[1] ?? ""];
144
468
  else {
145
- scan.conform.push({ rel: f.rel, n: sigLine, note: "signature verified (createHmac) + fail-closed 401 + delivery-id idempotency" });
469
+ const w = WRAP.exec(expr);
470
+ const args = w ? topLevelArgs(w[2]) : [];
471
+ if (w && args.length === 1 && REF.test(args[0]))
472
+ refs = [w[1], args[0]];
473
+ else {
474
+ out.push({ kind: "unresolved", n: l.n, path, why: `handler expression \`${expr}\` is neither an inline httpAction( nor a reference this instrument follows` });
475
+ return;
476
+ }
477
+ }
478
+ // An inline body that reads or verifies a signature itself is judged on its
479
+ // own span; a callee it cannot follow is then extra evidence missed, not the
480
+ // only evidence. Without that, an unfollowable callee leaves the route unread.
481
+ const inlineSelfEvident = inline && locs.some((x) => code(x.line) && (SIGNATURE_READ.test(x.line.raw) || SIG_TOKEN.test(x.line.raw)));
482
+ for (const ref of refs) {
483
+ const h = resolveHandler(f, ref, byRel);
484
+ if (h.kind === "unresolved" && inlineSelfEvident)
485
+ continue;
486
+ if (h.kind === "unresolved") {
487
+ out.push({ kind: "unresolved", n: l.n, path, why: refs.length > 1 || ref !== expr ? `handler \`${expr}\`: ${h.why}` : h.why });
488
+ return;
489
+ }
490
+ // The handler lives in a webhook-named file already judged whole.
491
+ if (h.file !== f && fileJudged.has(h.file.rel)) {
492
+ out.push({ kind: "judged-whole", n: l.n });
493
+ return;
494
+ }
495
+ if (h.file !== f)
496
+ handlerFile = handlerFile ? `${handlerFile}, ${h.file.rel}` : h.file.rel;
497
+ locs = [...locs, ...locate(h.file.rel, h.span)];
498
+ }
499
+ if (inline) {
500
+ if (handlerFile)
501
+ handlerFile = `${handlerFile} (called from the inline httpAction)`;
502
+ }
503
+ else if (refs.length > 0 && expr !== refs[0])
504
+ handlerFile = `${handlerFile ?? f.rel} (via \`${expr}\`)`;
505
+ const isWebhook = WEBHOOK_PATH.test(path) || locs.some((x) => code(x.line) && SIGNATURE_READ.test(x.line.raw));
506
+ if (isWebhook)
507
+ out.push({ kind: "webhook", n: l.n, path, locs, handlerFile });
508
+ });
509
+ return out;
510
+ }
511
+ export function scanR21(files) {
512
+ const scan = emptyScan();
513
+ const reached = httpReachedModules(files);
514
+ const byModule = new Map();
515
+ const byRel = new Map();
516
+ for (const f of files) {
517
+ byRel.set(f.rel, f);
518
+ const mod = convexModuleOf(f.rel);
519
+ if (mod !== undefined && isJudgeable(f))
520
+ byModule.set(mod, f);
521
+ }
522
+ const fileJudged = new Set();
523
+ // (1) webhook-NAMED files reachable from HTTP — judged whole (b271c26).
524
+ for (const f of files) {
525
+ if (!isJudgeable(f) || !isWebhookNamed(f))
526
+ continue;
527
+ const mod = convexModuleOf(f.rel);
528
+ if (!definesHttpEntry(f) && !(mod !== undefined && reached.has(mod)))
529
+ continue;
530
+ fileJudged.add(f.rel);
531
+ const first = f.lines.find((l) => code(l)) ?? f.lines[0];
532
+ judgeWebhookPath(scan, f.rel, first?.n ?? 1, locate(f.rel, f.lines), "");
533
+ }
534
+ // (2) webhook ROUTES in any other file — judged at the route, on its span
535
+ // plus its handler's declaration wherever that lives.
536
+ for (const f of files) {
537
+ if (!isJudgeable(f) || fileJudged.has(f.rel))
538
+ continue;
539
+ for (const r of readRoutes(f, byRel, fileJudged)) {
540
+ if (r.kind === "judged-whole")
541
+ continue;
542
+ const label = `route ${r.path || "(no path literal)"}: `;
543
+ if (r.kind === "unresolved") {
544
+ scan.cnj.push({ rel: f.rel, n: r.n, note: `${label}${r.why} — handler unreadable, applicability unknown; not judged, never a pass` });
545
+ continue;
546
+ }
547
+ const where = r.handlerFile ? `${label}handler in ${r.handlerFile}; ` : label;
548
+ // Delegated by function reference to a module that verifies.
549
+ const delegates = new Set();
550
+ for (const x of r.locs) {
551
+ if (!code(x.line))
552
+ continue;
553
+ for (const m of x.line.raw.matchAll(FN_REF))
554
+ delegates.add(m[1].slice(0, -1).replace(/\./g, "/"));
555
+ }
556
+ const targets = [...delegates].map((d) => byModule.get(d)).filter((t) => t !== undefined);
557
+ // A delegate already judged whole in (1) carries this route's verdict.
558
+ if (targets.some((t) => fileJudged.has(t.rel)))
559
+ continue;
560
+ const ownSig = r.locs.some((x) => code(x.line) && SIG_TOKEN.test(x.line.raw));
561
+ const verifier = ownSig ? undefined : targets.find((t) => t.lines.some((l) => code(l) && SIG_TOKEN.test(l.raw)));
562
+ if (verifier) {
563
+ judgeWebhookPath(scan, f.rel, r.n, [...r.locs, ...locate(verifier.rel, verifier.lines)], `${where}verification delegated to ${verifier.rel}; `);
564
+ continue;
565
+ }
566
+ // The route hands off to a webhook-NAMED module this tree does not hold:
567
+ // its verifier is outside the scan, so the route is not judged here
568
+ // (reachability is a tree property, in both directions).
569
+ const absentHook = [...delegates].filter((d) => !byModule.has(d) && /webhook/i.test(d));
570
+ if (!ownSig && absentHook.length > 0) {
571
+ scan.cnj.push({ rel: f.rel, n: r.n, note: `${where}no signature verification on the route; it delegates to ${absentHook.join(", ")}, not in the scanned tree — not statically confirmable` });
572
+ continue;
573
+ }
574
+ judgeWebhookPath(scan, f.rel, r.n, r.locs, where);
146
575
  }
147
576
  }
148
577
  return scan;
@@ -251,6 +680,181 @@ export function scanR33(files) {
251
680
  const INBOUND_JWT = /claims\.(aud|iss)\b|\.iss\b.*mismatch|verify\w*jwt/i;
252
681
  const AUD_ASSERT = /VP_CLERK_EXPECTED_AUD|aud claim mismatch|audMatches|expectedAud/i;
253
682
  const OUTBOUND_PASSTHROUGH = /["'`]?authorization["'`]?\s*[:=][^,\n]*(headers\.get\(\s*["'`]authorization|req(uest)?\.headers|callerToken|incomingToken|passthrough)/i;
683
+ // An `audience` KEY in the options object of a JWT-verify call is an audience
684
+ // assertion, judged on its VALUE, never on a name list (PR #44 follow-up: VP
685
+ // 30e821b mcp-server/src/auth.ts:882-885 `jwtVerify(token, jwks, { issuer,
686
+ // audience: CLERK_JWT_AUDIENCE })` with `CLERK_JWT_AUDIENCE =
687
+ // process.env.CLERK_JWT_AUDIENCE ?? "convex"` was reported as unasserted).
688
+ // counts: a non-empty string literal; a non-empty array literal; an identifier
689
+ // bound by a module-level `const` to either of those, or to
690
+ // `process.env.X ?? "<non-empty>"` / `|| "<non-empty>"`
691
+ // never: `undefined`, `""`, a const whose fallback is empty, a bare env read
692
+ // with no fallback (undefined at runtime disables the check in jose)
693
+ const VERIFY_CALL = /\b(?:jwtVerify|jwt\.verify|verifyToken|verify)\s*\(/;
694
+ const NON_EMPTY_LITERAL = /^(?:"[^"]+"|'[^']+'|`[^`$]+`|\[\s*["'`][^\]]*\])$/;
695
+ const ENV_WITH_FALLBACK = /^process\.env\.[A-Za-z0-9_]+\s*(?:\?\?|\|\|)\s*("[^"]+"|'[^']+'|`[^`$]+`)$/;
696
+ function audienceValueCounts(value, f) {
697
+ const v = value.trim();
698
+ if (NON_EMPTY_LITERAL.test(v))
699
+ return true;
700
+ if (!/^[A-Za-z_$][\w$]*$/.test(v) || v === "undefined")
701
+ return false;
702
+ const decl = new RegExp(`^(?:export\\s+)?const\\s+${v}\\s*(?::[^=]+)?=\\s*(.+?);?\\s*$`);
703
+ for (const l of f.lines) {
704
+ if (!code(l))
705
+ continue;
706
+ const m = decl.exec(l.raw);
707
+ if (m) {
708
+ const init = m[1].trim();
709
+ return NON_EMPTY_LITERAL.test(init) || ENV_WITH_FALLBACK.test(init);
710
+ }
711
+ }
712
+ return false;
713
+ }
714
+ /**
715
+ * The source with comments blanked to spaces and string/template CONTENTS blanked
716
+ * to `_` (quotes kept, newlines kept), so offsets line up with the original. All
717
+ * structural matching runs on this mask; values are sliced from the original.
718
+ */
719
+ function maskSource(src) {
720
+ const out = src.split("");
721
+ let i = 0;
722
+ while (i < src.length) {
723
+ const c = src[i];
724
+ const n = src[i + 1];
725
+ if (c === "/" && n === "/") {
726
+ while (i < src.length && src[i] !== "\n")
727
+ out[i++] = " ";
728
+ }
729
+ else if (c === "/" && n === "*") {
730
+ out[i++] = " ";
731
+ out[i++] = " ";
732
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
733
+ if (src[i] !== "\n")
734
+ out[i] = " ";
735
+ i++;
736
+ }
737
+ if (i < src.length) {
738
+ out[i++] = " ";
739
+ out[i++] = " ";
740
+ }
741
+ }
742
+ else if (c === '"' || c === "'" || c === "`") {
743
+ i++;
744
+ while (i < src.length && src[i] !== c) {
745
+ if (src[i] === "\\") {
746
+ out[i++] = "_";
747
+ if (i < src.length && src[i] !== "\n")
748
+ out[i] = "_";
749
+ i++;
750
+ continue;
751
+ }
752
+ if (src[i] !== "\n")
753
+ out[i] = "_";
754
+ i++;
755
+ }
756
+ i++;
757
+ }
758
+ else
759
+ i++;
760
+ }
761
+ return out.join("");
762
+ }
763
+ /** Index of the bracket closing the one opened at `open` in `masked`, or -1. */
764
+ function closeOf(masked, open) {
765
+ let depth = 0;
766
+ for (let i = open; i < masked.length; i++) {
767
+ const c = masked[i];
768
+ if (c === "(" || c === "{" || c === "[")
769
+ depth += 1;
770
+ else if (c === ")" || c === "}" || c === "]") {
771
+ depth -= 1;
772
+ if (depth === 0)
773
+ return i;
774
+ }
775
+ }
776
+ return -1;
777
+ }
778
+ /** Split [from, to) of `masked` on commas at nesting depth 0 → [start, end) spans. */
779
+ function topLevelParts(masked, from, to) {
780
+ const parts = [];
781
+ let depth = 0;
782
+ let start = from;
783
+ for (let i = from; i < to; i++) {
784
+ const c = masked[i];
785
+ if (c === "(" || c === "{" || c === "[")
786
+ depth += 1;
787
+ else if (c === ")" || c === "}" || c === "]")
788
+ depth -= 1;
789
+ else if (c === "," && depth === 0) {
790
+ parts.push([start, i]);
791
+ start = i + 1;
792
+ }
793
+ }
794
+ parts.push([start, to]);
795
+ return parts;
796
+ }
797
+ /** The `audience` property VALUE of the object literal whose `{` is at `open`
798
+ * (own properties only, never a nested object's), or undefined. A shorthand
799
+ * `{ audience }` yields the identifier `audience`. */
800
+ function audienceProp(src, masked, open) {
801
+ const close = closeOf(masked, open);
802
+ if (close === -1)
803
+ return undefined;
804
+ for (const [st, en] of topLevelParts(masked, open + 1, close)) {
805
+ const m = /^\s*audience\s*(?::|$)/.exec(masked.slice(st, en));
806
+ if (!m)
807
+ continue;
808
+ const colon = masked.indexOf(":", st);
809
+ const value = colon !== -1 && colon < en ? src.slice(colon + 1, en) : "audience";
810
+ return { value: value.trim(), at: st + m[0].indexOf("audience") };
811
+ }
812
+ return undefined;
813
+ }
814
+ /**
815
+ * Line of an `audience` option that belongs to a JWT-verify call's OWN argument
816
+ * list and whose value counts, or 0. Bound to the call: the scan runs from the
817
+ * call's `(` to its balanced `)` (strings and comments masked), and the key is
818
+ * read only (a) as an own property of an object-literal argument, or (b) through
819
+ * an identifier argument bound by a module-level `const NAME = { … }` holding the
820
+ * key. An `audience` in a nested call, an unrelated object below the call, a
821
+ * different call on the same line, a string or a comment is never read
822
+ * (Eta REVISE #44, mutant A2).
823
+ */
824
+ function audienceOptionLine(f) {
825
+ const src = f.lines.map((l) => l.raw).join("\n");
826
+ const masked = maskSource(src);
827
+ const lineAt = (idx) => src.slice(0, idx).split("\n").length;
828
+ const call = new RegExp(VERIFY_CALL.source, "g");
829
+ let m;
830
+ // biome-ignore lint/suspicious/noAssignInExpressions: standard regex-exec loop
831
+ while ((m = call.exec(masked)) !== null) {
832
+ const open = m.index + m[0].length - 1;
833
+ const close = closeOf(masked, open);
834
+ if (close === -1)
835
+ continue;
836
+ for (const [st, en] of topLevelParts(masked, open + 1, close)) {
837
+ const arg = masked.slice(st, en);
838
+ const lead = st + (arg.length - arg.trimStart().length);
839
+ let prop;
840
+ if (masked[lead] === "{") {
841
+ prop = audienceProp(src, masked, lead);
842
+ }
843
+ else {
844
+ const id = /^([A-Za-z_$][\w$]*)\s*$/.exec(arg.trim());
845
+ if (!id)
846
+ continue;
847
+ const decl = new RegExp(`^(?:export\\s+)?const\\s+${id[1].replace(/\$/g, "\\$")}\\s*(?::[^=\\n]+)?=\\s*\\{`, "m");
848
+ const d = decl.exec(masked);
849
+ if (d)
850
+ prop = audienceProp(src, masked, d.index + d[0].length - 1);
851
+ }
852
+ if (prop && audienceValueCounts(prop.value, f))
853
+ return lineAt(prop.at);
854
+ }
855
+ }
856
+ return 0;
857
+ }
254
858
  export function scanR34(files) {
255
859
  const scan = emptyScan();
256
860
  for (const f of files) {
@@ -269,9 +873,11 @@ export function scanR34(files) {
269
873
  scan.violate.push({ rel: f.rel, n: l.n, note: "outbound Authorization header forwards a caller-supplied token verbatim (token passthrough)" });
270
874
  }
271
875
  }
876
+ if (!audAssert)
877
+ audAssert = audienceOptionLine(f);
272
878
  if (inboundLine) {
273
879
  if (audAssert) {
274
- scan.conform.push({ rel: f.rel, n: audAssert, note: "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD / aud claim mismatch)" });
880
+ 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
881
  }
276
882
  else {
277
883
  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 +934,57 @@ export function scanR35(files) {
328
934
  const LOCKFILES = ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
329
935
  const WORKFLOW_DIR = ".github/workflows";
330
936
  const PUBLISH_HINT = /npm\s+publish|release|publish/i;
937
+ /**
938
+ * The directories R-36 searches, nearest first: the scanned root, then each
939
+ * ancestor up to and INCLUDING the git root (the first ancestor holding `.git`).
940
+ *
941
+ * The misread this closes: the documented invocation points the doctor at
942
+ * `<repo>/convex`, while the lockfile and `.github/workflows` are REPOSITORY
943
+ * artifacts living at the repo root. Reading only `tree.root` reported "no lockfile
944
+ * — dependencies are not pinned" on a repository with a committed `bun.lock`.
945
+ *
946
+ * The walk stops at the git root and never goes above it: a lockfile in some
947
+ * unrelated parent directory (a home directory, a monorepo host that is not this
948
+ * repository) does not pin this repository's dependencies. With no `.git` ancestor
949
+ * at all, only the scanned root itself is read (the pre-fix behaviour) — the walk
950
+ * is never widened on a guess.
951
+ */
952
+ export function r36SearchDirs(root) {
953
+ const chain = [];
954
+ let dir = resolve(root);
955
+ for (;;) {
956
+ chain.push(dir);
957
+ if (existsSync(join(dir, ".git")))
958
+ return chain;
959
+ const up = dirname(dir);
960
+ if (up === dir)
961
+ return [resolve(root)];
962
+ dir = up;
963
+ }
964
+ }
331
965
  export function evalR36(root) {
332
966
  const scan = emptyScan();
333
- const lock = LOCKFILES.find((n) => existsSync(join(root, n)));
967
+ const dirs = r36SearchDirs(root);
968
+ const relTo = (dir, name) => {
969
+ const r = relative(resolve(root), join(dir, name));
970
+ return r === "" ? name : r;
971
+ };
972
+ let lock;
973
+ for (const dir of dirs) {
974
+ const name = LOCKFILES.find((n) => existsSync(join(dir, n)));
975
+ if (name) {
976
+ lock = { dir, name };
977
+ break;
978
+ }
979
+ }
334
980
  if (!lock) {
335
- scan.violate.push({ rel: "(root)", n: 0, note: "no lockfile present — dependencies are not pinned" });
981
+ 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
982
  }
337
983
  else {
338
- scan.conform.push({ rel: lock, n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
984
+ scan.conform.push({ rel: relTo(lock.dir, lock.name), n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
339
985
  }
340
- const wfDir = join(root, WORKFLOW_DIR);
986
+ const wfBase = dirs.find((d) => existsSync(join(d, WORKFLOW_DIR))) ?? resolve(root);
987
+ const wfDir = join(wfBase, WORKFLOW_DIR);
341
988
  let publishFile = "";
342
989
  let provenance = false;
343
990
  let sawPublish = false;
@@ -348,7 +995,7 @@ export function evalR36(root) {
348
995
  const p = join(wfDir, name);
349
996
  if (!existsSync(p))
350
997
  continue;
351
- publishFile = `${WORKFLOW_DIR}/${name}`;
998
+ publishFile = relTo(wfBase, `${WORKFLOW_DIR}/${name}`);
352
999
  const body = readFileSync(p, "utf8");
353
1000
  if (PUBLISH_HINT.test(body))
354
1001
  sawPublish = true;
@@ -372,7 +1019,7 @@ export function evalR36(root) {
372
1019
  function srcDetector(ruleId, id, check, scanFn, emptyReason) {
373
1020
  return { id, run: (tree) => decide(ruleId, id, check, scanFn(tree.files), emptyReason) };
374
1021
  }
375
- export const R21 = srcDetector("R-21", "R-21 webhook signature-verified, idempotent, fail-closed", "a webhook path verifies its signature (createHmac), fails closed 401, keys idempotency on the delivery id", scanR21, "R-21: zero webhook files matched in the scanned tree — cannot certify webhook signature verification. NOT a clean bill.");
1022
+ export const R21 = srcDetector("R-21", "R-21 webhook signature-verified, idempotent, fail-closed", "a webhook path verifies its signature (createHmac), fails closed 401, keys idempotency on the delivery id", scanR21, "R-21: zero inbound webhook entry points in the scanned tree (a webhook-named file that defines an httpAction/http.route or that an httpAction reaches by function reference, or an http.route in any file whose path names a webhook provider or whose handler reads a signature header) — cannot certify webhook signature verification. NOT a clean bill.");
376
1023
  export const R22 = srcDetector("R-22", "R-22 secrets never in source, response, or log", "no secret-shaped literal in source; secrets read from process.env", scanR22, "R-22: zero convex/mcp source lines scanned — cannot certify secret handling. NOT a clean bill.");
377
1024
  export const R33 = srcDetector("R-33", "R-33 outbound fetch validated as an SSRF surface", "a caller-influenced fetch is guarded by assertBundleUrlSafe before dereference", scanR33, "R-33: zero caller-influenced fetch sites matched (okfBundle*/kb*) — cannot certify the SSRF surface. NOT a clean bill.");
378
1025
  export const R34 = srcDetector("R-34", "R-34 outbound credential is the backend's own, audience-validated", "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD); no outbound caller-token passthrough", scanR34, "R-34: zero inbound-JWT or outbound-Authorization sites matched — cannot certify audience/passthrough. NOT a clean bill.");