@yolk-sdk/conformance 0.1.0-canary.96
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 +21 -0
- package/README.md +316 -0
- package/dist/case.d.mts +89 -0
- package/dist/case.d.mts.map +1 -0
- package/dist/case.mjs +101 -0
- package/dist/case.mjs.map +1 -0
- package/dist/fixture.d.mts +432 -0
- package/dist/fixture.d.mts.map +1 -0
- package/dist/fixture.mjs +391 -0
- package/dist/fixture.mjs.map +1 -0
- package/dist/record.d.mts +96 -0
- package/dist/record.d.mts.map +1 -0
- package/dist/record.mjs +212 -0
- package/dist/record.mjs.map +1 -0
- package/dist/replay.d.mts +316 -0
- package/dist/replay.d.mts.map +1 -0
- package/dist/replay.mjs +272 -0
- package/dist/replay.mjs.map +1 -0
- package/dist/runner.d.mts +163 -0
- package/dist/runner.d.mts.map +1 -0
- package/dist/runner.mjs +264 -0
- package/dist/runner.mjs.map +1 -0
- package/dist/wire-internal.d.mts +90 -0
- package/dist/wire-internal.d.mts.map +1 -0
- package/dist/wire-internal.mjs +212 -0
- package/dist/wire-internal.mjs.map +1 -0
- package/package.json +75 -0
- package/src/case.ts +153 -0
- package/src/fixture.ts +625 -0
- package/src/record.ts +377 -0
- package/src/replay.ts +617 -0
- package/src/runner.ts +559 -0
- package/src/wire-internal.ts +382 -0
package/dist/runner.mjs
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { ConformanceMismatch } from "./case.mjs";
|
|
2
|
+
import { redactCredentialText } from "./wire-internal.mjs";
|
|
3
|
+
import { conformanceFixtureEvidence, fixtureAgeDays } from "./fixture.mjs";
|
|
4
|
+
import { Cause, Clock, Effect, Exit, Option, Predicate } from "effect";
|
|
5
|
+
//#region src/runner.ts
|
|
6
|
+
/**
|
|
7
|
+
* Conformance runner: decides which cases may run against a target (safety
|
|
8
|
+
* policy), runs each allowed case in isolation with freshly built layers, and
|
|
9
|
+
* produces a plain-data report.
|
|
10
|
+
*
|
|
11
|
+
* The runner never performs network I/O itself; whatever the per-case layer
|
|
12
|
+
* provides (a replay `HttpClient`, an emulator, or a host's live client) is
|
|
13
|
+
* what the case talks to.
|
|
14
|
+
*
|
|
15
|
+
* @experimental
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The safety policy. `undefined` means the case may run.
|
|
19
|
+
*
|
|
20
|
+
* - `replay` / `in-process` / `emulated`: every case runs.
|
|
21
|
+
* - `live`: `read` runs; `write-reversible` runs only with
|
|
22
|
+
* `allowWrites: 'reversible'` (else `writes-not-allowed`);
|
|
23
|
+
* `write-irreversible` runs only when its exact id is in
|
|
24
|
+
* `allowIrreversible` (else `manual-only`), independent of `allowWrites`.
|
|
25
|
+
*/
|
|
26
|
+
const conformanceSkipReason = (target, testCase) => {
|
|
27
|
+
if (target.kind !== "live") return;
|
|
28
|
+
switch (testCase.safety) {
|
|
29
|
+
case "read": return;
|
|
30
|
+
case "write-reversible": return target.allowWrites === "reversible" ? void 0 : "writes-not-allowed";
|
|
31
|
+
case "write-irreversible": return (target.allowIrreversible ?? []).includes(testCase.id) ? void 0 : "manual-only";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const staleAge = (date, now, maxAgeDays) => {
|
|
35
|
+
const age = fixtureAgeDays({ recordedAt: date }, now);
|
|
36
|
+
if (Number.isNaN(age)) return Option.some(void 0);
|
|
37
|
+
return age > maxAgeDays ? Option.some(age) : Option.none();
|
|
38
|
+
};
|
|
39
|
+
const withAge = (warning, ageDays) => ageDays === void 0 ? warning : {
|
|
40
|
+
...warning,
|
|
41
|
+
ageDays
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Warnings for one case. Case-level warnings always apply; fixture-level ones
|
|
45
|
+
* only on non-live targets and only when `fixtures` were supplied.
|
|
46
|
+
*/
|
|
47
|
+
const conformanceCaseWarnings = (testCase, context) => {
|
|
48
|
+
const maxAgeDays = context.maxFixtureAgeDays ?? 30;
|
|
49
|
+
const warnings = [];
|
|
50
|
+
if (testCase.observed === void 0) warnings.push({ kind: "unverified-case" });
|
|
51
|
+
else {
|
|
52
|
+
const stale = staleAge(testCase.observed.date, context.now, maxAgeDays);
|
|
53
|
+
if (Option.isSome(stale)) warnings.push(withAge({ kind: "stale-observation" }, stale.value));
|
|
54
|
+
}
|
|
55
|
+
const fixtures = context.fixtures;
|
|
56
|
+
if (context.target.kind === "live" || fixtures === void 0) return warnings;
|
|
57
|
+
for (const fixtureId of testCase.fixtures) {
|
|
58
|
+
const fixture = fixtures.find((candidate) => candidate.id === fixtureId);
|
|
59
|
+
if (fixture === void 0) {
|
|
60
|
+
warnings.push({
|
|
61
|
+
kind: "missing-fixture",
|
|
62
|
+
fixtureId
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const { evidence, date } = conformanceFixtureEvidence(fixture);
|
|
67
|
+
if (evidence === "unverified") warnings.push({
|
|
68
|
+
kind: "unverified-fixture",
|
|
69
|
+
fixtureId
|
|
70
|
+
});
|
|
71
|
+
const stale = date === void 0 ? Option.none() : staleAge(date, context.now, maxAgeDays);
|
|
72
|
+
if (Option.isSome(stale)) warnings.push(withAge({
|
|
73
|
+
kind: "stale-fixture",
|
|
74
|
+
fixtureId
|
|
75
|
+
}, stale.value));
|
|
76
|
+
}
|
|
77
|
+
return warnings;
|
|
78
|
+
};
|
|
79
|
+
const maxFailureMessageLength = 300;
|
|
80
|
+
const reportableTagPattern = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
81
|
+
const collapseAndCap = (message) => {
|
|
82
|
+
const compact = message.replace(/\s+/g, " ").trim();
|
|
83
|
+
return compact.length > maxFailureMessageLength ? `${compact.slice(0, maxFailureMessageLength - 3)}...` : compact;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* End index of the balanced `{...}` / `[...]` segment opening at `start` (strings respected);
|
|
87
|
+
* `undefined` when it never closes or closes with the wrong bracket.
|
|
88
|
+
*/
|
|
89
|
+
const balancedSegmentEnd = (text, start) => {
|
|
90
|
+
const expected = [];
|
|
91
|
+
let inString = false;
|
|
92
|
+
for (let index = start; index < text.length; index++) {
|
|
93
|
+
const char = text[index];
|
|
94
|
+
if (inString) {
|
|
95
|
+
if (char === "\\") index++;
|
|
96
|
+
else if (char === "\"") inString = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (char === "\"") inString = true;
|
|
100
|
+
else if (char === "{") expected.push("}");
|
|
101
|
+
else if (char === "[") expected.push("]");
|
|
102
|
+
else if (char === "}" || char === "]") {
|
|
103
|
+
if (expected.pop() !== char) return;
|
|
104
|
+
if (expected.length === 0) return index;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const elideJsonSpans = (text) => {
|
|
109
|
+
let output = "";
|
|
110
|
+
let index = 0;
|
|
111
|
+
while (index < text.length) {
|
|
112
|
+
const char = text[index];
|
|
113
|
+
if (char !== "{" && char !== "[") {
|
|
114
|
+
output += char;
|
|
115
|
+
index++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const end = balancedSegmentEnd(text, index);
|
|
119
|
+
output += "[json]";
|
|
120
|
+
if (end === void 0) return output;
|
|
121
|
+
index = end + 1;
|
|
122
|
+
}
|
|
123
|
+
return output;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Best-effort sanitizer for failure messages copied into a `ConformanceReport`. Redacts the
|
|
127
|
+
* credential patterns shared with the fixture secret scan (bearer tokens, API-key prefixes, JWTs,
|
|
128
|
+
* private keys, credential field pairs, credential query/form parameters, and credential header
|
|
129
|
+
* lines such as `Cookie:` or `X-Api-Key:` to the end of the line), replaces JSON-looking spans
|
|
130
|
+
* (balanced `{...}` / `[...]`) with `[json]`, collapses whitespace, and caps the length at 300
|
|
131
|
+
* characters. Hosts should still keep secrets out of error messages.
|
|
132
|
+
*/
|
|
133
|
+
const sanitizeConformanceMessage = (message) => collapseAndCap(elideJsonSpans(redactCredentialText(message)));
|
|
134
|
+
const sanitizeMismatchMessage = (message) => collapseAndCap(redactCredentialText(message));
|
|
135
|
+
const stringProperty = (value, key) => {
|
|
136
|
+
if (!Predicate.hasProperty(value, key)) return;
|
|
137
|
+
const property = value[key];
|
|
138
|
+
return Predicate.isString(property) && property.length > 0 ? property : void 0;
|
|
139
|
+
};
|
|
140
|
+
const describeValue = (kind, value) => {
|
|
141
|
+
const rawTag = stringProperty(value, "_tag");
|
|
142
|
+
const tag = rawTag !== void 0 && reportableTagPattern.test(rawTag) && redactCredentialText(rawTag) === rawTag ? rawTag : void 0;
|
|
143
|
+
const rawMessage = stringProperty(value, "message") ?? (Predicate.isString(value) && value.length > 0 ? value : void 0);
|
|
144
|
+
const failure = {
|
|
145
|
+
kind,
|
|
146
|
+
message: rawMessage === void 0 ? tag ?? (kind === "defect" ? "unexpected defect" : "case failed") : value instanceof ConformanceMismatch ? sanitizeMismatchMessage(rawMessage) : sanitizeConformanceMessage(rawMessage)
|
|
147
|
+
};
|
|
148
|
+
return tag === void 0 ? failure : {
|
|
149
|
+
...failure,
|
|
150
|
+
tag
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
const describeCause = (cause) => {
|
|
154
|
+
const error = Cause.findErrorOption(cause);
|
|
155
|
+
if (Option.isSome(error)) return describeValue("failure", error.value);
|
|
156
|
+
return describeValue("defect", Cause.squash(cause));
|
|
157
|
+
};
|
|
158
|
+
const summarize = (results) => {
|
|
159
|
+
const counts = {
|
|
160
|
+
passed: 0,
|
|
161
|
+
failed: 0,
|
|
162
|
+
skipped: 0
|
|
163
|
+
};
|
|
164
|
+
for (const result of results) counts[result.status] += 1;
|
|
165
|
+
return counts;
|
|
166
|
+
};
|
|
167
|
+
const identityResult = (result) => result;
|
|
168
|
+
const reportTarget = (target) => target.kind === "live" ? {
|
|
169
|
+
kind: target.kind,
|
|
170
|
+
account: target.account
|
|
171
|
+
} : { kind: target.kind };
|
|
172
|
+
function runConformance(cases, options) {
|
|
173
|
+
return Effect.gen(function* () {
|
|
174
|
+
const now = options.now ?? new Date(yield* Clock.currentTimeMillis);
|
|
175
|
+
const warningContext = {
|
|
176
|
+
target: options.target,
|
|
177
|
+
now,
|
|
178
|
+
fixtures: options.fixtures,
|
|
179
|
+
maxFixtureAgeDays: options.maxFixtureAgeDays
|
|
180
|
+
};
|
|
181
|
+
const runCase = (testCase) => Effect.gen(function* () {
|
|
182
|
+
const base = {
|
|
183
|
+
id: testCase.id,
|
|
184
|
+
safety: testCase.safety,
|
|
185
|
+
warnings: conformanceCaseWarnings(testCase, warningContext)
|
|
186
|
+
};
|
|
187
|
+
const skipReason = conformanceSkipReason(options.target, testCase);
|
|
188
|
+
if (skipReason !== void 0) return identityResult({
|
|
189
|
+
...base,
|
|
190
|
+
status: "skipped",
|
|
191
|
+
skipReason,
|
|
192
|
+
durationMs: 0
|
|
193
|
+
});
|
|
194
|
+
const started = yield* Clock.currentTimeMillis;
|
|
195
|
+
const exit = yield* Effect.suspend(() => testCase.run.pipe(Effect.provide(options.layer(testCase), { local: true }))).pipe(Effect.exit);
|
|
196
|
+
const durationMs = (yield* Clock.currentTimeMillis) - started;
|
|
197
|
+
if (Exit.isSuccess(exit)) return identityResult({
|
|
198
|
+
...base,
|
|
199
|
+
status: "passed",
|
|
200
|
+
durationMs
|
|
201
|
+
});
|
|
202
|
+
if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.interrupt;
|
|
203
|
+
return identityResult({
|
|
204
|
+
...base,
|
|
205
|
+
status: "failed",
|
|
206
|
+
failure: describeCause(exit.cause),
|
|
207
|
+
durationMs
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
const results = yield* Effect.forEach(cases, runCase, { concurrency: options.concurrency ?? 1 });
|
|
211
|
+
return {
|
|
212
|
+
target: reportTarget(options.target),
|
|
213
|
+
startedAt: now.toISOString(),
|
|
214
|
+
results,
|
|
215
|
+
summary: summarize(results)
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/** True when any case failed. Skipped cases never fail a report. */
|
|
220
|
+
const conformanceReportFailed = (report) => report.summary.failed > 0;
|
|
221
|
+
const statusLabel = {
|
|
222
|
+
passed: "PASS",
|
|
223
|
+
failed: "FAIL",
|
|
224
|
+
skipped: "SKIP"
|
|
225
|
+
};
|
|
226
|
+
const formatAge = (ageDays) => ageDays === void 0 ? "(unreadable date)" : `(${ageDays}d)`;
|
|
227
|
+
const formatWarning = (warning) => {
|
|
228
|
+
switch (warning.kind) {
|
|
229
|
+
case "unverified-case": return warning.kind;
|
|
230
|
+
case "stale-observation": return `${warning.kind}${formatAge(warning.ageDays)}`;
|
|
231
|
+
case "unverified-fixture":
|
|
232
|
+
case "missing-fixture": return `${warning.kind}:${warning.fixtureId}`;
|
|
233
|
+
case "stale-fixture": return `${warning.kind}:${warning.fixtureId}${formatAge(warning.ageDays)}`;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const formatDetail = (result) => {
|
|
237
|
+
if (result.skipReason !== void 0) return result.skipReason;
|
|
238
|
+
if (result.failure === void 0) return;
|
|
239
|
+
return `${result.failure.kind === "defect" ? "defect " : ""}${result.failure.tag === void 0 ? "" : `${result.failure.tag}: `}${result.failure.message}`;
|
|
240
|
+
};
|
|
241
|
+
/**
|
|
242
|
+
* Compact plain text (no colors): one line per case with status, id, safety,
|
|
243
|
+
* skip reason or failure message, and warnings, then a summary line.
|
|
244
|
+
*/
|
|
245
|
+
const formatConformanceReport = (report) => {
|
|
246
|
+
const lines = report.results.map((result) => {
|
|
247
|
+
const detail = formatDetail(result);
|
|
248
|
+
const warnings = result.warnings.length === 0 ? void 0 : `warnings: ${result.warnings.map(formatWarning).join(", ")}`;
|
|
249
|
+
return [
|
|
250
|
+
statusLabel[result.status],
|
|
251
|
+
result.id,
|
|
252
|
+
`[${result.safety}]`,
|
|
253
|
+
detail,
|
|
254
|
+
warnings
|
|
255
|
+
].filter(Predicate.isNotUndefined).join(" ");
|
|
256
|
+
});
|
|
257
|
+
const target = report.target.account === void 0 ? report.target.kind : `${report.target.kind} (account ${report.target.account})`;
|
|
258
|
+
const { passed, failed, skipped } = report.summary;
|
|
259
|
+
return [...lines, `${passed} passed, ${failed} failed, ${skipped} skipped; target ${target}; started ${report.startedAt}`].join("\n");
|
|
260
|
+
};
|
|
261
|
+
//#endregion
|
|
262
|
+
export { conformanceCaseWarnings, conformanceReportFailed, conformanceSkipReason, formatConformanceReport, runConformance, sanitizeConformanceMessage };
|
|
263
|
+
|
|
264
|
+
//# sourceMappingURL=runner.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runner.mjs","names":[],"sources":["../src/runner.ts"],"sourcesContent":["/**\n * Conformance runner: decides which cases may run against a target (safety\n * policy), runs each allowed case in isolation with freshly built layers, and\n * produces a plain-data report.\n *\n * The runner never performs network I/O itself; whatever the per-case layer\n * provides (a replay `HttpClient`, an emulator, or a host's live client) is\n * what the case talks to.\n *\n * @experimental\n */\nimport { Cause, Clock, Effect, Exit, Option, Predicate, type Layer } from 'effect'\nimport { ConformanceMismatch, type ConformanceCase, type ConformanceSafety } from './case.ts'\nimport { conformanceFixtureEvidence, fixtureAgeDays, type ConformanceFixture } from './fixture.ts'\nimport { redactCredentialText } from './wire-internal.ts'\n\n/**\n * Where cases run. `replay`, `in-process`, and `emulated` never touch a real\n * account, so every case runs. `live` talks to a real practice account and is\n * gated by the safety policy (see `conformanceSkipReason`).\n */\nexport type ConformanceTarget =\n | { readonly kind: 'replay' | 'in-process' | 'emulated' }\n | {\n readonly kind: 'live'\n /** Synthetic, non-identifying account label (for example `practice`). */\n readonly account: string\n /** `reversible` lets `write-reversible` cases run. Default `none`. */\n readonly allowWrites?: 'none' | 'reversible'\n /** Exact ids of `write-irreversible` cases a person explicitly started. */\n readonly allowIrreversible?: ReadonlyArray<string>\n }\n\nexport type ConformanceTargetKind = ConformanceTarget['kind']\n\nexport type ConformanceSkipReason = 'writes-not-allowed' | 'manual-only'\n\n/**\n * The safety policy. `undefined` means the case may run.\n *\n * - `replay` / `in-process` / `emulated`: every case runs.\n * - `live`: `read` runs; `write-reversible` runs only with\n * `allowWrites: 'reversible'` (else `writes-not-allowed`);\n * `write-irreversible` runs only when its exact id is in\n * `allowIrreversible` (else `manual-only`), independent of `allowWrites`.\n */\nexport const conformanceSkipReason = (\n target: ConformanceTarget,\n testCase: { readonly id: string; readonly safety: ConformanceSafety }\n): ConformanceSkipReason | undefined => {\n if (target.kind !== 'live') {\n return undefined\n }\n\n switch (testCase.safety) {\n case 'read':\n return undefined\n case 'write-reversible':\n return target.allowWrites === 'reversible' ? undefined : 'writes-not-allowed'\n case 'write-irreversible':\n return (target.allowIrreversible ?? []).includes(testCase.id) ? undefined : 'manual-only'\n }\n}\n\n/** Non-fatal findings attached to a case result. */\nexport type ConformanceWarning =\n /** The case has no `observed`: its claim was never watched against the real service. */\n | { readonly kind: 'unverified-case' }\n /** `observed.date` is older than the max age (`ageDays` absent when unreadable). */\n | { readonly kind: 'stale-observation'; readonly ageDays?: number }\n /** A referenced fixture is a synthetic placeholder (`evidence: 'unverified'`, or a `PortFixture` without `observed`). */\n | { readonly kind: 'unverified-fixture'; readonly fixtureId: string }\n /** A referenced fixture is older than the max age (`ageDays` absent when unreadable). */\n | { readonly kind: 'stale-fixture'; readonly fixtureId: string; readonly ageDays?: number }\n /** A referenced fixture id is not among the supplied fixtures. */\n | { readonly kind: 'missing-fixture'; readonly fixtureId: string }\n\nexport type ConformanceWarningKind = ConformanceWarning['kind']\n\n/**\n * Sanitized failure. `message` is a `ConformanceMismatch`'s case-authored\n * message (credential patterns redacted) or, for every other failure, layer\n * failure, and defect, the error's own message passed through\n * `sanitizeConformanceMessage` (credential patterns and header lines redacted, JSON\n * spans elided, whitespace collapsed, length capped). `tag` is the error's\n * `_tag` only when it is identifier-like. Request bodies, headers, and\n * `ConformanceMismatch` `expected`/`actual` details are never copied.\n */\nexport type ConformanceFailure = {\n /** `failure`: typed error (including layer build errors); `defect`: unexpected die. */\n readonly kind: 'failure' | 'defect'\n readonly tag?: string\n readonly message: string\n}\n\nexport type ConformanceCaseStatus = 'passed' | 'failed' | 'skipped'\n\nexport type ConformanceCaseResult = {\n readonly id: string\n readonly safety: ConformanceSafety\n readonly status: ConformanceCaseStatus\n readonly skipReason?: ConformanceSkipReason\n readonly failure?: ConformanceFailure\n readonly durationMs: number\n readonly warnings: ReadonlyArray<ConformanceWarning>\n}\n\nexport type ConformanceReport = {\n readonly target: { readonly kind: ConformanceTargetKind; readonly account?: string }\n /** ISO timestamp of the run start. */\n readonly startedAt: string\n readonly results: ReadonlyArray<ConformanceCaseResult>\n readonly summary: {\n readonly passed: number\n readonly failed: number\n readonly skipped: number\n }\n}\n\n/** What a case (or a union of cases) needs from its layer. */\nexport type ConformanceCaseRequirements<C> = C extends {\n readonly run: Effect.Effect<void, unknown, infer R>\n}\n ? R\n : never\n\ntype RunSettings = {\n readonly target: ConformanceTarget\n /**\n * Fixtures to check referenced ids against (evidence, staleness, missing ids): HTTP\n * `WireFixture`s and `PortFixture`s alike.\n */\n readonly fixtures?: ReadonlyArray<ConformanceFixture>\n /** Reference time for staleness and `startedAt`. Defaults to the Effect `Clock`. */\n readonly now?: Date\n /** Fixtures and observations older than this many whole days are stale. Default 30. */\n readonly maxFixtureAgeDays?: number\n /** Cases run in parallel up to this limit. Default 1: live accounts are shared. */\n readonly concurrency?: number\n}\n\n/**\n * Options for `runConformance` over cases of type `C` (a union when the cases\n * differ).\n *\n * `layer` is called once per case that runs and must provide everything that\n * case needs (`ConformanceCaseRequirements<C>`; providing more is fine). It is\n * called and built inside the case's failure boundary, with a fresh memo map\n * in its own scope, so state the layer allocates when it is built (replay\n * consumption, ledgers, emulator state) never leaks between cases, even when\n * the factory returns the same `Layer` value. Services captured in a shared\n * value (for example one `Layer.succeed(service, instance)`) or supplied by\n * the caller's environment are not rebuilt and stay shared. Build failures\n * (`LE`) and a throwing factory are reported as failed cases. `LR` is\n * whatever the layers still need from the caller (for example a live\n * `HttpClient`) and becomes the requirement of the whole run.\n */\nexport type ConformanceRunOptions<C, LE = never, LR = never> = RunSettings & {\n readonly layer: (testCase: C) => Layer.Layer<ConformanceCaseRequirements<C>, LE, LR>\n}\n\nconst staleAge = (\n date: string,\n now: Date,\n maxAgeDays: number\n): Option.Option<number | undefined> => {\n const age = fixtureAgeDays({ recordedAt: date }, now)\n\n if (Number.isNaN(age)) {\n return Option.some(undefined)\n }\n\n return age > maxAgeDays ? Option.some(age) : Option.none()\n}\n\nconst withAge = <W extends { readonly kind: string }>(\n warning: W,\n ageDays: number | undefined\n): W | (W & { readonly ageDays: number }) =>\n ageDays === undefined ? warning : { ...warning, ageDays }\n\n/**\n * Warnings for one case. Case-level warnings always apply; fixture-level ones\n * only on non-live targets and only when `fixtures` were supplied.\n */\nexport const conformanceCaseWarnings = (\n testCase: Pick<ConformanceCase<unknown, unknown>, 'observed' | 'fixtures'>,\n context: {\n readonly target: ConformanceTarget\n readonly now: Date\n readonly fixtures?: ReadonlyArray<ConformanceFixture> | undefined\n readonly maxFixtureAgeDays?: number | undefined\n }\n): ReadonlyArray<ConformanceWarning> => {\n const maxAgeDays = context.maxFixtureAgeDays ?? 30\n const warnings: Array<ConformanceWarning> = []\n\n if (testCase.observed === undefined) {\n warnings.push({ kind: 'unverified-case' })\n } else {\n const stale = staleAge(testCase.observed.date, context.now, maxAgeDays)\n\n if (Option.isSome(stale)) {\n warnings.push(withAge({ kind: 'stale-observation' }, stale.value))\n }\n }\n\n const fixtures = context.fixtures\n\n if (context.target.kind === 'live' || fixtures === undefined) {\n return warnings\n }\n\n for (const fixtureId of testCase.fixtures) {\n const fixture = fixtures.find(candidate => candidate.id === fixtureId)\n\n if (fixture === undefined) {\n warnings.push({ kind: 'missing-fixture', fixtureId })\n continue\n }\n\n const { evidence, date } = conformanceFixtureEvidence(fixture)\n\n if (evidence === 'unverified') {\n warnings.push({ kind: 'unverified-fixture', fixtureId })\n }\n\n // A synthetic `PortFixture` carries no date: it is unverified, not stale.\n const stale = date === undefined ? Option.none() : staleAge(date, context.now, maxAgeDays)\n\n if (Option.isSome(stale)) {\n warnings.push(withAge({ kind: 'stale-fixture', fixtureId }, stale.value))\n }\n }\n\n return warnings\n}\n\nconst maxFailureMessageLength = 300\n\n// Only identifier-like tags are reported; anything else (spaces, punctuation, payload text) is\n// dropped.\nconst reportableTagPattern = /^[A-Za-z][A-Za-z0-9_]*$/\n\nconst collapseAndCap = (message: string): string => {\n const compact = message.replace(/\\s+/g, ' ').trim()\n\n return compact.length > maxFailureMessageLength\n ? `${compact.slice(0, maxFailureMessageLength - 3)}...`\n : compact\n}\n\n/**\n * End index of the balanced `{...}` / `[...]` segment opening at `start` (strings respected);\n * `undefined` when it never closes or closes with the wrong bracket.\n */\nconst balancedSegmentEnd = (text: string, start: number): number | undefined => {\n const expected: Array<string> = []\n let inString = false\n\n for (let index = start; index < text.length; index++) {\n const char = text[index]\n\n if (inString) {\n if (char === '\\\\') {\n index++\n } else if (char === '\"') {\n inString = false\n }\n\n continue\n }\n\n if (char === '\"') {\n inString = true\n } else if (char === '{') {\n expected.push('}')\n } else if (char === '[') {\n expected.push(']')\n } else if (char === '}' || char === ']') {\n if (expected.pop() !== char) {\n return undefined\n }\n\n if (expected.length === 0) {\n return index\n }\n }\n }\n\n return undefined\n}\n\n// Replace every balanced `{...}` / `[...]` segment with `[json]`. An unbalanced opening bracket\n// (for example a truncated body) elides the rest of the message.\nconst elideJsonSpans = (text: string): string => {\n let output = ''\n let index = 0\n\n while (index < text.length) {\n const char = text[index]\n\n if (char !== '{' && char !== '[') {\n output += char\n index++\n continue\n }\n\n const end = balancedSegmentEnd(text, index)\n output += '[json]'\n\n if (end === undefined) {\n return output\n }\n\n index = end + 1\n }\n\n return output\n}\n\n/**\n * Best-effort sanitizer for failure messages copied into a `ConformanceReport`. Redacts the\n * credential patterns shared with the fixture secret scan (bearer tokens, API-key prefixes, JWTs,\n * private keys, credential field pairs, credential query/form parameters, and credential header\n * lines such as `Cookie:` or `X-Api-Key:` to the end of the line), replaces JSON-looking spans\n * (balanced `{...}` / `[...]`) with `[json]`, collapses whitespace, and caps the length at 300\n * characters. Hosts should still keep secrets out of error messages.\n */\nexport const sanitizeConformanceMessage = (message: string): string =>\n collapseAndCap(elideJsonSpans(redactCredentialText(message)))\n\n// A `ConformanceMismatch` message is written by the case author: keep it readable (no JSON\n// elision), but still redact credential patterns.\nconst sanitizeMismatchMessage = (message: string): string =>\n collapseAndCap(redactCredentialText(message))\n\nconst stringProperty = (value: unknown, key: string): string | undefined => {\n if (!Predicate.hasProperty(value, key)) {\n return undefined\n }\n\n const property = value[key]\n\n return Predicate.isString(property) && property.length > 0 ? property : undefined\n}\n\nconst describeValue = (kind: ConformanceFailure['kind'], value: unknown): ConformanceFailure => {\n const rawTag = stringProperty(value, '_tag')\n\n // Identifier-like and not credential-shaped (e.g. a `vck_...` value is never echoed).\n const tag =\n rawTag !== undefined &&\n reportableTagPattern.test(rawTag) &&\n redactCredentialText(rawTag) === rawTag\n ? rawTag\n : undefined\n\n const rawMessage =\n stringProperty(value, 'message') ??\n (Predicate.isString(value) && value.length > 0 ? value : undefined)\n\n const message =\n rawMessage === undefined\n ? (tag ?? (kind === 'defect' ? 'unexpected defect' : 'case failed'))\n : value instanceof ConformanceMismatch\n ? sanitizeMismatchMessage(rawMessage)\n : sanitizeConformanceMessage(rawMessage)\n\n const failure = { kind, message }\n\n return tag === undefined ? failure : { ...failure, tag }\n}\n\nconst describeCause = <E>(cause: Cause.Cause<E>): ConformanceFailure => {\n const error = Cause.findErrorOption(cause)\n\n if (Option.isSome(error)) {\n return describeValue('failure', error.value)\n }\n\n return describeValue('defect', Cause.squash(cause))\n}\n\ntype Counts = { passed: number; failed: number; skipped: number }\n\nconst summarize = (results: ReadonlyArray<ConformanceCaseResult>): Counts => {\n const counts: Counts = { passed: 0, failed: 0, skipped: 0 }\n\n for (const result of results) {\n counts[result.status] += 1\n }\n\n return counts\n}\n\n// Pins object literals to the result type so `status` stays a literal.\nconst identityResult = (result: ConformanceCaseResult): ConformanceCaseResult => result\n\nconst reportTarget = (target: ConformanceTarget): ConformanceReport['target'] =>\n target.kind === 'live' ? { kind: target.kind, account: target.account } : { kind: target.kind }\n\n/**\n * Run cases against a target and report. Skipped cases never build their\n * layer. Each running case gets a fresh layer and runs under `Effect.exit`:\n * failures, layer build failures, defects, and a throwing `layer` factory\n * become `failed` results and the run continues. Interruption is not\n * captured: interrupting the run (or a case interrupting itself) interrupts\n * the whole run.\n *\n * `C` is inferred as the union of the given case types, so cases with\n * different errors and requirements can share one run without annotations.\n */\nexport function runConformance<C extends ConformanceCase<unknown, unknown>, LE = never, LR = never>(\n cases: ReadonlyArray<C>,\n options: ConformanceRunOptions<C, LE, LR>\n): Effect.Effect<ConformanceReport, never, LR>\n// Implementation signature: one case type whose `run` needs exactly `R`, which\n// is what the public signature's `ConformanceCaseRequirements<C>` denotes.\nexport function runConformance<E, R, LE, LR>(\n cases: ReadonlyArray<ConformanceCase<E, R>>,\n options: RunSettings & {\n readonly layer: (testCase: ConformanceCase<E, R>) => Layer.Layer<R, LE, LR>\n }\n): Effect.Effect<ConformanceReport, never, LR> {\n return Effect.gen(function* () {\n const now = options.now ?? new Date(yield* Clock.currentTimeMillis)\n\n const warningContext = {\n target: options.target,\n now,\n fixtures: options.fixtures,\n maxFixtureAgeDays: options.maxFixtureAgeDays\n }\n\n const runCase = (testCase: ConformanceCase<E, R>) =>\n Effect.gen(function* () {\n const base = {\n id: testCase.id,\n safety: testCase.safety,\n warnings: conformanceCaseWarnings(testCase, warningContext)\n }\n\n const skipReason = conformanceSkipReason(options.target, testCase)\n\n if (skipReason !== undefined) {\n return identityResult({ ...base, status: 'skipped', skipReason, durationMs: 0 })\n }\n\n const started = yield* Clock.currentTimeMillis\n\n // The factory runs inside the exit boundary: a throwing factory fails this case only.\n const exit = yield* Effect.suspend(() =>\n testCase.run.pipe(Effect.provide(options.layer(testCase), { local: true }))\n ).pipe(Effect.exit)\n\n const durationMs = (yield* Clock.currentTimeMillis) - started\n\n if (Exit.isSuccess(exit)) {\n return identityResult({ ...base, status: 'passed', durationMs })\n }\n\n if (Cause.hasInterruptsOnly(exit.cause)) {\n return yield* Effect.interrupt\n }\n\n return identityResult({\n ...base,\n status: 'failed',\n failure: describeCause(exit.cause),\n durationMs\n })\n })\n\n const results = yield* Effect.forEach(cases, runCase, {\n concurrency: options.concurrency ?? 1\n })\n\n return {\n target: reportTarget(options.target),\n startedAt: now.toISOString(),\n results,\n summary: summarize(results)\n }\n })\n}\n\n/** True when any case failed. Skipped cases never fail a report. */\nexport const conformanceReportFailed = (report: ConformanceReport): boolean =>\n report.summary.failed > 0\n\nconst statusLabel: Record<ConformanceCaseStatus, string> = {\n passed: 'PASS',\n failed: 'FAIL',\n skipped: 'SKIP'\n}\n\nconst formatAge = (ageDays: number | undefined): string =>\n ageDays === undefined ? '(unreadable date)' : `(${ageDays}d)`\n\nconst formatWarning = (warning: ConformanceWarning): string => {\n switch (warning.kind) {\n case 'unverified-case':\n return warning.kind\n case 'stale-observation':\n return `${warning.kind}${formatAge(warning.ageDays)}`\n case 'unverified-fixture':\n case 'missing-fixture':\n return `${warning.kind}:${warning.fixtureId}`\n case 'stale-fixture':\n return `${warning.kind}:${warning.fixtureId}${formatAge(warning.ageDays)}`\n }\n}\n\nconst formatDetail = (result: ConformanceCaseResult): string | undefined => {\n if (result.skipReason !== undefined) {\n return result.skipReason\n }\n\n if (result.failure === undefined) {\n return undefined\n }\n\n const prefix = result.failure.kind === 'defect' ? 'defect ' : ''\n const tag = result.failure.tag === undefined ? '' : `${result.failure.tag}: `\n\n return `${prefix}${tag}${result.failure.message}`\n}\n\n/**\n * Compact plain text (no colors): one line per case with status, id, safety,\n * skip reason or failure message, and warnings, then a summary line.\n */\nexport const formatConformanceReport = (report: ConformanceReport): string => {\n const lines = report.results.map(result => {\n const detail = formatDetail(result)\n\n const warnings =\n result.warnings.length === 0\n ? undefined\n : `warnings: ${result.warnings.map(formatWarning).join(', ')}`\n\n return [statusLabel[result.status], result.id, `[${result.safety}]`, detail, warnings]\n .filter(Predicate.isNotUndefined)\n .join(' ')\n })\n\n const target =\n report.target.account === undefined\n ? report.target.kind\n : `${report.target.kind} (account ${report.target.account})`\n\n const { passed, failed, skipped } = report.summary\n\n return [\n ...lines,\n `${passed} passed, ${failed} failed, ${skipped} skipped; target ${target}; started ${report.startedAt}`\n ].join('\\n')\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Effect, Option } from "effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { HttpClientRequest } from "effect/unstable/http";
|
|
4
|
+
|
|
5
|
+
//#region src/wire-internal.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* True for header names that carry credentials or session state. Used by the
|
|
8
|
+
* fixture secret scan, recorder drop rules, and ledger redaction.
|
|
9
|
+
*/
|
|
10
|
+
declare const isCredentialHeaderName: (name: string) => boolean;
|
|
11
|
+
declare const redactedHeaderValue = "<redacted>";
|
|
12
|
+
/** `Bearer <token>` with a token-shaped value. */
|
|
13
|
+
declare const bearerPattern: RegExp;
|
|
14
|
+
/** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */
|
|
15
|
+
declare const apiKeyPatterns: ReadonlyArray<RegExp>;
|
|
16
|
+
/**
|
|
17
|
+
* Query-string or form-encoded credential parameter, anchored at the start of
|
|
18
|
+
* the text or after `?`/`&` (URLs and `application/x-www-form-urlencoded` bodies).
|
|
19
|
+
*/
|
|
20
|
+
declare const credentialParamPattern: RegExp;
|
|
21
|
+
/**
|
|
22
|
+
* The exact synthetic credential values a `PortFixture` may carry, keyed by lower-case parameter
|
|
23
|
+
* name: SigV4 presigned-URL placeholders for S3-compatible ports (the R2 conformance fixtures sign
|
|
24
|
+
* with them). `scanPortFixtureForSecrets` exempts a credential parameter only when its name is a
|
|
25
|
+
* key here and its whole raw value (up to `&`, `#`, whitespace, `"`, `<`, `>`, or the end),
|
|
26
|
+
* percent-decoded, equals that key's value exactly; anything else inside the value (a raw `?`, an
|
|
27
|
+
* encoded delimiter, any suffix), another parameter name, or another scope is flagged.
|
|
28
|
+
* `scanFixtureForSecrets` exempts nothing. Never build a placeholder by prefixing or suffixing a
|
|
29
|
+
* real value.
|
|
30
|
+
*/
|
|
31
|
+
declare const syntheticPortCredentialParams: Readonly<{
|
|
32
|
+
'x-amz-signature': "yolk-synthetic-signature";
|
|
33
|
+
'x-amz-credential': "yolk-synthetic-access-key-id/20260930/auto/s3/aws4_request";
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* True when `text` carries a credential query or form parameter that is not an exact synthetic
|
|
37
|
+
* placeholder (`syntheticPortCredentialParams`). Every `name=` is found and judged on its own: its
|
|
38
|
+
* raw value runs to the next structural boundary (see `rawParamValueAt`), is percent-decoded
|
|
39
|
+
* (`percentDecodedLayers`), and is exempt only when it equals that name's placeholder exactly. It
|
|
40
|
+
* flags at least whatever `credentialParamPattern` flags (a `name=` followed by any character but
|
|
41
|
+
* `&` or `#`), except the exact placeholders.
|
|
42
|
+
*/
|
|
43
|
+
declare const hasLiveCredentialParam: (text: string) => boolean;
|
|
44
|
+
/** A JSON object key (or similar field name) that holds a credential. */
|
|
45
|
+
declare const credentialFieldPattern: RegExp;
|
|
46
|
+
/**
|
|
47
|
+
* Redact every credential pattern shared with the fixture secret scan: credential header lines
|
|
48
|
+
* (first, to the end of the line), quoted credential header keys (value only), bearer tokens,
|
|
49
|
+
* API-key prefixes, JWTs, private keys, credential field pairs (`"api_key": "..."`,
|
|
50
|
+
* `password="..."`), and credential query/form parameters. Best effort; used on report messages.
|
|
51
|
+
* Known gap: escaped JSON inside a quoted string value (`"body":"{\\"x-api-key\\":...}"`) is not
|
|
52
|
+
* parsed.
|
|
53
|
+
* Quote-aware field redaction runs before parameter redaction so a quoted value with spaces is
|
|
54
|
+
* removed whole.
|
|
55
|
+
*/
|
|
56
|
+
declare const redactCredentialText: (text: string) => string;
|
|
57
|
+
declare const redactHeaders: (headers: Readonly<Record<string, string>>) => Record<string, string>;
|
|
58
|
+
/**
|
|
59
|
+
* Canonical absolute URL used for matching: hash removed and query parameters
|
|
60
|
+
* sorted by name, then value. Unparseable input is returned unchanged.
|
|
61
|
+
*/
|
|
62
|
+
declare const normalizeWireUrl: (input: string) => string;
|
|
63
|
+
/**
|
|
64
|
+
* Match an optional URL filter. A pattern ending in `*` is a prefix match on
|
|
65
|
+
* the normalized URL; otherwise the normalized URLs must be equal.
|
|
66
|
+
*/
|
|
67
|
+
declare const urlMatchesPattern: (pattern: string, normalizedUrl: string) => boolean;
|
|
68
|
+
/** Request body as UTF-8 text when it is an in-memory body; otherwise undefined. */
|
|
69
|
+
declare const requestBodyText: (request: HttpClientRequest.HttpClientRequest) => string | undefined;
|
|
70
|
+
/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */
|
|
71
|
+
declare const decodeBase64Bytes: (text: string) => Option.Option<Uint8Array>;
|
|
72
|
+
/**
|
|
73
|
+
* Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading
|
|
74
|
+
* byte-order mark is kept so that re-encoding yields the same bytes.
|
|
75
|
+
*/
|
|
76
|
+
declare const decodeUtf8Strict: (bytes: Uint8Array) => Option.Option<string>;
|
|
77
|
+
/** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */
|
|
78
|
+
declare const recordBytes: (bytes: Uint8Array) => {
|
|
79
|
+
readonly text: string;
|
|
80
|
+
} | {
|
|
81
|
+
readonly base64: string;
|
|
82
|
+
};
|
|
83
|
+
/** Parse text as JSON; `None` when it is not valid JSON. */
|
|
84
|
+
declare const parseJsonText: (text: string) => Effect.Effect<Option.Option<Schema.Json>>;
|
|
85
|
+
declare const headerRecord: (headers: Readonly<Record<string, string | undefined>>) => Record<string, string>;
|
|
86
|
+
declare const mediaType: (contentType: string | undefined) => string | undefined;
|
|
87
|
+
declare const isNullBodyStatus: (status: number) => boolean;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { apiKeyPatterns, bearerPattern, credentialFieldPattern, credentialParamPattern, decodeBase64Bytes, decodeUtf8Strict, hasLiveCredentialParam, headerRecord, isCredentialHeaderName, isNullBodyStatus, mediaType, normalizeWireUrl, parseJsonText, recordBytes, redactCredentialText, redactHeaders, redactedHeaderValue, requestBodyText, syntheticPortCredentialParams, urlMatchesPattern };
|
|
90
|
+
//# sourceMappingURL=wire-internal.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wire-internal.d.mts","names":[],"sources":["../src/wire-internal.ts"],"mappings":";;;;;;;AAgCA;;cAAa,sBAAA,GAA0B,IAAY;AAAA,cAUtC,mBAAA;AAVsC;AAAA,cAgBtC,aAAA,EAAa,MAAuC;;cAsBpD,cAAA,EAAgB,aAAa,CAAC,MAAA;;AA5BX;AAMhC;;cA+Ba,sBAAA,EAAsB,MAAgE;;AA/BlC;AAsBjE;;;;AAAiD;AASjD;;;cAqBa,6BAAA,EAA6B,QAAA;;;;;;;;;;;AAwC1C;cAAa,sBAAA,GAA0B,IAAY;;cAkBtC,sBAAA,EAAsB,MAAiD;AAlBjC;AAkBnD;;;;AAAoF;AAmFpF;;;;AArGmD,cAqGtC,oBAAA,GAAwB,IAAY;AAAA,cAsBpC,aAAA,GAAiB,OAAA,EAAS,QAAA,CAAS,MAAA,sBAAuB,MAAA;;;;;cAsB1D,gBAAA,GAAoB,KAAa;;;;;cA4BjC,iBAAA,GAAqB,OAAA,UAAiB,aAAqB;;cAM3D,eAAA,GACX,OAAA,EAAS,iBAAA,CAAkB,iBAAiB;AAzDyB;AAAA,cAyE1D,iBAAA,GAAqB,IAAA,aAAe,MAAA,CAAO,MAAM,CAAC,UAAA;;;;AAnDjB;cA0DjC,gBAAA,GAAoB,KAAA,EAAO,UAAA,KAAa,MAAA,CAAO,MAAM;;cAMrD,WAAA,GACX,KAAA,EAAO,UAAU;EAAA,SACL,IAAA;AAAA;EAAA,SAA4B,MAAA;AAAA;;cAS7B,aAAA,GAAiB,IAAA,aAAe,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,IAAA;AAAA,cAGnE,YAAA,GAAgB,OAAA,EAAS,QAAA,CAAS,MAAA,kCAAmC,MAAA;AAAA,cAYrE,SAAA,GAAa,WAA+B;AAAA,cAM5C,gBAAA,GAAoB,MAAc"}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { Effect, Encoding, Option, Predicate, Result } from "effect";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
//#region src/wire-internal.ts
|
|
4
|
+
const credentialHeaderNames = new Set([
|
|
5
|
+
"authorization",
|
|
6
|
+
"proxy-authorization",
|
|
7
|
+
"cookie",
|
|
8
|
+
"set-cookie",
|
|
9
|
+
"x-api-key",
|
|
10
|
+
"api-key",
|
|
11
|
+
"x-goog-api-key",
|
|
12
|
+
"x-auth-token",
|
|
13
|
+
"x-access-token",
|
|
14
|
+
"x-amz-security-token",
|
|
15
|
+
"x-vercel-oidc-token",
|
|
16
|
+
"x-csrf-token"
|
|
17
|
+
]);
|
|
18
|
+
const credentialHeaderPattern = /(api[-_]?key|secret|password|cookie|authorization)/i;
|
|
19
|
+
const credentialHeaderSegmentPattern = /(^|[-_])(token|key|auth)([-_]|$)/i;
|
|
20
|
+
/**
|
|
21
|
+
* True for header names that carry credentials or session state. Used by the
|
|
22
|
+
* fixture secret scan, recorder drop rules, and ledger redaction.
|
|
23
|
+
*/
|
|
24
|
+
const isCredentialHeaderName = (name) => {
|
|
25
|
+
const lower = name.toLowerCase();
|
|
26
|
+
return credentialHeaderNames.has(lower) || credentialHeaderPattern.test(lower) || credentialHeaderSegmentPattern.test(lower);
|
|
27
|
+
};
|
|
28
|
+
const redactedHeaderValue = "<redacted>";
|
|
29
|
+
/** `Bearer <token>` with a token-shaped value. */
|
|
30
|
+
const bearerPattern = /\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/i;
|
|
31
|
+
/** PEM private key header. */
|
|
32
|
+
const privateKeyPattern = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
33
|
+
/** Common API-key prefixes and JSON Web Tokens. */
|
|
34
|
+
const tokenPrefixPatterns = [
|
|
35
|
+
/\bsk-[A-Za-z0-9_-]{16,}/,
|
|
36
|
+
/\b[sr]k_(live|test)_[A-Za-z0-9]{16,}/,
|
|
37
|
+
/\bxai-[A-Za-z0-9]{20,}/,
|
|
38
|
+
/\bvck_[A-Za-z0-9]{16,}/,
|
|
39
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/,
|
|
40
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
41
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
42
|
+
/\bAIza[0-9A-Za-z_-]{35}/,
|
|
43
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
44
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/
|
|
45
|
+
];
|
|
46
|
+
/** Common API-key prefixes, JSON Web Tokens, and PEM private keys. */
|
|
47
|
+
const apiKeyPatterns = [...tokenPrefixPatterns, privateKeyPattern];
|
|
48
|
+
const credentialParamNames = "api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|id[_-]?token|auth|secret|password|client[_-]?secret|x-amz-signature|x-amz-credential|x-amz-security-token";
|
|
49
|
+
/**
|
|
50
|
+
* Query-string or form-encoded credential parameter, anchored at the start of
|
|
51
|
+
* the text or after `?`/`&` (URLs and `application/x-www-form-urlencoded` bodies).
|
|
52
|
+
*/
|
|
53
|
+
const credentialParamPattern = new RegExp(`(?:^|[?&])(${credentialParamNames})=[^&#]+`, "i");
|
|
54
|
+
const credentialParamNamesAt = new RegExp(`(?:^|[?&])(${credentialParamNames})=`, "gi");
|
|
55
|
+
const valueBoundary = /[&#\s"<>]/;
|
|
56
|
+
/**
|
|
57
|
+
* The exact synthetic credential values a `PortFixture` may carry, keyed by lower-case parameter
|
|
58
|
+
* name: SigV4 presigned-URL placeholders for S3-compatible ports (the R2 conformance fixtures sign
|
|
59
|
+
* with them). `scanPortFixtureForSecrets` exempts a credential parameter only when its name is a
|
|
60
|
+
* key here and its whole raw value (up to `&`, `#`, whitespace, `"`, `<`, `>`, or the end),
|
|
61
|
+
* percent-decoded, equals that key's value exactly; anything else inside the value (a raw `?`, an
|
|
62
|
+
* encoded delimiter, any suffix), another parameter name, or another scope is flagged.
|
|
63
|
+
* `scanFixtureForSecrets` exempts nothing. Never build a placeholder by prefixing or suffixing a
|
|
64
|
+
* real value.
|
|
65
|
+
*/
|
|
66
|
+
const syntheticPortCredentialParams = Object.freeze({
|
|
67
|
+
"x-amz-signature": "yolk-synthetic-signature",
|
|
68
|
+
"x-amz-credential": "yolk-synthetic-access-key-id/20260930/auto/s3/aws4_request"
|
|
69
|
+
});
|
|
70
|
+
const exactPlaceholders = new Map(Object.entries(syntheticPortCredentialParams));
|
|
71
|
+
/**
|
|
72
|
+
* `text` with `%XX` escapes decoded, repeatedly (at most three layers, then left as it is). The R2
|
|
73
|
+
* guard (`findR2PortFixtureSecrets` in `@yolk-sdk/connectors`) applies at most the same three
|
|
74
|
+
* percent-decoding rounds (plus its escape rounds): keep the percent depth in step.
|
|
75
|
+
*/
|
|
76
|
+
const percentDecodedLayers = (text) => {
|
|
77
|
+
let current = text;
|
|
78
|
+
for (let round = 0; round < 3 && /%[0-9A-Fa-f]{2}/.test(current); round++) current = current.replace(/%([0-9A-Fa-f]{2})/g, (_match, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
79
|
+
return current;
|
|
80
|
+
};
|
|
81
|
+
/** The raw, undecoded value starting at `start`: up to the first structural boundary. */
|
|
82
|
+
const rawParamValueAt = (text, start) => {
|
|
83
|
+
const rest = text.slice(start);
|
|
84
|
+
const end = rest.search(valueBoundary);
|
|
85
|
+
return end === -1 ? rest : rest.slice(0, end);
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* True when `text` carries a credential query or form parameter that is not an exact synthetic
|
|
89
|
+
* placeholder (`syntheticPortCredentialParams`). Every `name=` is found and judged on its own: its
|
|
90
|
+
* raw value runs to the next structural boundary (see `rawParamValueAt`), is percent-decoded
|
|
91
|
+
* (`percentDecodedLayers`), and is exempt only when it equals that name's placeholder exactly. It
|
|
92
|
+
* flags at least whatever `credentialParamPattern` flags (a `name=` followed by any character but
|
|
93
|
+
* `&` or `#`), except the exact placeholders.
|
|
94
|
+
*/
|
|
95
|
+
const hasLiveCredentialParam = (text) => [...text.matchAll(credentialParamNamesAt)].some((match) => {
|
|
96
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
97
|
+
const start = match.index + match[0].length;
|
|
98
|
+
const value = rawParamValueAt(text, start);
|
|
99
|
+
const next = text.charAt(start);
|
|
100
|
+
return (value.length > 0 || next !== "" && next !== "&" && next !== "#") && exactPlaceholders.get(name) !== percentDecodedLayers(value);
|
|
101
|
+
});
|
|
102
|
+
const credentialFieldNames = "(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|access[_-]?key[_-]?id|secret[_-]?access[_-]?key|authorization";
|
|
103
|
+
/** A JSON object key (or similar field name) that holds a credential. */
|
|
104
|
+
const credentialFieldPattern = new RegExp(`^(${credentialFieldNames})$`, "i");
|
|
105
|
+
const globally = (pattern) => new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
106
|
+
const bearerRedaction = /\bbearer\s+\S+/gi;
|
|
107
|
+
const apiKeyRedactions = [...tokenPrefixPatterns.map(globally), /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g];
|
|
108
|
+
const credentialParamRedaction = new RegExp(`(^|[?&\\s;,(])(${credentialParamNames})=[^&#\\s;,)]+`, "gi");
|
|
109
|
+
const credentialFieldRedaction = new RegExp(`(^|[^A-Za-z0-9_-])(["']?)(${credentialFieldNames})\\2(\\s*[:=]\\s*)(?!bearer <redacted>)(?:"(?:[^"\\\\]|\\\\.)*"?|'[^']*'?|(?:(?:basic|bearer|digest|negotiate|token)\\s+)?[^\\s,;&}\\]]+)`, "gi");
|
|
110
|
+
const headerLikeNamePattern = /(?:^|[^A-Za-z0-9_-])([A-Za-z][A-Za-z0-9_-]*)\s*:/g;
|
|
111
|
+
/**
|
|
112
|
+
* Redact the rest of a line after the first header-like `Name:` that `isCredentialHeaderName`
|
|
113
|
+
* accepts (`X-Api-Key: ...`, `Proxy-Authorization: Basic ...`, `Cookie: ...`), so every header the
|
|
114
|
+
* fixture scan flags is also redacted in report text. Runs on the raw text before any other pass,
|
|
115
|
+
* so a partially matched value (for example a key-shaped cookie name) never shields the rest of
|
|
116
|
+
* the line.
|
|
117
|
+
*/
|
|
118
|
+
const redactCredentialHeaderLines = (text) => text.split(/(\r\n|\r|\n)/).map((line) => {
|
|
119
|
+
headerLikeNamePattern.lastIndex = 0;
|
|
120
|
+
for (let match = headerLikeNamePattern.exec(line); match !== null;) {
|
|
121
|
+
const name = match[1];
|
|
122
|
+
if (name !== void 0 && isCredentialHeaderName(name)) return `${line.slice(0, match.index + match[0].length)} ${redactedHeaderValue}`;
|
|
123
|
+
match = headerLikeNamePattern.exec(line);
|
|
124
|
+
}
|
|
125
|
+
return line;
|
|
126
|
+
}).join("");
|
|
127
|
+
const quotedCredentialHeaderRedaction = /(["'])([A-Za-z][A-Za-z0-9_-]*)\1(\s*:\s*)("(?:[^"\\]|\\.)*"?|'[^']*'?|[^\s,;&{}[\]]+)/g;
|
|
128
|
+
const redactQuotedCredentialHeaders = (text) => text.replace(quotedCredentialHeaderRedaction, (match, quote, name, separator) => isCredentialHeaderName(name) ? `${quote}${name}${quote}${separator}${redactedHeaderValue}` : match);
|
|
129
|
+
/**
|
|
130
|
+
* Redact every credential pattern shared with the fixture secret scan: credential header lines
|
|
131
|
+
* (first, to the end of the line), quoted credential header keys (value only), bearer tokens,
|
|
132
|
+
* API-key prefixes, JWTs, private keys, credential field pairs (`"api_key": "..."`,
|
|
133
|
+
* `password="..."`), and credential query/form parameters. Best effort; used on report messages.
|
|
134
|
+
* Known gap: escaped JSON inside a quoted string value (`"body":"{\\"x-api-key\\":...}"`) is not
|
|
135
|
+
* parsed.
|
|
136
|
+
* Quote-aware field redaction runs before parameter redaction so a quoted value with spaces is
|
|
137
|
+
* removed whole.
|
|
138
|
+
*/
|
|
139
|
+
const redactCredentialText = (text) => {
|
|
140
|
+
let result = redactCredentialHeaderLines(text).replace(bearerRedaction, `Bearer ${redactedHeaderValue}`);
|
|
141
|
+
for (const pattern of apiKeyRedactions) result = result.replace(pattern, redactedHeaderValue);
|
|
142
|
+
return redactQuotedCredentialHeaders(result).replace(credentialFieldRedaction, (_match, prefix, quote, name, separator) => `${prefix}${quote}${name}${quote}${separator}${redactedHeaderValue}`).replace(credentialParamRedaction, (_match, prefix, name) => `${prefix}${name}=${redactedHeaderValue}`);
|
|
143
|
+
};
|
|
144
|
+
const redactHeaders = (headers) => {
|
|
145
|
+
const redacted = {};
|
|
146
|
+
for (const [name, value] of Object.entries(headers)) redacted[name] = isCredentialHeaderName(name) ? redactedHeaderValue : value;
|
|
147
|
+
return redacted;
|
|
148
|
+
};
|
|
149
|
+
const compareStrings = (left, right) => {
|
|
150
|
+
if (left < right) return -1;
|
|
151
|
+
return left > right ? 1 : 0;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* Canonical absolute URL used for matching: hash removed and query parameters
|
|
155
|
+
* sorted by name, then value. Unparseable input is returned unchanged.
|
|
156
|
+
*/
|
|
157
|
+
const normalizeWireUrl = (input) => {
|
|
158
|
+
if (!URL.canParse(input)) return input;
|
|
159
|
+
const url = new URL(input);
|
|
160
|
+
url.hash = "";
|
|
161
|
+
const params = [...url.searchParams.entries()].sort(([leftName, leftValue], [rightName, rightValue]) => leftName === rightName ? compareStrings(leftValue, rightValue) : compareStrings(leftName, rightName));
|
|
162
|
+
url.search = "";
|
|
163
|
+
for (const [name, value] of params) url.searchParams.append(name, value);
|
|
164
|
+
return url.toString();
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Match an optional URL filter. A pattern ending in `*` is a prefix match on
|
|
168
|
+
* the normalized URL; otherwise the normalized URLs must be equal.
|
|
169
|
+
*/
|
|
170
|
+
const urlMatchesPattern = (pattern, normalizedUrl) => pattern.endsWith("*") ? normalizedUrl.startsWith(pattern.slice(0, -1)) : normalizeWireUrl(pattern) === normalizedUrl;
|
|
171
|
+
/** Request body as UTF-8 text when it is an in-memory body; otherwise undefined. */
|
|
172
|
+
const requestBodyText = (request) => {
|
|
173
|
+
const body = request.body;
|
|
174
|
+
if (Predicate.isTagged(body, "Uint8Array")) return new TextDecoder().decode(body.body);
|
|
175
|
+
if (Predicate.isTagged(body, "Raw") && Predicate.isString(body.body)) return body.body;
|
|
176
|
+
};
|
|
177
|
+
/** Exact bytes of standard base64 text; `None` when the text is not valid base64. */
|
|
178
|
+
const decodeBase64Bytes = (text) => Result.getSuccess(Encoding.decodeBase64(text));
|
|
179
|
+
/**
|
|
180
|
+
* Decode bytes as UTF-8 only when they are valid UTF-8 on their own. A leading
|
|
181
|
+
* byte-order mark is kept so that re-encoding yields the same bytes.
|
|
182
|
+
*/
|
|
183
|
+
const decodeUtf8Strict = (bytes) => Option.liftThrowable(() => new TextDecoder("utf-8", {
|
|
184
|
+
fatal: true,
|
|
185
|
+
ignoreBOM: true
|
|
186
|
+
}).decode(bytes))();
|
|
187
|
+
/** Lossless recording of bytes: readable text when valid UTF-8, otherwise base64. */
|
|
188
|
+
const recordBytes = (bytes) => Option.match(decodeUtf8Strict(bytes), {
|
|
189
|
+
onNone: () => ({ base64: Encoding.encodeBase64(bytes) }),
|
|
190
|
+
onSome: (text) => ({ text })
|
|
191
|
+
});
|
|
192
|
+
const decodeJsonText = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Json));
|
|
193
|
+
/** Parse text as JSON; `None` when it is not valid JSON. */
|
|
194
|
+
const parseJsonText = (text) => decodeJsonText(text).pipe(Effect.option);
|
|
195
|
+
const headerRecord = (headers) => {
|
|
196
|
+
const record = {};
|
|
197
|
+
for (const [name, value] of Object.entries(headers)) if (value !== void 0) record[name.toLowerCase()] = value;
|
|
198
|
+
return record;
|
|
199
|
+
};
|
|
200
|
+
const mediaType = (contentType) => contentType?.split(";", 1)[0]?.trim().toLowerCase();
|
|
201
|
+
const nullBodyStatuses = new Set([
|
|
202
|
+
101,
|
|
203
|
+
103,
|
|
204
|
+
204,
|
|
205
|
+
205,
|
|
206
|
+
304
|
|
207
|
+
]);
|
|
208
|
+
const isNullBodyStatus = (status) => nullBodyStatuses.has(status);
|
|
209
|
+
//#endregion
|
|
210
|
+
export { apiKeyPatterns, bearerPattern, credentialFieldPattern, credentialParamPattern, decodeBase64Bytes, decodeUtf8Strict, hasLiveCredentialParam, headerRecord, isCredentialHeaderName, isNullBodyStatus, mediaType, normalizeWireUrl, parseJsonText, recordBytes, redactCredentialText, redactHeaders, redactedHeaderValue, requestBodyText, syntheticPortCredentialParams, urlMatchesPattern };
|
|
211
|
+
|
|
212
|
+
//# sourceMappingURL=wire-internal.mjs.map
|