@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/src/policy.ts
CHANGED
|
@@ -1,21 +1,80 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AstroConfig } from 'astro'
|
|
2
|
+
import {
|
|
3
|
+
type Audience,
|
|
4
|
+
type HeaderMap,
|
|
5
|
+
type InlineStyleAttributeInventory,
|
|
6
|
+
inlineStyleAttributeInventorySchema,
|
|
7
|
+
type RenderMode,
|
|
8
|
+
} from './schema.js'
|
|
2
9
|
|
|
3
10
|
export const REQUIRED_SECURITY_HEADERS = [
|
|
4
11
|
'content-security-policy',
|
|
5
12
|
'strict-transport-security',
|
|
13
|
+
'cross-origin-opener-policy',
|
|
14
|
+
'cross-origin-resource-policy',
|
|
6
15
|
'x-frame-options',
|
|
7
16
|
'x-content-type-options',
|
|
8
17
|
'referrer-policy',
|
|
9
18
|
'permissions-policy',
|
|
10
19
|
] as const
|
|
11
20
|
|
|
21
|
+
export type CrossOriginOpenerPolicy = 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none'
|
|
22
|
+
export type CrossOriginResourcePolicy = 'same-origin' | 'same-site' | 'cross-origin'
|
|
23
|
+
|
|
24
|
+
export interface StrictTransportSecurityOptions {
|
|
25
|
+
maxAgeSeconds?: number
|
|
26
|
+
includeSubDomains?: boolean
|
|
27
|
+
preload?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ContentSecurityPolicyOptions {
|
|
31
|
+
nonce?: string
|
|
32
|
+
reportUri?: string
|
|
33
|
+
reportTo?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
export interface HeaderPolicyOptions {
|
|
13
37
|
renderMode: RenderMode
|
|
14
38
|
audience: Audience
|
|
15
39
|
nonce?: string
|
|
40
|
+
cspReportUri?: string
|
|
41
|
+
cspReportTo?: string
|
|
42
|
+
contentSecurityPolicy?: false | ContentSecurityPolicyOptions
|
|
43
|
+
strictTransportSecurity?: StrictTransportSecurityOptions
|
|
44
|
+
crossOriginOpenerPolicy?: CrossOriginOpenerPolicy
|
|
45
|
+
crossOriginResourcePolicy?: CrossOriginResourcePolicy
|
|
16
46
|
markdownTwinUrl?: string
|
|
17
47
|
}
|
|
18
48
|
|
|
49
|
+
export interface AstroCspPolicyOptions {
|
|
50
|
+
scriptSources?: readonly string[]
|
|
51
|
+
styleSources?: readonly string[]
|
|
52
|
+
inlineStyleAttributes?: InlineStyleAttributeInventory
|
|
53
|
+
imgSources?: readonly string[]
|
|
54
|
+
fontSources?: readonly string[]
|
|
55
|
+
connectSources?: readonly string[]
|
|
56
|
+
mediaSources?: readonly string[]
|
|
57
|
+
frameSources?: readonly string[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type AstroNativeCspConfig = Exclude<
|
|
61
|
+
NonNullable<NonNullable<AstroConfig['security']>['csp']>,
|
|
62
|
+
boolean
|
|
63
|
+
>
|
|
64
|
+
|
|
65
|
+
type AstroCspDirective = NonNullable<AstroNativeCspConfig['directives']>[number]
|
|
66
|
+
|
|
67
|
+
export type AstroCspResource = NonNullable<
|
|
68
|
+
NonNullable<AstroNativeCspConfig['styleDirective']>['resources']
|
|
69
|
+
>[number]
|
|
70
|
+
|
|
71
|
+
export interface AstroCspConfig {
|
|
72
|
+
algorithm: 'SHA-256'
|
|
73
|
+
directives: AstroCspDirective[]
|
|
74
|
+
scriptDirective: { resources: string[] }
|
|
75
|
+
styleDirective: { resources: AstroCspResource[] }
|
|
76
|
+
}
|
|
77
|
+
|
|
19
78
|
export interface CachePolicyOptions {
|
|
20
79
|
renderMode: RenderMode
|
|
21
80
|
audience: Audience
|
|
@@ -25,8 +84,9 @@ export function buildSharedPublicationHeaders(
|
|
|
25
84
|
options: HeaderPolicyOptions,
|
|
26
85
|
): Record<string, string> {
|
|
27
86
|
const headers: Record<string, string> = {
|
|
28
|
-
'
|
|
29
|
-
'
|
|
87
|
+
'Strict-Transport-Security': buildStrictTransportSecurity(options.strictTransportSecurity),
|
|
88
|
+
'Cross-Origin-Opener-Policy': options.crossOriginOpenerPolicy ?? 'same-origin',
|
|
89
|
+
'Cross-Origin-Resource-Policy': options.crossOriginResourcePolicy ?? 'same-origin',
|
|
30
90
|
'X-Frame-Options': 'DENY',
|
|
31
91
|
'X-Content-Type-Options': 'nosniff',
|
|
32
92
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
@@ -34,6 +94,15 @@ export function buildSharedPublicationHeaders(
|
|
|
34
94
|
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), usb=()',
|
|
35
95
|
'Cache-Control': buildCacheControl(options),
|
|
36
96
|
}
|
|
97
|
+
if (options.contentSecurityPolicy !== false) {
|
|
98
|
+
headers['Content-Security-Policy'] = buildContentSecurityPolicy(
|
|
99
|
+
options.contentSecurityPolicy ?? {
|
|
100
|
+
nonce: options.nonce,
|
|
101
|
+
reportUri: options.cspReportUri,
|
|
102
|
+
reportTo: options.cspReportTo,
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
}
|
|
37
106
|
|
|
38
107
|
if (options.markdownTwinUrl) {
|
|
39
108
|
headers.Link = `<${options.markdownTwinUrl}>; rel="alternate"; type="text/markdown"`
|
|
@@ -42,15 +111,107 @@ export function buildSharedPublicationHeaders(
|
|
|
42
111
|
return headers
|
|
43
112
|
}
|
|
44
113
|
|
|
45
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Build framework-owned CSP configuration for Astro 7.1+.
|
|
116
|
+
* Astro hashes every script and style it emits, while this helper keeps the
|
|
117
|
+
* consumer's external source inventory exact and fail-closed.
|
|
118
|
+
*/
|
|
119
|
+
export function buildAstroCspConfig(options: AstroCspPolicyOptions = {}): AstroCspConfig {
|
|
120
|
+
const inlineStyleAttributes = options.inlineStyleAttributes
|
|
121
|
+
? inlineStyleAttributeInventorySchema.parse(options.inlineStyleAttributes)
|
|
122
|
+
: undefined
|
|
123
|
+
const scriptSources = exactCspSources(options.scriptSources)
|
|
124
|
+
const styleSources = exactCspSources(options.styleSources)
|
|
125
|
+
const imgSources = exactCspSources(options.imgSources)
|
|
126
|
+
const fontSources = exactCspSources(options.fontSources)
|
|
127
|
+
const connectSources = exactCspSources(options.connectSources)
|
|
128
|
+
const mediaSources = exactCspSources(options.mediaSources)
|
|
129
|
+
const frameSources = exactCspSources(options.frameSources)
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
algorithm: 'SHA-256',
|
|
133
|
+
directives: [
|
|
134
|
+
"default-src 'self'",
|
|
135
|
+
cspDirective('img-src', ["'self'", 'data:', 'blob:', ...imgSources]),
|
|
136
|
+
cspDirective('font-src', ["'self'", 'data:', ...fontSources]),
|
|
137
|
+
cspDirective('connect-src', ["'self'", ...connectSources]),
|
|
138
|
+
cspDirective('media-src', ["'self'", 'blob:', ...mediaSources]),
|
|
139
|
+
cspDirective('frame-src', ["'self'", ...frameSources]),
|
|
140
|
+
"object-src 'none'",
|
|
141
|
+
"base-uri 'self'",
|
|
142
|
+
"frame-ancestors 'none'",
|
|
143
|
+
"form-action 'self'",
|
|
144
|
+
'upgrade-insecure-requests',
|
|
145
|
+
],
|
|
146
|
+
scriptDirective: {
|
|
147
|
+
resources: uniqueCspSources(["'self'", ...scriptSources]),
|
|
148
|
+
},
|
|
149
|
+
styleDirective: {
|
|
150
|
+
resources: inlineStyleAttributes
|
|
151
|
+
? [
|
|
152
|
+
...uniqueCspSources(["'self'", ...styleSources]).map((resource) => ({
|
|
153
|
+
resource,
|
|
154
|
+
kind: 'element' as const,
|
|
155
|
+
})),
|
|
156
|
+
{ resource: "'unsafe-inline'", kind: 'attribute' as const },
|
|
157
|
+
]
|
|
158
|
+
: uniqueCspSources(["'self'", ...styleSources]),
|
|
159
|
+
},
|
|
160
|
+
} satisfies AstroNativeCspConfig
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function uniqueCspSources(sources: readonly string[]): string[] {
|
|
164
|
+
return [...new Set(sources)]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cspDirective(name: string, sources: readonly string[]): AstroCspDirective {
|
|
168
|
+
return `${name} ${[...new Set(sources)].join(' ')}` as AstroCspDirective
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function exactCspSources(sources: readonly string[] | undefined): string[] {
|
|
172
|
+
return [...new Set(sources ?? [])].map((source) => {
|
|
173
|
+
const normalized = source.trim()
|
|
174
|
+
if (normalized === "'self'") return normalized
|
|
175
|
+
if (
|
|
176
|
+
normalized === '' ||
|
|
177
|
+
normalized.includes('*') ||
|
|
178
|
+
normalized === 'https:' ||
|
|
179
|
+
normalized === 'http:' ||
|
|
180
|
+
normalized.startsWith("'unsafe-")
|
|
181
|
+
) {
|
|
182
|
+
throw new Error(`Astro CSP sources must be exact and cannot use ${source}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let parsed: URL
|
|
186
|
+
try {
|
|
187
|
+
parsed = new URL(normalized)
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error(`Astro CSP sources must be exact HTTPS/WSS origins: ${source}`)
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
(parsed.protocol !== 'https:' && parsed.protocol !== 'wss:') ||
|
|
193
|
+
parsed.username !== '' ||
|
|
194
|
+
parsed.password !== '' ||
|
|
195
|
+
parsed.search !== '' ||
|
|
196
|
+
parsed.hash !== '' ||
|
|
197
|
+
(parsed.pathname !== '' && parsed.pathname !== '/')
|
|
198
|
+
) {
|
|
199
|
+
throw new Error(`Astro CSP sources must be exact HTTPS/WSS origins: ${source}`)
|
|
200
|
+
}
|
|
201
|
+
return parsed.origin
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildContentSecurityPolicy(input?: string | ContentSecurityPolicyOptions): string {
|
|
206
|
+
const options = typeof input === 'string' ? { nonce: input } : (input ?? {})
|
|
46
207
|
const scriptSources = ["'self'"]
|
|
47
208
|
const styleSources = ["'self'"]
|
|
48
|
-
if (nonce) {
|
|
49
|
-
scriptSources.push(`'nonce-${nonce}'`)
|
|
50
|
-
styleSources.push(`'nonce-${nonce}'`)
|
|
209
|
+
if (options.nonce) {
|
|
210
|
+
scriptSources.push(`'nonce-${options.nonce}'`)
|
|
211
|
+
styleSources.push(`'nonce-${options.nonce}'`)
|
|
51
212
|
}
|
|
52
213
|
|
|
53
|
-
|
|
214
|
+
const directives = [
|
|
54
215
|
"default-src 'self'",
|
|
55
216
|
`script-src ${scriptSources.join(' ')}`,
|
|
56
217
|
`style-src ${styleSources.join(' ')}`,
|
|
@@ -61,7 +222,28 @@ export function buildContentSecurityPolicy(nonce?: string): string {
|
|
|
61
222
|
"base-uri 'self'",
|
|
62
223
|
"frame-ancestors 'none'",
|
|
63
224
|
'upgrade-insecure-requests',
|
|
64
|
-
]
|
|
225
|
+
]
|
|
226
|
+
if (options.reportTo) directives.push(`report-to ${options.reportTo}`)
|
|
227
|
+
if (options.reportUri) directives.push(`report-uri ${options.reportUri}`)
|
|
228
|
+
|
|
229
|
+
return directives.join('; ')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function buildStrictTransportSecurity(options: StrictTransportSecurityOptions = {}): string {
|
|
233
|
+
const maxAgeSeconds = options.maxAgeSeconds ?? 31536000
|
|
234
|
+
if (!Number.isInteger(maxAgeSeconds) || maxAgeSeconds < 31536000) {
|
|
235
|
+
throw new Error('Strict-Transport-Security maxAgeSeconds must be at least 31536000')
|
|
236
|
+
}
|
|
237
|
+
if (options.preload === true && options.includeSubDomains !== true) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
'Strict-Transport-Security preload requires includeSubDomains after host inventory proof',
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const directives = [`max-age=${maxAgeSeconds}`]
|
|
244
|
+
if (options.includeSubDomains === true) directives.push('includeSubDomains')
|
|
245
|
+
if (options.preload === true) directives.push('preload')
|
|
246
|
+
return directives.join('; ')
|
|
65
247
|
}
|
|
66
248
|
|
|
67
249
|
export function buildCacheControl({ renderMode, audience }: CachePolicyOptions): string {
|
|
@@ -73,14 +255,23 @@ export function buildCacheControl({ renderMode, audience }: CachePolicyOptions):
|
|
|
73
255
|
}
|
|
74
256
|
|
|
75
257
|
export function normalizeHeaders(headers: HeaderMap | undefined): Record<string, string> {
|
|
258
|
+
const entries = collectHeaders(headers)
|
|
76
259
|
const normalized: Record<string, string> = {}
|
|
77
|
-
|
|
260
|
+
for (const [name, values] of Object.entries(entries)) {
|
|
261
|
+
normalized[name] = values.join(', ')
|
|
262
|
+
}
|
|
263
|
+
return normalized
|
|
264
|
+
}
|
|
78
265
|
|
|
266
|
+
export function collectHeaders(headers: HeaderMap | undefined): Record<string, string[]> {
|
|
267
|
+
const normalized: Record<string, string[]> = {}
|
|
268
|
+
if (!headers) return normalized
|
|
79
269
|
for (const [name, value] of Object.entries(headers)) {
|
|
270
|
+
const normalizedName = name.toLowerCase()
|
|
80
271
|
if (typeof value === 'string') {
|
|
81
|
-
normalized[
|
|
272
|
+
normalized[normalizedName] = [...(normalized[normalizedName] ?? []), value]
|
|
82
273
|
} else if (Array.isArray(value)) {
|
|
83
|
-
normalized[
|
|
274
|
+
normalized[normalizedName] = [...(normalized[normalizedName] ?? []), ...value]
|
|
84
275
|
}
|
|
85
276
|
}
|
|
86
277
|
|