@growth-labs/conformance 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { extractHtmlSignals, getSchemaTypes, type HtmlSignals, nodeHasSchemaType } from './html.js'
2
- import { isPrivateAudience, normalizeHeaders, REQUIRED_SECURITY_HEADERS } from './policy.js'
2
+ import {
3
+ collectHeaders,
4
+ isPrivateAudience,
5
+ normalizeHeaders,
6
+ REQUIRED_SECURITY_HEADERS,
7
+ } from './policy.js'
3
8
  import {
4
9
  type Audience,
5
10
  type ConformanceCheckReceipt,
@@ -10,12 +15,31 @@ import {
10
15
  EVIDENCE_SCHEMA_VERSION,
11
16
  type FleetEvidenceBundle,
12
17
  fleetEvidenceBundleSchema,
18
+ REQUIRED_FUNCTIONAL_PROBE_NAMES,
13
19
  type SiteEvidenceBundle,
14
20
  siteEvidenceBundleSchema,
15
21
  } from './schema.js'
16
22
 
17
23
  const REQUIRED_PUBLICATION_SURFACES = ['sitemap', 'robots', 'feed'] as const
18
24
  const DISCOVER_HERO_MIN_WIDTH = 1200
25
+ const MIN_HSTS_MAX_AGE_SECONDS = 31536000
26
+ const VALID_COOP_VALUES = new Set(['same-origin', 'same-origin-allow-popups', 'unsafe-none'])
27
+ const VALID_CORP_VALUES = new Set(['same-origin', 'same-site', 'cross-origin'])
28
+ const EXECUTABLE_CSP_DIRECTIVES = [
29
+ 'default-src',
30
+ 'script-src',
31
+ 'script-src-elem',
32
+ 'script-src-attr',
33
+ ] as const
34
+ const INLINE_CSP_DIRECTIVES = [
35
+ 'default-src',
36
+ 'script-src',
37
+ 'script-src-elem',
38
+ 'script-src-attr',
39
+ 'style-src',
40
+ 'style-src-elem',
41
+ 'style-src-attr',
42
+ ] as const
19
43
 
20
44
  export interface RunConformanceOptions {
21
45
  now?: () => string
@@ -29,7 +53,8 @@ export function runSiteConformance(
29
53
  const generatedAt = options.now?.() ?? new Date().toISOString()
30
54
  const htmlSignals = extractHtmlSignals(fixture.response.html ?? fixture.response.body ?? '')
31
55
  const checks = [
32
- checkSecurityHeaders(fixture),
56
+ checkSecurityHeaders(fixture, fixture.response.html ?? fixture.response.body ?? ''),
57
+ checkSecurityReceipts(fixture),
33
58
  checkMetadata(fixture, htmlSignals),
34
59
  checkStructuredData(fixture, htmlSignals),
35
60
  checkImages(fixture, htmlSignals),
@@ -50,6 +75,7 @@ export function runSiteConformance(
50
75
  summary,
51
76
  checks,
52
77
  findings,
78
+ securityReceipts: fixture.securityReceipts,
53
79
  }
54
80
 
55
81
  return siteEvidenceBundleSchema.parse(bundle)
@@ -83,10 +109,29 @@ export function runFleetConformance(
83
109
  return fleetEvidenceBundleSchema.parse(bundle)
84
110
  }
85
111
 
86
- function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckReceipt {
112
+ function checkSecurityHeaders(fixture: ConformanceFixture, html: string): ConformanceCheckReceipt {
113
+ const headerEntries = collectHeaders(fixture.response.headers)
87
114
  const headers = normalizeHeaders(fixture.response.headers)
88
115
  const findings: ConformanceFinding[] = []
89
- const missingSecurityHeaders = REQUIRED_SECURITY_HEADERS.filter((name) => !headers[name])
116
+ const missingSecurityHeaders = REQUIRED_SECURITY_HEADERS.filter((name) => {
117
+ const values = headerEntries[name] ?? []
118
+ return !values.some((value) => value.trim().length > 0)
119
+ })
120
+
121
+ for (const header of REQUIRED_SECURITY_HEADERS) {
122
+ const values = headerEntries[header] ?? []
123
+ const distinctValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))]
124
+ if (distinctValues.length > 1) {
125
+ findings.push({
126
+ code: 'security.header.conflicting_values',
127
+ severity: 'error',
128
+ message: `Required security header ${header} has conflicting duplicate values.`,
129
+ path: `response.headers.${header}`,
130
+ evidence: JSON.stringify(distinctValues),
131
+ remediation: 'Emit one authoritative value for each required security header per response.',
132
+ })
133
+ }
134
+ }
90
135
 
91
136
  if (
92
137
  fixture.renderMode === 'ssr' &&
@@ -116,14 +161,28 @@ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckRece
116
161
  }
117
162
 
118
163
  const csp = headers['content-security-policy']
119
- if (csp?.toLowerCase().includes('unsafe-inline')) {
164
+ if (csp) findings.push(...validateContentSecurityPolicy(csp, html))
165
+
166
+ const coop = headers['cross-origin-opener-policy']
167
+ if (coop && !VALID_COOP_VALUES.has(coop.toLowerCase())) {
120
168
  findings.push({
121
- code: 'security.csp.unsafe_inline',
169
+ code: 'security.header.invalid',
122
170
  severity: 'error',
123
- message: 'Content-Security-Policy must not allow unsafe-inline script or style execution.',
124
- path: 'response.headers.content-security-policy',
125
- evidence: csp,
126
- remediation: 'Use nonces or hashes in the shared CSP policy instead of unsafe-inline.',
171
+ message:
172
+ 'Cross-Origin-Opener-Policy must be same-origin, same-origin-allow-popups, or unsafe-none.',
173
+ path: 'response.headers.cross-origin-opener-policy',
174
+ evidence: coop,
175
+ })
176
+ }
177
+
178
+ const corp = headers['cross-origin-resource-policy']
179
+ if (corp && !VALID_CORP_VALUES.has(corp.toLowerCase())) {
180
+ findings.push({
181
+ code: 'security.header.invalid',
182
+ severity: 'error',
183
+ message: 'Cross-Origin-Resource-Policy must be same-origin, same-site, or cross-origin.',
184
+ path: 'response.headers.cross-origin-resource-policy',
185
+ evidence: corp,
127
186
  })
128
187
  }
129
188
 
@@ -139,13 +198,49 @@ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckRece
139
198
  }
140
199
 
141
200
  const hsts = headers['strict-transport-security']
142
- if (hsts && !/max-age=\d+/i.test(hsts)) {
201
+ if (hsts) {
202
+ const hstsDirectives = parseDirectivePairs(hsts)
203
+ const maxAge = parseHstsMaxAge(hstsDirectives)
204
+ if (maxAge === null) {
205
+ findings.push({
206
+ code: 'security.header.invalid',
207
+ severity: 'error',
208
+ message: 'Strict-Transport-Security must declare max-age.',
209
+ path: 'response.headers.strict-transport-security',
210
+ evidence: hsts,
211
+ })
212
+ } else if (maxAge < MIN_HSTS_MAX_AGE_SECONDS) {
213
+ findings.push({
214
+ code: 'security.hsts.max_age_too_low',
215
+ severity: 'error',
216
+ message: 'Strict-Transport-Security max-age must be at least 31536000 seconds.',
217
+ path: 'response.headers.strict-transport-security',
218
+ evidence: `max-age=${maxAge}`,
219
+ remediation:
220
+ 'Use at least one year of HSTS. Add includeSubDomains/preload only after host inventory proof.',
221
+ })
222
+ }
223
+ if (hstsDirectives.has('preload') && !hstsDirectives.has('includesubdomains')) {
224
+ findings.push({
225
+ code: 'security.header.invalid',
226
+ severity: 'error',
227
+ message: 'Strict-Transport-Security preload requires includeSubDomains.',
228
+ path: 'response.headers.strict-transport-security',
229
+ evidence: hsts,
230
+ remediation:
231
+ 'Only add includeSubDomains/preload after the consumer has verified all subdomains can enforce HTTPS.',
232
+ })
233
+ }
234
+ }
235
+
236
+ const reportOnlyCsp = headers['content-security-policy-report-only']
237
+ if (reportOnlyCsp && !csp) {
143
238
  findings.push({
144
239
  code: 'security.header.invalid',
145
240
  severity: 'error',
146
- message: 'Strict-Transport-Security must declare max-age.',
147
- path: 'response.headers.strict-transport-security',
148
- evidence: hsts,
241
+ message: 'Report-only CSP cannot replace enforced Content-Security-Policy.',
242
+ path: 'response.headers.content-security-policy-report-only',
243
+ evidence: reportOnlyCsp,
149
244
  })
150
245
  }
151
246
 
@@ -170,6 +265,481 @@ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckRece
170
265
  return receipt('security-headers', findings)
171
266
  }
172
267
 
268
+ function checkSecurityReceipts(fixture: ConformanceFixture): ConformanceCheckReceipt {
269
+ const findings: ConformanceFinding[] = []
270
+ const probes = fixture.securityReceipts.functionalProbes
271
+ const probesByName = new Map(probes.map((probe) => [probe.name, probe]))
272
+
273
+ for (const required of REQUIRED_FUNCTIONAL_PROBE_NAMES) {
274
+ if (!probesByName.has(required)) {
275
+ findings.push({
276
+ code: 'security.functional_probe.missing',
277
+ severity: 'error',
278
+ message: `Missing enforced-CSP functional probe receipt: ${required}.`,
279
+ path: `securityReceipts.functionalProbes.${required}`,
280
+ remediation:
281
+ 'Provide a consumer-owned pass or documented NA receipt for every required functional surface.',
282
+ })
283
+ }
284
+ }
285
+
286
+ probes.forEach((probe, index) => {
287
+ const path = `securityReceipts.functionalProbes[${index}]`
288
+ if (probe.status === 'fail') {
289
+ findings.push({
290
+ code: 'security.functional_probe.failed',
291
+ severity: 'error',
292
+ message: `Functional probe ${probe.name} failed under enforced CSP.`,
293
+ path,
294
+ evidence: probe.evidence,
295
+ remediation:
296
+ 'Fix the consumer CSP/header contract before treating conformance evidence as passing.',
297
+ })
298
+ }
299
+ if (probe.status === 'pass' && !probe.evidence) {
300
+ findings.push({
301
+ code: 'security.functional_probe.evidence_missing',
302
+ severity: 'error',
303
+ message: `Functional probe ${probe.name} passed without explicit evidence.`,
304
+ path: `${path}.evidence`,
305
+ remediation: 'Attach a sanitized consumer-owned receipt for the successful probe.',
306
+ })
307
+ }
308
+ if (probe.status === 'na' && (!probe.capabilityBoundary || !probe.evidence)) {
309
+ findings.push({
310
+ code: 'security.functional_probe.na_undeclared',
311
+ severity: 'error',
312
+ message: `Functional probe ${probe.name} is NA without boundary and evidence.`,
313
+ path,
314
+ remediation:
315
+ 'Declare why the surface is out of scope and attach sanitized supporting evidence.',
316
+ })
317
+ }
318
+ })
319
+
320
+ const cspReports = fixture.securityReceipts.cspReports
321
+ if (cspReports.length === 0) {
322
+ findings.push({
323
+ code: 'security.csp_report.missing',
324
+ severity: 'error',
325
+ message: 'Missing CSP reporting or canary receipt for the enforced policy.',
326
+ path: 'securityReceipts.cspReports',
327
+ remediation:
328
+ 'Provide a consumer-owned CSP report endpoint or blocked-canary receipt proving reporting works.',
329
+ })
330
+ }
331
+
332
+ let passingCspReports = 0
333
+ cspReports.forEach((report, index) => {
334
+ const path = `securityReceipts.cspReports[${index}]`
335
+ if (report.status === 'pass') {
336
+ passingCspReports += 1
337
+ if (!report.evidence) {
338
+ findings.push({
339
+ code: 'security.csp_report.evidence_missing',
340
+ severity: 'error',
341
+ message: `CSP report receipt ${report.name} passed without explicit evidence.`,
342
+ path: `${path}.evidence`,
343
+ })
344
+ }
345
+ }
346
+ if (report.status === 'fail') {
347
+ findings.push({
348
+ code: 'security.csp_report.failed',
349
+ severity: 'error',
350
+ message: `CSP report receipt ${report.name} failed.`,
351
+ path,
352
+ evidence: report.evidence,
353
+ remediation:
354
+ 'Fix the consumer CSP reporting path before treating conformance evidence as passing.',
355
+ })
356
+ }
357
+ if (report.status === 'na' && (!report.capabilityBoundary || !report.evidence)) {
358
+ findings.push({
359
+ code: 'security.csp_report.na_undeclared',
360
+ severity: 'error',
361
+ message: `CSP report receipt ${report.name} is NA without boundary and evidence.`,
362
+ path,
363
+ })
364
+ }
365
+ })
366
+ if (cspReports.length > 0 && passingCspReports === 0) {
367
+ findings.push({
368
+ code: 'security.csp_report.missing',
369
+ severity: 'error',
370
+ message: 'At least one CSP reporting or canary receipt must pass.',
371
+ path: 'securityReceipts.cspReports',
372
+ })
373
+ }
374
+
375
+ return receipt('security-receipts', findings)
376
+ }
377
+
378
+ interface CspDirective {
379
+ name: string
380
+ sources: string[]
381
+ raw: string
382
+ }
383
+
384
+ interface InlineExecutableScript {
385
+ content: string
386
+ nonce?: string
387
+ path: string
388
+ }
389
+
390
+ function validateContentSecurityPolicy(csp: string, html: string): ConformanceFinding[] {
391
+ const findings: ConformanceFinding[] = []
392
+ const { directives, findings: parseFindings } = parseContentSecurityPolicy(csp)
393
+ findings.push(...parseFindings)
394
+
395
+ const defaultSources = lowerSources(directives.get('default-src')?.sources ?? [])
396
+ if (defaultSources.includes('*')) {
397
+ findings.push({
398
+ code: 'security.csp.default_src_wildcard',
399
+ severity: 'error',
400
+ message: 'Content-Security-Policy default-src must not allow wildcard sources.',
401
+ path: 'response.headers.content-security-policy.default-src',
402
+ evidence: directives.get('default-src')?.raw,
403
+ remediation:
404
+ 'Scope default-src to self or narrower origins, then add explicit directives for required external surfaces.',
405
+ })
406
+ }
407
+
408
+ for (const directiveName of EXECUTABLE_CSP_DIRECTIVES) {
409
+ const directive = directives.get(directiveName)
410
+ if (!directive) continue
411
+ if (lowerSources(directive.sources).includes("'unsafe-eval'")) {
412
+ findings.push({
413
+ code: 'security.csp.unsafe_eval',
414
+ severity: 'error',
415
+ message: 'Content-Security-Policy must not allow unsafe-eval.',
416
+ path: `response.headers.content-security-policy.${directiveName}`,
417
+ evidence: directive.raw,
418
+ remediation:
419
+ 'Remove unsafe-eval and replace any dependent runtime with nonce/hash-compatible script execution.',
420
+ })
421
+ }
422
+ }
423
+
424
+ for (const directiveName of INLINE_CSP_DIRECTIVES) {
425
+ const directive = directives.get(directiveName)
426
+ if (!directive) continue
427
+ if (lowerSources(directive.sources).includes("'unsafe-inline'")) {
428
+ findings.push({
429
+ code: 'security.csp.unsafe_inline',
430
+ severity: 'error',
431
+ message: 'Content-Security-Policy must not allow unsafe-inline script or style execution.',
432
+ path: `response.headers.content-security-policy.${directiveName}`,
433
+ evidence: directive.raw,
434
+ remediation: 'Use nonces or hashes in the shared CSP policy instead of unsafe-inline.',
435
+ })
436
+ }
437
+ }
438
+
439
+ const scriptSources = getEffectiveScriptSources(directives)
440
+ for (const script of extractInlineExecutableScripts(html)) {
441
+ if (inlineExecutableScriptAllowed(script, scriptSources)) continue
442
+ findings.push({
443
+ code: 'security.csp.inline_executable_without_nonce_or_hash',
444
+ severity: 'error',
445
+ message: 'Executable inline script lacks a CSP source matching its nonce or SHA-256 hash.',
446
+ path: script.path,
447
+ remediation:
448
+ 'Add a runtime nonce to the script and CSP, or add the exact SHA-256 hash source for the inline script body.',
449
+ })
450
+ }
451
+
452
+ return findings
453
+ }
454
+
455
+ function parseContentSecurityPolicy(csp: string): {
456
+ directives: Map<string, CspDirective>
457
+ findings: ConformanceFinding[]
458
+ } {
459
+ const directives = new Map<string, CspDirective>()
460
+ const findings: ConformanceFinding[] = []
461
+
462
+ csp.split(';').forEach((rawDirective, index) => {
463
+ const trimmed = rawDirective.trim()
464
+ if (!trimmed) return
465
+ const tokens = trimmed.split(/\s+/)
466
+ const name = tokens[0]?.toLowerCase() ?? ''
467
+ const sources = tokens.slice(1)
468
+
469
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
470
+ findings.push({
471
+ code: 'security.csp.malformed',
472
+ severity: 'error',
473
+ message: 'Content-Security-Policy contains a malformed directive name.',
474
+ path: `response.headers.content-security-policy[${index}]`,
475
+ evidence: trimmed,
476
+ })
477
+ return
478
+ }
479
+
480
+ if (directiveRequiresSources(name) && sources.length === 0) {
481
+ findings.push({
482
+ code: 'security.csp.malformed',
483
+ severity: 'error',
484
+ message: `Content-Security-Policy directive ${name} is missing source values.`,
485
+ path: `response.headers.content-security-policy.${name}`,
486
+ evidence: trimmed,
487
+ })
488
+ }
489
+
490
+ const existing = directives.get(name)
491
+ if (existing) {
492
+ if (normalizeCspSources(existing.sources) !== normalizeCspSources(sources)) {
493
+ findings.push({
494
+ code: 'security.csp.directive_conflict',
495
+ severity: 'error',
496
+ message: `Content-Security-Policy contains conflicting duplicate ${name} directives.`,
497
+ path: `response.headers.content-security-policy.${name}`,
498
+ evidence: `${existing.raw} | ${trimmed}`,
499
+ remediation: 'Emit one directive per CSP directive name with the complete source list.',
500
+ })
501
+ }
502
+ return
503
+ }
504
+
505
+ directives.set(name, { name, sources, raw: trimmed })
506
+ })
507
+
508
+ return { directives, findings }
509
+ }
510
+
511
+ function directiveRequiresSources(name: string): boolean {
512
+ return name === 'default-src' || name.endsWith('-src') || name.endsWith('-src-elem')
513
+ }
514
+
515
+ function normalizeCspSources(sources: string[]): string {
516
+ return lowerSources(sources).sort().join(' ')
517
+ }
518
+
519
+ function lowerSources(sources: string[]): string[] {
520
+ return sources.map((source) => source.toLowerCase())
521
+ }
522
+
523
+ function getEffectiveScriptSources(directives: Map<string, CspDirective>): string[] {
524
+ return (
525
+ directives.get('script-src-elem')?.sources ??
526
+ directives.get('script-src')?.sources ??
527
+ directives.get('default-src')?.sources ??
528
+ []
529
+ )
530
+ }
531
+
532
+ function inlineExecutableScriptAllowed(script: InlineExecutableScript, sources: string[]): boolean {
533
+ if (script.nonce && sources.includes(`'nonce-${script.nonce}'`)) return true
534
+ return sources.some((source) => hashSourceMatchesInlineScript(source, script.content))
535
+ }
536
+
537
+ function hashSourceMatchesInlineScript(source: string, content: string): boolean {
538
+ const match = /^'(sha256|sha384|sha512)-([^']+)'$/i.exec(source)
539
+ if (!match) return false
540
+ const algorithm = match[1]?.toLowerCase()
541
+ const expected = match[2]
542
+ return algorithm === 'sha256' && expected === sha256Base64(content)
543
+ }
544
+
545
+ function extractInlineExecutableScripts(html: string): InlineExecutableScript[] {
546
+ const scripts: InlineExecutableScript[] = []
547
+ const scriptPattern = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
548
+ let match = scriptPattern.exec(html)
549
+ let scriptIndex = 0
550
+ while (match !== null) {
551
+ const attributes = parseHtmlAttributes(match[1] ?? '')
552
+ const content = match[2] ?? ''
553
+ if (
554
+ !attributes.has('src') &&
555
+ isExecutableScriptType(attributes.get('type')) &&
556
+ content.trim().length > 0
557
+ ) {
558
+ const nonce = attributes.get('nonce')
559
+ scripts.push({
560
+ content,
561
+ nonce: nonce && nonce.length > 0 ? nonce : undefined,
562
+ path: `html.script[${scriptIndex}]`,
563
+ })
564
+ }
565
+ scriptIndex += 1
566
+ match = scriptPattern.exec(html)
567
+ }
568
+
569
+ return scripts
570
+ }
571
+
572
+ function parseHtmlAttributes(rawAttributes: string): Map<string, string> {
573
+ const attributes = new Map<string, string>()
574
+ const attributePattern = /([^\s"'=<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>]+)))?/g
575
+ let match = attributePattern.exec(rawAttributes)
576
+ while (match !== null) {
577
+ const name = match[1]?.toLowerCase()
578
+ if (!name) continue
579
+ attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '')
580
+ match = attributePattern.exec(rawAttributes)
581
+ }
582
+ return attributes
583
+ }
584
+
585
+ function isExecutableScriptType(type: string | undefined): boolean {
586
+ if (!type) return true
587
+ const normalized = type.split(';', 1)[0]?.trim().toLowerCase()
588
+ return (
589
+ normalized === '' ||
590
+ normalized === 'module' ||
591
+ normalized === 'text/javascript' ||
592
+ normalized === 'application/javascript' ||
593
+ normalized === 'text/ecmascript' ||
594
+ normalized === 'application/ecmascript' ||
595
+ normalized === 'text/jscript' ||
596
+ normalized === 'application/x-javascript'
597
+ )
598
+ }
599
+
600
+ function parseDirectivePairs(value: string): Map<string, string | true> {
601
+ const directives = new Map<string, string | true>()
602
+ for (const part of value.split(';')) {
603
+ const trimmed = part.trim()
604
+ if (!trimmed) continue
605
+ const [rawName, ...rawValue] = trimmed.split('=')
606
+ const name = rawName?.trim().toLowerCase()
607
+ if (!name) continue
608
+ const directiveValue = rawValue.length > 0 ? rawValue.join('=').trim() : true
609
+ directives.set(name, directiveValue)
610
+ }
611
+ return directives
612
+ }
613
+
614
+ function parseHstsMaxAge(directives: Map<string, string | true>): number | null {
615
+ const value = directives.get('max-age')
616
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) return null
617
+ return Number(value)
618
+ }
619
+
620
+ const SHA256_INITIAL_HASH = [
621
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
622
+ ] as const
623
+
624
+ const SHA256_ROUND_CONSTANTS = [
625
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
626
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
627
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
628
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
629
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
630
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
631
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
632
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
633
+ ] as const
634
+
635
+ function sha256Base64(input: string): string {
636
+ return bytesToBase64(sha256(new TextEncoder().encode(input)))
637
+ }
638
+
639
+ function sha256(message: Uint8Array): Uint8Array {
640
+ const bitLength = message.length * 8
641
+ const paddedLength = Math.ceil((message.length + 9) / 64) * 64
642
+ const padded = new Uint8Array(paddedLength)
643
+ padded.set(message)
644
+ padded[message.length] = 0x80
645
+ const bitLengthHigh = Math.floor(bitLength / 0x100000000)
646
+ const bitLengthLow = bitLength >>> 0
647
+ padded[paddedLength - 8] = (bitLengthHigh >>> 24) & 0xff
648
+ padded[paddedLength - 7] = (bitLengthHigh >>> 16) & 0xff
649
+ padded[paddedLength - 6] = (bitLengthHigh >>> 8) & 0xff
650
+ padded[paddedLength - 5] = bitLengthHigh & 0xff
651
+ padded[paddedLength - 4] = (bitLengthLow >>> 24) & 0xff
652
+ padded[paddedLength - 3] = (bitLengthLow >>> 16) & 0xff
653
+ padded[paddedLength - 2] = (bitLengthLow >>> 8) & 0xff
654
+ padded[paddedLength - 1] = bitLengthLow & 0xff
655
+
656
+ const hash: number[] = [...SHA256_INITIAL_HASH]
657
+ const words = new Array<number>(64).fill(0)
658
+ for (let chunkOffset = 0; chunkOffset < padded.length; chunkOffset += 64) {
659
+ for (let i = 0; i < 16; i += 1) {
660
+ const offset = chunkOffset + i * 4
661
+ words[i] =
662
+ (((padded[offset] ?? 0) << 24) |
663
+ ((padded[offset + 1] ?? 0) << 16) |
664
+ ((padded[offset + 2] ?? 0) << 8) |
665
+ (padded[offset + 3] ?? 0)) >>>
666
+ 0
667
+ }
668
+ for (let i = 16; i < 64; i += 1) {
669
+ const s0 =
670
+ rotateRight(words[i - 15] ?? 0, 7) ^
671
+ rotateRight(words[i - 15] ?? 0, 18) ^
672
+ ((words[i - 15] ?? 0) >>> 3)
673
+ const s1 =
674
+ rotateRight(words[i - 2] ?? 0, 17) ^
675
+ rotateRight(words[i - 2] ?? 0, 19) ^
676
+ ((words[i - 2] ?? 0) >>> 10)
677
+ words[i] = add32(words[i - 16] ?? 0, s0, words[i - 7] ?? 0, s1)
678
+ }
679
+
680
+ let [a, b, c, d, e, f, g, h] = hash
681
+ for (let i = 0; i < 64; i += 1) {
682
+ const sum1 = rotateRight(e ?? 0, 6) ^ rotateRight(e ?? 0, 11) ^ rotateRight(e ?? 0, 25)
683
+ const choice = ((e ?? 0) & (f ?? 0)) ^ (~(e ?? 0) & (g ?? 0))
684
+ const temp1 = add32(h ?? 0, sum1, choice, SHA256_ROUND_CONSTANTS[i] ?? 0, words[i] ?? 0)
685
+ const sum0 = rotateRight(a ?? 0, 2) ^ rotateRight(a ?? 0, 13) ^ rotateRight(a ?? 0, 22)
686
+ const majority = ((a ?? 0) & (b ?? 0)) ^ ((a ?? 0) & (c ?? 0)) ^ ((b ?? 0) & (c ?? 0))
687
+ const temp2 = add32(sum0, majority)
688
+ h = g
689
+ g = f
690
+ f = e
691
+ e = add32(d ?? 0, temp1)
692
+ d = c
693
+ c = b
694
+ b = a
695
+ a = add32(temp1, temp2)
696
+ }
697
+
698
+ hash[0] = add32(hash[0] ?? 0, a ?? 0)
699
+ hash[1] = add32(hash[1] ?? 0, b ?? 0)
700
+ hash[2] = add32(hash[2] ?? 0, c ?? 0)
701
+ hash[3] = add32(hash[3] ?? 0, d ?? 0)
702
+ hash[4] = add32(hash[4] ?? 0, e ?? 0)
703
+ hash[5] = add32(hash[5] ?? 0, f ?? 0)
704
+ hash[6] = add32(hash[6] ?? 0, g ?? 0)
705
+ hash[7] = add32(hash[7] ?? 0, h ?? 0)
706
+ }
707
+
708
+ const digest = new Uint8Array(32)
709
+ hash.forEach((word, index) => {
710
+ const offset = index * 4
711
+ digest[offset] = (word >>> 24) & 0xff
712
+ digest[offset + 1] = (word >>> 16) & 0xff
713
+ digest[offset + 2] = (word >>> 8) & 0xff
714
+ digest[offset + 3] = word & 0xff
715
+ })
716
+ return digest
717
+ }
718
+
719
+ function rotateRight(value: number, bits: number): number {
720
+ return (value >>> bits) | (value << (32 - bits))
721
+ }
722
+
723
+ function add32(...values: number[]): number {
724
+ return values.reduce((sum, value) => (sum + value) >>> 0, 0)
725
+ }
726
+
727
+ function bytesToBase64(bytes: Uint8Array): string {
728
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
729
+ let output = ''
730
+ for (let index = 0; index < bytes.length; index += 3) {
731
+ const first = bytes[index] ?? 0
732
+ const second = bytes[index + 1] ?? 0
733
+ const third = bytes[index + 2] ?? 0
734
+ const triple = (first << 16) | (second << 8) | third
735
+ output += alphabet[(triple >>> 18) & 0x3f]
736
+ output += alphabet[(triple >>> 12) & 0x3f]
737
+ output += index + 1 < bytes.length ? alphabet[(triple >>> 6) & 0x3f] : '='
738
+ output += index + 2 < bytes.length ? alphabet[triple & 0x3f] : '='
739
+ }
740
+ return output
741
+ }
742
+
173
743
  function checkMetadata(
174
744
  fixture: ConformanceFixture,
175
745
  htmlSignals: HtmlSignals,