@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/schema.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import { z } from 'zod'
2
2
 
3
- export const EVIDENCE_SCHEMA_VERSION = 'growth-labs.conformance.v1'
3
+ export const EVIDENCE_SCHEMA_VERSION = 'growth-labs.conformance.v2'
4
+ export const REQUIRED_FUNCTIONAL_PROBE_NAMES = [
5
+ 'oauth',
6
+ 'media',
7
+ 'images',
8
+ 'analytics',
9
+ 'embeds',
10
+ 'error-pages',
11
+ ] as const
4
12
 
5
13
  export const REQUIRED_NEGATIVE_FIXTURE_CODES = {
6
14
  'csp-unsafe-inline': 'security.csp.unsafe_inline',
15
+ 'missing-coop': 'security.header.missing',
16
+ 'missing-corp': 'security.header.missing',
17
+ 'hsts-below-one-year': 'security.hsts.max_age_too_low',
18
+ 'csp-wildcard-default-src': 'security.csp.default_src_wildcard',
19
+ 'csp-unsafe-eval': 'security.csp.unsafe_eval',
20
+ 'csp-inline-executable-without-nonce-or-hash':
21
+ 'security.csp.inline_executable_without_nonce_or_hash',
22
+ 'csp-style-attr-inline-without-inventory': 'security.csp.style_attr_inline_inventory_missing',
23
+ 'conflicting-duplicate-security-header': 'security.header.conflicting_values',
24
+ 'missing-functional-probe-receipt': 'security.functional_probe.missing',
25
+ 'missing-csp-report-receipt': 'security.csp_report.missing',
26
+ 'failed-csp-report-receipt': 'security.csp_report.failed',
7
27
  'headers-static-only-while-ssr-missing': 'security.headers.static_only_for_ssr',
8
28
  'undeclared-na': 'publication.na.undeclared',
9
29
  'stringified-is-accessible-for-free': 'structured_data.is_accessible_for_free.stringified',
@@ -62,6 +82,40 @@ export const cacheVariantSchema = z.object({
62
82
  headers: headerMapSchema.default({}),
63
83
  })
64
84
 
85
+ export const securityFunctionalProbeReceiptSchema = z.object({
86
+ name: z.string().min(1),
87
+ status: checkStatusSchema,
88
+ checkedAt: z.string().min(1).optional(),
89
+ evidence: z.string().min(1).optional(),
90
+ capabilityBoundary: z.string().min(1).optional(),
91
+ })
92
+
93
+ export const cspReportReceiptSchema = z.object({
94
+ name: z.string().min(1),
95
+ status: checkStatusSchema,
96
+ checkedAt: z.string().min(1).optional(),
97
+ evidence: z.string().min(1).optional(),
98
+ endpoint: z.string().min(1).optional(),
99
+ capabilityBoundary: z.string().min(1).optional(),
100
+ })
101
+
102
+ export const inlineStyleAttributeInventorySchema = z.object({
103
+ commitSha: z.string().regex(/^[0-9a-f]{40}$/),
104
+ occurrenceCount: z.number().int().positive(),
105
+ evidence: z.object({
106
+ tool: z.string().regex(/^[a-z0-9][a-z0-9:@/._-]*$/i),
107
+ outputHash: z.string().regex(/^sha256:[0-9a-f]{64}$/),
108
+ }),
109
+ })
110
+
111
+ export const securityReceiptsSchema = z
112
+ .object({
113
+ functionalProbes: z.array(securityFunctionalProbeReceiptSchema).default([]),
114
+ cspReports: z.array(cspReportReceiptSchema).default([]),
115
+ inlineStyleAttributes: inlineStyleAttributeInventorySchema.optional(),
116
+ })
117
+ .default({ functionalProbes: [], cspReports: [] })
118
+
65
119
  export const buildEvidenceSchema = z.object({
66
120
  commitSha: z.string().min(7).optional(),
67
121
  artifactId: z.string().min(1).optional(),
@@ -71,10 +125,81 @@ export const buildEvidenceSchema = z.object({
71
125
  ssrHeaderPolicy: z.boolean().default(false),
72
126
  })
73
127
 
128
+ const frameAncestorSourceSchema = z.string().superRefine((source, context) => {
129
+ if (source === "'none'" || source === "'self'" || source === 'https:') return
130
+ let parsed: URL
131
+ try {
132
+ parsed = new URL(source)
133
+ } catch {
134
+ context.addIssue({
135
+ code: z.ZodIssueCode.custom,
136
+ message: "Expected 'none', 'self', https:, or an exact HTTPS origin.",
137
+ })
138
+ return
139
+ }
140
+ if (
141
+ parsed.protocol !== 'https:' ||
142
+ parsed.username !== '' ||
143
+ parsed.password !== '' ||
144
+ parsed.search !== '' ||
145
+ parsed.hash !== '' ||
146
+ source !== parsed.origin
147
+ ) {
148
+ context.addIssue({
149
+ code: z.ZodIssueCode.custom,
150
+ message: "Expected 'none', 'self', https:, or an exact HTTPS origin.",
151
+ })
152
+ }
153
+ })
154
+
155
+ const framePolicySchema = z
156
+ .object({
157
+ ancestors: z
158
+ .array(frameAncestorSourceSchema)
159
+ .min(1)
160
+ .refine((sources) => new Set(sources).size === sources.length, {
161
+ message: 'Frame ancestor sources must be unique.',
162
+ }),
163
+ legacyHeader: z.enum(['DENY', 'SAMEORIGIN', 'omit']),
164
+ })
165
+ .superRefine((policy, context) => {
166
+ if (policy.ancestors.includes("'none'") && policy.ancestors.length !== 1) {
167
+ context.addIssue({
168
+ code: z.ZodIssueCode.custom,
169
+ path: ['ancestors'],
170
+ message: "The 'none' frame ancestor cannot be combined with other sources.",
171
+ })
172
+ }
173
+ if (
174
+ policy.legacyHeader === 'DENY' &&
175
+ (policy.ancestors.length !== 1 || policy.ancestors[0] !== "'none'")
176
+ ) {
177
+ context.addIssue({
178
+ code: z.ZodIssueCode.custom,
179
+ path: ['legacyHeader'],
180
+ message: "DENY is compatible only with frame-ancestors 'none'.",
181
+ })
182
+ }
183
+ if (
184
+ policy.legacyHeader === 'SAMEORIGIN' &&
185
+ (policy.ancestors.length !== 1 || policy.ancestors[0] !== "'self'")
186
+ ) {
187
+ context.addIssue({
188
+ code: z.ZodIssueCode.custom,
189
+ path: ['legacyHeader'],
190
+ message: "SAMEORIGIN is compatible only with frame-ancestors 'self'.",
191
+ })
192
+ }
193
+ })
194
+
74
195
  export const conformanceExpectedSchema = z.object({
75
196
  canonicalUrl: z.string().url().optional(),
76
197
  schemaTypes: z.array(z.string().min(1)).default([]),
77
198
  discoverEligible: z.boolean().default(true),
199
+ framePolicy: framePolicySchema.default({
200
+ ancestors: ["'none'"],
201
+ legacyHeader: 'DENY',
202
+ }),
78
203
  })
79
204
 
80
205
  export const conformanceFixtureSchema = z.object({
@@ -88,6 +213,7 @@ export const conformanceFixtureSchema = z.object({
88
213
  expected: conformanceExpectedSchema.default({}),
89
214
  publicationSurfaces: z.array(publicationSurfaceSchema).default([]),
90
215
  cacheVariants: z.array(cacheVariantSchema).default([]),
216
+ securityReceipts: securityReceiptsSchema,
91
217
  buildEvidence: buildEvidenceSchema.optional(),
92
218
  expectedFailingCode: z.string().min(1).optional(),
93
219
  })
@@ -125,6 +251,7 @@ export const siteEvidenceBundleSchema = z.object({
125
251
  summary: evidenceSummarySchema,
126
252
  checks: z.array(conformanceCheckReceiptSchema),
127
253
  findings: z.array(conformanceFindingSchema),
254
+ securityReceipts: securityReceiptsSchema,
128
255
  })
129
256
 
130
257
  export const fleetEvidenceBundleSchema = z.object({
@@ -143,6 +270,10 @@ export type HeaderMap = z.infer<typeof headerMapSchema>
143
270
  export type SurfaceResponse = z.infer<typeof surfaceResponseSchema>
144
271
  export type PublicationSurfaceEvidence = z.infer<typeof publicationSurfaceSchema>
145
272
  export type CacheVariant = z.infer<typeof cacheVariantSchema>
273
+ export type SecurityFunctionalProbeReceipt = z.infer<typeof securityFunctionalProbeReceiptSchema>
274
+ export type CspReportReceipt = z.infer<typeof cspReportReceiptSchema>
275
+ export type InlineStyleAttributeInventory = z.infer<typeof inlineStyleAttributeInventorySchema>
276
+ export type SecurityReceipts = z.infer<typeof securityReceiptsSchema>
146
277
  export type BuildEvidence = z.infer<typeof buildEvidenceSchema>
147
278
  export type ConformanceFixtureInput = z.input<typeof conformanceFixtureSchema>
148
279
  export type ConformanceFixture = z.infer<typeof conformanceFixtureSchema>