@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.
- package/README.md +48 -4
- package/dist/args.d.ts +1 -1
- package/dist/args.js +19 -1
- package/dist/build-stamp.json +2 -2
- package/dist/caller-resolution.js +11 -8
- package/dist/cli.js +164 -15
- package/dist/contract.d.ts +25 -7
- package/dist/contract.js +52 -7
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +2 -2
- package/dist/detectors/oracle-axis1.js +5 -4
- package/dist/detectors/oracle-axis2.d.ts +6 -0
- package/dist/detectors/oracle-axis2.js +32 -16
- package/dist/detectors/predicates.js +12 -3
- package/dist/detectors/shared.js +3 -2
- 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 +62 -17
- 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 +22 -0
- package/dist/detectors/source/guard-helper-flow.js +49 -6
- 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 +8 -1
- package/dist/detectors/source/identity.js +364 -35
- package/dist/detectors/source/jobs.js +13 -2
- 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.js +291 -0
- 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.js +455 -26
- 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.js +3 -2
- package/dist/detectors/structural.d.ts +6 -7
- package/dist/detectors/structural.js +75 -43
- package/dist/not-checkable.d.ts +1 -1
- package/dist/not-checkable.js +11 -0
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +27 -3
- package/dist/rule-class.d.ts +85 -0
- package/dist/rule-class.js +218 -0
- package/dist/tally.d.ts +13 -5
- package/dist/tally.js +14 -6
- 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/rule-classes-2825122.json +228 -0
- 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.
|
|
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.
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
|
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.");
|