@yiaany/ghostapi 0.1.7 → 0.1.9
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/CHANGELOG.md +42 -2
- package/CONTRIBUTING.md +119 -117
- package/LICENSE +21 -21
- package/README.md +208 -181
- package/SECURITY.md +53 -37
- package/dist/actions/gateway.d.ts +179 -0
- package/dist/actions/gateway.js +514 -0
- package/dist/actions/gateway.js.map +1 -0
- package/dist/actions/index.d.ts +2 -0
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -0
- package/dist/ai/aiClient.js +4 -4
- package/dist/ai/aiClient.js.map +1 -1
- package/dist/ai/aiGenerator.d.ts +2 -2
- package/dist/ai/aiGenerator.js +21 -72
- package/dist/ai/aiGenerator.js.map +1 -1
- package/dist/ai/prompts.js +51 -51
- package/dist/ai/prompts.js.map +1 -1
- package/dist/approvals/approvalInbox.d.ts +150 -0
- package/dist/approvals/approvalInbox.js +412 -0
- package/dist/approvals/approvalInbox.js.map +1 -0
- package/dist/approvals/index.d.ts +2 -0
- package/dist/approvals/index.js +2 -0
- package/dist/approvals/index.js.map +1 -0
- package/dist/behavior/behaviorStore.d.ts +2 -0
- package/dist/behavior/behaviorStore.js +50 -46
- package/dist/behavior/behaviorStore.js.map +1 -1
- package/dist/cache/index.d.ts +2 -0
- package/dist/cache/index.js +58 -14
- package/dist/cache/index.js.map +1 -1
- package/dist/cli/index.js +438 -58
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/openUrl.d.ts +10 -0
- package/dist/cli/openUrl.js +31 -0
- package/dist/cli/openUrl.js.map +1 -0
- package/dist/cli/parser.d.ts +155 -0
- package/dist/cli/parser.js +504 -2
- package/dist/cli/parser.js.map +1 -1
- package/dist/config/dataPaths.d.ts +31 -0
- package/dist/config/dataPaths.js +45 -0
- package/dist/config/dataPaths.js.map +1 -0
- package/dist/config/localConfig.d.ts +1 -2
- package/dist/config/localConfig.js +27 -21
- package/dist/config/localConfig.js.map +1 -1
- package/dist/config/serverConfig.d.ts +2 -0
- package/dist/config/serverConfig.js +8 -1
- package/dist/config/serverConfig.js.map +1 -1
- package/dist/contracts/contract.d.ts +76 -0
- package/dist/contracts/contract.js +603 -0
- package/dist/contracts/contract.js.map +1 -0
- package/dist/contracts/index.d.ts +2 -0
- package/dist/contracts/index.js +2 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/credentials/credentialBroker.d.ts +207 -0
- package/dist/credentials/credentialBroker.js +588 -0
- package/dist/credentials/credentialBroker.js.map +1 -0
- package/dist/credentials/index.d.ts +2 -0
- package/dist/credentials/index.js +2 -0
- package/dist/credentials/index.js.map +1 -0
- package/dist/dashboard/app.js +685 -615
- package/dist/dashboard/index.html +13 -7
- package/dist/dashboard/styles.css +349 -349
- package/dist/egress/capabilities.d.ts +32 -0
- package/dist/egress/capabilities.js +165 -0
- package/dist/egress/capabilities.js.map +1 -0
- package/dist/egress/linuxBootstrap.d.ts +1 -0
- package/dist/egress/linuxBootstrap.js +121 -0
- package/dist/egress/linuxBootstrap.js.map +1 -0
- package/dist/egress/run.d.ts +18 -0
- package/dist/egress/run.js +305 -0
- package/dist/egress/run.js.map +1 -0
- package/dist/errors/errorEngine.d.ts +2 -2
- package/dist/errors/errorEngine.js +3 -40
- package/dist/errors/errorEngine.js.map +1 -1
- package/dist/evals/evals.d.ts +126 -0
- package/dist/evals/evals.js +466 -0
- package/dist/evals/evals.js.map +1 -0
- package/dist/evals/index.d.ts +2 -0
- package/dist/evals/index.js +2 -0
- package/dist/evals/index.js.map +1 -0
- package/dist/evidence/index.d.ts +2 -0
- package/dist/evidence/index.js +2 -0
- package/dist/evidence/index.js.map +1 -0
- package/dist/evidence/report.d.ts +143 -0
- package/dist/evidence/report.js +556 -0
- package/dist/evidence/report.js.map +1 -0
- package/dist/fault/faultLab.d.ts +5 -4
- package/dist/fault/faultLab.js +66 -24
- package/dist/fault/faultLab.js.map +1 -1
- package/dist/index.d.ts +46 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -1
- package/dist/inventory/analysis.d.ts +12 -0
- package/dist/inventory/analysis.js +457 -0
- package/dist/inventory/analysis.js.map +1 -0
- package/dist/inventory/index.d.ts +5 -0
- package/dist/inventory/index.js +4 -0
- package/dist/inventory/index.js.map +1 -0
- package/dist/inventory/inventoryController.d.ts +53 -0
- package/dist/inventory/inventoryController.js +1044 -0
- package/dist/inventory/inventoryController.js.map +1 -0
- package/dist/inventory/types.d.ts +579 -0
- package/dist/inventory/types.js +787 -0
- package/dist/inventory/types.js.map +1 -0
- package/dist/landing/assets/index-CxWsopzP.css +1 -0
- package/dist/landing/assets/index-DZlpyhqd.js +55 -0
- package/dist/landing/assets/index-Dk-Xezs3.js +55 -0
- package/dist/landing/index.html +15 -15
- package/dist/ledger/actionLedger.d.ts +125 -0
- package/dist/ledger/actionLedger.js +594 -0
- package/dist/ledger/actionLedger.js.map +1 -0
- package/dist/ledger/index.d.ts +2 -0
- package/dist/ledger/index.js +2 -0
- package/dist/ledger/index.js.map +1 -0
- package/dist/mcp/server.js +4 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/policy/index.d.ts +3 -0
- package/dist/policy/index.js +2 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/policy.d.ts +14 -0
- package/dist/policy/policy.js +282 -0
- package/dist/policy/policy.js.map +1 -0
- package/dist/policy/types.d.ts +52 -0
- package/dist/policy/types.js +2 -0
- package/dist/policy/types.js.map +1 -0
- package/dist/productTelemetry/index.d.ts +16 -0
- package/dist/productTelemetry/index.js +142 -0
- package/dist/productTelemetry/index.js.map +1 -0
- package/dist/providers/conformance.d.ts +7 -0
- package/dist/providers/conformance.js +48 -0
- package/dist/providers/conformance.js.map +1 -0
- package/dist/providers/index.d.ts +4 -2
- package/dist/providers/index.js +3 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/packs/resendPack.d.ts +2 -0
- package/dist/providers/packs/resendPack.js +147 -0
- package/dist/providers/packs/resendPack.js.map +1 -0
- package/dist/providers/packs/stripePack.d.ts +2 -0
- package/dist/providers/packs/stripePack.js +852 -0
- package/dist/providers/packs/stripePack.js.map +1 -0
- package/dist/providers/registry.d.ts +7 -3
- package/dist/providers/registry.js +55 -4
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/resend.js +2 -11
- package/dist/providers/resend.js.map +1 -1
- package/dist/providers/runtime.d.ts +8 -0
- package/dist/providers/runtime.js +48 -0
- package/dist/providers/runtime.js.map +1 -0
- package/dist/providers/stripeWebhook.d.ts +13 -0
- package/dist/providers/stripeWebhook.js +50 -0
- package/dist/providers/stripeWebhook.js.map +1 -0
- package/dist/providers/types.d.ts +153 -0
- package/dist/proxy/cacheKey.js +3 -3
- package/dist/proxy/cacheKey.js.map +1 -1
- package/dist/proxy/providerDetector.d.ts +2 -7
- package/dist/proxy/providerDetector.js +4 -4
- package/dist/proxy/providerDetector.js.map +1 -1
- package/dist/proxy/proxyHandler.js +116 -12
- package/dist/proxy/proxyHandler.js.map +1 -1
- package/dist/proxy/requestNormalizer.d.ts +0 -1
- package/dist/proxy/requestNormalizer.js +2 -5
- package/dist/proxy/requestNormalizer.js.map +1 -1
- package/dist/reliability/costGovernance.d.ts +175 -0
- package/dist/reliability/costGovernance.js +515 -0
- package/dist/reliability/costGovernance.js.map +1 -0
- package/dist/reliability/index.d.ts +8 -0
- package/dist/reliability/index.js +5 -0
- package/dist/reliability/index.js.map +1 -0
- package/dist/reliability/reconciliation.d.ts +157 -0
- package/dist/reliability/reconciliation.js +522 -0
- package/dist/reliability/reconciliation.js.map +1 -0
- package/dist/reliability/runtimeHealth.d.ts +60 -0
- package/dist/reliability/runtimeHealth.js +356 -0
- package/dist/reliability/runtimeHealth.js.map +1 -0
- package/dist/reliability/slo.d.ts +131 -0
- package/dist/reliability/slo.js +375 -0
- package/dist/reliability/slo.js.map +1 -0
- package/dist/report/safetyReport.js +7 -3
- package/dist/report/safetyReport.js.map +1 -1
- package/dist/safety/index.d.ts +2 -0
- package/dist/safety/index.js +2 -0
- package/dist/safety/index.js.map +1 -0
- package/dist/safety/safetyController.d.ts +187 -0
- package/dist/safety/safetyController.js +477 -0
- package/dist/safety/safetyController.js.map +1 -0
- package/dist/scenarios/scenarioBundle.d.ts +75 -0
- package/dist/scenarios/scenarioBundle.js +750 -0
- package/dist/scenarios/scenarioBundle.js.map +1 -0
- package/dist/scenarios/scenarioStore.d.ts +1 -0
- package/dist/scenarios/scenarioStore.js +47 -32
- package/dist/scenarios/scenarioStore.js.map +1 -1
- package/dist/security/headerSanitizer.d.ts +2 -0
- package/dist/security/headerSanitizer.js +36 -1
- package/dist/security/headerSanitizer.js.map +1 -1
- package/dist/security/secrets.js +24 -4
- package/dist/security/secrets.js.map +1 -1
- package/dist/server/accessControl.d.ts +5 -0
- package/dist/server/accessControl.js +100 -0
- package/dist/server/accessControl.js.map +1 -0
- package/dist/server/createServer.d.ts +0 -5
- package/dist/server/createServer.js +9 -29
- package/dist/server/createServer.js.map +1 -1
- package/dist/server/eventsStore.d.ts +6 -1
- package/dist/server/eventsStore.js +83 -15
- package/dist/server/eventsStore.js.map +1 -1
- package/dist/server/routes.js +42 -10
- package/dist/server/routes.js.map +1 -1
- package/dist/server/sse.d.ts +2 -1
- package/dist/server/sse.js +15 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/setup/setupGenerator.js +53 -6
- package/dist/setup/setupGenerator.js.map +1 -1
- package/dist/state/stateStore.d.ts +5 -0
- package/dist/state/stateStore.js +48 -62
- package/dist/state/stateStore.js.map +1 -1
- package/dist/storage/fileStore.d.ts +10 -0
- package/dist/storage/fileStore.js +131 -0
- package/dist/storage/fileStore.js.map +1 -0
- package/dist/teamControl/controlPlane.d.ts +255 -0
- package/dist/teamControl/controlPlane.js +918 -0
- package/dist/teamControl/controlPlane.js.map +1 -0
- package/dist/teamControl/deployment.d.ts +33 -0
- package/dist/teamControl/deployment.js +67 -0
- package/dist/teamControl/deployment.js.map +1 -0
- package/dist/teamControl/index.d.ts +4 -0
- package/dist/teamControl/index.js +3 -0
- package/dist/teamControl/index.js.map +1 -0
- package/dist/tests/testGenerator.js +4 -1
- package/dist/tests/testGenerator.js.map +1 -1
- package/dist/trust/index.d.ts +2 -0
- package/dist/trust/index.js +2 -0
- package/dist/trust/index.js.map +1 -0
- package/dist/trust/trustLadder.d.ts +182 -0
- package/dist/trust/trustLadder.js +560 -0
- package/dist/trust/trustLadder.js.map +1 -0
- package/dist/worlds/index.d.ts +2 -0
- package/dist/worlds/index.js +2 -0
- package/dist/worlds/index.js.map +1 -0
- package/dist/worlds/worlds.d.ts +160 -0
- package/dist/worlds/worlds.js +491 -0
- package/dist/worlds/worlds.js.map +1 -0
- package/docs/assets/dashboard.png +0 -0
- package/docs/ci.md +49 -0
- package/docs/commercial/README.md +29 -0
- package/docs/commercial/data-inventory.md +36 -0
- package/docs/commercial/manual-invoicing.md +44 -0
- package/docs/commercial/metrics.md +51 -0
- package/docs/commercial/pricing.md +103 -0
- package/docs/commercial/privacy-policy-draft.md +41 -0
- package/docs/commercial/terms-topics-for-counsel.md +51 -0
- package/docs/design-partners/README.md +52 -0
- package/docs/design-partners/demo-narratives.md +33 -0
- package/docs/design-partners/design-partner-offer.md +39 -0
- package/docs/design-partners/discovery-questions.md +28 -0
- package/docs/design-partners/feedback-capture-template.md +48 -0
- package/docs/design-partners/icp-one-pager.md +43 -0
- package/docs/design-partners/onboarding-checklist.md +33 -0
- package/docs/design-partners/pilot-success-criteria.md +27 -0
- package/docs/design-partners/pricing-interview-script.md +18 -0
- package/docs/design-partners/telemetry-plan.md +48 -0
- package/docs/development/baseline.md +114 -0
- package/docs/development/onboarding-smoke.md +37 -0
- package/docs/development/verification-0.1.8.md +25 -0
- package/docs/enterprise-product-roadmap-ru.md +1879 -0
- package/docs/fundraising/README.md +87 -0
- package/docs/fundraising/data-room-checklist.md +72 -0
- package/docs/fundraising/demo-script.md +52 -0
- package/docs/fundraising/design-partners-50.md +74 -0
- package/docs/fundraising/launch-posts.md +37 -0
- package/docs/fundraising/metrics-and-evidence.md +30 -0
- package/docs/fundraising/roadmap-12-month.md +25 -0
- package/docs/fundraising/technical-due-diligence-index.md +37 -0
- package/docs/fundraising/yc-application.md +25 -0
- package/docs/github-actions.md +84 -0
- package/docs/hosted-pilot.md +111 -0
- package/docs/mcp.md +52 -50
- package/docs/operations/disaster-recovery-runbook.md +81 -0
- package/docs/operations/kill-switch-runbook.md +32 -0
- package/docs/policy.md +61 -0
- package/docs/providers/authoring-packs.md +175 -0
- package/docs/providers/stripe-core-pack.md +82 -0
- package/docs/release-checklist.md +83 -71
- package/docs/release-migration-and-rollback.md +63 -0
- package/docs/release-readiness.md +39 -0
- package/docs/security/action-gateway-threat-model.md +34 -0
- package/docs/security/action-ledger-incident-replay-threat-model.md +42 -0
- package/docs/security/approval-inbox-threat-model.md +35 -0
- package/docs/security/credential-broker-threat-model.md +52 -0
- package/docs/security/egress-threat-model.md +126 -0
- package/docs/security/inventory-threat-model.md +104 -0
- package/docs/security/kill-switch-budgets-threat-model.md +37 -0
- package/docs/security/reliability-threat-model.md +111 -0
- package/docs/security/trust-ladder-threat-model.md +33 -0
- package/docs/team-control-plane.md +72 -0
- package/docs/usage.md +571 -57
- package/examples/README.md +10 -0
- package/examples/agent-instructions/README.md +39 -39
- package/examples/ci-smoke/README.md +21 -0
- package/examples/ci-smoke/ghostapi.policy.yaml +22 -0
- package/examples/ci-smoke/package.json +9 -0
- package/examples/ci-smoke/production-egress.mjs +2 -0
- package/examples/ci-smoke/safe.mjs +17 -0
- package/examples/evals/README.md +24 -0
- package/examples/evals/retry-after.eval.json +52 -0
- package/examples/generic-rest/README.md +27 -27
- package/examples/github-fetch/README.md +35 -35
- package/examples/openai-streaming/README.md +16 -0
- package/examples/openai-streaming/streaming-tool-call.mjs +31 -0
- package/examples/policy/ghostapi.policy.yaml +27 -0
- package/examples/record-replay/README.md +19 -0
- package/examples/record-replay/replay-requests.json +22 -0
- package/examples/record-replay/stripe-checkout.har.json +30 -0
- package/examples/resend-node/README.md +22 -22
- package/examples/stripe-node/README.md +35 -29
- package/examples/stripe-node/checkout-flow.mjs +61 -0
- package/examples/twilio-node/README.md +21 -21
- package/examples/worlds/README.md +19 -0
- package/examples/worlds/subscription-recovery.mjs +12 -0
- package/package.json +27 -12
- package/dist/providers/stripe.d.ts +0 -2
- package/dist/providers/stripe.js +0 -15
- package/dist/providers/stripe.js.map +0 -1
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep, join } from "node:path";
|
|
3
|
+
import { getDataPaths } from "../config/dataPaths.js";
|
|
4
|
+
import { atomicWriteJson } from "../storage/fileStore.js";
|
|
5
|
+
import { isSecretFieldName, sanitizeSecretString, sanitizeSecrets } from "../security/secrets.js";
|
|
6
|
+
import { isSafeRelativeLocation } from "../security/headerSanitizer.js";
|
|
7
|
+
const MAX_CAPTURE_BYTES = 1024 * 1024;
|
|
8
|
+
const MAX_BUNDLE_BYTES = 512 * 1024;
|
|
9
|
+
const MAX_INTERACTIONS = 100;
|
|
10
|
+
const MAX_VARIABLES = 200;
|
|
11
|
+
const MAX_VALUE_DEPTH = 30;
|
|
12
|
+
const MAX_VALUE_KEYS = 200;
|
|
13
|
+
const VARIABLE_PATTERN = /\{\{ghostapi\.var\.([a-z][a-z0-9_]*)\}\}/g;
|
|
14
|
+
const SENSITIVE_QUERY_KEYS = new Set(["api_key", "apikey", "access_token", "refresh_token", "token", "secret", "key", "client_secret", "password", "authorization", "cookie"]);
|
|
15
|
+
const SAFE_REQUEST_HEADERS = new Set(["accept", "content-type"]);
|
|
16
|
+
const SAFE_RESPONSE_HEADERS = new Set(["content-type", "retry-after", "location"]);
|
|
17
|
+
const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
18
|
+
export class ScenarioBundleError extends Error {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ScenarioBundleError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class ScenarioReplayer {
|
|
25
|
+
bundle;
|
|
26
|
+
cursor = 0;
|
|
27
|
+
bindings = new Map();
|
|
28
|
+
constructor(bundle) {
|
|
29
|
+
this.bundle = bundle;
|
|
30
|
+
}
|
|
31
|
+
replay(request) {
|
|
32
|
+
const interaction = this.bundle.interactions[this.cursor];
|
|
33
|
+
if (interaction === undefined) {
|
|
34
|
+
throw new ScenarioBundleError(`Replay received an unexpected request after interaction ${this.cursor}. The bundle has no remaining interactions.`);
|
|
35
|
+
}
|
|
36
|
+
const actual = normalizeReplayRequest(request);
|
|
37
|
+
const candidateBindings = new Map(this.bindings);
|
|
38
|
+
const mismatches = compareRequest(interaction.request, actual, candidateBindings, this.bundle.variables);
|
|
39
|
+
if (mismatches.length > 0) {
|
|
40
|
+
throw new ScenarioBundleError(`Replay mismatch at interaction ${this.cursor + 1}: ${mismatches.join("; ")}. Replay is sequence-strict and never searches for an alternative match.`);
|
|
41
|
+
}
|
|
42
|
+
this.bindings.clear();
|
|
43
|
+
for (const [name, value] of candidateBindings)
|
|
44
|
+
this.bindings.set(name, value);
|
|
45
|
+
this.cursor += 1;
|
|
46
|
+
return {
|
|
47
|
+
index: this.cursor,
|
|
48
|
+
status: interaction.response.status,
|
|
49
|
+
headers: resolveHeaders(interaction.response.headers, this.bundle.variables, this.bindings),
|
|
50
|
+
body: resolveVariables(interaction.response.body, this.bundle.variables, this.bindings)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
get remaining() {
|
|
54
|
+
return this.bundle.interactions.length - this.cursor;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function prepareScenarioRecording(input, options) {
|
|
58
|
+
if (serializedBytes(input, "Recording input") > MAX_CAPTURE_BYTES)
|
|
59
|
+
throw new ScenarioBundleError(`Recording input exceeds ${MAX_CAPTURE_BYTES} bytes.`);
|
|
60
|
+
const allowedHosts = normalizeAllowedSandboxHosts(options.allowedSandboxHosts);
|
|
61
|
+
const rawInteractions = parseCapture(input);
|
|
62
|
+
if (rawInteractions.length === 0)
|
|
63
|
+
throw new ScenarioBundleError("Recording input does not contain any interactions.");
|
|
64
|
+
if (rawInteractions.length > MAX_INTERACTIONS)
|
|
65
|
+
throw new ScenarioBundleError(`Recording input exceeds ${MAX_INTERACTIONS} interactions.`);
|
|
66
|
+
const state = {
|
|
67
|
+
pii: { emails: true, phones: true, addresses: true, ...options.pii },
|
|
68
|
+
categories: new Set(),
|
|
69
|
+
replacements: 0,
|
|
70
|
+
variables: new Map()
|
|
71
|
+
};
|
|
72
|
+
const hosts = new Set();
|
|
73
|
+
const interactions = rawInteractions.map((raw) => {
|
|
74
|
+
const url = parseSandboxUrl(raw.request.url, allowedHosts, raw.request.headers);
|
|
75
|
+
hosts.add(url.hostname);
|
|
76
|
+
return {
|
|
77
|
+
request: {
|
|
78
|
+
method: raw.request.method,
|
|
79
|
+
path: sanitizePath(url, state),
|
|
80
|
+
headers: sanitizeHeaders(raw.request.headers, SAFE_REQUEST_HEADERS, state, false),
|
|
81
|
+
body: sanitizeBody(raw.request.body, raw.request.binary || raw.request.multipart, state)
|
|
82
|
+
},
|
|
83
|
+
response: {
|
|
84
|
+
status: raw.response.status,
|
|
85
|
+
headers: sanitizeHeaders(raw.response.headers, SAFE_RESPONSE_HEADERS, state, true),
|
|
86
|
+
body: sanitizeBody(raw.response.body, raw.response.binary || raw.response.multipart, state)
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const title = normalizeTitle(options.title ?? "Sandbox recording");
|
|
91
|
+
const summary = {
|
|
92
|
+
categories: [...state.categories].sort(),
|
|
93
|
+
replacements: state.replacements,
|
|
94
|
+
requiresApproval: state.categories.size > 0
|
|
95
|
+
};
|
|
96
|
+
const bundle = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
kind: "ghostapi.scenario-bundle",
|
|
99
|
+
metadata: { title, recordedAt: options.recordedAt ?? new Date().toISOString(), sandboxHosts: [...hosts].sort() },
|
|
100
|
+
sanitization: summary,
|
|
101
|
+
variables: [...state.variables.values()],
|
|
102
|
+
interactions
|
|
103
|
+
};
|
|
104
|
+
return validateScenarioBundle(bundle);
|
|
105
|
+
}
|
|
106
|
+
export async function prepareScenarioRecordingFromFile(path, options, projectRoot = process.cwd()) {
|
|
107
|
+
const source = await readBoundedJson(path, projectRoot, MAX_CAPTURE_BYTES, "Recording input");
|
|
108
|
+
return prepareScenarioRecording(source, options);
|
|
109
|
+
}
|
|
110
|
+
export async function writeScenarioBundle(bundle, outPath, projectRoot = process.cwd()) {
|
|
111
|
+
const valid = validateScenarioBundle(bundle);
|
|
112
|
+
const target = outPath === undefined
|
|
113
|
+
? join(getDataPaths().scenarios, `${slugify(valid.metadata.title)}.bundle.json`)
|
|
114
|
+
: await resolveOutputPath(outPath, projectRoot);
|
|
115
|
+
const serialized = JSON.stringify(valid);
|
|
116
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_BUNDLE_BYTES)
|
|
117
|
+
throw new ScenarioBundleError(`Scenario bundle exceeds ${MAX_BUNDLE_BYTES} bytes.`);
|
|
118
|
+
await atomicWriteJson(target, valid);
|
|
119
|
+
return target;
|
|
120
|
+
}
|
|
121
|
+
export async function loadScenarioBundle(path, projectRoot = process.cwd()) {
|
|
122
|
+
return validateScenarioBundle(await readBoundedJson(path, projectRoot, MAX_BUNDLE_BYTES, "Scenario bundle"));
|
|
123
|
+
}
|
|
124
|
+
export function createScenarioReplayer(bundle) {
|
|
125
|
+
return new ScenarioReplayer(validateScenarioBundle(bundle));
|
|
126
|
+
}
|
|
127
|
+
export function formatScenarioSanitizationSummary(summary) {
|
|
128
|
+
const categories = summary.categories.length === 0 ? "none" : summary.categories.join(", ");
|
|
129
|
+
return [
|
|
130
|
+
"GhostAPI recording sanitization summary",
|
|
131
|
+
`Categories: ${categories}`,
|
|
132
|
+
`Replacements: ${summary.replacements}`,
|
|
133
|
+
`Approval required: ${summary.requiresApproval ? "yes" : "no"}`
|
|
134
|
+
].join("\n");
|
|
135
|
+
}
|
|
136
|
+
export function validateScenarioBundle(input) {
|
|
137
|
+
const root = readObject(migrateScenarioBundle(input), "Scenario bundle must be an object.");
|
|
138
|
+
assertExactKeys(root, ["schemaVersion", "kind", "metadata", "sanitization", "variables", "interactions"], "Scenario bundle");
|
|
139
|
+
if (root.schemaVersion !== 1)
|
|
140
|
+
throw new ScenarioBundleError("Unsupported scenario bundle schema version.");
|
|
141
|
+
if (root.kind !== "ghostapi.scenario-bundle")
|
|
142
|
+
throw new ScenarioBundleError("Scenario bundle kind is invalid.");
|
|
143
|
+
const metadata = readObject(root.metadata, "Scenario bundle metadata is invalid.");
|
|
144
|
+
assertExactKeys(metadata, ["title", "recordedAt", "sandboxHosts"], "Scenario bundle metadata");
|
|
145
|
+
const title = normalizeTitle(metadata.title);
|
|
146
|
+
if (!isTimestamp(metadata.recordedAt))
|
|
147
|
+
throw new ScenarioBundleError("Scenario bundle recordedAt must be an ISO timestamp.");
|
|
148
|
+
const sandboxHosts = readHostArray(metadata.sandboxHosts);
|
|
149
|
+
const sanitization = readObject(root.sanitization, "Scenario bundle sanitization summary is invalid.");
|
|
150
|
+
assertExactKeys(sanitization, ["categories", "replacements", "requiresApproval"], "Scenario bundle sanitization summary");
|
|
151
|
+
const categories = readStringArray(sanitization.categories, "Scenario bundle sanitization categories are invalid.");
|
|
152
|
+
if (!Number.isInteger(sanitization.replacements) || sanitization.replacements < 0 || typeof sanitization.requiresApproval !== "boolean") {
|
|
153
|
+
throw new ScenarioBundleError("Scenario bundle sanitization summary is invalid.");
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(root.variables) || root.variables.length > MAX_VARIABLES)
|
|
156
|
+
throw new ScenarioBundleError(`Scenario bundle variables must contain at most ${MAX_VARIABLES} entries.`);
|
|
157
|
+
const names = new Set();
|
|
158
|
+
const variables = root.variables.map((entry) => {
|
|
159
|
+
const variable = readObject(entry, "Scenario bundle variable is invalid.");
|
|
160
|
+
assertExactKeys(variable, ["name", "value"], "Scenario bundle variable");
|
|
161
|
+
if (typeof variable.name !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(variable.name) || typeof variable.value !== "string" || variable.value.length > 2_000 || sanitizeSecretString(variable.value) !== variable.value) {
|
|
162
|
+
throw new ScenarioBundleError("Scenario bundle variable is invalid.");
|
|
163
|
+
}
|
|
164
|
+
if (names.has(variable.name))
|
|
165
|
+
throw new ScenarioBundleError(`Scenario bundle variable is duplicated: ${variable.name}`);
|
|
166
|
+
names.add(variable.name);
|
|
167
|
+
return { name: variable.name, value: variable.value };
|
|
168
|
+
});
|
|
169
|
+
if (!Array.isArray(root.interactions) || root.interactions.length === 0 || root.interactions.length > MAX_INTERACTIONS) {
|
|
170
|
+
throw new ScenarioBundleError(`Scenario bundle interactions must contain 1-${MAX_INTERACTIONS} entries.`);
|
|
171
|
+
}
|
|
172
|
+
const interactions = root.interactions.map((entry) => normalizeBundleInteraction(entry, names));
|
|
173
|
+
return {
|
|
174
|
+
schemaVersion: 1,
|
|
175
|
+
kind: "ghostapi.scenario-bundle",
|
|
176
|
+
metadata: { title, recordedAt: metadata.recordedAt, sandboxHosts },
|
|
177
|
+
sanitization: { categories: [...new Set(categories)].sort(), replacements: sanitization.replacements, requiresApproval: sanitization.requiresApproval },
|
|
178
|
+
variables,
|
|
179
|
+
interactions
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export function migrateScenarioBundle(input) {
|
|
183
|
+
const source = readObject(input, "Scenario bundle must be an object.");
|
|
184
|
+
if (source.schemaVersion === 1)
|
|
185
|
+
return source;
|
|
186
|
+
if (source.schemaVersion !== 0)
|
|
187
|
+
throw new ScenarioBundleError("Unsupported scenario bundle schema version; no migration is registered.");
|
|
188
|
+
assertExactKeys(source, ["schemaVersion", "kind", "title", "recordedAt", "sandboxHosts", "variables", "interactions"], "Scenario bundle v0");
|
|
189
|
+
return {
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
kind: source.kind,
|
|
192
|
+
metadata: { title: source.title, recordedAt: source.recordedAt, sandboxHosts: source.sandboxHosts },
|
|
193
|
+
// A migrated legacy bundle has no verifiable sanitizer receipt, so force operator review.
|
|
194
|
+
sanitization: { categories: ["legacy-bundle"], replacements: 0, requiresApproval: true },
|
|
195
|
+
variables: source.variables,
|
|
196
|
+
interactions: source.interactions
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function normalizeBundleInteraction(input, variableNames) {
|
|
200
|
+
const interaction = readObject(input, "Scenario bundle interaction is invalid.");
|
|
201
|
+
assertExactKeys(interaction, ["request", "response"], "Scenario bundle interaction");
|
|
202
|
+
const request = normalizeBundleRequest(interaction.request, variableNames);
|
|
203
|
+
const response = normalizeBundleResponse(interaction.response, variableNames);
|
|
204
|
+
return { request, response };
|
|
205
|
+
}
|
|
206
|
+
function normalizeBundleRequest(input, variableNames) {
|
|
207
|
+
const request = readObject(input, "Scenario bundle request is invalid.");
|
|
208
|
+
assertExactKeys(request, ["method", "path", "headers", "body"], "Scenario bundle request");
|
|
209
|
+
const method = normalizeMethod(request.method);
|
|
210
|
+
const path = normalizeRelativePath(request.path);
|
|
211
|
+
const headers = validateHeaders(request.headers, SAFE_REQUEST_HEADERS, variableNames, false);
|
|
212
|
+
validateValue(request.body, variableNames);
|
|
213
|
+
return { method, path, headers, body: structuredClone(request.body) };
|
|
214
|
+
}
|
|
215
|
+
function normalizeBundleResponse(input, variableNames) {
|
|
216
|
+
const response = readObject(input, "Scenario bundle response is invalid.");
|
|
217
|
+
assertExactKeys(response, ["status", "headers", "body"], "Scenario bundle response");
|
|
218
|
+
if (!Number.isInteger(response.status) || response.status < 100 || response.status > 599)
|
|
219
|
+
throw new ScenarioBundleError("Scenario bundle response status must be 100-599.");
|
|
220
|
+
const headers = validateHeaders(response.headers, SAFE_RESPONSE_HEADERS, variableNames, true);
|
|
221
|
+
validateValue(response.body, variableNames);
|
|
222
|
+
return { status: response.status, headers, body: structuredClone(response.body) };
|
|
223
|
+
}
|
|
224
|
+
function parseCapture(input) {
|
|
225
|
+
const capture = readObject(input, "Recording input must be an object.");
|
|
226
|
+
if (Array.isArray(capture.interactions))
|
|
227
|
+
return capture.interactions.map(parseDirectInteraction);
|
|
228
|
+
const log = readObject(capture.log, "Recording input must contain interactions or HAR log.entries.");
|
|
229
|
+
if (!Array.isArray(log.entries))
|
|
230
|
+
throw new ScenarioBundleError("HAR recording input must contain log.entries.");
|
|
231
|
+
return log.entries.map(parseHarInteraction);
|
|
232
|
+
}
|
|
233
|
+
function parseDirectInteraction(input) {
|
|
234
|
+
const interaction = readObject(input, "Recording interaction is invalid.");
|
|
235
|
+
const request = readObject(interaction.request, "Recording request is invalid.");
|
|
236
|
+
const response = readObject(interaction.response, "Recording response is invalid.");
|
|
237
|
+
const requestHeaders = readHeaderInput(request.headers);
|
|
238
|
+
const responseHeaders = readHeaderInput(response.headers);
|
|
239
|
+
return {
|
|
240
|
+
request: {
|
|
241
|
+
method: normalizeMethod(request.method),
|
|
242
|
+
url: readUrl(request.url),
|
|
243
|
+
headers: requestHeaders,
|
|
244
|
+
body: request.body ?? null,
|
|
245
|
+
binary: request.bodyEncoding === "base64",
|
|
246
|
+
multipart: isMultipart(requestHeaders)
|
|
247
|
+
},
|
|
248
|
+
response: {
|
|
249
|
+
status: readStatus(response.status),
|
|
250
|
+
headers: responseHeaders,
|
|
251
|
+
body: response.body ?? null,
|
|
252
|
+
binary: response.bodyEncoding === "base64",
|
|
253
|
+
multipart: isMultipart(responseHeaders)
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function parseHarInteraction(input) {
|
|
258
|
+
const entry = readObject(input, "HAR entry is invalid.");
|
|
259
|
+
const request = readObject(entry.request, "HAR request is invalid.");
|
|
260
|
+
const response = readObject(entry.response, "HAR response is invalid.");
|
|
261
|
+
const requestHeaders = readHeaderInput(request.headers);
|
|
262
|
+
const responseHeaders = readHeaderInput(response.headers);
|
|
263
|
+
const postData = request.postData === undefined ? null : readObject(request.postData, "HAR request postData is invalid.");
|
|
264
|
+
const content = response.content === undefined ? null : readObject(response.content, "HAR response content is invalid.");
|
|
265
|
+
return {
|
|
266
|
+
request: {
|
|
267
|
+
method: normalizeMethod(request.method),
|
|
268
|
+
url: readUrl(request.url),
|
|
269
|
+
headers: requestHeaders,
|
|
270
|
+
body: parseCaptureText(postData?.text, postData?.mimeType),
|
|
271
|
+
binary: postData?.encoding === "base64",
|
|
272
|
+
multipart: isMultipart(requestHeaders) || isMultipartMime(postData?.mimeType)
|
|
273
|
+
},
|
|
274
|
+
response: {
|
|
275
|
+
status: readStatus(response.status),
|
|
276
|
+
headers: responseHeaders,
|
|
277
|
+
body: parseCaptureText(content?.text, content?.mimeType),
|
|
278
|
+
binary: content?.encoding === "base64",
|
|
279
|
+
multipart: isMultipart(responseHeaders) || isMultipartMime(content?.mimeType)
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
function parseCaptureText(value, mimeType) {
|
|
284
|
+
if (typeof value !== "string")
|
|
285
|
+
return null;
|
|
286
|
+
if (typeof mimeType === "string" && /json/i.test(mimeType)) {
|
|
287
|
+
try {
|
|
288
|
+
return JSON.parse(value);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return value;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
function sanitizePath(url, state) {
|
|
297
|
+
const query = [];
|
|
298
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
299
|
+
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
|
|
300
|
+
mark(state, "known-api-key");
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const sanitized = sanitizeValue(value, state, key);
|
|
304
|
+
query.push(`${encodeURIComponent(key)}=${encodeScenarioQueryValue(typeof sanitized === "string" ? sanitized : String(sanitized))}`);
|
|
305
|
+
}
|
|
306
|
+
const suffix = query.length === 0 ? "" : `?${query.join("&")}`;
|
|
307
|
+
const sanitizedPathname = sanitizeSecretString(url.pathname);
|
|
308
|
+
if (sanitizedPathname !== url.pathname)
|
|
309
|
+
mark(state, "known-api-key");
|
|
310
|
+
return `${replaceUnstableIdentifiers(sanitizedPathname, state, "path_id")}${suffix}`;
|
|
311
|
+
}
|
|
312
|
+
function encodeScenarioQueryValue(value) {
|
|
313
|
+
return encodeURIComponent(value).replace(/%7B%7Bghostapi\.var\.([a-z0-9_]+)%7D%7D/gi, "{{ghostapi.var.$1}}");
|
|
314
|
+
}
|
|
315
|
+
function sanitizeHeaders(headers, allowed, state, response) {
|
|
316
|
+
const sanitized = {};
|
|
317
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
318
|
+
const lower = name.toLowerCase();
|
|
319
|
+
if (isSecretFieldName(lower)) {
|
|
320
|
+
mark(state, lower.includes("cookie") ? "cookie" : lower.includes("authorization") ? "authorization" : "secret-header");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!allowed.has(lower))
|
|
324
|
+
continue;
|
|
325
|
+
if (response && lower === "location" && !isSafeRelativeLocation(value)) {
|
|
326
|
+
sanitized.location = "/__ghostapi_redirect_blocked__";
|
|
327
|
+
mark(state, "external-redirect");
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const valueAfterSecretSanitization = sanitizeSecretString(value);
|
|
331
|
+
if (valueAfterSecretSanitization !== value)
|
|
332
|
+
mark(state, "known-api-key");
|
|
333
|
+
sanitized[lower] = valueAfterSecretSanitization.slice(0, 2_000);
|
|
334
|
+
}
|
|
335
|
+
return sanitized;
|
|
336
|
+
}
|
|
337
|
+
function sanitizeBody(value, binary, state) {
|
|
338
|
+
if (binary) {
|
|
339
|
+
mark(state, "binary-payload");
|
|
340
|
+
return "[GhostAPI omitted binary payload]";
|
|
341
|
+
}
|
|
342
|
+
return sanitizeValue(value, state);
|
|
343
|
+
}
|
|
344
|
+
function sanitizeValue(value, state, key, depth = 0) {
|
|
345
|
+
if (depth > MAX_VALUE_DEPTH)
|
|
346
|
+
throw new ScenarioBundleError(`Recording value exceeds maximum depth of ${MAX_VALUE_DEPTH}.`);
|
|
347
|
+
if (Array.isArray(value))
|
|
348
|
+
return value.map((entry) => sanitizeValue(entry, state, key, depth + 1));
|
|
349
|
+
if (value !== null && typeof value === "object") {
|
|
350
|
+
const entries = Object.entries(value);
|
|
351
|
+
if (entries.length > MAX_VALUE_KEYS)
|
|
352
|
+
throw new ScenarioBundleError(`Recording object exceeds ${MAX_VALUE_KEYS} keys.`);
|
|
353
|
+
return Object.fromEntries(entries.flatMap(([childKey, childValue]) => {
|
|
354
|
+
if (isSecretFieldName(childKey)) {
|
|
355
|
+
mark(state, "known-api-key");
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
return [[childKey, sanitizeValue(childValue, state, childKey, depth + 1)]];
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
if (typeof value !== "string")
|
|
362
|
+
return value;
|
|
363
|
+
if (key !== undefined && isSecretFieldName(key)) {
|
|
364
|
+
mark(state, "known-api-key");
|
|
365
|
+
return "***";
|
|
366
|
+
}
|
|
367
|
+
if (state.pii.addresses && key !== undefined && /(?:address|line1|line2|city|state|postal|zip)/i.test(key)) {
|
|
368
|
+
mark(state, "address");
|
|
369
|
+
return "[redacted:address]";
|
|
370
|
+
}
|
|
371
|
+
let result = sanitizeSecretString(value);
|
|
372
|
+
if (result !== value)
|
|
373
|
+
mark(state, "known-api-key");
|
|
374
|
+
if (state.pii.emails && /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i.test(result)) {
|
|
375
|
+
result = result.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted:email]");
|
|
376
|
+
mark(state, "email");
|
|
377
|
+
}
|
|
378
|
+
if (state.pii.phones && /(?:\+\d[\d(). -]{7,}\d|\b\d{3}[ .-]\d{3}[ .-]\d{4}\b)/.test(result)) {
|
|
379
|
+
result = result.replace(/(?:\+\d[\d(). -]{7,}\d|\b\d{3}[ .-]\d{3}[ .-]\d{4}\b)/g, "[redacted:phone]");
|
|
380
|
+
mark(state, "phone");
|
|
381
|
+
}
|
|
382
|
+
if (isTimestamp(result))
|
|
383
|
+
return variableReference(getVariable(state, result, "timestamp", deterministicTimestamp(state.variables.size + 1)));
|
|
384
|
+
return replaceUnstableIdentifiers(result, state, key);
|
|
385
|
+
}
|
|
386
|
+
function replaceUnstableIdentifiers(value, state, key) {
|
|
387
|
+
return value.replace(/\b(?:cus|pi|in|sub|evt|re|ch|pm|price|prod|cs|ord|user|msg|email)_[A-Za-z0-9]+\b|\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, (match) => {
|
|
388
|
+
const prefix = match.includes("_") ? match.split("_", 1)[0].toLowerCase() : "id";
|
|
389
|
+
const nameHint = key !== undefined && /id$/i.test(key) ? key.replace(/[^a-z0-9]+/gi, "_").toLowerCase() : `${prefix}_id`;
|
|
390
|
+
return variableReference(getVariable(state, match, nameHint, `${prefix}_recorded_${state.variables.size + 1}`));
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
function getVariable(state, raw, hint, value) {
|
|
394
|
+
const existing = state.variables.get(raw);
|
|
395
|
+
if (existing !== undefined)
|
|
396
|
+
return existing;
|
|
397
|
+
if (state.variables.size >= MAX_VARIABLES)
|
|
398
|
+
throw new ScenarioBundleError(`Recording exceeds ${MAX_VARIABLES} stateful variables.`);
|
|
399
|
+
const base = hint.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "").toLowerCase() || "value";
|
|
400
|
+
let name = base;
|
|
401
|
+
let suffix = 1;
|
|
402
|
+
const names = new Set([...state.variables.values()].map((entry) => entry.name));
|
|
403
|
+
while (names.has(name))
|
|
404
|
+
name = `${base}_${suffix++}`;
|
|
405
|
+
const variable = { name, value };
|
|
406
|
+
state.variables.set(raw, variable);
|
|
407
|
+
mark(state, hint === "timestamp" ? "timestamp" : "unstable-id");
|
|
408
|
+
return variable;
|
|
409
|
+
}
|
|
410
|
+
function variableReference(variable) {
|
|
411
|
+
return `{{ghostapi.var.${variable.name}}}`;
|
|
412
|
+
}
|
|
413
|
+
function mark(state, category) {
|
|
414
|
+
state.categories.add(category);
|
|
415
|
+
state.replacements += 1;
|
|
416
|
+
}
|
|
417
|
+
function parseSandboxUrl(value, allowedHosts, headers) {
|
|
418
|
+
let url;
|
|
419
|
+
try {
|
|
420
|
+
url = new URL(value);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
throw new ScenarioBundleError("Recording request URL must be absolute HTTPS.");
|
|
424
|
+
}
|
|
425
|
+
if (url.protocol !== "https:")
|
|
426
|
+
throw new ScenarioBundleError("Recording permits HTTPS sandbox traffic only.");
|
|
427
|
+
if (!allowedHosts.has(url.hostname))
|
|
428
|
+
throw new ScenarioBundleError(`Recording host is not explicitly allowed as a sandbox host: ${url.hostname}`);
|
|
429
|
+
if (!looksLikeSandboxHost(url.hostname, headers))
|
|
430
|
+
throw new ScenarioBundleError(`Recording host does not pass the sandbox safety check: ${url.hostname}`);
|
|
431
|
+
return url;
|
|
432
|
+
}
|
|
433
|
+
function looksLikeSandboxHost(host, headers) {
|
|
434
|
+
if (host === "api.stripe.com")
|
|
435
|
+
return /\b(?:sk|rk)_test_[A-Za-z0-9_-]+/i.test(headers.authorization ?? "");
|
|
436
|
+
return host === "localhost" || host.endsWith(".localhost") || /(?:sandbox|test)/i.test(host);
|
|
437
|
+
}
|
|
438
|
+
function normalizeAllowedSandboxHosts(hosts) {
|
|
439
|
+
if (hosts.length === 0)
|
|
440
|
+
throw new ScenarioBundleError("Recording requires at least one explicit --allow-sandbox-host.");
|
|
441
|
+
const normalized = hosts.map((host) => host.trim().toLowerCase()).filter(Boolean);
|
|
442
|
+
if (normalized.length !== hosts.length || normalized.some((host) => !/^[a-z0-9.-]+$/.test(host) || host.includes("..") || host.startsWith(".") || host.endsWith("."))) {
|
|
443
|
+
throw new ScenarioBundleError("Allowed sandbox hosts must be plain host names without paths, ports, or wildcards.");
|
|
444
|
+
}
|
|
445
|
+
return new Set(normalized);
|
|
446
|
+
}
|
|
447
|
+
function normalizeReplayRequest(input) {
|
|
448
|
+
return {
|
|
449
|
+
method: normalizeMethod(input.method),
|
|
450
|
+
path: normalizeRelativePath(input.path),
|
|
451
|
+
headers: normalizeReplayHeaders(input.headers),
|
|
452
|
+
body: sanitizeSecrets(input.body ?? null)
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
function normalizeReplayHeaders(headers) {
|
|
456
|
+
if (headers === undefined)
|
|
457
|
+
return {};
|
|
458
|
+
const result = {};
|
|
459
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
460
|
+
const lower = name.toLowerCase();
|
|
461
|
+
if (SAFE_REQUEST_HEADERS.has(lower))
|
|
462
|
+
result[lower] = Array.isArray(value) ? value.join(", ") : value;
|
|
463
|
+
}
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
function compareRequest(expected, actual, bindings, variables) {
|
|
467
|
+
const mismatches = [];
|
|
468
|
+
if (expected.method !== actual.method)
|
|
469
|
+
mismatches.push(`method expected ${expected.method}, received ${actual.method}`);
|
|
470
|
+
if (!matchTemplate(expected.path, actual.path, bindings, variables))
|
|
471
|
+
mismatches.push(`path expected ${expected.path}, received ${actual.path}`);
|
|
472
|
+
if (!matchValue(expected.headers, actual.headers, bindings, variables))
|
|
473
|
+
mismatches.push("safe request headers differ");
|
|
474
|
+
if (!matchValue(expected.body, actual.body, bindings, variables))
|
|
475
|
+
mismatches.push("request body differs");
|
|
476
|
+
return mismatches;
|
|
477
|
+
}
|
|
478
|
+
function resolveHeaders(headers, variables, bindings) {
|
|
479
|
+
return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, resolveTemplate(value, variables, bindings)]));
|
|
480
|
+
}
|
|
481
|
+
function resolveVariables(value, variables, bindings) {
|
|
482
|
+
if (Array.isArray(value))
|
|
483
|
+
return value.map((entry) => resolveVariables(entry, variables, bindings));
|
|
484
|
+
if (value !== null && typeof value === "object")
|
|
485
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, resolveVariables(entry, variables, bindings)]));
|
|
486
|
+
return typeof value === "string" ? resolveTemplate(value, variables, bindings) : value;
|
|
487
|
+
}
|
|
488
|
+
function resolveTemplate(value, variables, bindings) {
|
|
489
|
+
const dictionary = new Map(variables.map((entry) => [entry.name, entry.value]));
|
|
490
|
+
return value.replace(VARIABLE_PATTERN, (_match, name) => {
|
|
491
|
+
const resolved = bindings.get(name) ?? dictionary.get(name) ?? "";
|
|
492
|
+
bindings.set(name, resolved);
|
|
493
|
+
return resolved;
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function matchValue(expected, actual, bindings, variables) {
|
|
497
|
+
if (typeof expected === "string" && typeof actual === "string")
|
|
498
|
+
return matchTemplate(expected, actual, bindings, variables);
|
|
499
|
+
if (expected === null || actual === null || typeof expected !== "object" || typeof actual !== "object")
|
|
500
|
+
return expected === actual;
|
|
501
|
+
if (Array.isArray(expected) || Array.isArray(actual)) {
|
|
502
|
+
return Array.isArray(expected) && Array.isArray(actual) && expected.length === actual.length && expected.every((entry, index) => matchValue(entry, actual[index], bindings, variables));
|
|
503
|
+
}
|
|
504
|
+
const expectedEntries = Object.entries(expected);
|
|
505
|
+
const actualRecord = actual;
|
|
506
|
+
return expectedEntries.length === Object.keys(actualRecord).length && expectedEntries.every(([key, entry]) => Object.hasOwn(actualRecord, key) && matchValue(entry, actualRecord[key], bindings, variables));
|
|
507
|
+
}
|
|
508
|
+
function matchTemplate(expected, actual, bindings, variables) {
|
|
509
|
+
const known = new Set(variables.map((entry) => entry.name));
|
|
510
|
+
const matches = [...expected.matchAll(VARIABLE_PATTERN)];
|
|
511
|
+
if (matches.length === 0)
|
|
512
|
+
return expected === actual;
|
|
513
|
+
let pattern = "^";
|
|
514
|
+
let cursor = 0;
|
|
515
|
+
const names = [];
|
|
516
|
+
for (const match of matches) {
|
|
517
|
+
const index = match.index ?? 0;
|
|
518
|
+
pattern += escapeRegExp(expected.slice(cursor, index));
|
|
519
|
+
const name = match[1];
|
|
520
|
+
if (!known.has(name))
|
|
521
|
+
return false;
|
|
522
|
+
const bound = bindings.get(name);
|
|
523
|
+
pattern += bound === undefined ? "(.+?)" : `(${escapeRegExp(bound)})`;
|
|
524
|
+
names.push(name);
|
|
525
|
+
cursor = index + match[0].length;
|
|
526
|
+
}
|
|
527
|
+
pattern += `${escapeRegExp(expected.slice(cursor))}$`;
|
|
528
|
+
const result = new RegExp(pattern).exec(actual);
|
|
529
|
+
if (result === null)
|
|
530
|
+
return false;
|
|
531
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
532
|
+
const name = names[index];
|
|
533
|
+
const value = result[index + 1];
|
|
534
|
+
const existing = bindings.get(name);
|
|
535
|
+
if (existing !== undefined && existing !== value)
|
|
536
|
+
return false;
|
|
537
|
+
bindings.set(name, value);
|
|
538
|
+
}
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
function escapeRegExp(value) {
|
|
542
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
543
|
+
}
|
|
544
|
+
function validateHeaders(input, allowed, variableNames, response) {
|
|
545
|
+
const headers = readObject(input, "Scenario bundle headers must be an object.");
|
|
546
|
+
const normalized = {};
|
|
547
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
548
|
+
const lower = name.toLowerCase();
|
|
549
|
+
if (!allowed.has(lower) || typeof value !== "string" || value.length > 2_000 || isSecretFieldName(lower))
|
|
550
|
+
throw new ScenarioBundleError(`Scenario bundle header is not allowed: ${name}`);
|
|
551
|
+
if (response && lower === "location" && !isSafeRelativeLocation(value))
|
|
552
|
+
throw new ScenarioBundleError("Scenario bundle redirect location must be relative.");
|
|
553
|
+
validateTemplate(value, variableNames);
|
|
554
|
+
normalized[lower] = value;
|
|
555
|
+
}
|
|
556
|
+
return normalized;
|
|
557
|
+
}
|
|
558
|
+
function validateValue(value, variableNames, depth = 0) {
|
|
559
|
+
if (depth > MAX_VALUE_DEPTH)
|
|
560
|
+
throw new ScenarioBundleError(`Scenario bundle value exceeds maximum depth of ${MAX_VALUE_DEPTH}.`);
|
|
561
|
+
if (typeof value === "string") {
|
|
562
|
+
if (value.length > 64 * 1024)
|
|
563
|
+
throw new ScenarioBundleError("Scenario bundle string exceeds 65536 characters.");
|
|
564
|
+
if (sanitizeSecretString(value) !== value)
|
|
565
|
+
throw new ScenarioBundleError("Scenario bundle contains a secret-shaped value.");
|
|
566
|
+
validateTemplate(value, variableNames);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
570
|
+
return;
|
|
571
|
+
if (Array.isArray(value)) {
|
|
572
|
+
for (const entry of value)
|
|
573
|
+
validateValue(entry, variableNames, depth + 1);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (typeof value === "object") {
|
|
577
|
+
const entries = Object.entries(value);
|
|
578
|
+
if (entries.length > MAX_VALUE_KEYS)
|
|
579
|
+
throw new ScenarioBundleError(`Scenario bundle object exceeds ${MAX_VALUE_KEYS} keys.`);
|
|
580
|
+
for (const [key, entry] of entries) {
|
|
581
|
+
if (isSecretFieldName(key))
|
|
582
|
+
throw new ScenarioBundleError(`Scenario bundle contains a secret-shaped field: ${key}`);
|
|
583
|
+
validateValue(entry, variableNames, depth + 1);
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
throw new ScenarioBundleError("Scenario bundle values must be JSON values.");
|
|
588
|
+
}
|
|
589
|
+
function validateTemplate(value, variableNames) {
|
|
590
|
+
for (const match of value.matchAll(VARIABLE_PATTERN)) {
|
|
591
|
+
if (!variableNames.has(match[1]))
|
|
592
|
+
throw new ScenarioBundleError(`Scenario bundle references an undeclared variable: ${match[1]}`);
|
|
593
|
+
}
|
|
594
|
+
if (value.includes("{{ghostapi.var.")) {
|
|
595
|
+
const stripped = value.replace(VARIABLE_PATTERN, "");
|
|
596
|
+
if (stripped.includes("{{ghostapi.var."))
|
|
597
|
+
throw new ScenarioBundleError("Scenario bundle contains an invalid variable reference.");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
async function readBoundedJson(path, projectRoot, limit, label) {
|
|
601
|
+
const target = await resolveExistingPath(path, projectRoot, label);
|
|
602
|
+
const source = await readFile(target, "utf8");
|
|
603
|
+
if (Buffer.byteLength(source, "utf8") > limit)
|
|
604
|
+
throw new ScenarioBundleError(`${label} exceeds ${limit} bytes.`);
|
|
605
|
+
try {
|
|
606
|
+
return JSON.parse(source);
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
throw new ScenarioBundleError(`${label} is not valid JSON.`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function resolveExistingPath(path, projectRoot, label) {
|
|
613
|
+
const target = isAbsolute(path) ? resolve(path) : resolve(projectRoot, path);
|
|
614
|
+
assertPathInsideAllowedRoots(projectRoot, target, label);
|
|
615
|
+
const info = await lstat(target);
|
|
616
|
+
if (!info.isFile() || info.isSymbolicLink())
|
|
617
|
+
throw new ScenarioBundleError(`${label} must be a regular non-symlink file.`);
|
|
618
|
+
if (!await isRealPathInsideAllowedRoots(target, projectRoot))
|
|
619
|
+
throw new ScenarioBundleError(`${label} resolves outside the project root or GHOSTAPI_DATA_DIR through a symlink.`);
|
|
620
|
+
return target;
|
|
621
|
+
}
|
|
622
|
+
async function resolveOutputPath(path, projectRoot) {
|
|
623
|
+
const target = isAbsolute(path) ? resolve(path) : resolve(projectRoot, path);
|
|
624
|
+
assertPathInsideAllowedRoots(projectRoot, target, "Scenario bundle output");
|
|
625
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
626
|
+
const parent = await lstat(dirname(target));
|
|
627
|
+
if (!parent.isDirectory() || parent.isSymbolicLink())
|
|
628
|
+
throw new ScenarioBundleError("Scenario bundle output parent must be a real directory, not a symlink.");
|
|
629
|
+
if (!await isRealPathInsideAllowedRoots(dirname(target), projectRoot))
|
|
630
|
+
throw new ScenarioBundleError("Scenario bundle output parent resolves outside the project root or GHOSTAPI_DATA_DIR through a symlink.");
|
|
631
|
+
const existing = await lstat(target).catch((error) => isErrorCode(error, "ENOENT") ? null : Promise.reject(error));
|
|
632
|
+
if (existing?.isSymbolicLink())
|
|
633
|
+
throw new ScenarioBundleError("Scenario bundle output path must not be a symlink.");
|
|
634
|
+
return target;
|
|
635
|
+
}
|
|
636
|
+
function assertPathInsideAllowedRoots(projectRoot, target, label) {
|
|
637
|
+
if (!isInside(projectRoot, target) && !isInside(getDataPaths().root, target))
|
|
638
|
+
throw new ScenarioBundleError(`${label} path traversal outside the project root or GHOSTAPI_DATA_DIR is not allowed.`);
|
|
639
|
+
if (basename(target).trim() === "")
|
|
640
|
+
throw new ScenarioBundleError(`${label} path must include a file name.`);
|
|
641
|
+
}
|
|
642
|
+
function isInside(root, target) {
|
|
643
|
+
const relativePath = relative(resolve(root), target);
|
|
644
|
+
return relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath);
|
|
645
|
+
}
|
|
646
|
+
async function isRealPathInsideAllowedRoots(target, projectRoot) {
|
|
647
|
+
const realTarget = await realpath(target);
|
|
648
|
+
const realProjectRoot = await realpath(projectRoot);
|
|
649
|
+
const dataRoot = await realpath(getDataPaths().root).catch(() => null);
|
|
650
|
+
return isInside(realProjectRoot, realTarget) || (dataRoot !== null && isInside(dataRoot, realTarget));
|
|
651
|
+
}
|
|
652
|
+
function readObject(value, message) {
|
|
653
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
654
|
+
throw new ScenarioBundleError(message);
|
|
655
|
+
return value;
|
|
656
|
+
}
|
|
657
|
+
function assertExactKeys(value, expected, label) {
|
|
658
|
+
const unknown = Object.keys(value).filter((key) => !expected.includes(key));
|
|
659
|
+
if (unknown.length > 0)
|
|
660
|
+
throw new ScenarioBundleError(`${label} contains unknown field: ${unknown[0]}`);
|
|
661
|
+
}
|
|
662
|
+
function readHeaderInput(value) {
|
|
663
|
+
if (value === undefined)
|
|
664
|
+
return {};
|
|
665
|
+
if (Array.isArray(value)) {
|
|
666
|
+
return Object.fromEntries(value.map((entry) => {
|
|
667
|
+
const header = readObject(entry, "Capture header is invalid.");
|
|
668
|
+
if (typeof header.name !== "string" || typeof header.value !== "string")
|
|
669
|
+
throw new ScenarioBundleError("Capture header is invalid.");
|
|
670
|
+
return [header.name.toLowerCase(), header.value];
|
|
671
|
+
}));
|
|
672
|
+
}
|
|
673
|
+
const record = readObject(value, "Capture headers are invalid.");
|
|
674
|
+
return Object.fromEntries(Object.entries(record).map(([name, headerValue]) => {
|
|
675
|
+
if (typeof headerValue !== "string")
|
|
676
|
+
throw new ScenarioBundleError("Capture header values must be strings.");
|
|
677
|
+
return [name.toLowerCase(), headerValue];
|
|
678
|
+
}));
|
|
679
|
+
}
|
|
680
|
+
function normalizeMethod(value) {
|
|
681
|
+
if (typeof value !== "string" || !METHODS.has(value.toUpperCase()))
|
|
682
|
+
throw new ScenarioBundleError("HTTP method is invalid.");
|
|
683
|
+
return value.toUpperCase();
|
|
684
|
+
}
|
|
685
|
+
function readUrl(value) {
|
|
686
|
+
if (typeof value !== "string" || value.length > 8_000)
|
|
687
|
+
throw new ScenarioBundleError("Recording request URL is invalid.");
|
|
688
|
+
return value;
|
|
689
|
+
}
|
|
690
|
+
function readStatus(value) {
|
|
691
|
+
if (!Number.isInteger(value) || value < 100 || value > 599)
|
|
692
|
+
throw new ScenarioBundleError("Capture response status must be 100-599.");
|
|
693
|
+
return value;
|
|
694
|
+
}
|
|
695
|
+
function normalizeRelativePath(value) {
|
|
696
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 8_000 || !value.startsWith("/") || value.startsWith("//") || /[\r\n]/.test(value)) {
|
|
697
|
+
throw new ScenarioBundleError("Scenario request path must be a relative path.");
|
|
698
|
+
}
|
|
699
|
+
return value;
|
|
700
|
+
}
|
|
701
|
+
function readHostArray(value) {
|
|
702
|
+
const hosts = readStringArray(value, "Scenario bundle sandbox hosts are invalid.").map((host) => host.toLowerCase());
|
|
703
|
+
if (hosts.length === 0 || hosts.some((host) => !/^[a-z0-9.-]+$/.test(host)))
|
|
704
|
+
throw new ScenarioBundleError("Scenario bundle sandbox hosts are invalid.");
|
|
705
|
+
return [...new Set(hosts)].sort();
|
|
706
|
+
}
|
|
707
|
+
function readStringArray(value, message) {
|
|
708
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length > 256))
|
|
709
|
+
throw new ScenarioBundleError(message);
|
|
710
|
+
return value;
|
|
711
|
+
}
|
|
712
|
+
function isMultipart(headers) {
|
|
713
|
+
return isMultipartMime(headers["content-type"]);
|
|
714
|
+
}
|
|
715
|
+
function isMultipartMime(value) {
|
|
716
|
+
return typeof value === "string" && /^multipart\//i.test(value);
|
|
717
|
+
}
|
|
718
|
+
function isTimestamp(value) {
|
|
719
|
+
return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) && Number.isFinite(Date.parse(value));
|
|
720
|
+
}
|
|
721
|
+
function deterministicTimestamp(index) {
|
|
722
|
+
return `2020-01-01T00:00:${String(index % 60).padStart(2, "0")}.000Z`;
|
|
723
|
+
}
|
|
724
|
+
function normalizeTitle(value) {
|
|
725
|
+
if (typeof value !== "string" || value.trim() === "" || value.trim().length > 120)
|
|
726
|
+
throw new ScenarioBundleError("Scenario bundle title must be 1-120 characters.");
|
|
727
|
+
return value.trim();
|
|
728
|
+
}
|
|
729
|
+
function slugify(value) {
|
|
730
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || "sandbox-recording";
|
|
731
|
+
}
|
|
732
|
+
function stableStringify(value) {
|
|
733
|
+
if (value === null || typeof value !== "object")
|
|
734
|
+
return JSON.stringify(value);
|
|
735
|
+
if (Array.isArray(value))
|
|
736
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
737
|
+
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${stableStringify(entry)}`).join(",")}}`;
|
|
738
|
+
}
|
|
739
|
+
function serializedBytes(value, label) {
|
|
740
|
+
try {
|
|
741
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
742
|
+
}
|
|
743
|
+
catch {
|
|
744
|
+
throw new ScenarioBundleError(`${label} must be JSON-serializable.`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
function isErrorCode(error, code) {
|
|
748
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
749
|
+
}
|
|
750
|
+
//# sourceMappingURL=scenarioBundle.js.map
|