@haystackeditor/cli 0.24.1 → 0.25.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/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
- package/dist/assets/capture/release.json +4 -0
- package/dist/assets/telemetry/runtime.cjs +829 -1254
- package/dist/capture/adapters/client-routes.js +383 -0
- package/dist/capture/adapters/django.js +134 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +74 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next-build.js +113 -0
- package/dist/capture/adapters/next.js +494 -0
- package/dist/capture/adapters/nuxt.js +199 -0
- package/dist/capture/adapters/rails.js +178 -0
- package/dist/capture/adapters/react-router.js +439 -0
- package/dist/capture/adapters/sveltekit.js +109 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +135 -0
- package/dist/capture/app-config.js +107 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +332 -0
- package/dist/capture/html.js +74 -0
- package/dist/capture/js-ast.js +400 -0
- package/dist/capture/manifest.js +95 -0
- package/dist/capture/project.js +177 -0
- package/dist/capture/route-pattern.js +119 -0
- package/dist/capture/script-release.js +47 -0
- package/dist/capture/tag.js +74 -0
- package/dist/capture/url-rewrites.js +232 -0
- package/dist/capture-step.js +56 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +86 -0
- package/dist/commands/init-capture.js +426 -0
- package/dist/commands/init-telemetry.js +1028 -0
- package/dist/commands/init.js +78 -5
- package/dist/commands/server-telemetry-contract.d.ts +66 -0
- package/dist/commands/server-telemetry-contract.js +127 -0
- package/dist/commands/telemetry-token.js +238 -0
- package/dist/commands/telemetry.d.ts +161 -8
- package/dist/commands/telemetry.js +940 -158
- package/dist/commands/verify-onboarding.js +21 -1
- package/dist/commands/verify.js +56 -9
- package/dist/index.js +85 -6
- package/dist/schema.js +2 -2
- package/dist/telemetry/next-loader.cjs +66 -9
- package/dist/telemetry/next.d.ts +11 -3
- package/dist/telemetry/next.js +95 -15
- package/dist/telemetry/typed-source.d.ts +47 -0
- package/dist/telemetry/typed-source.js +379 -0
- package/package.json +4 -2
- package/schemas/init.v1.json +63 -4
- package/schemas/pre-verify.v1.json +60 -3
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Security-Policy (CAPTURE-V1 rule 8b(iii)): init finds the app's policy where the repository keeps it (headers
|
|
3
|
+
* config, middleware, meta tags, framework CSP options, server configs) and reports the exact additions the tag needs;
|
|
4
|
+
* it never edits or loosens a policy, and never proposes a wildcard or 'unsafe-inline'.
|
|
5
|
+
*
|
|
6
|
+
* The tag needs: connect-src 'self' (the manifest, fetched from the app's own origin) and https://c.haystack.sh (the
|
|
7
|
+
* ingest); script-src 'self' (the self-hosted files). A connect-src that is absent falls back to default-src, so adding
|
|
8
|
+
* one copies default-src's sources (otherwise everything else default-src allowed for fetches would break).
|
|
9
|
+
* 'strict-dynamic' ignores 'self': Next.js's Script loader is itself a trusted script and may insert the tag; a plain
|
|
10
|
+
* <script> then needs the page's nonce.
|
|
11
|
+
*/
|
|
12
|
+
import fg from 'fast-glob';
|
|
13
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
14
|
+
import { extname, join, relative } from 'node:path';
|
|
15
|
+
import { SAXParser } from 'parse5-sax-parser';
|
|
16
|
+
import { CAPTURE_ORIGIN } from '../commands/capture-contract.js';
|
|
17
|
+
import { keyName, parseFile, parseJsonLike, unwrapTs, walk } from './js-ast.js';
|
|
18
|
+
import { toPosix } from './project.js';
|
|
19
|
+
const SCAN = ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx,json,toml,html,erb,rb,py,conf,yaml,yml}', '**/_headers'];
|
|
20
|
+
const IGNORE = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**', '**/.next/**', '**/out/**', '**/vendor/**',
|
|
21
|
+
'**/.svelte-kit/**', '**/.nuxt/**', '**/.output/**', '**/coverage/**', '**/tmp/**', '**/.venv/**', '**/venv/**',
|
|
22
|
+
'**/site-packages/**', '**/_haystack/**', '**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/package-lock.json',
|
|
23
|
+
'**/*.min.js'];
|
|
24
|
+
const MARKER = /content[-_]?security[-_]?policy|\bCSP_[A-Z_]+_SRC\b|\bcsp\s*:|\bhelmet\b/i;
|
|
25
|
+
const DIRECTIVE = /\b(default|script|script-src-elem|connect|style|img|font|frame|object|base|form|worker|media|manifest)-src\b|\bscript-src-elem\b/;
|
|
26
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
27
|
+
const DYNAMIC = '<dynamic>';
|
|
28
|
+
function parsePolicy(policy) {
|
|
29
|
+
const directives = new Map();
|
|
30
|
+
for (const part of policy.split(';')) {
|
|
31
|
+
const [name, ...sources] = part.trim().split(/\s+/);
|
|
32
|
+
if (!name || directives.has(name.toLowerCase()))
|
|
33
|
+
continue;
|
|
34
|
+
directives.set(name.toLowerCase(), sources);
|
|
35
|
+
}
|
|
36
|
+
return directives;
|
|
37
|
+
}
|
|
38
|
+
function allowsCaptureOrigin(sources) {
|
|
39
|
+
return sources.some(source => ['*', 'https:', CAPTURE_ORIGIN, 'c.haystack.sh', 'https://*.haystack.sh', '*.haystack.sh'].includes(source.toLowerCase()));
|
|
40
|
+
}
|
|
41
|
+
/** Does the list allow the page's own origin? Production origins are https (rule 8b), so `https:` and `*` cover it, as
|
|
42
|
+
* does naming every production origin. */
|
|
43
|
+
function allowsSelf(sources, origins) {
|
|
44
|
+
return sources.some(source => source === '\'self\'' || source === '*' || source === 'https:')
|
|
45
|
+
|| (origins.length > 0 && origins.every(origin => sources.includes(origin)));
|
|
46
|
+
}
|
|
47
|
+
/** The additions one readable policy needs for the tag on these production origins. */
|
|
48
|
+
export function analyzePolicy(policy, nextLoader, origins) {
|
|
49
|
+
const directives = parsePolicy(policy);
|
|
50
|
+
const additions = [];
|
|
51
|
+
const notes = [];
|
|
52
|
+
const defaults = directives.get('default-src');
|
|
53
|
+
const connect = directives.get('connect-src');
|
|
54
|
+
const connectSources = connect ?? defaults;
|
|
55
|
+
if (connectSources) {
|
|
56
|
+
const missing = [...(allowsSelf(connectSources, origins) ? [] : ['\'self\'']), ...(allowsCaptureOrigin(connectSources) ? [] : [CAPTURE_ORIGIN])];
|
|
57
|
+
if (missing.length > 0) {
|
|
58
|
+
const kept = connectSources.filter(source => source !== '\'none\'');
|
|
59
|
+
additions.push(connect
|
|
60
|
+
? `connect-src ${[...kept, ...missing].join(' ')} (add ${missing.join(' ')})`
|
|
61
|
+
: `connect-src ${[...kept, ...missing].join(' ')} (a new directive: default-src's sources plus ${missing.join(' ')})`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const scriptName = directives.has('script-src-elem') ? 'script-src-elem' : directives.has('script-src') ? 'script-src' : directives.has('default-src') ? 'default-src' : null;
|
|
65
|
+
const scriptSources = scriptName ? directives.get(scriptName) : null;
|
|
66
|
+
if (scriptSources && scriptSources.includes('\'strict-dynamic\'')) {
|
|
67
|
+
notes.push(nextLoader
|
|
68
|
+
? `${scriptName} uses 'strict-dynamic': Next.js's Script loader inserts the tag, which 'strict-dynamic' allows while Next's own scripts carry the nonce.`
|
|
69
|
+
: `${scriptName} uses 'strict-dynamic', which ignores 'self': give each Haystack <script> the page's nonce attribute.`);
|
|
70
|
+
if (!nextLoader)
|
|
71
|
+
additions.push(`nonce on the Haystack <script> tags (${scriptName} uses 'strict-dynamic')`);
|
|
72
|
+
}
|
|
73
|
+
else if (scriptSources && !allowsSelf(scriptSources, origins)) {
|
|
74
|
+
const kept = scriptSources.filter(source => source !== '\'none\'');
|
|
75
|
+
additions.push(scriptName === 'default-src'
|
|
76
|
+
? `script-src ${[...kept, '\'self\''].join(' ')} (a new directive: default-src's sources plus 'self')`
|
|
77
|
+
: `${scriptName} ${[...kept, '\'self\''].join(' ')} (add 'self')`);
|
|
78
|
+
}
|
|
79
|
+
if (policy.includes(DYNAMIC))
|
|
80
|
+
notes.push('Parts of this policy are computed at runtime; init read only its literal parts.');
|
|
81
|
+
return { additions, notes };
|
|
82
|
+
}
|
|
83
|
+
function lineOf(text, offset) {
|
|
84
|
+
return text.slice(0, offset).split('\n').length;
|
|
85
|
+
}
|
|
86
|
+
/** The policy text a JS/TS/JSON node spells: a string, a template (expressions as <dynamic>), an array of directive
|
|
87
|
+
* strings joined, or an object of directives (helmet, SvelteKit kit.csp, nuxt-security). */
|
|
88
|
+
function policyFromNode(node) {
|
|
89
|
+
const current = unwrapTs(node);
|
|
90
|
+
if (!current)
|
|
91
|
+
return null;
|
|
92
|
+
if (current.type === 'StringLiteral')
|
|
93
|
+
return current.value;
|
|
94
|
+
if (current.type === 'TemplateLiteral') {
|
|
95
|
+
return current.quasis.map((quasi, index) => quasi.value.cooked + (index < current.expressions.length ? DYNAMIC : '')).join('');
|
|
96
|
+
}
|
|
97
|
+
if (current.type === 'CallExpression' && current.callee.type === 'MemberExpression') {
|
|
98
|
+
const method = current.callee.property.name;
|
|
99
|
+
if (method === 'join' && current.callee.object.type === 'ArrayExpression') {
|
|
100
|
+
const parts = current.callee.object.elements.map((element) => (element ? policyFromNode(element) : null));
|
|
101
|
+
return parts.every((part) => part !== null) ? parts.join('; ') : null;
|
|
102
|
+
}
|
|
103
|
+
if (method === 'replace' || method === 'trim' || method === 'replaceAll')
|
|
104
|
+
return policyFromNode(current.callee.object);
|
|
105
|
+
}
|
|
106
|
+
if (current.type === 'ObjectExpression') {
|
|
107
|
+
const directives = [];
|
|
108
|
+
for (const property of current.properties) {
|
|
109
|
+
if (property.type !== 'ObjectProperty')
|
|
110
|
+
return null;
|
|
111
|
+
const name = keyName(property);
|
|
112
|
+
if (name === null)
|
|
113
|
+
return null;
|
|
114
|
+
const kebab = name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
|
115
|
+
if (!/-src(-elem)?$/.test(kebab))
|
|
116
|
+
continue;
|
|
117
|
+
const value = unwrapTs(property.value);
|
|
118
|
+
if (value.type !== 'ArrayExpression') {
|
|
119
|
+
directives.push(`${kebab} ${DYNAMIC}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const sources = value.elements.map((element) => (element?.type === 'StringLiteral' ? element.value : DYNAMIC))
|
|
123
|
+
// SvelteKit spells keywords without quotes ('self', 'strict-dynamic').
|
|
124
|
+
.map((source) => (['self', 'none', 'strict-dynamic', 'unsafe-inline', 'unsafe-eval'].includes(source) ? `'${source}'` : source));
|
|
125
|
+
directives.push(`${kebab} ${sources.join(' ')}`);
|
|
126
|
+
}
|
|
127
|
+
return directives.length > 0 ? directives.join('; ') : null;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
function jsPolicies(text, file, json) {
|
|
132
|
+
const ast = json ? parseJsonLike(text, file) : parseFile(text, file);
|
|
133
|
+
if (!ast)
|
|
134
|
+
return [{ offset: 0, policy: null, reportOnly: false }];
|
|
135
|
+
const found = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const helmet = json ? null : ast.program.body.flatMap((statement) => (statement.type === 'ImportDeclaration' && statement.source.value === 'helmet'
|
|
138
|
+
? statement.specifiers.filter((specifier) => specifier.type === 'ImportDefaultSpecifier').map((specifier) => specifier.local.name) : []))[0] ?? null;
|
|
139
|
+
walk(json ? ast : ast.program, node => {
|
|
140
|
+
if (seen.has(node))
|
|
141
|
+
return false;
|
|
142
|
+
// { key: 'Content-Security-Policy', value: ... } (Next.js headers(), vercel.json) and 'Content-Security-Policy': ...
|
|
143
|
+
if (node.type === 'ObjectExpression') {
|
|
144
|
+
const keyProperty = node.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'key');
|
|
145
|
+
const valueProperty = node.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'value');
|
|
146
|
+
const header = keyProperty && unwrapTs(keyProperty.value).type === 'StringLiteral' ? unwrapTs(keyProperty.value).value.toLowerCase() : null;
|
|
147
|
+
if (header && header.startsWith('content-security-policy') && valueProperty) {
|
|
148
|
+
found.push({ offset: node.start, policy: resolvePolicy(valueProperty.value, ast), reportOnly: header.endsWith('report-only') });
|
|
149
|
+
seen.add(valueProperty.value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (node.type === 'ObjectProperty') {
|
|
153
|
+
const name = keyName(node)?.toLowerCase() ?? '';
|
|
154
|
+
if (name.startsWith('content-security-policy') || name === 'contentsecuritypolicy' || name === 'csp') {
|
|
155
|
+
const value = unwrapTs(node.value);
|
|
156
|
+
if (value.type === 'BooleanLiteral')
|
|
157
|
+
return undefined;
|
|
158
|
+
const directives = value.type === 'ObjectExpression'
|
|
159
|
+
? (value.properties.find((property) => property.type === 'ObjectProperty' && keyName(property) === 'directives')?.value ?? value) : value;
|
|
160
|
+
const policy = resolvePolicy(directives, ast);
|
|
161
|
+
// `csp` is a common key for other things; it counts only when it spells a policy.
|
|
162
|
+
if (name !== 'csp' || (policy !== null && DIRECTIVE.test(policy)))
|
|
163
|
+
found.push({ offset: node.start, policy, reportOnly: name.endsWith('report-only') });
|
|
164
|
+
seen.add(node.value);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && helmet !== null && node.callee.name === helmet) {
|
|
168
|
+
const options = node.arguments[0] ? unwrapTs(node.arguments[0]) : null;
|
|
169
|
+
const configured = options?.type === 'ObjectExpression'
|
|
170
|
+
&& options.properties.some((property) => property.type !== 'ObjectProperty' || keyName(property) === 'contentSecurityPolicy');
|
|
171
|
+
if (!configured)
|
|
172
|
+
found.push({ offset: node.start, policy: HELMET_DEFAULT, reportOnly: false });
|
|
173
|
+
}
|
|
174
|
+
// headers.set('Content-Security-Policy', value) (middleware) and res.setHeader(...).
|
|
175
|
+
if (node.type === 'CallExpression' && node.arguments.length >= 2 && node.arguments[0].type === 'StringLiteral'
|
|
176
|
+
&& node.arguments[0].value.toLowerCase().startsWith('content-security-policy')) {
|
|
177
|
+
found.push({ offset: node.start, policy: resolvePolicy(node.arguments[1], ast), reportOnly: node.arguments[0].value.toLowerCase().endsWith('report-only') });
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
});
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
/** The initializer of the one variable named `name` anywhere in the module (a policy built in a middleware function),
|
|
184
|
+
* or null when there is none or more than one. */
|
|
185
|
+
function declaratorInit(ast, name) {
|
|
186
|
+
const inits = [];
|
|
187
|
+
walk(ast.program ?? ast, node => {
|
|
188
|
+
if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier' && node.id.name === name && node.init)
|
|
189
|
+
inits.push(node.init);
|
|
190
|
+
});
|
|
191
|
+
return inits.length === 1 ? inits[0] : null;
|
|
192
|
+
}
|
|
193
|
+
/** A policy expression, through variables (`const cspHeader = \`...\``) and string clean-ups (`.replace(...).trim()`). */
|
|
194
|
+
function resolvePolicy(node, ast, depth = 0) {
|
|
195
|
+
const current = unwrapTs(node);
|
|
196
|
+
if (!current || depth > 6)
|
|
197
|
+
return null;
|
|
198
|
+
if (current.type === 'Identifier') {
|
|
199
|
+
const init = declaratorInit(ast, current.name);
|
|
200
|
+
return init ? resolvePolicy(init, ast, depth + 1) : null;
|
|
201
|
+
}
|
|
202
|
+
if (current.type === 'CallExpression' && current.callee.type === 'MemberExpression'
|
|
203
|
+
&& ['replace', 'replaceAll', 'trim'].includes(current.callee.property.name)) {
|
|
204
|
+
return resolvePolicy(current.callee.object, ast, depth + 1);
|
|
205
|
+
}
|
|
206
|
+
// A header's value is its policy whatever it says (`frame-ancestors 'none'` restricts nothing the tag does); a computed
|
|
207
|
+
// value init cannot read stays null.
|
|
208
|
+
return policyFromNode(current);
|
|
209
|
+
}
|
|
210
|
+
/** helmet()'s default policy (helmet 5–8), which applies when its options leave contentSecurityPolicy alone. */
|
|
211
|
+
const HELMET_DEFAULT = "default-src 'self'; base-uri 'self'; font-src 'self' https: data:; form-action 'self'; frame-ancestors 'self';"
|
|
212
|
+
+ " img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self' https: 'unsafe-inline'";
|
|
213
|
+
function metaPolicies(text) {
|
|
214
|
+
const found = [];
|
|
215
|
+
const parser = new SAXParser({ sourceCodeLocationInfo: true });
|
|
216
|
+
parser.on('startTag', token => {
|
|
217
|
+
if (token.tagName !== 'meta')
|
|
218
|
+
return;
|
|
219
|
+
const equiv = token.attrs.find(attribute => attribute.name === 'http-equiv')?.value.toLowerCase();
|
|
220
|
+
if (equiv !== 'content-security-policy')
|
|
221
|
+
return;
|
|
222
|
+
const content = token.attrs.find(attribute => attribute.name === 'content')?.value ?? null;
|
|
223
|
+
found.push({ offset: token.sourceCodeLocation?.startOffset ?? 0, policy: content && !content.includes('<%') && !content.includes('{{') ? content : null, reportOnly: false });
|
|
224
|
+
});
|
|
225
|
+
parser.write(text);
|
|
226
|
+
parser.end();
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
const RUBY_KEYWORDS = { self: '\'self\'', none: '\'none\'', https: 'https:', http: 'http:', data: 'data:', blob: 'blob:',
|
|
230
|
+
unsafe_inline: '\'unsafe-inline\'', unsafe_eval: '\'unsafe-eval\'', strict_dynamic: '\'strict-dynamic\'', report_sample: '\'report-sample\'' };
|
|
231
|
+
/** Rails' content_security_policy DSL (`policy.script_src :self, :https`), one directive per line. */
|
|
232
|
+
function rubyPolicy(text) {
|
|
233
|
+
const directives = [];
|
|
234
|
+
for (const line of text.split('\n')) {
|
|
235
|
+
const match = /^\s*policy\.([a-z_]+_src(?:_elem)?)\s+(.*)$/.exec(line);
|
|
236
|
+
if (!match)
|
|
237
|
+
continue;
|
|
238
|
+
const sources = match[2].split(',').map(source => source.trim()).map(source => {
|
|
239
|
+
if (source.startsWith(':'))
|
|
240
|
+
return RUBY_KEYWORDS[source.slice(1)] ?? DYNAMIC;
|
|
241
|
+
const quoted = /^["']([^"']+)["']$/.exec(source);
|
|
242
|
+
return quoted ? quoted[1] : DYNAMIC;
|
|
243
|
+
});
|
|
244
|
+
directives.push(`${match[1].replace(/_/g, '-')} ${sources.join(' ')}`);
|
|
245
|
+
}
|
|
246
|
+
return directives.length > 0 ? directives.join('; ') : null;
|
|
247
|
+
}
|
|
248
|
+
/** django-csp's CSP_<DIRECTIVE> = (...) settings. */
|
|
249
|
+
function pythonPolicy(text) {
|
|
250
|
+
const directives = [];
|
|
251
|
+
for (const match of text.matchAll(/^CSP_([A-Z_]+_SRC(?:_ELEM)?)\s*=\s*[([]([^)\]]*)[)\]]/gm)) {
|
|
252
|
+
// Python string literals: "'self'" is the CSP keyword 'self'.
|
|
253
|
+
const sources = [...match[2].matchAll(/"([^"]*)"|'([^']*)'/g)].map(item => item[1] ?? item[2]);
|
|
254
|
+
directives.push(`${match[1].toLowerCase().replace(/_/g, '-')} ${sources.join(' ')}`);
|
|
255
|
+
}
|
|
256
|
+
return directives.length > 0 ? directives.join('; ') : null;
|
|
257
|
+
}
|
|
258
|
+
/** Header-file forms: Netlify/Cloudflare _headers, netlify.toml, nginx add_header, YAML. */
|
|
259
|
+
function textPolicies(text) {
|
|
260
|
+
const found = [];
|
|
261
|
+
let offset = 0;
|
|
262
|
+
for (const line of text.split('\n')) {
|
|
263
|
+
const at = line.toLowerCase().indexOf('content-security-policy');
|
|
264
|
+
if (at !== -1 && !line.trimStart().startsWith('#')) {
|
|
265
|
+
let rest = line.slice(at + 'content-security-policy'.length);
|
|
266
|
+
const reportOnly = rest.toLowerCase().startsWith('-report-only');
|
|
267
|
+
if (reportOnly)
|
|
268
|
+
rest = rest.slice('-report-only'.length);
|
|
269
|
+
rest = rest.replace(/^["']?\s*[:=]?\s*/, '');
|
|
270
|
+
const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : null;
|
|
271
|
+
const close = quote ? rest.indexOf(quote, 1) : -1;
|
|
272
|
+
const policy = (quote ? rest.slice(1, close === -1 ? undefined : close) : rest.replace(/;?\s*$/, '')).trim();
|
|
273
|
+
found.push({ offset: offset + at, policy: DIRECTIVE.test(policy) ? policy : null, reportOnly });
|
|
274
|
+
}
|
|
275
|
+
offset += line.length + 1;
|
|
276
|
+
}
|
|
277
|
+
return found;
|
|
278
|
+
}
|
|
279
|
+
/** Every Content-Security-Policy the app's directory (and the repository root's deploy configs) keeps, with the
|
|
280
|
+
* additions each needs. */
|
|
281
|
+
export function findCsp(repoRoot, appDir, nextLoader, origins) {
|
|
282
|
+
const roots = appDir === repoRoot ? [appDir] : [appDir, repoRoot];
|
|
283
|
+
const files = new Set();
|
|
284
|
+
for (const [index, root] of roots.entries()) {
|
|
285
|
+
const patterns = index === 0 ? SCAN : ['vercel.json', 'netlify.toml', '_headers', 'nginx.conf', '*.conf'];
|
|
286
|
+
for (const file of fg.sync(patterns, { cwd: root, ignore: IGNORE, deep: index === 0 ? 8 : 1, onlyFiles: true, dot: false }))
|
|
287
|
+
files.add(join(root, file));
|
|
288
|
+
}
|
|
289
|
+
const findings = [];
|
|
290
|
+
for (const file of [...files].sort()) {
|
|
291
|
+
try {
|
|
292
|
+
if (statSync(file).size > MAX_FILE_BYTES)
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const text = readFileSync(file, 'utf8');
|
|
299
|
+
if (!MARKER.test(text))
|
|
300
|
+
continue;
|
|
301
|
+
const extension = extname(file);
|
|
302
|
+
const where = toPosix(relative(repoRoot, file));
|
|
303
|
+
let policies;
|
|
304
|
+
if (['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx'].includes(extension))
|
|
305
|
+
policies = jsPolicies(text, file, false);
|
|
306
|
+
else if (extension === '.json')
|
|
307
|
+
policies = jsPolicies(text, file, true);
|
|
308
|
+
else if (extension === '.html' || extension === '.erb')
|
|
309
|
+
policies = metaPolicies(text);
|
|
310
|
+
// Rails generates its initializer fully commented out: only active `policy.` lines make a policy.
|
|
311
|
+
else if (extension === '.rb') {
|
|
312
|
+
policies = rubyPolicy(text) === null ? [] : [{ offset: text.search(/^[ \t]*policy\./m), policy: rubyPolicy(text), reportOnly: /^\s*[^#\n]*report_only\s*=\s*true/m.test(text) }];
|
|
313
|
+
}
|
|
314
|
+
else if (extension === '.py')
|
|
315
|
+
policies = [{ offset: 0, policy: pythonPolicy(text), reportOnly: /CSP_REPORT_ONLY\s*=\s*True/.test(text) }];
|
|
316
|
+
else
|
|
317
|
+
policies = textPolicies(text);
|
|
318
|
+
const unique = policies.filter((item, index) => policies.findIndex(other => other.policy === item.policy && other.reportOnly === item.reportOnly) === index);
|
|
319
|
+
for (const { offset, policy, reportOnly } of unique) {
|
|
320
|
+
const analysis = policy === null ? null : analyzePolicy(policy, nextLoader, origins);
|
|
321
|
+
findings.push({
|
|
322
|
+
where: `${where}:${lineOf(text, offset)}`,
|
|
323
|
+
policy,
|
|
324
|
+
reportOnly,
|
|
325
|
+
additions: analysis ? analysis.additions : [`connect-src must allow 'self' ${CAPTURE_ORIGIN}, and script-src (or default-src) must allow 'self'`],
|
|
326
|
+
notes: [...(analysis ? analysis.notes : ['init could not read this policy, which is computed at runtime.']),
|
|
327
|
+
...(reportOnly && (!analysis || analysis.additions.length > 0) ? ['This policy is report-only: it does not block the tag, but would report it.'] : [])],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { findings, looked: `${toPosix(relative(repoRoot, appDir)) || '.'} (headers config, middleware, meta tags, framework CSP options, server configs)` };
|
|
332
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding the tag to an HTML document or a server template (index.html, SvelteKit's app.html, Rails ERB layouts, Django
|
|
3
|
+
* templates). parse5's tokenizer reads the tags lexically, the way a browser's tokenizer does (comments, script and
|
|
4
|
+
* style raw text, quoted attributes), without building a tree: a tree builder treats template text in <head>
|
|
5
|
+
* (`<%= csrf_meta_tags %>`, `{% block %}`, `%sveltekit.head%`) as the end of the head and loses the real </head>.
|
|
6
|
+
*/
|
|
7
|
+
import { SAXParser } from 'parse5-sax-parser';
|
|
8
|
+
import { indentUnit, lineIndent, startsLine } from './js-ast.js';
|
|
9
|
+
import { ourScriptKind } from './tag.js';
|
|
10
|
+
export function scanHtml(text) {
|
|
11
|
+
const scan = { heads: [], headEnds: [], ours: [] };
|
|
12
|
+
let open = null;
|
|
13
|
+
const parser = new SAXParser({ sourceCodeLocationInfo: true });
|
|
14
|
+
parser.on('startTag', token => {
|
|
15
|
+
const location = token.sourceCodeLocation;
|
|
16
|
+
if (!location)
|
|
17
|
+
return;
|
|
18
|
+
if (token.tagName === 'head')
|
|
19
|
+
scan.heads.push({ start: location.startOffset, end: location.endOffset });
|
|
20
|
+
if (token.tagName === 'script') {
|
|
21
|
+
const src = token.attrs.find(attribute => attribute.name === 'src')?.value;
|
|
22
|
+
open = src !== undefined && ourScriptKind(src) !== null ? { start: location.startOffset, end: location.endOffset } : null;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
parser.on('endTag', token => {
|
|
26
|
+
const location = token.sourceCodeLocation;
|
|
27
|
+
if (!location)
|
|
28
|
+
return;
|
|
29
|
+
if (token.tagName === 'head')
|
|
30
|
+
scan.headEnds.push({ start: location.startOffset, end: location.endOffset });
|
|
31
|
+
if (token.tagName === 'script' && open) {
|
|
32
|
+
scan.ours.push({ start: open.start, end: location.endOffset });
|
|
33
|
+
open = null;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
parser.write(text);
|
|
37
|
+
parser.end();
|
|
38
|
+
return scan;
|
|
39
|
+
}
|
|
40
|
+
/** The range to delete for an element: its whole line when nothing else is on it. */
|
|
41
|
+
function wholeLine(text, range) {
|
|
42
|
+
const lineStart = text.lastIndexOf('\n', range.start - 1) + 1;
|
|
43
|
+
const lineEnd = text.indexOf('\n', range.end);
|
|
44
|
+
const end = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
45
|
+
return text.slice(lineStart, range.start).trim() === '' && text.slice(range.end, end).trim() === '' ? { start: lineStart, end } : range;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The document with exactly `tags` as init's scripts, at the end of its one <head>: unchanged when they are already
|
|
49
|
+
* there; otherwise init's earlier scripts (an older version) are removed and these inserted, so a rerun never duplicates.
|
|
50
|
+
* A document without exactly one <head> and one </head> is a manual step.
|
|
51
|
+
*/
|
|
52
|
+
export function withHeadTags(text, tags) {
|
|
53
|
+
const scan = scanHtml(text);
|
|
54
|
+
if (scan.heads.length !== 1 || scan.headEnds.length !== 1) {
|
|
55
|
+
return { manual: `it has ${scan.heads.length} <head> and ${scan.headEnds.length} </head> tags, not one of each` };
|
|
56
|
+
}
|
|
57
|
+
const current = scan.ours.map(range => text.slice(range.start, range.end));
|
|
58
|
+
if (current.length === tags.length && current.every((tag, index) => tag === tags[index]))
|
|
59
|
+
return { content: text };
|
|
60
|
+
// Remove init's earlier tags, then add these before </head>.
|
|
61
|
+
let edited = text;
|
|
62
|
+
const removals = scan.ours.map(range => wholeLine(text, range)).sort((a, b) => b.start - a.start);
|
|
63
|
+
for (const range of removals)
|
|
64
|
+
edited = edited.slice(0, range.start) + edited.slice(range.end);
|
|
65
|
+
const headEnd = scanHtml(edited).headEnds[0];
|
|
66
|
+
if (!headEnd)
|
|
67
|
+
return { manual: 'its </head> could not be found again after removing an older tag' };
|
|
68
|
+
if (startsLine(edited, headEnd.start)) {
|
|
69
|
+
const indent = lineIndent(edited, headEnd.start) + indentUnit(text);
|
|
70
|
+
const lineStart = edited.lastIndexOf('\n', headEnd.start - 1) + 1;
|
|
71
|
+
return { content: edited.slice(0, lineStart) + tags.map(tag => `${indent}${tag}\n`).join('') + edited.slice(lineStart) };
|
|
72
|
+
}
|
|
73
|
+
return { content: edited.slice(0, headEnd.start) + tags.join('') + edited.slice(headEnd.start) };
|
|
74
|
+
}
|