@vantageos/backend-doctor 0.1.14 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -5
- package/dist/args.d.ts +16 -0
- package/dist/args.js +72 -0
- package/dist/build-stamp.d.ts +13 -0
- package/dist/build-stamp.js +76 -0
- package/dist/build-stamp.json +4 -0
- package/dist/caller-resolution.d.ts +222 -0
- package/dist/caller-resolution.js +1198 -0
- package/dist/cli.js +249 -17
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage-cli.js +8 -0
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.d.ts +5 -3
- package/dist/detectors/oracle-axis1.js +28 -18
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +37 -22
- package/dist/detectors/predicates.d.ts +3 -0
- package/dist/detectors/predicates.js +93 -19
- package/dist/detectors/shared.js +8 -7
- package/dist/detectors/source/_ast.d.ts +92 -0
- package/dist/detectors/source/_ast.js +353 -0
- package/dist/detectors/source/_scan.js +4 -3
- package/dist/detectors/source/backfill.js +71 -18
- package/dist/detectors/source/contract.js +21 -13
- package/dist/detectors/source/cross-tenant-write.d.ts +46 -0
- package/dist/detectors/source/cross-tenant-write.js +1408 -0
- package/dist/detectors/source/guard-helper-flow.d.ts +134 -0
- package/dist/detectors/source/guard-helper-flow.js +1216 -0
- package/dist/detectors/source/identity-by-id.d.ts +80 -0
- package/dist/detectors/source/identity-by-id.js +1783 -0
- package/dist/detectors/source/identity.d.ts +58 -2
- package/dist/detectors/source/identity.js +1158 -28
- package/dist/detectors/source/isolation.d.ts +11 -1
- package/dist/detectors/source/isolation.js +106 -25
- package/dist/detectors/source/jobs.js +178 -4
- package/dist/detectors/source/lifecycle.js +14 -2
- package/dist/detectors/source/local-identity.d.ts +26 -0
- package/dist/detectors/source/local-identity.js +661 -0
- package/dist/detectors/source/package-principal.d.ts +9 -0
- package/dist/detectors/source/package-principal.js +177 -0
- package/dist/detectors/source/r6-shapes.d.ts +20 -0
- package/dist/detectors/source/r6-shapes.js +285 -0
- package/dist/detectors/source/readbudget.d.ts +1 -0
- package/dist/detectors/source/readbudget.js +358 -4
- package/dist/detectors/source/recipient-by-id.d.ts +40 -0
- package/dist/detectors/source/recipient-by-id.js +1108 -0
- package/dist/detectors/source/refusal.d.ts +14 -0
- package/dist/detectors/source/refusal.js +75 -2
- package/dist/detectors/source/security.d.ts +16 -0
- package/dist/detectors/source/security.js +680 -33
- package/dist/detectors/source/write-isolation.d.ts +8 -1
- package/dist/detectors/source/write-isolation.js +64 -30
- package/dist/detectors/source/writesafety.js +27 -14
- package/dist/detectors/standard/_doc.d.ts +1 -1
- package/dist/detectors/standard/_doc.js +5 -4
- package/dist/detectors/standard/r41-frontier.js +3 -2
- package/dist/detectors/standard/r46-returns-validator.d.ts +8 -0
- package/dist/detectors/standard/r46-returns-validator.js +48 -16
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +84 -48
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/oracle.d.ts +92 -3
- package/dist/oracle.js +229 -12
- package/dist/registry.d.ts +8 -1
- package/dist/registry.js +74 -8
- package/dist/report.d.ts +11 -1
- package/dist/report.js +15 -2
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/state-rank.d.ts +5 -0
- package/dist/state-rank.js +9 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +15 -13
- package/dist/target.d.ts +32 -0
- package/dist/target.js +173 -0
- package/dist/violation-count.d.ts +48 -0
- package/dist/violation-count.js +94 -0
- package/fixtures/backend-standard-2825122.md +722 -0
- package/fixtures/backend-standard-4dcd3e0.md +722 -0
- package/fixtures/cr1-classes/_generated/api.d.ts +24 -0
- package/fixtures/cr1-classes/surface.ts +206 -0
- package/fixtures/cr1-empty/nothing.ts +1 -0
- package/fixtures/cr1-refusal-on-resolved/_generated/api.d.ts +24 -0
- package/fixtures/cr1-refusal-on-resolved/doors.ts +180 -0
- package/fixtures/cr1-refusal-on-resolved/lib/auth.ts +104 -0
- package/fixtures/r34-vp-auth-30e821b/auth-805-812.ts.txt +8 -0
- package/fixtures/r34-vp-auth-30e821b/auth-878-890.ts.txt +13 -0
- package/fixtures/rule-classes-2825122.json +228 -0
- package/fixtures/vp-writer-tier-1e4ab36.csv +55 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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) || !
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 (
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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.");
|