@switchboard.spot/cli 0.2.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/LICENSE +7 -0
- package/README.md +95 -0
- package/bin/switchboard.js +61 -0
- package/lib/client.js +150 -0
- package/lib/commands/account.js +81 -0
- package/lib/commands/auth.js +369 -0
- package/lib/commands/billing.js +87 -0
- package/lib/commands/doctor.js +74 -0
- package/lib/commands/endUsers.js +51 -0
- package/lib/commands/env.js +393 -0
- package/lib/commands/health.js +24 -0
- package/lib/commands/init.js +75 -0
- package/lib/commands/integration.js +38 -0
- package/lib/commands/keys.js +106 -0
- package/lib/commands/org.js +55 -0
- package/lib/commands/projects.js +197 -0
- package/lib/commands/setup.js +76 -0
- package/lib/commands/usage.js +52 -0
- package/lib/commands/verify.js +143 -0
- package/lib/commands/workspaces.js +92 -0
- package/lib/config.js +132 -0
- package/lib/credentialStore.js +312 -0
- package/lib/output.js +112 -0
- package/lib/verify/index.js +762 -0
- package/package.json +49 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_ARTIFACTS_DIR = "switchboard-verification";
|
|
5
|
+
|
|
6
|
+
export const CHECK_IDS = {
|
|
7
|
+
PAGE_LOADS: "page_loads",
|
|
8
|
+
NO_CONSOLE_ERRORS: "no_console_errors",
|
|
9
|
+
NO_FAILED_REQUESTS: "no_failed_requests",
|
|
10
|
+
CLIENT_CONFIG_REACHABLE: "client_config_reachable",
|
|
11
|
+
ANONYMOUS_SESSION_CREATED: "anonymous_session_created",
|
|
12
|
+
CHAT_COMPLETION_SUCCEEDS: "chat_completion_succeeds",
|
|
13
|
+
SWITCHBOARD_REQUEST_OBSERVED: "switchboard_request_observed",
|
|
14
|
+
NO_SERVER_SECRET_EXPOSED: "no_server_secret_exposed",
|
|
15
|
+
NO_PROVIDER_SECRET_EXPOSED: "no_provider_secret_exposed",
|
|
16
|
+
NO_BROWSER_AUTHORIZATION_HEADER: "no_browser_authorization_header",
|
|
17
|
+
ORIGIN_ALLOWED: "origin_allowed",
|
|
18
|
+
PRODUCTION_SAFETY_READY: "production_safety_ready",
|
|
19
|
+
CORS_CREDENTIALS_SAFE: "cors_credentials_safe",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_SCENARIO = {
|
|
23
|
+
checks: [
|
|
24
|
+
{ type: "visit", url: "/" },
|
|
25
|
+
{ type: "noConsoleErrors" },
|
|
26
|
+
{ type: "noFailedRequests" },
|
|
27
|
+
{ type: "switchboardClientConfig" },
|
|
28
|
+
{ type: "switchboardAnonymousSession" },
|
|
29
|
+
{
|
|
30
|
+
type: "switchboardChat",
|
|
31
|
+
prompt: "Reply with exactly: switchboard verification ok",
|
|
32
|
+
},
|
|
33
|
+
{ type: "noSecretExposure" },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const CHECK_TYPE_TO_ID = new Map([
|
|
38
|
+
["visit", CHECK_IDS.PAGE_LOADS],
|
|
39
|
+
["noConsoleErrors", CHECK_IDS.NO_CONSOLE_ERRORS],
|
|
40
|
+
["noFailedRequests", CHECK_IDS.NO_FAILED_REQUESTS],
|
|
41
|
+
["switchboardClientConfig", CHECK_IDS.CLIENT_CONFIG_REACHABLE],
|
|
42
|
+
["switchboardAnonymousSession", CHECK_IDS.ANONYMOUS_SESSION_CREATED],
|
|
43
|
+
["switchboardChat", CHECK_IDS.CHAT_COMPLETION_SUCCEEDS],
|
|
44
|
+
["switchboardRequestObserved", CHECK_IDS.SWITCHBOARD_REQUEST_OBSERVED],
|
|
45
|
+
["noSecretExposure", CHECK_IDS.NO_SERVER_SECRET_EXPOSED],
|
|
46
|
+
["noBrowserAuthorizationHeader", CHECK_IDS.NO_BROWSER_AUTHORIZATION_HEADER],
|
|
47
|
+
["corsCredentialsSafe", CHECK_IDS.CORS_CREDENTIALS_SAFE],
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
const SERVER_SECRET_PATTERNS = [
|
|
51
|
+
{ name: "SWITCHBOARD_API_KEY", pattern: /\bSWITCHBOARD_API_KEY\b/ },
|
|
52
|
+
{ name: "switchboard live key", pattern: /\bsb_live_[A-Za-z0-9_-]{6,}\b/ },
|
|
53
|
+
{ name: "switchboard test key", pattern: /\bsb_test_[A-Za-z0-9_-]{6,}\b/ },
|
|
54
|
+
{ name: "account session token", pattern: /\bsb_sess_[A-Za-z0-9_-]{6,}\b/ },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const PROVIDER_SECRET_PATTERNS = [
|
|
58
|
+
{ name: "OpenAI API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{16,}\b/ },
|
|
59
|
+
{ name: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/ },
|
|
60
|
+
{ name: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const PROTECTED_AUTH_HOST_PATTERNS = [
|
|
64
|
+
/(^|\.)switchboard\.spot$/i,
|
|
65
|
+
/(^|\.)api\.openai\.com$/i,
|
|
66
|
+
/(^|\.)api\.anthropic\.com$/i,
|
|
67
|
+
/(^|\.)generativelanguage\.googleapis\.com$/i,
|
|
68
|
+
/(^|\.)api\.mistral\.ai$/i,
|
|
69
|
+
/(^|\.)api\.groq\.com$/i,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
73
|
+
|
|
74
|
+
export function loadScenario(filePath) {
|
|
75
|
+
if (!filePath) return DEFAULT_SCENARIO;
|
|
76
|
+
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new VerificationInputError(`Could not read scenario JSON: ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return validateScenario(parsed);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function validateScenario(scenario) {
|
|
88
|
+
if (!scenario || typeof scenario !== "object" || !Array.isArray(scenario.checks)) {
|
|
89
|
+
throw new VerificationInputError("Scenario must be a JSON object with a checks array.");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [index, check] of scenario.checks.entries()) {
|
|
93
|
+
if (!check || typeof check !== "object" || typeof check.type !== "string") {
|
|
94
|
+
throw new VerificationInputError(`Scenario check ${index} must have a string type.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!CHECK_TYPE_TO_ID.has(check.type)) {
|
|
98
|
+
throw new VerificationInputError(`Unknown scenario check type: ${check.type}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (check.type === "visit") {
|
|
102
|
+
validateVisitPath(check.url ?? "/");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return scenario;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function verifySwitchboardSetup(options = {}) {
|
|
110
|
+
return runVerification({ ...options, mode: "setup" });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function verifySwitchboardPublish(options = {}) {
|
|
114
|
+
return runVerification({ ...options, mode: "publish" });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function verifySwitchboardBrowser(options = {}) {
|
|
118
|
+
return runVerification({ ...options, mode: "browser" });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function runVerification(options = {}) {
|
|
122
|
+
const mode = options.mode ?? "setup";
|
|
123
|
+
const scenario = validateScenario(options.scenario ?? loadScenario(options.scenarioPath));
|
|
124
|
+
const report = createReport(mode);
|
|
125
|
+
const artifactsDir = options.artifactsDir ?? DEFAULT_ARTIFACTS_DIR;
|
|
126
|
+
const appUrl = parseRequiredUrl(options.url, "--url");
|
|
127
|
+
const clientUrl = options.clientUrl ? parseRequiredUrl(options.clientUrl, "--client-url") : null;
|
|
128
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
129
|
+
let productionOrigin = options.productionOrigin;
|
|
130
|
+
|
|
131
|
+
if (mode === "publish") {
|
|
132
|
+
validatePublishUrl(appUrl, "--url");
|
|
133
|
+
if (!options.productionOrigin) {
|
|
134
|
+
addFailure(report, CHECK_IDS.ORIGIN_ALLOWED, "--production-origin is required in publish mode.", {
|
|
135
|
+
severity: "critical",
|
|
136
|
+
code: "missing_production_origin",
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
const parsedProductionOrigin = parseRequiredUrl(options.productionOrigin, "--production-origin");
|
|
140
|
+
validatePublishUrl(parsedProductionOrigin, "--production-origin");
|
|
141
|
+
productionOrigin = parsedProductionOrigin.origin;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (scenarioNeedsClientUrl(scenario) && !clientUrl) {
|
|
146
|
+
addFailure(report, CHECK_IDS.CLIENT_CONFIG_REACHABLE, "--client-url is required for Switchboard scenario checks.", {
|
|
147
|
+
severity: "critical",
|
|
148
|
+
code: "missing_client_url",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!hasCriticalFindings(report)) {
|
|
153
|
+
await runBrowserScenario({
|
|
154
|
+
report,
|
|
155
|
+
mode,
|
|
156
|
+
appUrl,
|
|
157
|
+
clientUrl,
|
|
158
|
+
scenario,
|
|
159
|
+
artifactsDir,
|
|
160
|
+
headed: Boolean(options.headed),
|
|
161
|
+
timeoutMs,
|
|
162
|
+
prompt: options.prompt,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (mode === "publish") {
|
|
167
|
+
applyProjectProductionSafety(report, {
|
|
168
|
+
productionOrigin,
|
|
169
|
+
project: options.project,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
report.production_safety = "not_checked";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
finalizeReport(report);
|
|
176
|
+
return report;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function createReport(mode) {
|
|
180
|
+
return {
|
|
181
|
+
object: "verification_report",
|
|
182
|
+
mode,
|
|
183
|
+
status: "failed",
|
|
184
|
+
publishable: false,
|
|
185
|
+
functional: false,
|
|
186
|
+
secure: false,
|
|
187
|
+
production_safety: "blocked",
|
|
188
|
+
checks: [],
|
|
189
|
+
findings: [],
|
|
190
|
+
artifacts: {},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function evaluateEvidence(report, evidence) {
|
|
195
|
+
const consoleErrors = evidence.consoleMessages.filter((message) => message.type === "error");
|
|
196
|
+
addCheck(report, {
|
|
197
|
+
id: CHECK_IDS.NO_CONSOLE_ERRORS,
|
|
198
|
+
status: consoleErrors.length === 0 ? "passed" : "failed",
|
|
199
|
+
message:
|
|
200
|
+
consoleErrors.length === 0
|
|
201
|
+
? "No browser console errors were observed."
|
|
202
|
+
: `${consoleErrors.length} browser console error(s) were observed.`,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
addCheck(report, {
|
|
206
|
+
id: CHECK_IDS.NO_FAILED_REQUESTS,
|
|
207
|
+
status: evidence.failedRequests.length === 0 ? "passed" : "failed",
|
|
208
|
+
message:
|
|
209
|
+
evidence.failedRequests.length === 0
|
|
210
|
+
? "No failed browser requests were observed."
|
|
211
|
+
: `${evidence.failedRequests.length} failed browser request(s) were observed.`,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const authLeak = browserAuthorizationFinding(evidence.requests);
|
|
215
|
+
addCheck(report, {
|
|
216
|
+
id: CHECK_IDS.NO_BROWSER_AUTHORIZATION_HEADER,
|
|
217
|
+
status: authLeak ? "failed" : "passed",
|
|
218
|
+
message: authLeak
|
|
219
|
+
? "A browser-visible Authorization bearer header was sent to Switchboard or a provider endpoint."
|
|
220
|
+
: "No browser-visible Authorization bearer header was sent to protected endpoints.",
|
|
221
|
+
});
|
|
222
|
+
if (authLeak) report.findings.push(authLeak);
|
|
223
|
+
|
|
224
|
+
const secretFindings = secretExposureFindings(evidence.texts);
|
|
225
|
+
const serverFindings = secretFindings.filter((finding) => finding.category === "server_secret");
|
|
226
|
+
const providerFindings = secretFindings.filter((finding) => finding.category === "provider_secret");
|
|
227
|
+
|
|
228
|
+
addCheck(report, {
|
|
229
|
+
id: CHECK_IDS.NO_SERVER_SECRET_EXPOSED,
|
|
230
|
+
status: serverFindings.length === 0 ? "passed" : "failed",
|
|
231
|
+
message:
|
|
232
|
+
serverFindings.length === 0
|
|
233
|
+
? "No Switchboard server secrets were visible to the browser."
|
|
234
|
+
: "Switchboard server secrets were visible to the browser.",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
addCheck(report, {
|
|
238
|
+
id: CHECK_IDS.NO_PROVIDER_SECRET_EXPOSED,
|
|
239
|
+
status: providerFindings.length === 0 ? "passed" : "failed",
|
|
240
|
+
message:
|
|
241
|
+
providerFindings.length === 0
|
|
242
|
+
? "No provider secrets were visible to the browser."
|
|
243
|
+
: "Provider secrets were visible to the browser.",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
report.findings.push(...secretFindings);
|
|
247
|
+
|
|
248
|
+
const corsFinding = corsCredentialsFinding(evidence.responses ?? []);
|
|
249
|
+
addCheck(report, {
|
|
250
|
+
id: CHECK_IDS.CORS_CREDENTIALS_SAFE,
|
|
251
|
+
status: corsFinding ? "failed" : "passed",
|
|
252
|
+
message: corsFinding
|
|
253
|
+
? "A response allowed credentials with a wildcard CORS origin."
|
|
254
|
+
: "No wildcard credentialed CORS response was observed.",
|
|
255
|
+
});
|
|
256
|
+
if (corsFinding) report.findings.push(corsFinding);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function applyProjectProductionSafety(report, { productionOrigin, project }) {
|
|
260
|
+
if (!project) {
|
|
261
|
+
addFailure(report, CHECK_IDS.PRODUCTION_SAFETY_READY, "Project production safety could not be checked.", {
|
|
262
|
+
severity: "critical",
|
|
263
|
+
code: "production_safety_unchecked",
|
|
264
|
+
});
|
|
265
|
+
report.production_safety = "blocked";
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const allowedOrigins = Array.isArray(project.allowed_origins) ? project.allowed_origins : [];
|
|
270
|
+
const originAllowed = allowedOrigins.includes(productionOrigin);
|
|
271
|
+
addCheck(report, {
|
|
272
|
+
id: CHECK_IDS.ORIGIN_ALLOWED,
|
|
273
|
+
status: originAllowed ? "passed" : "failed",
|
|
274
|
+
message: originAllowed
|
|
275
|
+
? "Production origin is present in project allowed_origins."
|
|
276
|
+
: "Production origin is not present in project allowed_origins.",
|
|
277
|
+
});
|
|
278
|
+
if (!originAllowed) {
|
|
279
|
+
report.findings.push({
|
|
280
|
+
severity: "critical",
|
|
281
|
+
code: "origin_not_allowed",
|
|
282
|
+
check_id: CHECK_IDS.ORIGIN_ALLOWED,
|
|
283
|
+
message: "Add the production origin to the project allowed_origins before publishing.",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const safety = project.production_safety ?? {};
|
|
288
|
+
const safetyReady = safety.status === "ready";
|
|
289
|
+
addCheck(report, {
|
|
290
|
+
id: CHECK_IDS.PRODUCTION_SAFETY_READY,
|
|
291
|
+
status: safetyReady ? "passed" : "failed",
|
|
292
|
+
message: safetyReady
|
|
293
|
+
? "Project production safety is ready."
|
|
294
|
+
: "Project production safety is blocked.",
|
|
295
|
+
details: safety,
|
|
296
|
+
});
|
|
297
|
+
if (!safetyReady) {
|
|
298
|
+
report.findings.push({
|
|
299
|
+
severity: "critical",
|
|
300
|
+
code: "production_safety_blocked",
|
|
301
|
+
check_id: CHECK_IDS.PRODUCTION_SAFETY_READY,
|
|
302
|
+
message: "Resolve project production_safety blockers before publishing.",
|
|
303
|
+
details: safety,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
report.production_safety = safetyReady && originAllowed ? "ready" : "blocked";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function finalizeReport(report) {
|
|
311
|
+
const failedChecks = report.checks.filter((check) => check.status === "failed");
|
|
312
|
+
const criticalFindings = report.findings.filter((finding) => finding.severity === "critical");
|
|
313
|
+
|
|
314
|
+
report.functional = !failedChecks.some((check) =>
|
|
315
|
+
[
|
|
316
|
+
CHECK_IDS.PAGE_LOADS,
|
|
317
|
+
CHECK_IDS.NO_CONSOLE_ERRORS,
|
|
318
|
+
CHECK_IDS.NO_FAILED_REQUESTS,
|
|
319
|
+
CHECK_IDS.CLIENT_CONFIG_REACHABLE,
|
|
320
|
+
CHECK_IDS.ANONYMOUS_SESSION_CREATED,
|
|
321
|
+
CHECK_IDS.CHAT_COMPLETION_SUCCEEDS,
|
|
322
|
+
CHECK_IDS.SWITCHBOARD_REQUEST_OBSERVED,
|
|
323
|
+
].includes(check.id),
|
|
324
|
+
);
|
|
325
|
+
report.secure = criticalFindings.length === 0;
|
|
326
|
+
report.publishable =
|
|
327
|
+
report.mode === "publish" &&
|
|
328
|
+
report.functional &&
|
|
329
|
+
report.secure &&
|
|
330
|
+
report.production_safety === "ready";
|
|
331
|
+
report.status = report.publishable || (report.mode !== "publish" && report.functional && report.secure)
|
|
332
|
+
? "passed"
|
|
333
|
+
: "failed";
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function runBrowserScenario({
|
|
337
|
+
report,
|
|
338
|
+
appUrl,
|
|
339
|
+
clientUrl,
|
|
340
|
+
scenario,
|
|
341
|
+
artifactsDir,
|
|
342
|
+
headed,
|
|
343
|
+
timeoutMs = 30000,
|
|
344
|
+
prompt,
|
|
345
|
+
}) {
|
|
346
|
+
let chromium;
|
|
347
|
+
try {
|
|
348
|
+
({ chromium } = await import("playwright"));
|
|
349
|
+
} catch {
|
|
350
|
+
throw new VerificationInputError(
|
|
351
|
+
"Playwright is required for browser verification. Install CLI dependencies with npm install in packages/cli.",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
356
|
+
const evidence = emptyEvidence();
|
|
357
|
+
const browser = await chromium.launch({ headless: !headed });
|
|
358
|
+
const context = await browser.newContext();
|
|
359
|
+
const page = await context.newPage();
|
|
360
|
+
|
|
361
|
+
wireEvidence(page, evidence);
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
for (const check of scenario.checks) {
|
|
365
|
+
await executeScenarioCheck({
|
|
366
|
+
check,
|
|
367
|
+
report,
|
|
368
|
+
page,
|
|
369
|
+
context,
|
|
370
|
+
appUrl,
|
|
371
|
+
clientUrl,
|
|
372
|
+
evidence,
|
|
373
|
+
timeoutMs,
|
|
374
|
+
prompt,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await collectBrowserState(page, context, evidence);
|
|
379
|
+
const screenshot = path.join(artifactsDir, "final.png");
|
|
380
|
+
await page.screenshot({ path: screenshot, fullPage: true });
|
|
381
|
+
report.artifacts.screenshot = screenshot;
|
|
382
|
+
} finally {
|
|
383
|
+
await browser.close();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
evaluateEvidence(report, evidence);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function executeScenarioCheck({
|
|
390
|
+
check,
|
|
391
|
+
report,
|
|
392
|
+
page,
|
|
393
|
+
appUrl,
|
|
394
|
+
clientUrl,
|
|
395
|
+
evidence,
|
|
396
|
+
timeoutMs,
|
|
397
|
+
prompt,
|
|
398
|
+
}) {
|
|
399
|
+
switch (check.type) {
|
|
400
|
+
case "visit": {
|
|
401
|
+
const target = new URL(check.url ?? "/", appUrl);
|
|
402
|
+
const response = await page.goto(target.href, {
|
|
403
|
+
waitUntil: "networkidle",
|
|
404
|
+
timeout: timeoutMs,
|
|
405
|
+
});
|
|
406
|
+
const ok = Boolean(response?.ok());
|
|
407
|
+
addCheck(report, {
|
|
408
|
+
id: CHECK_IDS.PAGE_LOADS,
|
|
409
|
+
status: ok ? "passed" : "failed",
|
|
410
|
+
message: ok ? "Page loaded successfully." : `Page load returned HTTP ${response?.status() ?? "unknown"}.`,
|
|
411
|
+
});
|
|
412
|
+
evidence.texts.push({ source: target.href, text: await page.content() });
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
case "switchboardClientConfig": {
|
|
417
|
+
const result = await browserFetchJson(page, clientUrl, "/client/config", { method: "GET" });
|
|
418
|
+
addCheck(report, {
|
|
419
|
+
id: CHECK_IDS.CLIENT_CONFIG_REACHABLE,
|
|
420
|
+
status: result.ok ? "passed" : "failed",
|
|
421
|
+
message: result.ok ? "Switchboard client config is reachable." : result.error,
|
|
422
|
+
});
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
case "switchboardAnonymousSession": {
|
|
427
|
+
const result = await browserFetchJson(page, clientUrl, "/auth/anonymous/session", {
|
|
428
|
+
method: "POST",
|
|
429
|
+
body: {
|
|
430
|
+
browser_challenge_token: check.browserChallengeToken ?? "switchboard-verification",
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
if (result.data?.token) {
|
|
434
|
+
await page.evaluate((token) => window.sessionStorage.setItem("sb_verify_session", token), result.data.token);
|
|
435
|
+
}
|
|
436
|
+
addCheck(report, {
|
|
437
|
+
id: CHECK_IDS.ANONYMOUS_SESSION_CREATED,
|
|
438
|
+
status: result.ok && Boolean(result.data?.token) ? "passed" : "failed",
|
|
439
|
+
message:
|
|
440
|
+
result.ok && result.data?.token
|
|
441
|
+
? "Anonymous end-user session was created."
|
|
442
|
+
: result.error || "Anonymous end-user session response did not include a token.",
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case "switchboardChat": {
|
|
448
|
+
const session = await page.evaluate(() => window.sessionStorage.getItem("sb_verify_session"));
|
|
449
|
+
const result = await browserFetchJson(page, clientUrl, "/chat/completions", {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: session ? { "X-Switchboard-End-User-Session": session } : {},
|
|
452
|
+
body: {
|
|
453
|
+
model: check.model ?? "default",
|
|
454
|
+
messages: [{ role: "user", content: prompt ?? check.prompt }],
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
addCheck(report, {
|
|
458
|
+
id: CHECK_IDS.CHAT_COMPLETION_SUCCEEDS,
|
|
459
|
+
status: result.ok ? "passed" : "failed",
|
|
460
|
+
message: result.ok ? "Chat completion succeeded through the client gateway." : result.error,
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
case "switchboardRequestObserved": {
|
|
466
|
+
const observed = evidence.requests.some((request) => isSwitchboardEmbedPath(request.url));
|
|
467
|
+
addCheck(report, {
|
|
468
|
+
id: CHECK_IDS.SWITCHBOARD_REQUEST_OBSERVED,
|
|
469
|
+
status: observed ? "passed" : "failed",
|
|
470
|
+
message: observed ? "Switchboard embed request was observed." : "No Switchboard embed request was observed.",
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
default:
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function browserFetchJson(page, clientUrl, endpointPath, init) {
|
|
481
|
+
return page.evaluate(
|
|
482
|
+
async ({ clientUrl, endpointPath, init }) => {
|
|
483
|
+
try {
|
|
484
|
+
const headers = new Headers(init.headers ?? {});
|
|
485
|
+
headers.set("Accept", "application/json");
|
|
486
|
+
if (init.body) headers.set("Content-Type", "application/json");
|
|
487
|
+
|
|
488
|
+
const response = await fetch(new URL(endpointPath, clientUrl).href, {
|
|
489
|
+
method: init.method,
|
|
490
|
+
headers,
|
|
491
|
+
body: init.body ? JSON.stringify(init.body) : undefined,
|
|
492
|
+
});
|
|
493
|
+
const text = await response.text();
|
|
494
|
+
let data = null;
|
|
495
|
+
try {
|
|
496
|
+
data = text ? JSON.parse(text) : null;
|
|
497
|
+
} catch {
|
|
498
|
+
data = { raw: text };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
ok: response.ok,
|
|
503
|
+
status: response.status,
|
|
504
|
+
data,
|
|
505
|
+
error: response.ok ? null : `HTTP ${response.status}`,
|
|
506
|
+
};
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return { ok: false, status: 0, data: null, error: error.message };
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
{ clientUrl: clientUrl.href, endpointPath, init },
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function wireEvidence(page, evidence) {
|
|
516
|
+
page.on("console", (message) => {
|
|
517
|
+
evidence.consoleMessages.push({
|
|
518
|
+
type: message.type(),
|
|
519
|
+
text: message.text(),
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
page.on("pageerror", (error) => {
|
|
523
|
+
evidence.consoleMessages.push({
|
|
524
|
+
type: "error",
|
|
525
|
+
text: error.message,
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
page.on("request", (request) => {
|
|
529
|
+
evidence.requests.push({
|
|
530
|
+
url: request.url(),
|
|
531
|
+
method: request.method(),
|
|
532
|
+
headers: request.headers(),
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
page.on("requestfailed", (request) => {
|
|
536
|
+
evidence.failedRequests.push({
|
|
537
|
+
url: request.url(),
|
|
538
|
+
method: request.method(),
|
|
539
|
+
failure: request.failure()?.errorText ?? "request failed",
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
page.on("response", async (response) => {
|
|
543
|
+
evidence.responses.push({
|
|
544
|
+
url: response.url(),
|
|
545
|
+
status: response.status(),
|
|
546
|
+
headers: response.headers(),
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
if (response.status() >= 400) {
|
|
550
|
+
evidence.failedRequests.push({
|
|
551
|
+
url: response.url(),
|
|
552
|
+
method: response.request().method(),
|
|
553
|
+
status: response.status(),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const contentType = response.headers()["content-type"] ?? "";
|
|
558
|
+
if (/text|javascript|json|css|html/i.test(contentType)) {
|
|
559
|
+
try {
|
|
560
|
+
evidence.texts.push({ source: response.url(), text: await response.text() });
|
|
561
|
+
} catch {
|
|
562
|
+
/* Response bodies are best-effort evidence only. */
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function collectBrowserState(page, context, evidence) {
|
|
569
|
+
const cookies = await context.cookies();
|
|
570
|
+
evidence.texts.push({ source: "cookies", text: JSON.stringify(cookies) });
|
|
571
|
+
const storage = await page.evaluate(() =>
|
|
572
|
+
JSON.stringify({
|
|
573
|
+
localStorage: Object.fromEntries(Object.entries(window.localStorage)),
|
|
574
|
+
sessionStorage: Object.fromEntries(Object.entries(window.sessionStorage)),
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
evidence.texts.push({ source: "storage", text: storage });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function emptyEvidence() {
|
|
581
|
+
return {
|
|
582
|
+
consoleMessages: [],
|
|
583
|
+
failedRequests: [],
|
|
584
|
+
requests: [],
|
|
585
|
+
responses: [],
|
|
586
|
+
texts: [],
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function browserAuthorizationFinding(requests) {
|
|
591
|
+
for (const request of requests) {
|
|
592
|
+
const authorization = request.headers?.authorization;
|
|
593
|
+
if (!/^Bearer\s+/i.test(authorization ?? "")) continue;
|
|
594
|
+
|
|
595
|
+
const host = safeHost(request.url);
|
|
596
|
+
if (PROTECTED_AUTH_HOST_PATTERNS.some((pattern) => pattern.test(host))) {
|
|
597
|
+
return {
|
|
598
|
+
severity: "critical",
|
|
599
|
+
category: "authorization_header",
|
|
600
|
+
code: "browser_authorization_header",
|
|
601
|
+
check_id: CHECK_IDS.NO_BROWSER_AUTHORIZATION_HEADER,
|
|
602
|
+
message: "Browser sent an Authorization bearer token to a protected AI gateway/provider endpoint.",
|
|
603
|
+
evidence: { url: request.url },
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function secretExposureFindings(texts) {
|
|
612
|
+
const findings = [];
|
|
613
|
+
for (const text of texts) {
|
|
614
|
+
for (const rule of SERVER_SECRET_PATTERNS) {
|
|
615
|
+
if (rule.pattern.test(text.text)) {
|
|
616
|
+
findings.push(secretFinding("server_secret", CHECK_IDS.NO_SERVER_SECRET_EXPOSED, rule, text.source));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
for (const rule of PROVIDER_SECRET_PATTERNS) {
|
|
621
|
+
if (rule.pattern.test(text.text)) {
|
|
622
|
+
findings.push(secretFinding("provider_secret", CHECK_IDS.NO_PROVIDER_SECRET_EXPOSED, rule, text.source));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return findings;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function secretFinding(category, checkId, rule, source) {
|
|
631
|
+
return {
|
|
632
|
+
severity: "critical",
|
|
633
|
+
category,
|
|
634
|
+
code: "browser_secret_exposure",
|
|
635
|
+
check_id: checkId,
|
|
636
|
+
message: `${rule.name} was visible in browser-accessible state or assets.`,
|
|
637
|
+
evidence: { source },
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function corsCredentialsFinding(responses) {
|
|
642
|
+
for (const response of responses) {
|
|
643
|
+
if (
|
|
644
|
+
response.headers["access-control-allow-origin"] === "*" &&
|
|
645
|
+
/^true$/i.test(response.headers["access-control-allow-credentials"] ?? "")
|
|
646
|
+
) {
|
|
647
|
+
return {
|
|
648
|
+
severity: "critical",
|
|
649
|
+
category: "cors",
|
|
650
|
+
code: "cors_credentials_wildcard_origin",
|
|
651
|
+
check_id: CHECK_IDS.CORS_CREDENTIALS_SAFE,
|
|
652
|
+
message: "Response used Access-Control-Allow-Credentials with wildcard Access-Control-Allow-Origin.",
|
|
653
|
+
evidence: { url: response.url },
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function scenarioNeedsClientUrl(scenario) {
|
|
662
|
+
return scenario.checks.some((check) => check.type.startsWith("switchboard"));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function validateVisitPath(value) {
|
|
666
|
+
if (typeof value !== "string") {
|
|
667
|
+
throw new VerificationInputError("Visit check url must be a string.");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
try {
|
|
671
|
+
new URL(value, "https://example.test");
|
|
672
|
+
} catch {
|
|
673
|
+
throw new VerificationInputError(`Malformed visit URL: ${value}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function parseRequiredUrl(value, flagName) {
|
|
678
|
+
if (!value) throw new VerificationInputError(`${flagName} is required.`);
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
return new URL(value);
|
|
682
|
+
} catch {
|
|
683
|
+
throw new VerificationInputError(`${flagName} must be an absolute URL.`);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function normalizeTimeoutMs(value) {
|
|
688
|
+
if (value === undefined || value === null) return undefined;
|
|
689
|
+
|
|
690
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
|
|
691
|
+
if (!Number.isInteger(parsed) || String(parsed) !== String(value).trim() || parsed <= 0) {
|
|
692
|
+
throw new VerificationInputError("--timeout-ms must be a positive integer.");
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return parsed;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function validatePublishUrl(url, flagName) {
|
|
699
|
+
if (url.protocol !== "https:") {
|
|
700
|
+
throw new VerificationInputError(`${flagName} must use HTTPS in publish mode.`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (LOCAL_HOSTS.has(normalizedHostname(url))) {
|
|
704
|
+
throw new VerificationInputError(`${flagName} must not be localhost in publish mode.`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function normalizedHostname(url) {
|
|
709
|
+
return url.hostname.replace(/^\[|\]$/g, "");
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function addFailure(report, checkId, message, finding) {
|
|
713
|
+
addCheck(report, { id: checkId, status: "failed", message });
|
|
714
|
+
report.findings.push({
|
|
715
|
+
severity: finding.severity,
|
|
716
|
+
code: finding.code,
|
|
717
|
+
check_id: checkId,
|
|
718
|
+
message,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function addCheck(report, check) {
|
|
723
|
+
const existing = report.checks.find((item) => item.id === check.id);
|
|
724
|
+
if (existing) {
|
|
725
|
+
Object.assign(existing, check);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
report.checks.push(check);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function hasCriticalFindings(report) {
|
|
733
|
+
return report.findings.some((finding) => finding.severity === "critical");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isSwitchboardEmbedPath(value) {
|
|
737
|
+
try {
|
|
738
|
+
const url = new URL(value);
|
|
739
|
+
return (
|
|
740
|
+
/\/m\/[^/]+\/v1\/client\/config$/.test(url.pathname) ||
|
|
741
|
+
/\/m\/[^/]+\/v1\/auth\/anonymous\/session$/.test(url.pathname) ||
|
|
742
|
+
/\/m\/[^/]+\/v1\/chat\/completions$/.test(url.pathname)
|
|
743
|
+
);
|
|
744
|
+
} catch {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function safeHost(value) {
|
|
750
|
+
try {
|
|
751
|
+
return new URL(value).hostname;
|
|
752
|
+
} catch {
|
|
753
|
+
return "";
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export class VerificationInputError extends Error {
|
|
758
|
+
constructor(message) {
|
|
759
|
+
super(message);
|
|
760
|
+
this.name = "VerificationInputError";
|
|
761
|
+
}
|
|
762
|
+
}
|