@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/src/fixture.ts
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture data models: recorded HTTP exchanges with outside services (`WireFixture`) and recorded
|
|
3
|
+
* calls through a host-provided port that is not HTTP (`PortFixture`, for example an email client
|
|
4
|
+
* port).
|
|
5
|
+
*
|
|
6
|
+
* Fixtures are plain, serializable data. They must contain synthetic or
|
|
7
|
+
* scrubbed content only: never credentials, cookies, or customer data. Use
|
|
8
|
+
* `scanFixtureForSecrets` / `scanPortFixtureForSecrets` before committing a fixture.
|
|
9
|
+
*
|
|
10
|
+
* @experimental
|
|
11
|
+
*/
|
|
12
|
+
import { Option, Predicate } from 'effect'
|
|
13
|
+
import * as Schema from 'effect/Schema'
|
|
14
|
+
import { ConformanceObservation } from './case.ts'
|
|
15
|
+
import {
|
|
16
|
+
apiKeyPatterns,
|
|
17
|
+
bearerPattern,
|
|
18
|
+
credentialFieldPattern,
|
|
19
|
+
credentialParamPattern,
|
|
20
|
+
decodeBase64Bytes,
|
|
21
|
+
hasLiveCredentialParam,
|
|
22
|
+
isCredentialHeaderName
|
|
23
|
+
} from './wire-internal.ts'
|
|
24
|
+
|
|
25
|
+
export { syntheticPortCredentialParams } from './wire-internal.ts'
|
|
26
|
+
|
|
27
|
+
/** `verified` = recorded from a live service; `unverified` = synthetic placeholder. */
|
|
28
|
+
export const WireFixtureEvidence = Schema.Literals(['verified', 'unverified'])
|
|
29
|
+
|
|
30
|
+
export type WireFixtureEvidence = typeof WireFixtureEvidence.Type
|
|
31
|
+
|
|
32
|
+
/** Lowercase header name to value. Request headers are allowlisted and never credentials. */
|
|
33
|
+
export const WireHeaders = Schema.Record(Schema.String, Schema.String)
|
|
34
|
+
|
|
35
|
+
export type WireHeaders = typeof WireHeaders.Type
|
|
36
|
+
|
|
37
|
+
const HttpStatus = Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 }))
|
|
38
|
+
|
|
39
|
+
export const WireRequest = Schema.Struct({
|
|
40
|
+
method: Schema.NonEmptyString,
|
|
41
|
+
/** Absolute URL including any query string. */
|
|
42
|
+
url: Schema.NonEmptyString,
|
|
43
|
+
headers: Schema.optionalKey(WireHeaders),
|
|
44
|
+
/** Parsed JSON request body, or the raw text when it is not JSON (for example a form body). */
|
|
45
|
+
body: Schema.optionalKey(Schema.Json)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export type WireRequest = typeof WireRequest.Type
|
|
49
|
+
|
|
50
|
+
const Base64String = Schema.String.check(Schema.isBase64())
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One recorded network chunk. A chunk that is valid UTF-8 on its own is stored
|
|
54
|
+
* as readable text (empty chunks as `""`); any other chunk is stored as
|
|
55
|
+
* `{ base64 }` holding its exact bytes. Replay emits exactly these bytes.
|
|
56
|
+
*/
|
|
57
|
+
export const WireChunk = Schema.Union([Schema.String, Schema.Struct({ base64: Base64String })])
|
|
58
|
+
|
|
59
|
+
export type WireChunk = typeof WireChunk.Type
|
|
60
|
+
|
|
61
|
+
// `Never` keys keep the response shapes mutually exclusive when decoding: a
|
|
62
|
+
// response carries exactly one of `body`, `bodyBase64`, or `chunks`.
|
|
63
|
+
const absent = Schema.optionalKey(Schema.Never)
|
|
64
|
+
|
|
65
|
+
/** A response whose whole body is valid UTF-8, stored as one string. */
|
|
66
|
+
export const WireTextBodyResponse = Schema.Struct({
|
|
67
|
+
status: HttpStatus,
|
|
68
|
+
headers: WireHeaders,
|
|
69
|
+
body: Schema.String,
|
|
70
|
+
bodyBase64: absent,
|
|
71
|
+
chunks: absent
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export type WireTextBodyResponse = typeof WireTextBodyResponse.Type
|
|
75
|
+
|
|
76
|
+
/** A response whose whole body is not valid UTF-8 (for example a PDF), stored as base64 bytes. */
|
|
77
|
+
export const WireBase64BodyResponse = Schema.Struct({
|
|
78
|
+
status: HttpStatus,
|
|
79
|
+
headers: WireHeaders,
|
|
80
|
+
bodyBase64: Base64String,
|
|
81
|
+
body: absent,
|
|
82
|
+
chunks: absent
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
export type WireBase64BodyResponse = typeof WireBase64BodyResponse.Type
|
|
86
|
+
|
|
87
|
+
/** A response recorded as one whole body: exactly one of `body` or `bodyBase64`. */
|
|
88
|
+
export const WireBodyResponse = Schema.Union([WireTextBodyResponse, WireBase64BodyResponse])
|
|
89
|
+
|
|
90
|
+
export type WireBodyResponse = typeof WireBodyResponse.Type
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A streamed response (for example `text/event-stream`). Each entry is one
|
|
94
|
+
* network chunk (see `WireChunk`); chunk boundaries and bytes are preserved on
|
|
95
|
+
* replay.
|
|
96
|
+
*/
|
|
97
|
+
export const WireStreamResponse = Schema.Struct({
|
|
98
|
+
status: HttpStatus,
|
|
99
|
+
headers: WireHeaders,
|
|
100
|
+
chunks: Schema.Array(WireChunk),
|
|
101
|
+
body: absent,
|
|
102
|
+
bodyBase64: absent
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export type WireStreamResponse = typeof WireStreamResponse.Type
|
|
106
|
+
|
|
107
|
+
export const WireResponse = Schema.Union([
|
|
108
|
+
WireTextBodyResponse,
|
|
109
|
+
WireBase64BodyResponse,
|
|
110
|
+
WireStreamResponse
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
export type WireResponse = typeof WireResponse.Type
|
|
114
|
+
|
|
115
|
+
export const WireExchange = Schema.Struct({
|
|
116
|
+
request: WireRequest,
|
|
117
|
+
response: WireResponse
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export type WireExchange = typeof WireExchange.Type
|
|
121
|
+
|
|
122
|
+
const RecordedAtDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/))
|
|
123
|
+
|
|
124
|
+
export const WireFixture = Schema.Struct({
|
|
125
|
+
id: Schema.NonEmptyString,
|
|
126
|
+
caseId: Schema.NonEmptyString,
|
|
127
|
+
evidence: WireFixtureEvidence,
|
|
128
|
+
/** Calendar date of the recording (`YYYY-MM-DD`, UTC). */
|
|
129
|
+
recordedAt: RecordedAtDate,
|
|
130
|
+
/** Synthetic account label, for example `synthetic`; never a real account name. */
|
|
131
|
+
account: Schema.NonEmptyString,
|
|
132
|
+
endpoint: Schema.NonEmptyString,
|
|
133
|
+
model: Schema.optionalKey(Schema.String),
|
|
134
|
+
note: Schema.optionalKey(Schema.String),
|
|
135
|
+
exchanges: Schema.NonEmptyArray(WireExchange)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
export type WireFixture = typeof WireFixture.Type
|
|
139
|
+
|
|
140
|
+
/** Decode unknown input (for example a JSON file) into a `WireFixture`. */
|
|
141
|
+
export const decodeWireFixture = Schema.decodeUnknownEffect(WireFixture)
|
|
142
|
+
|
|
143
|
+
export const isWireStreamResponse = (response: WireResponse): response is WireStreamResponse =>
|
|
144
|
+
Predicate.hasProperty(response, 'chunks')
|
|
145
|
+
|
|
146
|
+
export const isWireBase64BodyResponse = (
|
|
147
|
+
response: WireResponse
|
|
148
|
+
): response is WireBase64BodyResponse => Predicate.hasProperty(response, 'bodyBase64')
|
|
149
|
+
|
|
150
|
+
const millisPerDay = 86_400_000
|
|
151
|
+
|
|
152
|
+
const recordedAtPattern = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
153
|
+
|
|
154
|
+
const recordedAtMillis = (recordedAt: string): number => {
|
|
155
|
+
const match = recordedAtPattern.exec(recordedAt)
|
|
156
|
+
|
|
157
|
+
if (match === null) {
|
|
158
|
+
return Number.NaN
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Whole days between `recordedAt` (UTC midnight) and `now`. `NaN` when
|
|
166
|
+
* `recordedAt` is not a `YYYY-MM-DD` date.
|
|
167
|
+
*/
|
|
168
|
+
export const fixtureAgeDays = (fixture: Pick<WireFixture, 'recordedAt'>, now: Date): number =>
|
|
169
|
+
Math.floor((now.getTime() - recordedAtMillis(fixture.recordedAt)) / millisPerDay)
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* True when the fixture is older than `maxAgeDays` (default 30). Fixtures with
|
|
173
|
+
* an unreadable `recordedAt` are treated as stale.
|
|
174
|
+
*/
|
|
175
|
+
export const isFixtureStale = (
|
|
176
|
+
fixture: Pick<WireFixture, 'recordedAt'>,
|
|
177
|
+
now: Date,
|
|
178
|
+
maxAgeDays = 30
|
|
179
|
+
): boolean => {
|
|
180
|
+
const age = fixtureAgeDays(fixture, now)
|
|
181
|
+
|
|
182
|
+
return Number.isNaN(age) || age > maxAgeDays
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export type FixtureSecretIssueKind =
|
|
186
|
+
| 'credential_header'
|
|
187
|
+
| 'bearer_token'
|
|
188
|
+
| 'api_key'
|
|
189
|
+
| 'credential_query_param'
|
|
190
|
+
| 'credential_field'
|
|
191
|
+
|
|
192
|
+
/** A secret-scan finding. `location` is a path into the fixture; the secret itself is never echoed. */
|
|
193
|
+
export type FixtureSecretIssue = {
|
|
194
|
+
readonly kind: FixtureSecretIssueKind
|
|
195
|
+
readonly location: string
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type IssueSink = Array<FixtureSecretIssue>
|
|
199
|
+
|
|
200
|
+
const scanText = (text: string, location: string, issues: IssueSink): void => {
|
|
201
|
+
if (bearerPattern.test(text)) {
|
|
202
|
+
issues.push({ kind: 'bearer_token', location })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (apiKeyPatterns.some(pattern => pattern.test(text))) {
|
|
206
|
+
issues.push({ kind: 'api_key', location })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const scanHeaders = (
|
|
211
|
+
headers: WireHeaders | undefined,
|
|
212
|
+
location: string,
|
|
213
|
+
issues: IssueSink
|
|
214
|
+
): void => {
|
|
215
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
216
|
+
const headerLocation = `${location}.${name}`
|
|
217
|
+
|
|
218
|
+
if (isCredentialHeaderName(name)) {
|
|
219
|
+
issues.push({ kind: 'credential_header', location: headerLocation })
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
scanText(value, headerLocation, issues)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const scanUrl = (url: string, location: string, issues: IssueSink): void => {
|
|
227
|
+
if (credentialParamPattern.test(url)) {
|
|
228
|
+
issues.push({ kind: 'credential_query_param', location })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
scanText(url, location, issues)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const scanJson = (value: Schema.Json, location: string, issues: IssueSink): void => {
|
|
235
|
+
if (Predicate.isString(value)) {
|
|
236
|
+
scanText(value, location, issues)
|
|
237
|
+
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
value.forEach((item, index) => scanJson(item, `${location}[${index}]`, issues))
|
|
243
|
+
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (value !== null && !Predicate.isNumber(value) && !Predicate.isBoolean(value)) {
|
|
248
|
+
for (const [key, item] of Object.entries(value)) {
|
|
249
|
+
const itemLocation = `${location}.${key}`
|
|
250
|
+
|
|
251
|
+
if (credentialFieldPattern.test(key) && Predicate.isString(item) && item.length > 0) {
|
|
252
|
+
issues.push({ kind: 'credential_field', location: itemLocation })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
scanJson(item, itemLocation, issues)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const parseJsonOption = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Json))
|
|
261
|
+
|
|
262
|
+
// `data:` payloads of each server-sent event, multi-line data joined with `\n`.
|
|
263
|
+
// SSE allows CRLF, LF, or bare CR line endings; normalize before splitting events.
|
|
264
|
+
const sseDataPayloads = (text: string): ReadonlyArray<string> =>
|
|
265
|
+
text
|
|
266
|
+
.replace(/\r\n?/g, '\n')
|
|
267
|
+
.split('\n\n')
|
|
268
|
+
.flatMap(event => {
|
|
269
|
+
const data = event
|
|
270
|
+
.split('\n')
|
|
271
|
+
.filter(line => line.startsWith('data:'))
|
|
272
|
+
.map(line => line.slice('data:'.length).replace(/^ /, ''))
|
|
273
|
+
|
|
274
|
+
return data.length > 0 ? [data.join('\n')] : []
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Scan a whole payload (request/response body or reassembled stream): token
|
|
279
|
+
* patterns, form-encoded credential parameters, and credential fields in the
|
|
280
|
+
* payload itself when it is JSON, or in each SSE `data:` payload that is JSON
|
|
281
|
+
* (located as `<location>.events[n]`).
|
|
282
|
+
*/
|
|
283
|
+
const scanPayload = (text: string, location: string, issues: IssueSink): void => {
|
|
284
|
+
scanText(text, location, issues)
|
|
285
|
+
|
|
286
|
+
if (credentialParamPattern.test(text)) {
|
|
287
|
+
issues.push({ kind: 'credential_query_param', location })
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const json = parseJsonOption(text)
|
|
291
|
+
|
|
292
|
+
if (Option.isSome(json)) {
|
|
293
|
+
scanJson(json.value, location, issues)
|
|
294
|
+
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
sseDataPayloads(text).forEach((payload, index) => {
|
|
299
|
+
const event = parseJsonOption(payload)
|
|
300
|
+
|
|
301
|
+
if (Option.isSome(event)) {
|
|
302
|
+
scanJson(event.value, `${location}.events[${index}]`, issues)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const lossyText = (bytes: Uint8Array): string => new TextDecoder().decode(bytes)
|
|
308
|
+
|
|
309
|
+
const chunkBytes = (chunk: WireChunk): Uint8Array =>
|
|
310
|
+
Predicate.isString(chunk)
|
|
311
|
+
? new TextEncoder().encode(chunk)
|
|
312
|
+
: Option.getOrElse(decodeBase64Bytes(chunk.base64), () => new Uint8Array())
|
|
313
|
+
|
|
314
|
+
const concatBytes = (parts: ReadonlyArray<Uint8Array>): Uint8Array => {
|
|
315
|
+
const joined = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
|
|
316
|
+
let offset = 0
|
|
317
|
+
|
|
318
|
+
for (const part of parts) {
|
|
319
|
+
joined.set(part, offset)
|
|
320
|
+
offset += part.length
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return joined
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const scanStream = (
|
|
327
|
+
chunks: ReadonlyArray<WireChunk>,
|
|
328
|
+
location: string,
|
|
329
|
+
issues: IssueSink
|
|
330
|
+
): void => {
|
|
331
|
+
const parts = chunks.map(chunkBytes)
|
|
332
|
+
|
|
333
|
+
parts.forEach((bytes, index) => scanText(lossyText(bytes), `${location}[${index}]`, issues))
|
|
334
|
+
|
|
335
|
+
// The reassembled stream catches secrets split across chunks and JSON
|
|
336
|
+
// credential fields inside SSE events. Bytes are decoded non-fatally.
|
|
337
|
+
scanPayload(lossyText(concatBytes(parts)), location, issues)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const uniqueIssues = (issues: IssueSink): ReadonlyArray<FixtureSecretIssue> => {
|
|
341
|
+
const seen = new Set<string>()
|
|
342
|
+
|
|
343
|
+
return issues.filter(issue => {
|
|
344
|
+
const key = `${issue.kind} ${issue.location}`
|
|
345
|
+
|
|
346
|
+
if (seen.has(key)) {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
seen.add(key)
|
|
351
|
+
|
|
352
|
+
return true
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Pure secret scan. Flags credential headers, bearer tokens, common API-key
|
|
358
|
+
* prefixes, JWTs, private keys, credential query/form parameters, and
|
|
359
|
+
* credential JSON fields anywhere in the fixture: metadata, URLs, headers,
|
|
360
|
+
* request bodies, response bodies (text or decodable base64), each stream
|
|
361
|
+
* chunk, and the reassembled stream (so a secret split across chunks is still
|
|
362
|
+
* found). JSON bodies and SSE `data:` payloads get the credential-field scan.
|
|
363
|
+
* Issues name locations only. Returns an empty array when clean.
|
|
364
|
+
*/
|
|
365
|
+
export const scanFixtureForSecrets = (fixture: WireFixture): ReadonlyArray<FixtureSecretIssue> => {
|
|
366
|
+
const issues: IssueSink = []
|
|
367
|
+
|
|
368
|
+
scanText(fixture.id, 'id', issues)
|
|
369
|
+
scanText(fixture.caseId, 'caseId', issues)
|
|
370
|
+
scanText(fixture.account, 'account', issues)
|
|
371
|
+
scanUrl(fixture.endpoint, 'endpoint', issues)
|
|
372
|
+
|
|
373
|
+
if (fixture.model !== undefined) {
|
|
374
|
+
scanText(fixture.model, 'model', issues)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (fixture.note !== undefined) {
|
|
378
|
+
scanText(fixture.note, 'note', issues)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fixture.exchanges.forEach((exchange, index) => {
|
|
382
|
+
const base = `exchanges[${index}]`
|
|
383
|
+
const requestBody = exchange.request.body
|
|
384
|
+
|
|
385
|
+
scanUrl(exchange.request.url, `${base}.request.url`, issues)
|
|
386
|
+
scanHeaders(exchange.request.headers, `${base}.request.headers`, issues)
|
|
387
|
+
|
|
388
|
+
if (Predicate.isString(requestBody)) {
|
|
389
|
+
scanPayload(requestBody, `${base}.request.body`, issues)
|
|
390
|
+
} else if (requestBody !== undefined) {
|
|
391
|
+
scanJson(requestBody, `${base}.request.body`, issues)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const response = exchange.response
|
|
395
|
+
|
|
396
|
+
scanHeaders(response.headers, `${base}.response.headers`, issues)
|
|
397
|
+
|
|
398
|
+
if (isWireStreamResponse(response)) {
|
|
399
|
+
scanStream(response.chunks, `${base}.response.chunks`, issues)
|
|
400
|
+
} else if (isWireBase64BodyResponse(response)) {
|
|
401
|
+
const bytes = decodeBase64Bytes(response.bodyBase64)
|
|
402
|
+
|
|
403
|
+
if (Option.isSome(bytes)) {
|
|
404
|
+
scanPayload(lossyText(bytes.value), `${base}.response.bodyBase64`, issues)
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
scanPayload(response.body, `${base}.response.body`, issues)
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
return uniqueIssues(issues)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Port fixtures: one recorded call through a host-provided port that is not HTTP.
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A failure a port answered instead of a value. `expected` is the port's value-level failure (for
|
|
418
|
+
* example a connector `ActionResult.failure`); `error` is the port's typed error channel. `code`
|
|
419
|
+
* is a sanitized classification, never raw provider text.
|
|
420
|
+
*/
|
|
421
|
+
export const PortFailure = Schema.Struct({
|
|
422
|
+
kind: Schema.Literals(['expected', 'error']),
|
|
423
|
+
code: Schema.NonEmptyString,
|
|
424
|
+
message: Schema.String,
|
|
425
|
+
status: Schema.optionalKey(Schema.Int)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
export type PortFailure = typeof PortFailure.Type
|
|
429
|
+
|
|
430
|
+
const portFixtureFields = {
|
|
431
|
+
id: Schema.NonEmptyString,
|
|
432
|
+
/** Port name, for example `EmailClient`. */
|
|
433
|
+
port: Schema.NonEmptyString,
|
|
434
|
+
/** Port method, for example `listMessages`. */
|
|
435
|
+
method: Schema.NonEmptyString,
|
|
436
|
+
/** The request as plain JSON, with every credential field removed (`redactPortPayload`). */
|
|
437
|
+
request: Schema.Json,
|
|
438
|
+
/**
|
|
439
|
+
* The live observation this call was recorded from. Absent means a synthetic placeholder
|
|
440
|
+
* (`unverified`); present means `verified` as of `observed.date`.
|
|
441
|
+
*/
|
|
442
|
+
observed: Schema.optionalKey(ConformanceObservation),
|
|
443
|
+
note: Schema.optionalKey(Schema.String)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** A port call answered with a value (`response`, plain JSON). */
|
|
447
|
+
export const PortValueFixture = Schema.Struct({
|
|
448
|
+
...portFixtureFields,
|
|
449
|
+
response: Schema.Json,
|
|
450
|
+
failure: absent
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
export type PortValueFixture = typeof PortValueFixture.Type
|
|
454
|
+
|
|
455
|
+
/** A port call answered with a failure. */
|
|
456
|
+
export const PortFailureFixture = Schema.Struct({
|
|
457
|
+
...portFixtureFields,
|
|
458
|
+
failure: PortFailure,
|
|
459
|
+
response: absent
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
export type PortFailureFixture = typeof PortFailureFixture.Type
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* One recorded call through a host-provided port: the port, the method, the credential-free JSON
|
|
466
|
+
* request, and exactly one of `response` or `failure`. Plain JSON only, so one fixture backs
|
|
467
|
+
* replay, an emulator, and parity checks alike.
|
|
468
|
+
*/
|
|
469
|
+
export const PortFixture = Schema.Union([PortValueFixture, PortFailureFixture])
|
|
470
|
+
|
|
471
|
+
export type PortFixture = typeof PortFixture.Type
|
|
472
|
+
|
|
473
|
+
/** Decode unknown input (for example a JSON file) into a `PortFixture`. */
|
|
474
|
+
export const decodePortFixture = Schema.decodeUnknownEffect(PortFixture)
|
|
475
|
+
|
|
476
|
+
/** Any fixture a conformance case may cite: an HTTP `WireFixture` or a `PortFixture`. */
|
|
477
|
+
export type ConformanceFixture = WireFixture | PortFixture
|
|
478
|
+
|
|
479
|
+
export const isPortFixture = (fixture: ConformanceFixture): fixture is PortFixture =>
|
|
480
|
+
!Predicate.hasProperty(fixture, 'exchanges')
|
|
481
|
+
|
|
482
|
+
export const isPortFailureFixture = (fixture: PortFixture): fixture is PortFailureFixture =>
|
|
483
|
+
Predicate.hasProperty(fixture, 'failure') && fixture.failure !== undefined
|
|
484
|
+
|
|
485
|
+
/** Evidence and (when known) date of a fixture. */
|
|
486
|
+
export type ConformanceFixtureEvidence = {
|
|
487
|
+
readonly evidence: WireFixtureEvidence
|
|
488
|
+
readonly date?: string
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Evidence and date of any fixture: a `WireFixture`'s `evidence` and `recordedAt`; a
|
|
493
|
+
* `PortFixture` is `verified` as of `observed.date` when observed, else `unverified` without a
|
|
494
|
+
* date.
|
|
495
|
+
*/
|
|
496
|
+
export const conformanceFixtureEvidence = (
|
|
497
|
+
fixture: ConformanceFixture
|
|
498
|
+
): ConformanceFixtureEvidence => {
|
|
499
|
+
if (!isPortFixture(fixture)) {
|
|
500
|
+
return { evidence: fixture.evidence, date: fixture.recordedAt }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return fixture.observed === undefined
|
|
504
|
+
? { evidence: 'unverified' }
|
|
505
|
+
: { evidence: 'verified', date: fixture.observed.date }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const portCredentialKeyPattern = /^credentials?$/i
|
|
509
|
+
|
|
510
|
+
const isJsonRecord = (value: Schema.Json): value is Schema.JsonObject =>
|
|
511
|
+
value !== null && Predicate.isObject(value) && !Array.isArray(value)
|
|
512
|
+
|
|
513
|
+
/** True for a port payload key that holds a credential (`credential`, `password`, `token`, ...). */
|
|
514
|
+
export const isPortCredentialKey = (key: string): boolean =>
|
|
515
|
+
portCredentialKeyPattern.test(key) || credentialFieldPattern.test(key)
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* A copy of a port payload without credential fields (see `isPortCredentialKey`: `credential(s)`
|
|
519
|
+
* plus the credential field names, AWS-style `accessKeyId` / `secretAccessKey` / `sessionToken`
|
|
520
|
+
* and their snake_case forms included), at any depth.
|
|
521
|
+
* Port requests often carry the resolved credential; record and compare requests only after this.
|
|
522
|
+
*/
|
|
523
|
+
export const redactPortPayload = (value: Schema.Json): Schema.Json => {
|
|
524
|
+
if (Array.isArray(value)) {
|
|
525
|
+
return value.map(redactPortPayload)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!isJsonRecord(value)) {
|
|
529
|
+
return value
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const copy: Record<string, Schema.Json> = {}
|
|
533
|
+
|
|
534
|
+
for (const [key, item] of Object.entries(value)) {
|
|
535
|
+
if (!isPortCredentialKey(key)) {
|
|
536
|
+
copy[key] = redactPortPayload(item)
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return copy
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** A port string: the token patterns plus credential query or form parameters (a signed URL). */
|
|
544
|
+
const scanPortText = (text: string, location: string, issues: IssueSink): void => {
|
|
545
|
+
scanText(text, location, issues)
|
|
546
|
+
|
|
547
|
+
if (hasLiveCredentialParam(text)) {
|
|
548
|
+
issues.push({ kind: 'credential_query_param', location })
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const scanPortJson = (value: Schema.Json, location: string, issues: IssueSink): void => {
|
|
553
|
+
scanJson(value, location, issues)
|
|
554
|
+
|
|
555
|
+
const visit = (item: Schema.Json, itemLocation: string): void => {
|
|
556
|
+
if (Predicate.isString(item)) {
|
|
557
|
+
if (hasLiveCredentialParam(item)) {
|
|
558
|
+
issues.push({ kind: 'credential_query_param', location: itemLocation })
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
if (Array.isArray(item)) {
|
|
565
|
+
item.forEach((entry, index) => visit(entry, `${itemLocation}[${index}]`))
|
|
566
|
+
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (!isJsonRecord(item)) {
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
for (const [key, entry] of Object.entries(item)) {
|
|
575
|
+
if (portCredentialKeyPattern.test(key) && entry !== null) {
|
|
576
|
+
issues.push({ kind: 'credential_field', location: `${itemLocation}.${key}` })
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
visit(entry, `${itemLocation}.${key}`)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
visit(value, location)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Pure secret scan of a `PortFixture`: the same token patterns and credential JSON fields as
|
|
588
|
+
* `scanFixtureForSecrets`, plus any non-null `credential` / `credentials` field (port requests must
|
|
589
|
+
* be recorded through `redactPortPayload`), plus credential query or form parameters inside every
|
|
590
|
+
* JSON string value, `note`, and `failure.message` (a signed URL a port answered, such as an S3
|
|
591
|
+
* presigned URL's `X-Amz-Signature` / `X-Amz-Credential` / `X-Amz-Security-Token`), each `name=`
|
|
592
|
+
* found and judged on its own. The only exemption: the raw value, up to `&`, `#`, whitespace, `"`,
|
|
593
|
+
* `<`, `>`, or the end (never `?` or `'`), percent-decoded, exactly equals that name's entry in
|
|
594
|
+
* `syntheticPortCredentialParams`. Parameters with escaped names (`&`, percent-encoded) are not
|
|
595
|
+
* found. Covers metadata, the request, the response, and the failure. Issues name locations only.
|
|
596
|
+
* Returns an empty array when clean.
|
|
597
|
+
*/
|
|
598
|
+
export const scanPortFixtureForSecrets = (
|
|
599
|
+
fixture: PortFixture
|
|
600
|
+
): ReadonlyArray<FixtureSecretIssue> => {
|
|
601
|
+
const issues: IssueSink = []
|
|
602
|
+
|
|
603
|
+
scanText(fixture.id, 'id', issues)
|
|
604
|
+
scanText(fixture.port, 'port', issues)
|
|
605
|
+
scanText(fixture.method, 'method', issues)
|
|
606
|
+
|
|
607
|
+
if (fixture.observed !== undefined) {
|
|
608
|
+
scanText(fixture.observed.account, 'observed.account', issues)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (fixture.note !== undefined) {
|
|
612
|
+
scanPortText(fixture.note, 'note', issues)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
scanPortJson(fixture.request, 'request', issues)
|
|
616
|
+
|
|
617
|
+
if (isPortFailureFixture(fixture)) {
|
|
618
|
+
scanText(fixture.failure.code, 'failure.code', issues)
|
|
619
|
+
scanPortText(fixture.failure.message, 'failure.message', issues)
|
|
620
|
+
} else {
|
|
621
|
+
scanPortJson(fixture.response, 'response', issues)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return uniqueIssues(issues)
|
|
625
|
+
}
|