@growth-labs/conformance 0.2.0 → 0.3.1

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
@@ -19,6 +19,7 @@ export const REQUIRED_NEGATIVE_FIXTURE_CODES = {
19
19
  'csp-unsafe-eval': 'security.csp.unsafe_eval',
20
20
  'csp-inline-executable-without-nonce-or-hash':
21
21
  'security.csp.inline_executable_without_nonce_or_hash',
22
+ 'csp-style-attr-inline-without-inventory': 'security.csp.style_attr_inline_inventory_missing',
22
23
  'conflicting-duplicate-security-header': 'security.header.conflicting_values',
23
24
  'missing-functional-probe-receipt': 'security.functional_probe.missing',
24
25
  'missing-csp-report-receipt': 'security.csp_report.missing',
@@ -98,10 +99,20 @@ export const cspReportReceiptSchema = z.object({
98
99
  capabilityBoundary: z.string().min(1).optional(),
99
100
  })
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
+
101
111
  export const securityReceiptsSchema = z
102
112
  .object({
103
113
  functionalProbes: z.array(securityFunctionalProbeReceiptSchema).default([]),
104
114
  cspReports: z.array(cspReportReceiptSchema).default([]),
115
+ inlineStyleAttributes: inlineStyleAttributeInventorySchema.optional(),
105
116
  })
106
117
  .default({ functionalProbes: [], cspReports: [] })
107
118
 
@@ -114,10 +125,81 @@ export const buildEvidenceSchema = z.object({
114
125
  ssrHeaderPolicy: z.boolean().default(false),
115
126
  })
116
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
+
117
195
  export const conformanceExpectedSchema = z.object({
118
196
  canonicalUrl: z.string().url().optional(),
119
197
  schemaTypes: z.array(z.string().min(1)).default([]),
120
198
  discoverEligible: z.boolean().default(true),
199
+ framePolicy: framePolicySchema.default({
200
+ ancestors: ["'none'"],
201
+ legacyHeader: 'DENY',
202
+ }),
121
203
  })
122
204
 
123
205
  export const conformanceFixtureSchema = z.object({
@@ -190,6 +272,7 @@ export type PublicationSurfaceEvidence = z.infer<typeof publicationSurfaceSchema
190
272
  export type CacheVariant = z.infer<typeof cacheVariantSchema>
191
273
  export type SecurityFunctionalProbeReceipt = z.infer<typeof securityFunctionalProbeReceiptSchema>
192
274
  export type CspReportReceipt = z.infer<typeof cspReportReceiptSchema>
275
+ export type InlineStyleAttributeInventory = z.infer<typeof inlineStyleAttributeInventorySchema>
193
276
  export type SecurityReceipts = z.infer<typeof securityReceiptsSchema>
194
277
  export type BuildEvidence = z.infer<typeof buildEvidenceSchema>
195
278
  export type ConformanceFixtureInput = z.input<typeof conformanceFixtureSchema>