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