@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/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 (`&amp;`, 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
+ }