@vantageos/backend-doctor 0.2.0 → 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 (63) hide show
  1. package/README.md +48 -4
  2. package/dist/args.d.ts +1 -1
  3. package/dist/args.js +19 -1
  4. package/dist/build-stamp.json +2 -2
  5. package/dist/caller-resolution.js +11 -8
  6. package/dist/cli.js +164 -15
  7. package/dist/contract.d.ts +25 -7
  8. package/dist/contract.js +52 -7
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +2 -2
  11. package/dist/detectors/oracle-axis1.js +5 -4
  12. package/dist/detectors/oracle-axis2.d.ts +6 -0
  13. package/dist/detectors/oracle-axis2.js +32 -16
  14. package/dist/detectors/predicates.js +12 -3
  15. package/dist/detectors/shared.js +3 -2
  16. package/dist/detectors/source/_ast.d.ts +92 -0
  17. package/dist/detectors/source/_ast.js +353 -0
  18. package/dist/detectors/source/_scan.js +4 -3
  19. package/dist/detectors/source/backfill.js +62 -17
  20. package/dist/detectors/source/contract.js +21 -13
  21. package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
  22. package/dist/detectors/source/cross-tenant-write.js +1408 -0
  23. package/dist/detectors/source/guard-helper-flow.d.ts +22 -0
  24. package/dist/detectors/source/guard-helper-flow.js +49 -6
  25. package/dist/detectors/source/identity-by-id.d.ts +80 -0
  26. package/dist/detectors/source/identity-by-id.js +1783 -0
  27. package/dist/detectors/source/identity.d.ts +8 -1
  28. package/dist/detectors/source/identity.js +364 -35
  29. package/dist/detectors/source/jobs.js +13 -2
  30. package/dist/detectors/source/lifecycle.js +14 -2
  31. package/dist/detectors/source/local-identity.d.ts +26 -0
  32. package/dist/detectors/source/local-identity.js +661 -0
  33. package/dist/detectors/source/package-principal.d.ts +9 -0
  34. package/dist/detectors/source/package-principal.js +177 -0
  35. package/dist/detectors/source/r6-shapes.d.ts +20 -0
  36. package/dist/detectors/source/r6-shapes.js +285 -0
  37. package/dist/detectors/source/readbudget.js +291 -0
  38. package/dist/detectors/source/recipient-by-id.d.ts +40 -0
  39. package/dist/detectors/source/recipient-by-id.js +1108 -0
  40. package/dist/detectors/source/refusal.d.ts +14 -0
  41. package/dist/detectors/source/refusal.js +75 -2
  42. package/dist/detectors/source/security.js +455 -26
  43. package/dist/detectors/source/writesafety.js +27 -14
  44. package/dist/detectors/standard/_doc.d.ts +1 -1
  45. package/dist/detectors/standard/_doc.js +5 -4
  46. package/dist/detectors/standard/r41-frontier.js +3 -2
  47. package/dist/detectors/standard/r46-returns-validator.js +3 -2
  48. package/dist/detectors/structural.d.ts +6 -7
  49. package/dist/detectors/structural.js +75 -43
  50. package/dist/not-checkable.d.ts +1 -1
  51. package/dist/not-checkable.js +11 -0
  52. package/dist/registry.d.ts +1 -1
  53. package/dist/registry.js +27 -3
  54. package/dist/rule-class.d.ts +85 -0
  55. package/dist/rule-class.js +218 -0
  56. package/dist/tally.d.ts +13 -5
  57. package/dist/tally.js +14 -6
  58. package/dist/violation-count.d.ts +48 -0
  59. package/dist/violation-count.js +94 -0
  60. package/fixtures/backend-standard-2825122.md +722 -0
  61. package/fixtures/backend-standard-4dcd3e0.md +722 -0
  62. package/fixtures/rule-classes-2825122.json +228 -0
  63. package/package.json +1 -1
@@ -1,5 +1,19 @@
1
1
  import type { Detector } from "../../engine-types.js";
2
2
  import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
3
+ /**
4
+ * The text of every comment in `src`, read by a LEXER rather than by line shape
5
+ * (Argus REVISE, PR #32 @6b2b6ad). A trailing `// …` after code and a bare line
6
+ * inside a block comment are comments; a line inside a string or a template
7
+ * literal is data, even when it starts with `//`. Five states: code, line
8
+ * comment, block comment, a `'`/`"` string, a template literal.
9
+ *
10
+ * Declared limit: a regex literal containing a quote or a backtick (`/"/`,
11
+ * `` /`/ ``) is read as opening a string. A `'`/`"` state closes at the end of
12
+ * its line, so the damage stays on that line; a backtick state does not close
13
+ * at a newline, so a regex literal holding a backtick hides every comment up to
14
+ * the next backtick. Rare in a handler body; named here, not hidden.
15
+ */
16
+ export declare function commentsOf(src: string): string;
3
17
  export declare function scanR47(files: SrcFile[]): Scan;
4
18
  export declare const R47: Detector;
5
19
  /** In the form the orchestrator spreads into REGISTRY. */
@@ -1,3 +1,4 @@
1
+ import { hasJustificationTokenInText } from "../oracle-axis2.js";
1
2
  import { code, emptyScan, isJudgeable, SOURCE_STANDARD_PIN, srcDetector, } from "./_scan.js";
2
3
  // a read-only query opener — the function class R-47 governs.
3
4
  const QUERY_OPEN = /\b(?:internalQuery|query)\s*\(/;
@@ -55,6 +56,62 @@ function queryBlock(lines, i) {
55
56
  }
56
57
  return { lines: body, unterminated: seenOpen };
57
58
  }
59
+ /**
60
+ * The text of every comment in `src`, read by a LEXER rather than by line shape
61
+ * (Argus REVISE, PR #32 @6b2b6ad). A trailing `// …` after code and a bare line
62
+ * inside a block comment are comments; a line inside a string or a template
63
+ * literal is data, even when it starts with `//`. Five states: code, line
64
+ * comment, block comment, a `'`/`"` string, a template literal.
65
+ *
66
+ * Declared limit: a regex literal containing a quote or a backtick (`/"/`,
67
+ * `` /`/ ``) is read as opening a string. A `'`/`"` state closes at the end of
68
+ * its line, so the damage stays on that line; a backtick state does not close
69
+ * at a newline, so a regex literal holding a backtick hides every comment up to
70
+ * the next backtick. Rare in a handler body; named here, not hidden.
71
+ */
72
+ export function commentsOf(src) {
73
+ let out = "";
74
+ let state = "code";
75
+ for (let i = 0; i < src.length; i++) {
76
+ const c = src[i];
77
+ const next = src[i + 1];
78
+ if (state === "code") {
79
+ if (c === "/" && next === "/") {
80
+ state = "line";
81
+ i++;
82
+ }
83
+ else if (c === "/" && next === "*") {
84
+ state = "block";
85
+ i++;
86
+ }
87
+ else if (c === "'" || c === '"' || c === "`") {
88
+ state = c;
89
+ }
90
+ }
91
+ else if (state === "line") {
92
+ out += c;
93
+ if (c === "\n")
94
+ state = "code";
95
+ }
96
+ else if (state === "block") {
97
+ if (c === "*" && next === "/") {
98
+ state = "code";
99
+ i++;
100
+ out += "\n";
101
+ }
102
+ else {
103
+ out += c;
104
+ }
105
+ }
106
+ else if (c === "\\") {
107
+ i++;
108
+ }
109
+ else if (c === state || (c === "\n" && state !== "`")) {
110
+ state = "code";
111
+ }
112
+ }
113
+ return out;
114
+ }
58
115
  export function scanR47(files) {
59
116
  const scan = emptyScan();
60
117
  for (const f of files) {
@@ -86,6 +143,11 @@ export function scanR47(files) {
86
143
  .map((l) => l.raw)
87
144
  .join("\n");
88
145
  const hasRefusal = REFUSAL.test(blockText);
146
+ // The justification token is a WRITTEN RECORD, so it is read from the
147
+ // handler's COMMENTS only, found by a lexer (`commentsOf`), never by line
148
+ // shape. A string or template literal is executing data and never
149
+ // justifies (Argus REVISE, PR #32 @515ca1f and @6b2b6ad).
150
+ const commentText = commentsOf(block.lines.map((l) => l.raw).join("\n"));
89
151
  for (const st of statements(block.lines)) {
90
152
  const isUnbounded = COLLECT.test(st.text) && !BOUNDED.test(st.text);
91
153
  if (!isUnbounded)
@@ -106,11 +168,22 @@ export function scanR47(files) {
106
168
  note: `${fn}: wide non-index-bounded scan on "${table}" but the handler carries a REFUSAL path (R-16 error contract naming the narrowing args) — the correct R-47 behaviour (the tasks.list SCAN_CAP_EXCEEDED shape)`,
107
169
  });
108
170
  }
171
+ else if (hasJustificationTokenInText(commentText)) {
172
+ // judgement retired (task k179jnhx, §23): flagged — no refusal path;
173
+ // the enclosing handler's written JUSTIFIÉ:/JUSTIFIED: token IS the
174
+ // accepted remedy record (refuse / tighter index / declared bound),
175
+ // decided by presence, never a person's opinion of which is right.
176
+ scan.conform.push({
177
+ rel: f.rel,
178
+ n: st.n,
179
+ note: `${fn}: wide, non-index-bounded \`.query("${table}").collect()\` with NO refusal path, but the handler carries a written JUSTIFIÉ:/JUSTIFIED: token — accepted, not silently passed`,
180
+ });
181
+ }
109
182
  else {
110
- scan.cnj.push({
183
+ scan.violate.push({
111
184
  rel: f.rel,
112
185
  n: st.n,
113
- note: `${fn}: wide, non-index-bounded \`.query("${table}").collect()\` in a query with NO refusal path — the read cap can truncate it BEFORE any filter runs, returning a fraction indistinguishable from a full match. The human half is owed: refuse (R-16), add a tighter index, or declare a bound (R-30)? Never a silent green`,
186
+ note: `${fn}: wide, non-index-bounded \`.query("${table}").collect()\` in a query with NO refusal path and no written JUSTIFIÉ:/JUSTIFIED: token — the read cap can truncate it BEFORE any filter runs, returning a fraction indistinguishable from a full match. Owes a fix (refuse (R-16) / tighter index / declared bound (R-30)) or a recorded justification`,
114
187
  });
115
188
  }
116
189
  }
@@ -23,6 +23,7 @@
23
23
  // matched so the branch is provably reachable.
24
24
  import { existsSync, readFileSync } from "node:fs";
25
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;
@@ -590,7 +1019,7 @@ export function evalR36(root) {
590
1019
  function srcDetector(ruleId, id, check, scanFn, emptyReason) {
591
1020
  return { id, run: (tree) => decide(ruleId, id, check, scanFn(tree.files), emptyReason) };
592
1021
  }
593
- 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.");
594
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.");
595
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.");
596
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.");