Skip to content

Commit 64060f2

Browse files
committed
Add hash-static command for browserless CSP generation; bump to 0.8.0
Introduces a new CLI command that scans built HTML files on disk, hashes every inline <script>, <style>, style="" attribute, and on*="" event handler, and emits (or injects) a ready-to-deploy CSP policy. No Playwright, no network, no session database — suitable for CI. This is the right shape for static-site build pipelines (VitePress, Next.js static export, Astro, etc.) where the deployed inline content is fully knowable from disk. For sites that also inject inline content at runtime, --extra-script-elem/--extra-style-elem accept hashes captured from a one-off crawl. - src/static-html-analyser.ts: extraction, policy building, meta tag injection. Captures empty-string attribute hashes (browsers evaluate CSP against present-but-empty attributes) and auto-adds 'unsafe-hashes' to attribute directives (CSP3 §2.3.2). - src/cli.ts: new 'hash-static' command with --inject, --format, --extra-{script,style}-{elem,attr} flags. - Wire into docs:build via predocs:build hook so the docs site gets its CSP auto-generated on every build (including CI, where dist/ doesn't exist yet). - docs/cli/hash-static.md: new CLI reference page with when-to-use guidance and examples. - Tests (22 new) covering extraction, file walking, policy building, meta injection idempotency, and entity decoding.
1 parent 78289c8 commit 64060f2

8 files changed

Lines changed: 689 additions & 14 deletions

File tree

‎docs/.vitepress/config.mts‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,12 @@ export default defineConfig({
88
base: '/',
99
ignoreDeadLinks: true,
1010

11+
// Note: the Content-Security-Policy meta tag is NOT declared here — it is
12+
// injected post-build by scripts/generate-csp.mjs, which hashes the actual
13+
// inline content from the built HTML so the policy never drifts from the
14+
// output. See the script header for how to capture runtime-injected hashes.
1115
head: [
1216
['link', { rel: 'icon', type: 'image/png', href: '/favicon.png' }],
13-
[
14-
'meta',
15-
{
16-
'http-equiv': 'Content-Security-Policy',
17-
content:
18-
"default-src 'self'; base-uri 'self'; font-src 'self'; form-action 'self'; img-src 'self' data:; object-src 'none'; script-src-elem 'self' 'sha256-8nM4WrpM1u8GC8Dt6Qu3Cw8hRBgGGhoYBrmd0tBQ+QQ=' 'sha256-DQUgNM9X0iH2019NkUxeBvnBoEoRKkHBc/I0iLqPNPA=' 'sha256-La1r0VSk0Po4KFI0duEKhmPu+u0I416JW3oONqtdf4M='; style-src-attr 'unsafe-hashes' 'sha256-+hZXdsbhLzxxkvd2M1OswNwbdnZLTO/zrekviXJwBXU=' 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=' 'sha256-B10mpnNxgkVfOTJci9xDpY8b+vrCTbVAb2abhZDQzPQ=' 'sha256-K6tyCl4ixBxHA7EhQQQ3FPdMIASAMOY5X3dG/dN495w=' 'sha256-UBwgzPF93ltSrtsHPxvIhqcs2cGF57mEDa4nGXu5Z2E=' 'sha256-Wi3+8jbn12vus9Oq4FOqEUCOpuRG3clBaVvLZZ2b9Fs=' 'sha256-cmdnZAQ9Sz/DBsLKbUCeA/dpDVLa2vMPOArwu9K/DLc=' 'sha256-g3qMmuQ1iSjSYGpi8/uhXrdcZGFemU5eoRtBX6xjeUc=' 'sha256-iYwYhiMcsGmXCUzLEpEzZNz5dINrlkqf1sLbLhEcqGM=' 'sha256-mc/FArgxuOj5vGNP2U9rZeJ70zL4CCOy+qHHI5Pvxr0=' 'sha256-qNJfWKpUa+9t3V7DObXtkSGVtQaK5sVnLbVurtS2IQE=' 'sha256-u9VPJmPho+yfz5iKnELpLCwdgYLsePehd2H36mSWFdQ=' 'sha256-xWy5hUKvawTkfXjCN9TJovpVPCC5q4K1RkOLcTfiqBk='; style-src-elem 'self'",
19-
},
20-
],
2117
['meta', { name: 'theme-color', content: '#5b7ee5' }],
2218
['meta', { name: 'og:type', content: 'website' }],
2319
['meta', { name: 'og:title', content: 'CSP Analyser' }],
@@ -63,6 +59,7 @@ export default defineConfig({
6359
{ text: 'permissions', link: '/cli/permissions' },
6460
{ text: 'sessions', link: '/cli/sessions' },
6561
{ text: 'setup', link: '/cli/setup' },
62+
{ text: 'hash-static', link: '/cli/hash-static' },
6663
],
6764
},
6865
],

‎docs/cli/hash-static.md‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# hash-static
2+
3+
Generate a CSP policy by scanning static HTML files for inline content — no browser or crawl required.
4+
5+
Use this as a post-build step when you ship a static site: it hashes every inline `<script>`, `<style>`, `style="..."` attribute, and `on*="..."` event handler across the built HTML, dedupes across files, and either prints the policy or writes it straight into each `<head>` as a `<meta>` tag.
6+
7+
## When to use this vs `crawl`
8+
9+
| Scenario | Use |
10+
|---|---|
11+
| You have a static build on disk and want deterministic hashes | `hash-static` |
12+
| You need to capture inline content injected by JS at runtime | `crawl` (dynamic) |
13+
| You want both: static build + framework-injected inline content | `hash-static` with `--extra-*` flags seeded from a one-off crawl |
14+
15+
`hash-static` is fast (no Playwright, no network) and ideal for CI. It only sees what is in the HTML on disk — content added by JavaScript at runtime (e.g. some framework hydration scripts) is invisible to it. For those cases, run `crawl` once against a preview server, capture the missing hashes, and feed them back via `--extra-*`.
16+
17+
## Usage
18+
19+
```bash
20+
csp-analyser hash-static <path>... [options]
21+
```
22+
23+
`<path>` may be a file or a directory; directories are walked recursively for `*.html` files. Multiple paths are allowed.
24+
25+
## Options
26+
27+
| Option | Default | Description |
28+
|---|---|---|
29+
| `--inject` | `false` | Rewrite each scanned HTML in place to include the generated CSP as a `<meta http-equiv="Content-Security-Policy">` immediately after `<head>`. Any existing CSP `<meta>` is replaced. |
30+
| `--format <fmt>` | `meta` | Output format when not using `--inject` (see [export formats](../guides/export-formats)). |
31+
| `--report-only` | `false` | Emit `Content-Security-Policy-Report-Only` instead of the enforcing header. |
32+
| `--extra-script-elem <src>` | — | Extra source expression for `script-src-elem`. Repeatable. Use for runtime-injected inline scripts. |
33+
| `--extra-style-elem <src>` | — | Extra source expression for `style-src-elem`. Repeatable. |
34+
| `--extra-style-attr <src>` | — | Extra source expression for `style-src-attr`. Repeatable. |
35+
| `--extra-script-attr <src>` | — | Extra source expression for `script-src-attr`. Repeatable. |
36+
37+
## What it captures
38+
39+
For each HTML file under the given paths, `hash-static` extracts:
40+
41+
- `<script>...</script>` blocks without a `src` attribute → `script-src-elem`
42+
- `<style>...</style>` blocks → `style-src-elem`
43+
- Every `style="..."` attribute value (including empty strings) → `style-src-attr`
44+
- Every `on*="..."` event handler attribute value (including empty strings) → `script-src-attr`
45+
46+
Empty-string values are included deliberately: browsers evaluate CSP against `<div style="">` and require the empty-string SHA-256 (`sha256-47DEQpj8...`) to be listed for the attribute to apply.
47+
48+
When any hashes end up under `style-src-attr` or `script-src-attr`, `'unsafe-hashes'` is added automatically — without it, the browser silently ignores attribute-context hashes (CSP3 §2.3.2).
49+
50+
## Output
51+
52+
The generated directive map always includes a secure baseline:
53+
54+
- `default-src 'self'`
55+
- `base-uri 'self'`
56+
- `form-action 'self'`
57+
- `font-src 'self'`
58+
- `img-src 'self' data:`
59+
- `object-src 'none'`
60+
61+
Plus `script-src-elem` / `style-src-elem` / `style-src-attr` / `script-src-attr` populated from the scan.
62+
63+
## Examples
64+
65+
### Generate and print the meta tag
66+
67+
```bash
68+
csp-analyser hash-static dist/
69+
```
70+
71+
### Inject into every HTML file in the build output
72+
73+
```bash
74+
csp-analyser hash-static dist/ --inject
75+
```
76+
77+
### As a post-build step in `package.json`
78+
79+
```json
80+
{
81+
"scripts": {
82+
"build": "vitepress build docs && csp-analyser hash-static docs/.vitepress/dist --inject"
83+
}
84+
}
85+
```
86+
87+
### Include runtime-injected hashes captured by a prior crawl
88+
89+
```bash
90+
csp-analyser hash-static dist/ --inject \
91+
--extra-style-elem "'sha256-skqujXORqzxt1aE0NNXxujEanPTX6raoqSscTV/Ww/Y='" \
92+
--extra-script-elem "'sha256-someRuntimeInjectedScriptHash='"
93+
```
94+
95+
### Export as Cloudflare Pages `_headers`
96+
97+
```bash
98+
csp-analyser hash-static dist/ --format cloudflare-pages > dist/_headers
99+
```
100+
101+
## Notes
102+
103+
- **HTML parsing is regex-based**, tuned for compliant machine-generated output (VitePress, Next.js, Astro, etc.). Hand-written HTML with unusual quoting may not parse cleanly.
104+
- **Runs without a database** — it does not create a session and cannot be compared, scored, or diffed via the session-based commands.
105+
- If you want to both hash static content *and* capture runtime-injected hashes automatically, run `crawl` against a local preview instead.

‎docs/cli/index.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Usage:
1717
csp-analyser score [session-id] Score policy (defaults to latest session)
1818
csp-analyser sessions List all analysis sessions
1919
csp-analyser permissions [session-id] Show Permissions-Policy headers (defaults to latest)
20+
csp-analyser hash-static <path>... Hash inline content in static HTML files
21+
and emit a CSP (no browser required)
2022
2123
Options:
2224
--depth <n> Crawl depth (default: 1, crawl only)
@@ -50,6 +52,7 @@ Options:
5052
| [`score`](./score) | `[session-id]` | Score a policy against security best practices |
5153
| [`sessions`](./sessions) | -- | List all analysis sessions with ID, status, timestamp, and violation count |
5254
| [`permissions`](./permissions) | `[session-id]` | Show Permissions-Policy headers captured during crawling |
55+
| [`hash-static`](./hash-static) | `<path>...` | Hash inline content in static HTML files and emit or inject a CSP (no browser required) |
5356

5457
## Global options
5558

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@makerx/csp-analyser",
3-
"version": "0.7.3",
3+
"version": "0.8.0",
44
"type": "module",
55
"description": "Headlessly browse websites with a deny-all report-only CSP, capture violations, and generate production-ready CSP policies",
66
"license": "MIT",
@@ -43,7 +43,9 @@
4343
"start": "node dist/mcp-server.js",
4444
"cli": "node dist/cli.js",
4545
"docs:dev": "vitepress dev docs",
46-
"docs:build": "vitepress build docs",
46+
"docs:csp": "node dist/cli.js hash-static docs/.vitepress/dist --inject --extra-style-elem 'sha256-skqujXORqzxt1aE0NNXxujEanPTX6raoqSscTV/Ww/Y='",
47+
"predocs:build": "npm run build",
48+
"docs:build": "vitepress build docs && npm run docs:csp",
4749
"docs:preview": "vitepress preview docs"
4850
},
4951
"dependencies": {

‎src/cli.ts‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type Command =
5757
| 'sessions'
5858
| 'setup'
5959
| 'start'
60+
| 'hash-static'
6061
| 'help'
6162
| 'version';
6263

@@ -77,6 +78,15 @@ export interface ParsedArgs {
7778
strictDynamic: boolean;
7879
hash: boolean;
7980
stripUnsafeEval: boolean;
81+
/** For hash-static: file/directory paths to scan */
82+
inputs?: string[];
83+
/** For hash-static: write the generated meta tag into each scanned HTML */
84+
inject: boolean;
85+
/** For hash-static: extra source expressions to add to specific directives */
86+
extraScriptElem?: string[];
87+
extraStyleElem?: string[];
88+
extraStyleAttr?: string[];
89+
extraScriptAttr?: string[];
8090
}
8191

8292
// ── Valid values ─────────────────────────────────────────────────────────
@@ -108,6 +118,8 @@ Usage:
108118
csp-analyser score [session-id] Score policy (defaults to latest session)
109119
csp-analyser sessions List all analysis sessions
110120
csp-analyser permissions [session-id] Show Permissions-Policy headers (defaults to latest)
121+
csp-analyser hash-static <path>... Hash inline content in static HTML files
122+
and emit a CSP (no browser required)
111123
112124
Options:
113125
--depth <n> Crawl depth (default: 1, crawl only)
@@ -126,6 +138,14 @@ Options:
126138
--no-color Disable colored output (also respects NO_COLOR env)
127139
--help, -h Show this help
128140
--version, -v Show version
141+
142+
hash-static options:
143+
--inject Rewrite each scanned HTML to include the generated
144+
<meta http-equiv="Content-Security-Policy"> in <head>
145+
--extra-script-elem <src> Extra source(s) for script-src-elem (repeatable)
146+
--extra-style-elem <src> Extra source(s) for style-src-elem (repeatable)
147+
--extra-style-attr <src> Extra source(s) for style-src-attr (repeatable)
148+
--extra-script-attr <src> Extra source(s) for script-src-attr (repeatable)
129149
`;
130150

131151
// ── Argument parsing ────────────────────────────────────────────────────
@@ -144,6 +164,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
144164
strictDynamic: false,
145165
hash: false,
146166
stripUnsafeEval: false,
167+
inject: false,
147168
};
148169
}
149170
if (argv.includes('--version') || argv.includes('-v')) {
@@ -158,6 +179,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
158179
strictDynamic: false,
159180
hash: false,
160181
stripUnsafeEval: false,
182+
inject: false,
161183
};
162184
}
163185

@@ -177,6 +199,11 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
177199
'strip-unsafe-eval': { type: 'boolean', default: false },
178200
'report-only': { type: 'boolean', default: false },
179201
'no-color': { type: 'boolean', default: false },
202+
inject: { type: 'boolean', default: false },
203+
'extra-script-elem': { type: 'string', multiple: true },
204+
'extra-style-elem': { type: 'string', multiple: true },
205+
'extra-style-attr': { type: 'string', multiple: true },
206+
'extra-script-attr': { type: 'string', multiple: true },
180207
},
181208
allowPositionals: true,
182209
strict: false,
@@ -201,6 +228,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
201228
'sessions',
202229
'setup',
203230
'start',
231+
'hash-static',
204232
].includes(command)
205233
) {
206234
throw new Error(`Unknown command: ${command ?? '(none)'}. Run with --help for usage.`);
@@ -219,6 +247,7 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
219247
strictDynamic: false,
220248
hash: false,
221249
stripUnsafeEval: false,
250+
inject: false,
222251
};
223252
}
224253

@@ -260,7 +289,9 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
260289
);
261290
}
262291

263-
const format = (values.format as string | undefined) ?? 'header';
292+
// hash-static defaults to meta (since the common path is --inject into HTML)
293+
const format =
294+
(values.format as string | undefined) ?? (command === 'hash-static' ? 'meta' : 'header');
264295
if (!VALID_FORMATS.has(format)) {
265296
throw new Error(
266297
`Invalid format: "${format}". Must be header, meta, nginx, apache, cloudflare, cloudflare-pages, azure-frontdoor, helmet, or json.`,
@@ -278,9 +309,27 @@ export function parseCliArgs(argv: string[]): ParsedArgs {
278309
nonce: ((values.nonce as boolean | undefined) ?? false) || ((values['strict-dynamic'] as boolean | undefined) ?? false),
279310
hash: (values.hash as boolean | undefined) ?? false,
280311
stripUnsafeEval: (values['strip-unsafe-eval'] as boolean | undefined) ?? false,
312+
inject: (values.inject as boolean | undefined) ?? false,
281313
};
282314

283-
if ((command === 'crawl' || command === 'interactive') && positionalArg) {
315+
if (command === 'hash-static') {
316+
// Positional args are file/directory paths to scan. Require at least one.
317+
const inputs = positionals.slice(1) as string[];
318+
if (inputs.length === 0) {
319+
throw new Error(
320+
'Missing path argument for "hash-static". Usage: hash-static <path> [<path>...] [--inject]',
321+
);
322+
}
323+
parsed.inputs = inputs;
324+
const eScript = values['extra-script-elem'] as string[] | undefined;
325+
const eStyle = values['extra-style-elem'] as string[] | undefined;
326+
const eStyleAttr = values['extra-style-attr'] as string[] | undefined;
327+
const eScriptAttr = values['extra-script-attr'] as string[] | undefined;
328+
if (eScript) parsed.extraScriptElem = eScript;
329+
if (eStyle) parsed.extraStyleElem = eStyle;
330+
if (eStyleAttr) parsed.extraStyleAttr = eStyleAttr;
331+
if (eScriptAttr) parsed.extraScriptAttr = eScriptAttr;
332+
} else if ((command === 'crawl' || command === 'interactive') && positionalArg) {
284333
validateTargetUrl(positionalArg);
285334
parsed.url = positionalArg;
286335
} else if (command === 'diff') {
@@ -547,6 +596,47 @@ async function runExportCommand(args: ParsedArgs): Promise<void> {
547596
}
548597
}
549598

599+
async function runHashStaticCommand(args: ParsedArgs): Promise<void> {
600+
const { scanHtmlFiles, buildStaticPolicy, injectCspMeta } = await import(
601+
'./static-html-analyser.js'
602+
);
603+
const { directivesToString } = await import('./policy-formatter.js');
604+
const { readFileSync } = await import('node:fs');
605+
const { writeFileSync } = await import('node:fs');
606+
607+
const paths = args.inputs ?? [];
608+
const { result, files } = await scanHtmlFiles(paths);
609+
const directives = buildStaticPolicy(result, {
610+
extraScriptElem: args.extraScriptElem,
611+
extraStyleElem: args.extraStyleElem,
612+
extraStyleAttr: args.extraStyleAttr,
613+
extraScriptAttr: args.extraScriptAttr,
614+
});
615+
const policyString = directivesToString(directives);
616+
617+
if (args.inject) {
618+
if (files.length === 0) {
619+
throw new Error(`No HTML files found under: ${paths.join(', ')}`);
620+
}
621+
for (const file of files) {
622+
const html = readFileSync(file, 'utf8');
623+
writeFileSync(file, injectCspMeta(html, policyString));
624+
}
625+
process.stdout.write(
626+
`CSP injected into ${files.length} HTML file(s). ` +
627+
`script-src-elem hashes: ${result.scriptElem.size}, ` +
628+
`style-src-elem: ${result.styleElem.size}, ` +
629+
`style-src-attr: ${result.styleAttr.size}, ` +
630+
`script-src-attr: ${result.scriptAttr.size}.\n`,
631+
);
632+
return;
633+
}
634+
635+
// Otherwise format according to --format and print.
636+
const formatted = formatPolicy(directives, args.format, args.reportOnly);
637+
process.stdout.write(formatted + '\n');
638+
}
639+
550640
async function runDiffCommand(args: ParsedArgs): Promise<void> {
551641
if (!args.sessionId || !args.sessionIdB) {
552642
throw new Error('Both session IDs are required for the diff command');
@@ -890,6 +980,9 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
890980
case 'permissions':
891981
await runPermissionsCommand(args);
892982
return;
983+
case 'hash-static':
984+
await runHashStaticCommand(args);
985+
return;
893986
}
894987
} catch (err) {
895988
const message = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)