@growth-labs/conformance 0.1.0 → 0.3.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,32 @@ 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 requiredSecurityHeaders = REQUIRED_SECURITY_HEADERS.filter(
117
+ (name) => name !== 'x-frame-options' || fixture.expected.framePolicy.legacyHeader !== 'omit',
118
+ )
119
+ const missingSecurityHeaders = requiredSecurityHeaders.filter((name) => {
120
+ const values = headerEntries[name] ?? []
121
+ return !values.some((value) => value.trim().length > 0)
122
+ })
123
+
124
+ for (const header of REQUIRED_SECURITY_HEADERS) {
125
+ const values = headerEntries[header] ?? []
126
+ const distinctValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))]
127
+ if (distinctValues.length > 1) {
128
+ findings.push({
129
+ code: 'security.header.conflicting_values',
130
+ severity: 'error',
131
+ message: `Required security header ${header} has conflicting duplicate values.`,
132
+ path: `response.headers.${header}`,
133
+ evidence: JSON.stringify(distinctValues),
134
+ remediation: 'Emit one authoritative value for each required security header per response.',
135
+ })
136
+ }
137
+ }
90
138
 
91
139
  if (
92
140
  fixture.renderMode === 'ssr' &&
@@ -116,14 +164,54 @@ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckRece
116
164
  }
117
165
 
118
166
  const csp = headers['content-security-policy']
119
- if (csp?.toLowerCase().includes('unsafe-inline')) {
167
+ if (csp) findings.push(...validateContentSecurityPolicy(csp, html, fixture))
168
+
169
+ const xFrameOptions = headers['x-frame-options']
170
+ const expectedLegacyFrameHeader = fixture.expected.framePolicy.legacyHeader
171
+ if (expectedLegacyFrameHeader === 'omit' && xFrameOptions) {
172
+ findings.push({
173
+ code: 'security.frame_legacy_conflict',
174
+ severity: 'error',
175
+ message: 'X-Frame-Options conflicts with the declared embeddable frame-ancestors policy.',
176
+ path: 'response.headers.x-frame-options',
177
+ evidence: xFrameOptions,
178
+ remediation:
179
+ 'Omit the legacy header only on the explicitly declared embeddable route and retain the exact CSP frame-ancestors directive.',
180
+ })
181
+ } else if (
182
+ expectedLegacyFrameHeader !== 'omit' &&
183
+ xFrameOptions &&
184
+ xFrameOptions.toUpperCase() !== expectedLegacyFrameHeader
185
+ ) {
186
+ findings.push({
187
+ code: 'security.header.invalid',
188
+ severity: 'error',
189
+ message: `X-Frame-Options must be ${expectedLegacyFrameHeader} for this response class.`,
190
+ path: 'response.headers.x-frame-options',
191
+ evidence: xFrameOptions,
192
+ })
193
+ }
194
+
195
+ const coop = headers['cross-origin-opener-policy']
196
+ if (coop && !VALID_COOP_VALUES.has(coop.toLowerCase())) {
197
+ findings.push({
198
+ code: 'security.header.invalid',
199
+ severity: 'error',
200
+ message:
201
+ 'Cross-Origin-Opener-Policy must be same-origin, same-origin-allow-popups, or unsafe-none.',
202
+ path: 'response.headers.cross-origin-opener-policy',
203
+ evidence: coop,
204
+ })
205
+ }
206
+
207
+ const corp = headers['cross-origin-resource-policy']
208
+ if (corp && !VALID_CORP_VALUES.has(corp.toLowerCase())) {
120
209
  findings.push({
121
- code: 'security.csp.unsafe_inline',
210
+ code: 'security.header.invalid',
122
211
  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.',
212
+ message: 'Cross-Origin-Resource-Policy must be same-origin, same-site, or cross-origin.',
213
+ path: 'response.headers.cross-origin-resource-policy',
214
+ evidence: corp,
127
215
  })
128
216
  }
129
217
 
@@ -139,13 +227,49 @@ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckRece
139
227
  }
140
228
 
141
229
  const hsts = headers['strict-transport-security']
142
- if (hsts && !/max-age=\d+/i.test(hsts)) {
230
+ if (hsts) {
231
+ const hstsDirectives = parseDirectivePairs(hsts)
232
+ const maxAge = parseHstsMaxAge(hstsDirectives)
233
+ if (maxAge === null) {
234
+ findings.push({
235
+ code: 'security.header.invalid',
236
+ severity: 'error',
237
+ message: 'Strict-Transport-Security must declare max-age.',
238
+ path: 'response.headers.strict-transport-security',
239
+ evidence: hsts,
240
+ })
241
+ } else if (maxAge < MIN_HSTS_MAX_AGE_SECONDS) {
242
+ findings.push({
243
+ code: 'security.hsts.max_age_too_low',
244
+ severity: 'error',
245
+ message: 'Strict-Transport-Security max-age must be at least 31536000 seconds.',
246
+ path: 'response.headers.strict-transport-security',
247
+ evidence: `max-age=${maxAge}`,
248
+ remediation:
249
+ 'Use at least one year of HSTS. Add includeSubDomains/preload only after host inventory proof.',
250
+ })
251
+ }
252
+ if (hstsDirectives.has('preload') && !hstsDirectives.has('includesubdomains')) {
253
+ findings.push({
254
+ code: 'security.header.invalid',
255
+ severity: 'error',
256
+ message: 'Strict-Transport-Security preload requires includeSubDomains.',
257
+ path: 'response.headers.strict-transport-security',
258
+ evidence: hsts,
259
+ remediation:
260
+ 'Only add includeSubDomains/preload after the consumer has verified all subdomains can enforce HTTPS.',
261
+ })
262
+ }
263
+ }
264
+
265
+ const reportOnlyCsp = headers['content-security-policy-report-only']
266
+ if (reportOnlyCsp && !csp) {
143
267
  findings.push({
144
268
  code: 'security.header.invalid',
145
269
  severity: 'error',
146
- message: 'Strict-Transport-Security must declare max-age.',
147
- path: 'response.headers.strict-transport-security',
148
- evidence: hsts,
270
+ message: 'Report-only CSP cannot replace enforced Content-Security-Policy.',
271
+ path: 'response.headers.content-security-policy-report-only',
272
+ evidence: reportOnlyCsp,
149
273
  })
150
274
  }
151
275
 
@@ -170,6 +294,541 @@ function checkSecurityHeaders(fixture: ConformanceFixture): ConformanceCheckRece
170
294
  return receipt('security-headers', findings)
171
295
  }
172
296
 
297
+ function checkSecurityReceipts(fixture: ConformanceFixture): ConformanceCheckReceipt {
298
+ const findings: ConformanceFinding[] = []
299
+ const probes = fixture.securityReceipts.functionalProbes
300
+ const probesByName = new Map(probes.map((probe) => [probe.name, probe]))
301
+
302
+ for (const required of REQUIRED_FUNCTIONAL_PROBE_NAMES) {
303
+ if (!probesByName.has(required)) {
304
+ findings.push({
305
+ code: 'security.functional_probe.missing',
306
+ severity: 'error',
307
+ message: `Missing enforced-CSP functional probe receipt: ${required}.`,
308
+ path: `securityReceipts.functionalProbes.${required}`,
309
+ remediation:
310
+ 'Provide a consumer-owned pass or documented NA receipt for every required functional surface.',
311
+ })
312
+ }
313
+ }
314
+
315
+ probes.forEach((probe, index) => {
316
+ const path = `securityReceipts.functionalProbes[${index}]`
317
+ if (probe.status === 'fail') {
318
+ findings.push({
319
+ code: 'security.functional_probe.failed',
320
+ severity: 'error',
321
+ message: `Functional probe ${probe.name} failed under enforced CSP.`,
322
+ path,
323
+ evidence: probe.evidence,
324
+ remediation:
325
+ 'Fix the consumer CSP/header contract before treating conformance evidence as passing.',
326
+ })
327
+ }
328
+ if (probe.status === 'pass' && !probe.evidence) {
329
+ findings.push({
330
+ code: 'security.functional_probe.evidence_missing',
331
+ severity: 'error',
332
+ message: `Functional probe ${probe.name} passed without explicit evidence.`,
333
+ path: `${path}.evidence`,
334
+ remediation: 'Attach a sanitized consumer-owned receipt for the successful probe.',
335
+ })
336
+ }
337
+ if (probe.status === 'na' && (!probe.capabilityBoundary || !probe.evidence)) {
338
+ findings.push({
339
+ code: 'security.functional_probe.na_undeclared',
340
+ severity: 'error',
341
+ message: `Functional probe ${probe.name} is NA without boundary and evidence.`,
342
+ path,
343
+ remediation:
344
+ 'Declare why the surface is out of scope and attach sanitized supporting evidence.',
345
+ })
346
+ }
347
+ })
348
+
349
+ const cspReports = fixture.securityReceipts.cspReports
350
+ if (cspReports.length === 0) {
351
+ findings.push({
352
+ code: 'security.csp_report.missing',
353
+ severity: 'error',
354
+ message: 'Missing CSP reporting or canary receipt for the enforced policy.',
355
+ path: 'securityReceipts.cspReports',
356
+ remediation:
357
+ 'Provide a consumer-owned CSP report endpoint or blocked-canary receipt proving reporting works.',
358
+ })
359
+ }
360
+
361
+ let passingCspReports = 0
362
+ cspReports.forEach((report, index) => {
363
+ const path = `securityReceipts.cspReports[${index}]`
364
+ if (report.status === 'pass') {
365
+ passingCspReports += 1
366
+ if (!report.evidence) {
367
+ findings.push({
368
+ code: 'security.csp_report.evidence_missing',
369
+ severity: 'error',
370
+ message: `CSP report receipt ${report.name} passed without explicit evidence.`,
371
+ path: `${path}.evidence`,
372
+ })
373
+ }
374
+ }
375
+ if (report.status === 'fail') {
376
+ findings.push({
377
+ code: 'security.csp_report.failed',
378
+ severity: 'error',
379
+ message: `CSP report receipt ${report.name} failed.`,
380
+ path,
381
+ evidence: report.evidence,
382
+ remediation:
383
+ 'Fix the consumer CSP reporting path before treating conformance evidence as passing.',
384
+ })
385
+ }
386
+ if (report.status === 'na' && (!report.capabilityBoundary || !report.evidence)) {
387
+ findings.push({
388
+ code: 'security.csp_report.na_undeclared',
389
+ severity: 'error',
390
+ message: `CSP report receipt ${report.name} is NA without boundary and evidence.`,
391
+ path,
392
+ })
393
+ }
394
+ })
395
+ if (cspReports.length > 0 && passingCspReports === 0) {
396
+ findings.push({
397
+ code: 'security.csp_report.missing',
398
+ severity: 'error',
399
+ message: 'At least one CSP reporting or canary receipt must pass.',
400
+ path: 'securityReceipts.cspReports',
401
+ })
402
+ }
403
+
404
+ return receipt('security-receipts', findings)
405
+ }
406
+
407
+ interface CspDirective {
408
+ name: string
409
+ sources: string[]
410
+ raw: string
411
+ }
412
+
413
+ interface InlineExecutableScript {
414
+ content: string
415
+ nonce?: string
416
+ path: string
417
+ }
418
+
419
+ function validateContentSecurityPolicy(
420
+ csp: string,
421
+ html: string,
422
+ fixture: ConformanceFixture,
423
+ ): ConformanceFinding[] {
424
+ const findings: ConformanceFinding[] = []
425
+ const { directives, findings: parseFindings } = parseContentSecurityPolicy(csp)
426
+ findings.push(...parseFindings)
427
+ const frameAncestors = directives.get('frame-ancestors')
428
+ const expectedFrameAncestors = fixture.expected.framePolicy.ancestors
429
+ if (!frameAncestors) {
430
+ findings.push({
431
+ code: 'security.csp.frame_ancestors_missing',
432
+ severity: 'error',
433
+ message: 'Content-Security-Policy must declare frame-ancestors.',
434
+ path: 'response.headers.content-security-policy.frame-ancestors',
435
+ })
436
+ } else if (
437
+ normalizeCspSources(frameAncestors.sources) !== normalizeCspSources(expectedFrameAncestors)
438
+ ) {
439
+ findings.push({
440
+ code: 'security.csp.frame_ancestors_mismatch',
441
+ severity: 'error',
442
+ message:
443
+ 'Content-Security-Policy frame-ancestors does not match the declared response class.',
444
+ path: 'response.headers.content-security-policy.frame-ancestors',
445
+ evidence: frameAncestors.raw,
446
+ remediation:
447
+ 'Emit only the exact declared frame ancestors and keep embeddable routes explicitly bounded.',
448
+ })
449
+ }
450
+
451
+ const defaultSources = lowerSources(directives.get('default-src')?.sources ?? [])
452
+ if (defaultSources.includes('*')) {
453
+ findings.push({
454
+ code: 'security.csp.default_src_wildcard',
455
+ severity: 'error',
456
+ message: 'Content-Security-Policy default-src must not allow wildcard sources.',
457
+ path: 'response.headers.content-security-policy.default-src',
458
+ evidence: directives.get('default-src')?.raw,
459
+ remediation:
460
+ 'Scope default-src to self or narrower origins, then add explicit directives for required external surfaces.',
461
+ })
462
+ }
463
+
464
+ for (const directiveName of EXECUTABLE_CSP_DIRECTIVES) {
465
+ const directive = directives.get(directiveName)
466
+ if (!directive) continue
467
+ if (lowerSources(directive.sources).includes("'unsafe-eval'")) {
468
+ findings.push({
469
+ code: 'security.csp.unsafe_eval',
470
+ severity: 'error',
471
+ message: 'Content-Security-Policy must not allow unsafe-eval.',
472
+ path: `response.headers.content-security-policy.${directiveName}`,
473
+ evidence: directive.raw,
474
+ remediation:
475
+ 'Remove unsafe-eval and replace any dependent runtime with nonce/hash-compatible script execution.',
476
+ })
477
+ }
478
+ }
479
+
480
+ for (const directiveName of INLINE_CSP_DIRECTIVES) {
481
+ const directive = directives.get(directiveName)
482
+ if (!directive) continue
483
+ if (lowerSources(directive.sources).includes("'unsafe-inline'")) {
484
+ if (directiveName === 'style-src-attr') {
485
+ const inventory = fixture.securityReceipts.inlineStyleAttributes
486
+ if (!inventory) {
487
+ findings.push({
488
+ code: 'security.csp.style_attr_inline_inventory_missing',
489
+ severity: 'error',
490
+ message:
491
+ "style-src-attr 'unsafe-inline' requires a commit-bound inline-style-attribute inventory receipt.",
492
+ path: 'securityReceipts.inlineStyleAttributes',
493
+ evidence: directive.raw,
494
+ remediation:
495
+ 'Remove the allowance or attach the exact consumer commit SHA, positive occurrence count, and scan evidence.',
496
+ })
497
+ continue
498
+ }
499
+
500
+ if (
501
+ !fixture.buildEvidence?.commitSha ||
502
+ fixture.buildEvidence.commitSha !== inventory.commitSha
503
+ ) {
504
+ findings.push({
505
+ code: 'security.csp.style_attr_inline_inventory_stale',
506
+ severity: 'error',
507
+ message:
508
+ 'The inline-style-attribute inventory must match the exact commit in build evidence.',
509
+ path: 'securityReceipts.inlineStyleAttributes.commitSha',
510
+ evidence: `inventory=${inventory.commitSha}; build=${fixture.buildEvidence?.commitSha ?? 'missing'}`,
511
+ remediation:
512
+ 'Rescan the exact build commit before retaining the style attribute allowance.',
513
+ })
514
+ }
515
+ continue
516
+ }
517
+ findings.push({
518
+ code: 'security.csp.unsafe_inline',
519
+ severity: 'error',
520
+ message: 'Content-Security-Policy must not allow unsafe-inline script or style execution.',
521
+ path: `response.headers.content-security-policy.${directiveName}`,
522
+ evidence: directive.raw,
523
+ remediation: 'Use nonces or hashes in the shared CSP policy instead of unsafe-inline.',
524
+ })
525
+ }
526
+ }
527
+
528
+ const scriptSources = getEffectiveScriptSources(directives)
529
+ for (const script of extractInlineExecutableScripts(html)) {
530
+ if (inlineExecutableScriptAllowed(script, scriptSources)) continue
531
+ findings.push({
532
+ code: 'security.csp.inline_executable_without_nonce_or_hash',
533
+ severity: 'error',
534
+ message: 'Executable inline script lacks a CSP source matching its nonce or SHA-256 hash.',
535
+ path: script.path,
536
+ remediation:
537
+ 'Add a runtime nonce to the script and CSP, or add the exact SHA-256 hash source for the inline script body.',
538
+ })
539
+ }
540
+
541
+ return findings
542
+ }
543
+
544
+ function parseContentSecurityPolicy(csp: string): {
545
+ directives: Map<string, CspDirective>
546
+ findings: ConformanceFinding[]
547
+ } {
548
+ const directives = new Map<string, CspDirective>()
549
+ const findings: ConformanceFinding[] = []
550
+
551
+ csp.split(';').forEach((rawDirective, index) => {
552
+ const trimmed = rawDirective.trim()
553
+ if (!trimmed) return
554
+ const tokens = trimmed.split(/\s+/)
555
+ const name = tokens[0]?.toLowerCase() ?? ''
556
+ const sources = tokens.slice(1)
557
+
558
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
559
+ findings.push({
560
+ code: 'security.csp.malformed',
561
+ severity: 'error',
562
+ message: 'Content-Security-Policy contains a malformed directive name.',
563
+ path: `response.headers.content-security-policy[${index}]`,
564
+ evidence: trimmed,
565
+ })
566
+ return
567
+ }
568
+
569
+ if (directiveRequiresSources(name) && sources.length === 0) {
570
+ findings.push({
571
+ code: 'security.csp.malformed',
572
+ severity: 'error',
573
+ message: `Content-Security-Policy directive ${name} is missing source values.`,
574
+ path: `response.headers.content-security-policy.${name}`,
575
+ evidence: trimmed,
576
+ })
577
+ }
578
+
579
+ const existing = directives.get(name)
580
+ if (existing) {
581
+ if (normalizeCspSources(existing.sources) !== normalizeCspSources(sources)) {
582
+ findings.push({
583
+ code: 'security.csp.directive_conflict',
584
+ severity: 'error',
585
+ message: `Content-Security-Policy contains conflicting duplicate ${name} directives.`,
586
+ path: `response.headers.content-security-policy.${name}`,
587
+ evidence: `${existing.raw} | ${trimmed}`,
588
+ remediation: 'Emit one directive per CSP directive name with the complete source list.',
589
+ })
590
+ }
591
+ return
592
+ }
593
+
594
+ directives.set(name, { name, sources, raw: trimmed })
595
+ })
596
+
597
+ return { directives, findings }
598
+ }
599
+
600
+ function directiveRequiresSources(name: string): boolean {
601
+ return name === 'default-src' || name.endsWith('-src') || name.endsWith('-src-elem')
602
+ }
603
+
604
+ function normalizeCspSources(sources: string[]): string {
605
+ return lowerSources(sources).sort().join(' ')
606
+ }
607
+
608
+ function lowerSources(sources: string[]): string[] {
609
+ return sources.map((source) => source.toLowerCase())
610
+ }
611
+
612
+ function getEffectiveScriptSources(directives: Map<string, CspDirective>): string[] {
613
+ return (
614
+ directives.get('script-src-elem')?.sources ??
615
+ directives.get('script-src')?.sources ??
616
+ directives.get('default-src')?.sources ??
617
+ []
618
+ )
619
+ }
620
+
621
+ function inlineExecutableScriptAllowed(script: InlineExecutableScript, sources: string[]): boolean {
622
+ if (script.nonce && sources.includes(`'nonce-${script.nonce}'`)) return true
623
+ return sources.some((source) => hashSourceMatchesInlineScript(source, script.content))
624
+ }
625
+
626
+ function hashSourceMatchesInlineScript(source: string, content: string): boolean {
627
+ const match = /^'(sha256|sha384|sha512)-([^']+)'$/.exec(source)
628
+ if (!match) return false
629
+ const algorithm = match[1]?.toLowerCase()
630
+ const expected = match[2]
631
+ return algorithm === 'sha256' && expected === sha256Base64(content)
632
+ }
633
+
634
+ function extractInlineExecutableScripts(html: string): InlineExecutableScript[] {
635
+ const scripts: InlineExecutableScript[] = []
636
+ const scriptPattern = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi
637
+ let match = scriptPattern.exec(html)
638
+ let scriptIndex = 0
639
+ while (match !== null) {
640
+ const attributes = parseHtmlAttributes(match[1] ?? '')
641
+ const content = match[2] ?? ''
642
+ if (
643
+ !attributes.has('src') &&
644
+ isExecutableScriptType(attributes.get('type')) &&
645
+ content.trim().length > 0
646
+ ) {
647
+ const nonce = attributes.get('nonce')
648
+ scripts.push({
649
+ content,
650
+ nonce: nonce && nonce.length > 0 ? nonce : undefined,
651
+ path: `html.script[${scriptIndex}]`,
652
+ })
653
+ }
654
+ scriptIndex += 1
655
+ match = scriptPattern.exec(html)
656
+ }
657
+
658
+ return scripts
659
+ }
660
+
661
+ function parseHtmlAttributes(rawAttributes: string): Map<string, string> {
662
+ const attributes = new Map<string, string>()
663
+ const attributePattern = /([^\s"'=<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>]+)))?/g
664
+ let match = attributePattern.exec(rawAttributes)
665
+ while (match !== null) {
666
+ const name = match[1]?.toLowerCase()
667
+ if (!name) continue
668
+ attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '')
669
+ match = attributePattern.exec(rawAttributes)
670
+ }
671
+ return attributes
672
+ }
673
+
674
+ function isExecutableScriptType(type: string | undefined): boolean {
675
+ if (!type) return true
676
+ const normalized = type.split(';', 1)[0]?.trim().toLowerCase()
677
+ return (
678
+ normalized === '' ||
679
+ normalized === 'module' ||
680
+ normalized === 'text/javascript' ||
681
+ normalized === 'application/javascript' ||
682
+ normalized === 'text/ecmascript' ||
683
+ normalized === 'application/ecmascript' ||
684
+ normalized === 'text/jscript' ||
685
+ normalized === 'application/x-javascript'
686
+ )
687
+ }
688
+
689
+ function parseDirectivePairs(value: string): Map<string, string | true> {
690
+ const directives = new Map<string, string | true>()
691
+ for (const part of value.split(';')) {
692
+ const trimmed = part.trim()
693
+ if (!trimmed) continue
694
+ const [rawName, ...rawValue] = trimmed.split('=')
695
+ const name = rawName?.trim().toLowerCase()
696
+ if (!name) continue
697
+ const directiveValue = rawValue.length > 0 ? rawValue.join('=').trim() : true
698
+ directives.set(name, directiveValue)
699
+ }
700
+ return directives
701
+ }
702
+
703
+ function parseHstsMaxAge(directives: Map<string, string | true>): number | null {
704
+ const value = directives.get('max-age')
705
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) return null
706
+ return Number(value)
707
+ }
708
+
709
+ const SHA256_INITIAL_HASH = [
710
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
711
+ ] as const
712
+
713
+ const SHA256_ROUND_CONSTANTS = [
714
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
715
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
716
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
717
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
718
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
719
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
720
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
721
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
722
+ ] as const
723
+
724
+ function sha256Base64(input: string): string {
725
+ return bytesToBase64(sha256(new TextEncoder().encode(input)))
726
+ }
727
+
728
+ function sha256(message: Uint8Array): Uint8Array {
729
+ const bitLength = message.length * 8
730
+ const paddedLength = Math.ceil((message.length + 9) / 64) * 64
731
+ const padded = new Uint8Array(paddedLength)
732
+ padded.set(message)
733
+ padded[message.length] = 0x80
734
+ const bitLengthHigh = Math.floor(bitLength / 0x100000000)
735
+ const bitLengthLow = bitLength >>> 0
736
+ padded[paddedLength - 8] = (bitLengthHigh >>> 24) & 0xff
737
+ padded[paddedLength - 7] = (bitLengthHigh >>> 16) & 0xff
738
+ padded[paddedLength - 6] = (bitLengthHigh >>> 8) & 0xff
739
+ padded[paddedLength - 5] = bitLengthHigh & 0xff
740
+ padded[paddedLength - 4] = (bitLengthLow >>> 24) & 0xff
741
+ padded[paddedLength - 3] = (bitLengthLow >>> 16) & 0xff
742
+ padded[paddedLength - 2] = (bitLengthLow >>> 8) & 0xff
743
+ padded[paddedLength - 1] = bitLengthLow & 0xff
744
+
745
+ const hash: number[] = [...SHA256_INITIAL_HASH]
746
+ const words = new Array<number>(64).fill(0)
747
+ for (let chunkOffset = 0; chunkOffset < padded.length; chunkOffset += 64) {
748
+ for (let i = 0; i < 16; i += 1) {
749
+ const offset = chunkOffset + i * 4
750
+ words[i] =
751
+ (((padded[offset] ?? 0) << 24) |
752
+ ((padded[offset + 1] ?? 0) << 16) |
753
+ ((padded[offset + 2] ?? 0) << 8) |
754
+ (padded[offset + 3] ?? 0)) >>>
755
+ 0
756
+ }
757
+ for (let i = 16; i < 64; i += 1) {
758
+ const s0 =
759
+ rotateRight(words[i - 15] ?? 0, 7) ^
760
+ rotateRight(words[i - 15] ?? 0, 18) ^
761
+ ((words[i - 15] ?? 0) >>> 3)
762
+ const s1 =
763
+ rotateRight(words[i - 2] ?? 0, 17) ^
764
+ rotateRight(words[i - 2] ?? 0, 19) ^
765
+ ((words[i - 2] ?? 0) >>> 10)
766
+ words[i] = add32(words[i - 16] ?? 0, s0, words[i - 7] ?? 0, s1)
767
+ }
768
+
769
+ let [a, b, c, d, e, f, g, h] = hash
770
+ for (let i = 0; i < 64; i += 1) {
771
+ const sum1 = rotateRight(e ?? 0, 6) ^ rotateRight(e ?? 0, 11) ^ rotateRight(e ?? 0, 25)
772
+ const choice = ((e ?? 0) & (f ?? 0)) ^ (~(e ?? 0) & (g ?? 0))
773
+ const temp1 = add32(h ?? 0, sum1, choice, SHA256_ROUND_CONSTANTS[i] ?? 0, words[i] ?? 0)
774
+ const sum0 = rotateRight(a ?? 0, 2) ^ rotateRight(a ?? 0, 13) ^ rotateRight(a ?? 0, 22)
775
+ const majority = ((a ?? 0) & (b ?? 0)) ^ ((a ?? 0) & (c ?? 0)) ^ ((b ?? 0) & (c ?? 0))
776
+ const temp2 = add32(sum0, majority)
777
+ h = g
778
+ g = f
779
+ f = e
780
+ e = add32(d ?? 0, temp1)
781
+ d = c
782
+ c = b
783
+ b = a
784
+ a = add32(temp1, temp2)
785
+ }
786
+
787
+ hash[0] = add32(hash[0] ?? 0, a ?? 0)
788
+ hash[1] = add32(hash[1] ?? 0, b ?? 0)
789
+ hash[2] = add32(hash[2] ?? 0, c ?? 0)
790
+ hash[3] = add32(hash[3] ?? 0, d ?? 0)
791
+ hash[4] = add32(hash[4] ?? 0, e ?? 0)
792
+ hash[5] = add32(hash[5] ?? 0, f ?? 0)
793
+ hash[6] = add32(hash[6] ?? 0, g ?? 0)
794
+ hash[7] = add32(hash[7] ?? 0, h ?? 0)
795
+ }
796
+
797
+ const digest = new Uint8Array(32)
798
+ hash.forEach((word, index) => {
799
+ const offset = index * 4
800
+ digest[offset] = (word >>> 24) & 0xff
801
+ digest[offset + 1] = (word >>> 16) & 0xff
802
+ digest[offset + 2] = (word >>> 8) & 0xff
803
+ digest[offset + 3] = word & 0xff
804
+ })
805
+ return digest
806
+ }
807
+
808
+ function rotateRight(value: number, bits: number): number {
809
+ return (value >>> bits) | (value << (32 - bits))
810
+ }
811
+
812
+ function add32(...values: number[]): number {
813
+ return values.reduce((sum, value) => (sum + value) >>> 0, 0)
814
+ }
815
+
816
+ function bytesToBase64(bytes: Uint8Array): string {
817
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
818
+ let output = ''
819
+ for (let index = 0; index < bytes.length; index += 3) {
820
+ const first = bytes[index] ?? 0
821
+ const second = bytes[index + 1] ?? 0
822
+ const third = bytes[index + 2] ?? 0
823
+ const triple = (first << 16) | (second << 8) | third
824
+ output += alphabet[(triple >>> 18) & 0x3f]
825
+ output += alphabet[(triple >>> 12) & 0x3f]
826
+ output += index + 1 < bytes.length ? alphabet[(triple >>> 6) & 0x3f] : '='
827
+ output += index + 2 < bytes.length ? alphabet[triple & 0x3f] : '='
828
+ }
829
+ return output
830
+ }
831
+
173
832
  function checkMetadata(
174
833
  fixture: ConformanceFixture,
175
834
  htmlSignals: HtmlSignals,