@vantageos/backend-doctor 0.1.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 +88 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +94 -0
- package/dist/contract.d.ts +18 -0
- package/dist/contract.js +29 -0
- package/dist/coverage-cli.d.ts +2 -0
- package/dist/coverage-cli.js +17 -0
- package/dist/coverage.d.ts +32 -0
- package/dist/coverage.js +77 -0
- package/dist/detectors/oracle-axis1.d.ts +86 -0
- package/dist/detectors/oracle-axis1.js +221 -0
- package/dist/detectors/oracle-axis2.d.ts +22 -0
- package/dist/detectors/oracle-axis2.js +149 -0
- package/dist/detectors/predicates.d.ts +19 -0
- package/dist/detectors/predicates.js +334 -0
- package/dist/detectors/shared.d.ts +56 -0
- package/dist/detectors/shared.js +103 -0
- package/dist/detectors/source/_scan.d.ts +43 -0
- package/dist/detectors/source/_scan.js +82 -0
- package/dist/detectors/source/backfill.d.ts +27 -0
- package/dist/detectors/source/backfill.js +211 -0
- package/dist/detectors/source/contract.d.ts +33 -0
- package/dist/detectors/source/contract.js +385 -0
- package/dist/detectors/source/identity.d.ts +47 -0
- package/dist/detectors/source/identity.js +410 -0
- package/dist/detectors/source/jobs.d.ts +10 -0
- package/dist/detectors/source/jobs.js +209 -0
- package/dist/detectors/source/lifecycle.d.ts +6 -0
- package/dist/detectors/source/lifecycle.js +147 -0
- package/dist/detectors/source/readbudget.d.ts +6 -0
- package/dist/detectors/source/readbudget.js +133 -0
- package/dist/detectors/source/refusal.d.ts +6 -0
- package/dist/detectors/source/refusal.js +125 -0
- package/dist/detectors/source/security.d.ts +37 -0
- package/dist/detectors/source/security.js +392 -0
- package/dist/detectors/source/writesafety.d.ts +14 -0
- package/dist/detectors/source/writesafety.js +345 -0
- package/dist/detectors/standard/_doc.d.ts +66 -0
- package/dist/detectors/standard/_doc.js +152 -0
- package/dist/detectors/standard/r41-frontier.d.ts +58 -0
- package/dist/detectors/standard/r41-frontier.js +176 -0
- package/dist/detectors/standard/r46-returns-validator.d.ts +61 -0
- package/dist/detectors/standard/r46-returns-validator.js +474 -0
- package/dist/detectors/standard/standard-doc.d.ts +12 -0
- package/dist/detectors/standard/standard-doc.js +271 -0
- package/dist/detectors/structural.d.ts +14 -0
- package/dist/detectors/structural.js +83 -0
- package/dist/engine-types.d.ts +10 -0
- package/dist/engine-types.js +1 -0
- package/dist/not-checkable.d.ts +22 -0
- package/dist/not-checkable.js +37 -0
- package/dist/oracle.d.ts +33 -0
- package/dist/oracle.js +133 -0
- package/dist/registry.d.ts +11 -0
- package/dist/registry.js +140 -0
- package/dist/report.d.ts +35 -0
- package/dist/report.js +138 -0
- package/dist/severity.d.ts +30 -0
- package/dist/severity.js +121 -0
- package/dist/tally.d.ts +57 -0
- package/dist/tally.js +152 -0
- package/dist/trigger.d.ts +47 -0
- package/dist/trigger.js +143 -0
- package/fixtures/backend-standard-51ec7af1.md +648 -0
- package/fixtures/backend-standard-a9d7877b.md +629 -0
- package/fixtures/mcp-app-standard-a74a042b.md +279 -0
- package/fixtures/mcp-extension-standard-a74a042b.md +259 -0
- package/fixtures/mcp-server-standard-a74a042b.md +434 -0
- package/fixtures/mcp-standard-a74a042b.md +354 -0
- package/fixtures/vp-1200-green/.pin +1 -0
- package/fixtures/vp-1200-green/convex/migrations.ts +350 -0
- package/fixtures/vp-1200-green/convex/schema.ts +1243 -0
- package/fixtures/vp-1200-red/.pin +1 -0
- package/fixtures/vp-1200-red/convex/migrations.ts +239 -0
- package/fixtures/vp-1200-red/convex/schema.ts +1243 -0
- package/fixtures/vp-by-tool-day158.csv +190 -0
- package/fixtures/vp-convex/.github/workflows/npm-publish.yml +163 -0
- package/fixtures/vp-convex/.pin +1 -0
- package/fixtures/vp-convex/bun.lock +783 -0
- package/fixtures/vp-convex/convex/auth.config.ts +8 -0
- package/fixtures/vp-convex/convex/briefingNotes.ts +321 -0
- package/fixtures/vp-convex/convex/convex.config.ts +14 -0
- package/fixtures/vp-convex/convex/credentials.ts +591 -0
- package/fixtures/vp-convex/convex/crons.ts +72 -0
- package/fixtures/vp-convex/convex/dashboard.ts +199 -0
- package/fixtures/vp-convex/convex/gumroadWebhook.ts +815 -0
- package/fixtures/vp-convex/convex/issues.ts +544 -0
- package/fixtures/vp-convex/convex/kb.ts +305 -0
- package/fixtures/vp-convex/convex/kbMutations.ts +192 -0
- package/fixtures/vp-convex/convex/kbShared.ts +56 -0
- package/fixtures/vp-convex/convex/licenses.ts +443 -0
- package/fixtures/vp-convex/convex/memories.ts +406 -0
- package/fixtures/vp-convex/convex/memoriesScoped.ts +220 -0
- package/fixtures/vp-convex/convex/messages.ts +971 -0
- package/fixtures/vp-convex/convex/migrations.ts +239 -0
- package/fixtures/vp-convex/convex/missionTemplates.ts +369 -0
- package/fixtures/vp-convex/convex/oauth.ts +1608 -0
- package/fixtures/vp-convex/convex/oauthMigrations.ts +96 -0
- package/fixtures/vp-convex/convex/okfBundleNode.ts +1031 -0
- package/fixtures/vp-convex/convex/recurringTasks.ts +386 -0
- package/fixtures/vp-convex/convex/schema.ts +1229 -0
- package/fixtures/vp-convex/convex/tasks.ts +2782 -0
- package/fixtures/vp-r31-green/.pin +1 -0
- package/fixtures/vp-r31-green/convex/migrations.ts +350 -0
- package/fixtures/vp-r31-red/.pin +1 -0
- package/fixtures/vp-r31-red/convex/migrations.ts +286 -0
- package/fixtures/vr-by-tool-day159.csv +89 -0
- package/package.json +39 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// The SIX SOURCE-TREE security detectors — R-21, R-22, R-33, R-34, R-35, R-36.
|
|
2
|
+
//
|
|
3
|
+
// Wave-1 detectors consult the ORACLE CSV and IGNORE the tree. These six are a
|
|
4
|
+
// NEW detector kind: they read the REAL backend SOURCE the engine hands each
|
|
5
|
+
// detector in `run(tree)`. Each scans `tree.files` (`.rel` path, `.lines[]` with
|
|
6
|
+
// `.n`, `.raw`, `.comment`) — and, for R-36, reads two co-artifacts at
|
|
7
|
+
// `tree.root` (the lockfile and the publish workflow) the way a runtime detector
|
|
8
|
+
// reads `evals.json` at `runtime.root`.
|
|
9
|
+
//
|
|
10
|
+
// THE LAW (the one that caught R-5): every predicate is derived from the ACTUAL
|
|
11
|
+
// SOURCE the detector opens, NEVER from the standard's prose illustration. The
|
|
12
|
+
// clearest case is R-33: §9a of the pinned standard names `okfBundleNode.ts:916`
|
|
13
|
+
// as a raw UNGUARDED fetch, but the real source at lines 915-916 now reads
|
|
14
|
+
// `const url = assertBundleUrlSafe(args.bundleUrl); const res = await fetch(url)`
|
|
15
|
+
// — the site is GUARDED. The detector derives its predicate from the source, so
|
|
16
|
+
// it reports both okf fetch sites as conform and records that the §9a-illustrated
|
|
17
|
+
// gap is remediated. The stale prose does not override the source.
|
|
18
|
+
//
|
|
19
|
+
// Three states only (present / non-conforming / could-not-judge) — plus `absent`
|
|
20
|
+
// where a scan legitimately finds no applicable site. A could-not-judge ALWAYS
|
|
21
|
+
// carries a machine-readable `reason`; a scan that matched zero applicable sites
|
|
22
|
+
// abstains (never a silent pass). Every zero-count branch names the real site it
|
|
23
|
+
// matched so the branch is provably reachable.
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
/** The standard all six are pinned against (short hash a9d7877b). */
|
|
27
|
+
export const SOURCE_STANDARD_PIN = "analysis/vantagepeers/backend-standard/backend-standard.md @ a9d7877b4947ed8ae61d234433debb2182a2ba44 (elpiarthera/ElPi-Corp) §15";
|
|
28
|
+
function emptyScan() {
|
|
29
|
+
return { conform: [], violate: [], cnj: [] };
|
|
30
|
+
}
|
|
31
|
+
/** basename of a `rel` path, lower-cased. */
|
|
32
|
+
function base(rel) {
|
|
33
|
+
return (rel.split("/").pop() ?? rel).toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
/** a source file we should judge — never a test or generated file. */
|
|
36
|
+
function isJudgeable(f) {
|
|
37
|
+
const b = base(f.rel);
|
|
38
|
+
return !b.endsWith(".test.ts") && !f.rel.includes("__tests__") && !f.rel.includes("_generated/");
|
|
39
|
+
}
|
|
40
|
+
/** an EXECUTING line (not a comment-only line). Predicates must read code, not
|
|
41
|
+
* prose — this is what makes the neuter-proof land on an executing line. */
|
|
42
|
+
function code(l) {
|
|
43
|
+
return !l.comment;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reduce a scan to ONE engine DetectorResult.
|
|
47
|
+
*
|
|
48
|
+
* Precedence for the SOURCE kind: a concrete VIOLATION site is decisive
|
|
49
|
+
* (non-conforming) — a found bad site is never a silent pass and outranks an
|
|
50
|
+
* abstain; else any could-not-judge → could-not-judge WITH a reason; else any
|
|
51
|
+
* conform site → present; else (zero applicable sites) → could-not-judge naming
|
|
52
|
+
* the empty scan (an instrument that matched nothing has NOT certified a clean
|
|
53
|
+
* bill). This divergence from the oracle wave's cnj-first order is deliberate:
|
|
54
|
+
* a source violation is a real file:line, not a denominator question.
|
|
55
|
+
*/
|
|
56
|
+
function decide(ruleId, id, check, scan, emptyReason) {
|
|
57
|
+
const nC = scan.conform.length;
|
|
58
|
+
const nV = scan.violate.length;
|
|
59
|
+
const nJ = scan.cnj.length;
|
|
60
|
+
const breakdown = `[conform=${nC} non-conforming=${nV} could-not-judge=${nJ}]`;
|
|
61
|
+
const fmt = (s) => s.slice(0, 3).map((x) => `${x.rel}:${x.n} (${x.note})`).join("; ");
|
|
62
|
+
if (nV > 0) {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
anchor: `${scan.violate[0].rel}:${scan.violate[0].n}`,
|
|
66
|
+
state: "non-conforming",
|
|
67
|
+
verdict: `${ruleId}: ${nV} site(s) VIOLATE (${check}). ${breakdown} Violations: ${fmt(scan.violate)}.${nC ? ` Conform site(s): ${fmt(scan.conform)}.` : ""}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (nJ > 0) {
|
|
71
|
+
const reason = `${ruleId}: ${nJ} site(s) could not be judged — ${fmt(scan.cnj)}. This is NOT a clean bill. ${breakdown}`;
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
anchor: `${scan.cnj[0].rel}:${scan.cnj[0].n}`,
|
|
75
|
+
state: "could-not-judge",
|
|
76
|
+
verdict: reason,
|
|
77
|
+
reason,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (nC > 0) {
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
anchor: `${scan.conform[0].rel}:${scan.conform[0].n}`,
|
|
84
|
+
state: "present",
|
|
85
|
+
verdict: `${ruleId}: ${nC} applicable site(s) conform (${check}); 0 violations. ${breakdown} Conform: ${fmt(scan.conform)}.`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// zero applicable sites — abstain with a machine-readable reason, never a pass.
|
|
89
|
+
return {
|
|
90
|
+
id,
|
|
91
|
+
anchor: "source:no-applicable-site",
|
|
92
|
+
state: "could-not-judge",
|
|
93
|
+
verdict: emptyReason,
|
|
94
|
+
reason: emptyReason,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// R-21 — an inbound webhook verifies its signature, is idempotent on its
|
|
99
|
+
// delivery id, and fails closed. (standard §15 line 121 / §16 line 257)
|
|
100
|
+
//
|
|
101
|
+
// SOURCE READ: convex/gumroadWebhook.ts — `verifyGumroadSignature` builds
|
|
102
|
+
// `createHmac("sha256", secret)` (line 503), the handler returns `status: 401`
|
|
103
|
+
// on a missing/invalid signature (lines 590, 603) BEFORE any mutation, and keys
|
|
104
|
+
// idempotency on `gumroadOrderId` from `sale_id`/`order_id` (lines ~539-546).
|
|
105
|
+
// PATTERN derived from that file: a webhook path must (a) verify a signature
|
|
106
|
+
// (createHmac / verify*Signature / timingSafeEqual / hmac), (b) fail closed with
|
|
107
|
+
// a 401/403 reject, (c) key on a delivery id. A webhook file with no signature
|
|
108
|
+
// verification is the violation.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
const SIG_TOKEN = /createhmac|verify\w*signature|timingsafeequal|\bhmac\b|x-hub-signature|x-gumroad/i;
|
|
111
|
+
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) {
|
|
114
|
+
return /webhook/i.test(base(f.rel));
|
|
115
|
+
}
|
|
116
|
+
export function scanR21(files) {
|
|
117
|
+
const scan = emptyScan();
|
|
118
|
+
for (const f of files) {
|
|
119
|
+
if (!isJudgeable(f) || !isWebhookFile(f))
|
|
120
|
+
continue;
|
|
121
|
+
let sigLine = 0;
|
|
122
|
+
let failClosed = false;
|
|
123
|
+
let delivery = false;
|
|
124
|
+
for (const l of f.lines) {
|
|
125
|
+
if (!code(l))
|
|
126
|
+
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;
|
|
133
|
+
}
|
|
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" });
|
|
137
|
+
}
|
|
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" });
|
|
140
|
+
}
|
|
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" });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
scan.conform.push({ rel: f.rel, n: sigLine, note: "signature verified (createHmac) + fail-closed 401 + delivery-id idempotency" });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return scan;
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// R-22 — a secret is read from the environment, never committed, returned, or
|
|
152
|
+
// logged. (standard §15 line 123)
|
|
153
|
+
//
|
|
154
|
+
// SOURCE READ: convex/auth.config.ts references `process.env.CLERK_JWT_ISSUER_
|
|
155
|
+
// DOMAIN` (line 4) — a conform env-read, no literal. PATTERN: a line holding a
|
|
156
|
+
// secret-SHAPED literal (a provider key/token/PEM assigned as a string) is the
|
|
157
|
+
// violation; a `process.env.<SECRET>` read is the conform evidence. A grep of
|
|
158
|
+
// convex/ + mcp-server/src returns ZERO literals today, so the env-reads are the
|
|
159
|
+
// reachable conform sites and the violation half is exercised by a crafted line.
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
const SECRET_LITERAL = /["'`](sk-[A-Za-z0-9]{16,}|sk_(live|test)_[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)/;
|
|
162
|
+
const ENV_SECRET_READ = /process\.env\.[A-Z0-9_]+/;
|
|
163
|
+
function isConvexOrMcp(f) {
|
|
164
|
+
return /(^|\/)convex\//.test(f.rel) || /(^|\/)mcp-server\/src\//.test(f.rel);
|
|
165
|
+
}
|
|
166
|
+
export function scanR22(files) {
|
|
167
|
+
const scan = emptyScan();
|
|
168
|
+
for (const f of files) {
|
|
169
|
+
if (!isJudgeable(f) || !isConvexOrMcp(f))
|
|
170
|
+
continue;
|
|
171
|
+
for (const l of f.lines) {
|
|
172
|
+
if (!code(l))
|
|
173
|
+
continue;
|
|
174
|
+
if (SECRET_LITERAL.test(l.raw)) {
|
|
175
|
+
scan.violate.push({ rel: f.rel, n: l.n, note: "hardcoded secret-shaped literal committed to source" });
|
|
176
|
+
}
|
|
177
|
+
else if (ENV_SECRET_READ.test(l.raw)) {
|
|
178
|
+
scan.conform.push({ rel: f.rel, n: l.n, note: "secret read from process.env, not a literal" });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return scan;
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// R-33 — an outbound fetch to a caller-influenced address is SSRF-guarded.
|
|
186
|
+
// (standard §15 line 149 / §9a inventory lines 184-196)
|
|
187
|
+
//
|
|
188
|
+
// SOURCE READ: convex/okfBundleNode.ts — `assertBundleUrlSafe` (line 555) is the
|
|
189
|
+
// scheme+range validator; the first fetch at line 702 uses its result
|
|
190
|
+
// (`fetch(safeUrl.toString())`) and the SECOND fetch at line 916 is now
|
|
191
|
+
// `const url = assertBundleUrlSafe(args.bundleUrl); const res = await fetch(url)`
|
|
192
|
+
// (guard at 915). §9a's prose still labels 916 "raw, unguarded" — STALE. The
|
|
193
|
+
// detector derives its predicate from the source: a caller-influenced fetch is
|
|
194
|
+
// conform iff a `assertBundleUrlSafe` guard feeds it within the preceding window;
|
|
195
|
+
// unguarded is the violation. Both okf sites are conform today (§9a gap
|
|
196
|
+
// remediated); the violation branch is exercised by a crafted unguarded fetch.
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
const GUARD_TOKEN = /assertBundleUrlSafe|assertUrlSafe|ssrf|validateUrl|isUrlSafe/i;
|
|
199
|
+
const FETCH_CALL = /\bfetch\s*\(/;
|
|
200
|
+
const GUARD_WINDOW = 5;
|
|
201
|
+
/** the §9a caller-influenced fetch surface: the bundle/kb import files. */
|
|
202
|
+
function isBundleFetchFile(f) {
|
|
203
|
+
const b = base(f.rel);
|
|
204
|
+
return /okfbundle/.test(b) || /^kb/.test(b);
|
|
205
|
+
}
|
|
206
|
+
export function scanR33(files) {
|
|
207
|
+
const scan = emptyScan();
|
|
208
|
+
for (const f of files) {
|
|
209
|
+
if (!isJudgeable(f) || !isBundleFetchFile(f))
|
|
210
|
+
continue;
|
|
211
|
+
for (let i = 0; i < f.lines.length; i++) {
|
|
212
|
+
const l = f.lines[i];
|
|
213
|
+
if (!code(l) || !FETCH_CALL.test(l.raw))
|
|
214
|
+
continue;
|
|
215
|
+
// a fetch of a fixed string-literal host is the backend's OWN choice, not
|
|
216
|
+
// caller-influenced — out of R-33's surface.
|
|
217
|
+
if (/fetch\s*\(\s*["'`]https?:\/\//.test(l.raw))
|
|
218
|
+
continue;
|
|
219
|
+
let guarded = false;
|
|
220
|
+
let guardLine = 0;
|
|
221
|
+
for (let j = Math.max(0, i - GUARD_WINDOW); j <= i; j++) {
|
|
222
|
+
if (code(f.lines[j]) && GUARD_TOKEN.test(f.lines[j].raw)) {
|
|
223
|
+
guarded = true;
|
|
224
|
+
guardLine = f.lines[j].n;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (guarded) {
|
|
229
|
+
scan.conform.push({ rel: f.rel, n: l.n, note: `caller-influenced fetch guarded by assertBundleUrlSafe at line ${guardLine}` });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
scan.violate.push({ rel: f.rel, n: l.n, note: "caller-influenced outbound fetch with no SSRF guard (assertBundleUrlSafe) before dereference" });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return scan;
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// R-34 — an outbound call carries the backend's own credential (never the
|
|
240
|
+
// caller's), and an inbound token's audience is validated. (standard §15 line 159)
|
|
241
|
+
//
|
|
242
|
+
// SOURCE READ: convex/credentials.ts — inbound Clerk JWT verification asserts the
|
|
243
|
+
// audience against `process.env.VP_CLERK_EXPECTED_AUD` (line 188), matching
|
|
244
|
+
// `claims.aud` (lines 194-197) and refusing with "JWT aud claim mismatch" (line
|
|
245
|
+
// 200). PATTERN, two halves: (a) a file that verifies an inbound JWT (reads
|
|
246
|
+
// `claims.aud` / an issuer check) MUST assert audience — else violation; (b) an
|
|
247
|
+
// outbound `Authorization`/`Bearer` header whose value is a caller-supplied token
|
|
248
|
+
// (forwarded from an inbound header) is a passthrough violation. credentials.ts
|
|
249
|
+
// is the conform site; no passthrough exists today.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
const INBOUND_JWT = /claims\.(aud|iss)\b|\.iss\b.*mismatch|verify\w*jwt/i;
|
|
252
|
+
const AUD_ASSERT = /VP_CLERK_EXPECTED_AUD|aud claim mismatch|audMatches|expectedAud/i;
|
|
253
|
+
const OUTBOUND_PASSTHROUGH = /["'`]?authorization["'`]?\s*[:=][^,\n]*(headers\.get\(\s*["'`]authorization|req(uest)?\.headers|callerToken|incomingToken|passthrough)/i;
|
|
254
|
+
export function scanR34(files) {
|
|
255
|
+
const scan = emptyScan();
|
|
256
|
+
for (const f of files) {
|
|
257
|
+
if (!isJudgeable(f) || !isConvexOrMcp(f))
|
|
258
|
+
continue;
|
|
259
|
+
let inboundLine = 0;
|
|
260
|
+
let audAssert = 0;
|
|
261
|
+
for (const l of f.lines) {
|
|
262
|
+
if (!code(l))
|
|
263
|
+
continue;
|
|
264
|
+
if (!inboundLine && INBOUND_JWT.test(l.raw))
|
|
265
|
+
inboundLine = l.n;
|
|
266
|
+
if (!audAssert && AUD_ASSERT.test(l.raw))
|
|
267
|
+
audAssert = l.n;
|
|
268
|
+
if (OUTBOUND_PASSTHROUGH.test(l.raw)) {
|
|
269
|
+
scan.violate.push({ rel: f.rel, n: l.n, note: "outbound Authorization header forwards a caller-supplied token verbatim (token passthrough)" });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (inboundLine) {
|
|
273
|
+
if (audAssert) {
|
|
274
|
+
scan.conform.push({ rel: f.rel, n: audAssert, note: "inbound JWT audience asserted (VP_CLERK_EXPECTED_AUD / aud claim mismatch)" });
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
scan.violate.push({ rel: f.rel, n: inboundLine, note: "inbound JWT verified with no audience assertion — accepts a token minted for another party" });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return scan;
|
|
282
|
+
}
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// R-35 — content the backend returns toward a model is untrusted DATA, never
|
|
285
|
+
// instructions. (standard §15 line 162)
|
|
286
|
+
//
|
|
287
|
+
// SOURCE READ: a sweep of convex/ + mcp-server/src for a fetched/stored value
|
|
288
|
+
// interpolated into an instruction-position string (a var named system / prompt /
|
|
289
|
+
// instruction built with a template literal that splices content) with NO
|
|
290
|
+
// provenance marker. No such splice site exists in the scanned paths today, so
|
|
291
|
+
// the detector reports honestly (absent → abstain naming the empty scan, never a
|
|
292
|
+
// silent pass) and the violation branch is exercised by a crafted splice line.
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
const INSTRUCTION_LHS = /(const|let|var)\s+\w*(system|prompt|instruction)\w*\s*[:=]/i;
|
|
295
|
+
const CONTENT_SPLICE = /\$\{[^}]*(chunk|doc|content|corpus|row\.|result|body|fetched|message\.text|payload)/i;
|
|
296
|
+
const PROVENANCE_MARKER = /provenance|untrusted|source\s*:|data\s*:|role\s*:\s*["'`]user/i;
|
|
297
|
+
export function scanR35(files) {
|
|
298
|
+
const scan = emptyScan();
|
|
299
|
+
for (const f of files) {
|
|
300
|
+
if (!isJudgeable(f) || !isConvexOrMcp(f))
|
|
301
|
+
continue;
|
|
302
|
+
for (const l of f.lines) {
|
|
303
|
+
if (!code(l))
|
|
304
|
+
continue;
|
|
305
|
+
if (INSTRUCTION_LHS.test(l.raw) && CONTENT_SPLICE.test(l.raw)) {
|
|
306
|
+
if (PROVENANCE_MARKER.test(l.raw)) {
|
|
307
|
+
scan.conform.push({ rel: f.rel, n: l.n, note: "content splice into instruction position carries a provenance marker" });
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
scan.violate.push({ rel: f.rel, n: l.n, note: "third-party/stored content spliced into a system/instruction position with no provenance marker (LLM01)" });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return scan;
|
|
316
|
+
}
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// R-36 — the backend's dependencies are pinned and the artifact carries
|
|
319
|
+
// provenance. (standard §15 line 165)
|
|
320
|
+
//
|
|
321
|
+
// CO-ARTIFACT READ at `tree.root`: the lockfile (bun.lock — pins every dep by
|
|
322
|
+
// construction) and .github/workflows/npm-publish.yml, whose publish step runs
|
|
323
|
+
// `npm publish --provenance --access public` (line 163). PATTERN: a lockfile MUST
|
|
324
|
+
// be present (the pin) AND a publish path MUST carry `--provenance`. A missing
|
|
325
|
+
// lockfile, or a publish with no `--provenance`, is the violation; no publish
|
|
326
|
+
// workflow at all is could-not-judge.
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
const LOCKFILES = ["bun.lock", "bun.lockb", "package-lock.json", "pnpm-lock.yaml", "yarn.lock"];
|
|
329
|
+
const WORKFLOW_DIR = ".github/workflows";
|
|
330
|
+
const PUBLISH_HINT = /npm\s+publish|release|publish/i;
|
|
331
|
+
export function evalR36(root) {
|
|
332
|
+
const scan = emptyScan();
|
|
333
|
+
const lock = LOCKFILES.find((n) => existsSync(join(root, n)));
|
|
334
|
+
if (!lock) {
|
|
335
|
+
scan.violate.push({ rel: "(root)", n: 0, note: "no lockfile present — dependencies are not pinned" });
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
scan.conform.push({ rel: lock, n: 1, note: "lockfile present — every dependency pinned by the lockfile" });
|
|
339
|
+
}
|
|
340
|
+
const wfDir = join(root, WORKFLOW_DIR);
|
|
341
|
+
let publishFile = "";
|
|
342
|
+
let provenance = false;
|
|
343
|
+
let sawPublish = false;
|
|
344
|
+
if (existsSync(wfDir)) {
|
|
345
|
+
// read the conventional publish workflow directly; a broad dir walk is not
|
|
346
|
+
// needed — the standard names npm-publish as the publish path.
|
|
347
|
+
for (const name of ["npm-publish.yml", "npm-publish.yaml", "publish.yml", "release.yml"]) {
|
|
348
|
+
const p = join(wfDir, name);
|
|
349
|
+
if (!existsSync(p))
|
|
350
|
+
continue;
|
|
351
|
+
publishFile = `${WORKFLOW_DIR}/${name}`;
|
|
352
|
+
const body = readFileSync(p, "utf8");
|
|
353
|
+
if (PUBLISH_HINT.test(body))
|
|
354
|
+
sawPublish = true;
|
|
355
|
+
if (/--provenance/.test(body)) {
|
|
356
|
+
provenance = true;
|
|
357
|
+
const n = body.split("\n").findIndex((line) => line.includes("--provenance")) + 1;
|
|
358
|
+
scan.conform.push({ rel: publishFile, n, note: "publish step carries --provenance attestation" });
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!publishFile) {
|
|
364
|
+
scan.cnj.push({ rel: WORKFLOW_DIR, n: 0, note: "no publish workflow found at .github/workflows — provenance not statically confirmable here" });
|
|
365
|
+
}
|
|
366
|
+
else if (sawPublish && !provenance) {
|
|
367
|
+
scan.violate.push({ rel: publishFile, n: 1, note: "publish workflow has no --provenance flag — artifact ships unattested" });
|
|
368
|
+
}
|
|
369
|
+
return scan;
|
|
370
|
+
}
|
|
371
|
+
// ---- detector wrappers ----------------------------------------------------
|
|
372
|
+
function srcDetector(ruleId, id, check, scanFn, emptyReason) {
|
|
373
|
+
return { id, run: (tree) => decide(ruleId, id, check, scanFn(tree.files), emptyReason) };
|
|
374
|
+
}
|
|
375
|
+
export const R21 = srcDetector("R-21", "R-21 webhook signature-verified, idempotent, fail-closed", "a webhook path verifies its signature (createHmac), fails closed 401, keys idempotency on the delivery id", scanR21, "R-21: zero webhook files matched in the scanned tree — cannot certify webhook signature verification. NOT a clean bill.");
|
|
376
|
+
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
|
+
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
|
+
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.");
|
|
379
|
+
export const R35 = srcDetector("R-35", "R-35 model-facing content is untrusted data", "no third-party/stored content spliced into an instruction position without a provenance marker", scanR35, "R-35: no instruction-position content splice found on the scanned convex/mcp paths — absent (no such site), reported honestly, not a certified pass.");
|
|
380
|
+
export const R36 = {
|
|
381
|
+
id: "R-36 dependencies pinned + provenance attested",
|
|
382
|
+
run: (tree) => decide("R-36", "R-36 dependencies pinned + provenance attested", "a lockfile pins every dependency and the publish path carries --provenance", evalR36(tree.root), "R-36: could not read the target root for a lockfile or publish workflow. NOT a clean bill."),
|
|
383
|
+
};
|
|
384
|
+
/** The six, in a form the orchestrator spreads into REGISTRY. */
|
|
385
|
+
export const SOURCE_SECURITY_DETECTORS = [
|
|
386
|
+
{ detector: R21, ruleId: "R-21", standardPin: SOURCE_STANDARD_PIN },
|
|
387
|
+
{ detector: R22, ruleId: "R-22", standardPin: SOURCE_STANDARD_PIN },
|
|
388
|
+
{ detector: R33, ruleId: "R-33", standardPin: SOURCE_STANDARD_PIN },
|
|
389
|
+
{ detector: R34, ruleId: "R-34", standardPin: SOURCE_STANDARD_PIN },
|
|
390
|
+
{ detector: R35, ruleId: "R-35", standardPin: SOURCE_STANDARD_PIN },
|
|
391
|
+
{ detector: R36, ruleId: "R-36", standardPin: SOURCE_STANDARD_PIN },
|
|
392
|
+
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Detector } from "../../engine-types.js";
|
|
2
|
+
import { type Scan, type SourceDetectorEntry, type SrcFile } from "./_scan.js";
|
|
3
|
+
export declare function scanR17(files: SrcFile[]): Scan;
|
|
4
|
+
export declare function scanR18(files: SrcFile[]): Scan;
|
|
5
|
+
export declare function scanR29(files: SrcFile[]): Scan;
|
|
6
|
+
export declare function scanR30(files: SrcFile[]): Scan;
|
|
7
|
+
export declare function scanR31(files: SrcFile[]): Scan;
|
|
8
|
+
export declare const R17: Detector;
|
|
9
|
+
export declare const R18: Detector;
|
|
10
|
+
export declare const R29: Detector;
|
|
11
|
+
export declare const R30: Detector;
|
|
12
|
+
export declare const R31: Detector;
|
|
13
|
+
/** The five, in a form the orchestrator spreads into REGISTRY. */
|
|
14
|
+
export declare const SOURCE_WRITESAFETY_DETECTORS: SourceDetectorEntry[];
|