From 4ce16639986656b100b53e9ad0a0bbfc30c8a9b1 Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Wed, 8 Jul 2026 00:26:34 +0800 Subject: [PATCH 1/6] feat(deploy): OPS-A deploy-freshness guard + SEC-B frame-ancestors cleanup + P3 closeout status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the roadmap's first Next-item skeleton (the deploy-freshness guard) and records the P3 closeout progress verified this session. Deploy-freshness guard (OPS-A): - packages/deploy/src/freshness.ts — pure parse/compare of the live site's published dataset-meta stars_sha256 vs main HEAD; fetch wrapper throws on network/HTTP/malformed so the monitor never silently concludes "fresh". - `deploy freshness` CLI subcommand: reads main HEAD's dataset-meta, fetches the public live artifact (URL derived from GITHUB_REPOSITORY or --url), exits 0 fresh / 3 on drift (with a step-summary alert) / 1 on unreachable. - .github/workflows/deploy-freshness.yml — daily read-only monitor; no secret, no write, contents:read only. A persistent red run is the silent-freeze signal the OPS-A invariant warns about. - tests cover match, mismatch, missing (404), malformed JSON, schema-invalid, and network failure. Verified live: reports "fresh" against the real Pages site. SEC-B follow-up: - Drop `frame-ancestors 'none'` from the CSP meta. Browsers ignore it (and X-Frame-Options) when delivered via , so it provided no protection and logged a console error on every load. Verified in a real browser that the strict CSP otherwise does not break the app (React mounts, styles apply). P3 closeout status (docs/P3-ai-spec.md), accurate to what was verified: - public-dashboard visual confirmation: DONE (live, under the new CSP); - no-churn replay deterministic half: DONE offline (verify-artifacts + byte- identical re-serialization + hash/count match); - live credentialed planner replay: PENDING (owner's STAR_SYNC_TOKEN step). Also gitignore .playwright-mcp/ browser-session artifacts. Does NOT touch AI budget, PROV-5, max_total_per_run, or semantic search. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k4Jo11N3mwLtb3r4fMMJK --- .github/workflows/deploy-freshness.yml | 48 +++++++++++++++ .gitignore | 3 + apps/dashboard/index.html | 7 ++- docs/P3-ai-spec.md | 33 ++++++++--- packages/deploy/src/cli.ts | 64 +++++++++++++++++++- packages/deploy/src/freshness.ts | 77 ++++++++++++++++++++++++ packages/deploy/tests/freshness.test.ts | 78 +++++++++++++++++++++++++ packages/deploy/tests/verify.test.ts | 2 +- 8 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/deploy-freshness.yml create mode 100644 packages/deploy/src/freshness.ts create mode 100644 packages/deploy/tests/freshness.test.ts diff --git a/.github/workflows/deploy-freshness.yml b/.github/workflows/deploy-freshness.yml new file mode 100644 index 0000000..af3dd85 --- /dev/null +++ b/.github/workflows/deploy-freshness.yml @@ -0,0 +1,48 @@ +name: Deploy freshness + +# OPS-A guard: prove the LIVE site matches main HEAD. The daily sync commits +# fresh data and relies on a workflow-triggering push token to fire pages.yml +# (see sync-stars.yml's OPS-A invariant). If that ever breaks, the commit lands +# but no deploy runs and the site silently freezes on stale data with no failed +# run to notice. This job turns that invisible failure into a red scheduled run. +on: + schedule: + # A few hours after the 05:23 sync + its deploy, so a healthy deploy has + # settled. A one-off drift can be a deploy in flight; a persistent red here + # is the silent-freeze signal. + - cron: '41 9 * * *' + workflow_dispatch: + +# Strictly read-only: this monitor never writes to the repo, needs no Pages or +# id-token permission, and uses NO secret — it reads committed data and fetches +# only the PUBLIC published artifact. +permissions: + contents: read + +concurrency: + group: deploy-freshness + cancel-in-progress: true + +jobs: + freshness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: main + + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # Compares the public live dataset-meta.json against main HEAD's committed + # copy. Exits non-zero on drift (GitHub notifies on a failed scheduled run) + # or if the live site is unreachable/malformed — we never conclude "fresh" + # from a site we could not read. The live URL is derived from + # GITHUB_REPOSITORY; pass --url to override for a custom domain. + - name: check deploy freshness + run: pnpm exec tsx packages/deploy/src/cli.ts freshness --data . diff --git a/.gitignore b/.gitignore index 55f2805..2e033a1 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ classifier-state.json .claude/ .idea/ .vscode/ + +# Playwright MCP browser session artifacts +.playwright-mcp/ diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html index 35c6bfd..1dbd4b0 100644 --- a/apps/dashboard/index.html +++ b/apps/dashboard/index.html @@ -5,10 +5,13 @@ + packages/deploy/src/verify.ts before loosening any directive. NB: + frame-ancestors is intentionally omitted — it (like X-Frame-Options) is + ignored when delivered via , so clickjacking protection is simply + not available on Pages; keeping it here only logged a console error. --> StarLedger diff --git a/docs/P3-ai-spec.md b/docs/P3-ai-spec.md index d59334e..143b6b6 100644 --- a/docs/P3-ai-spec.md +++ b/docs/P3-ai-spec.md @@ -462,13 +462,27 @@ through Pages. The public artifact bytes and metadata hashes were verified again repositories. The third run exercised the configured `max_total_per_run: 3` budget without exceeding it. -**Final operational closeout remains pending.** `verify-ai-provenance` remains -required alongside `verify-agent-artifacts` and CI on `main`. Continue bounded, -manual backfill with exactly one executor enabled. Before P3 is fully closed, -visually confirm the public dashboard's category/tag facets and secondary summary, -then run the executor unchanged after the current collection is fully accounted -for and prove it produces neither an artifact PR nor byte churn. Existing fixture -and no-op tests support this behavior but do not replace the required live replay. +**Operational closeout — status (updated 2026-07-08).** `verify-ai-provenance` +remains required alongside `verify-agent-artifacts` and CI on `main`. Continue +bounded, manual backfill with exactly one executor enabled. + +- ✅ **Public-dashboard visual confirmation — DONE.** Verified live at + `https://f-e-u-e-r.github.io/starledger/` under the new strict CSP: React + mounts and the stylesheet applies (the CSP does not break the app), and the + category/tag facets, AI badge, secondary summary, AI tags, the "N of M + enriched" counter (`5 of 550`), and AI-aware search all render. +- ✅ **No-churn replay, deterministic half — DONE (offline).** + `classifier verify-artifacts` passes on the committed pair, re-serializing the + committed annotations is byte-identical, and the meta `annotations_sha256` + + `annotation_count` match. So the assembler produces zero byte churn on an + unchanged set. +- ⏳ **No-churn replay, live half — PENDING (credentialed).** Running + `classifier plan --current ai-annotations.json` against live README OIDs at the + current base (to prove the planner emits zero jobs) needs a `STAR_SYNC_TOKEN` + and is the owner's step; the offline checks above support but do not replace it. + +Existing fixture and no-op tests support this behavior but do not replace the +required live planner replay. `pnpm p3-gate` is the aggregate gate: typecheck, lint, format, the full test suite (AI schema drift, fingerprint/planner, injection fixtures, structural + provenance @@ -478,8 +492,9 @@ build, and generated-schema drift. ## P3 exit conditions The contract, gate, and implementation conditions below are met and tested. Live -artifact publication is also verified; the visible-dashboard check and no-churn -replay remain pending the operational closeout above. +artifact publication is verified, and (2026-07-08) the visible-dashboard check is +DONE and the deterministic no-churn replay is verified offline; only the live +credentialed planner replay remains pending the operational closeout above. - canonical stars remain untouched by AI; - jobs are generated only by trusted deterministic code; diff --git a/packages/deploy/src/cli.ts b/packages/deploy/src/cli.ts index 6e945a5..682be4c 100644 --- a/packages/deploy/src/cli.ts +++ b/packages/deploy/src/cli.ts @@ -1,7 +1,9 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { argv, env, exit } from 'node:process'; +import { DatasetMetaSchema } from '@starred/schema'; import { verifyDatasetIntegrity } from './dataset'; +import { checkFreshness } from './freshness'; import { writeFixtureDataset } from './fixture'; import { DATASET_META_FILE, @@ -26,6 +28,27 @@ function derivedBase(): string { return env.GITHUB_ACTIONS && repo ? `/${repo}/` : '/'; } +/** Public URL of the live dataset-meta.json for a project Pages site, or undefined. */ +function derivedLiveMetaUrl(): string | undefined { + const slug = env.GITHUB_REPOSITORY; // "owner/repo" + if (!slug || !slug.includes('/')) return undefined; + const [owner, name] = slug.split('/'); + if (!owner || !name) return undefined; + // Pages subdomains are the owner login lowercased; the repo path keeps its case. + return `https://${owner.toLowerCase()}.github.io/${name}/${DATASET_META_FILE}`; +} + +/** Best-effort append to the GitHub Actions run summary (no-op off CI). */ +function appendStepSummary(markdown: string): void { + const path = env.GITHUB_STEP_SUMMARY; + if (!path) return; + try { + appendFileSync(path, markdown + '\n'); + } catch { + /* the summary is diagnostic only; never let it fail the check */ + } +} + async function main(): Promise { const cmd = argv[2]; const dist = flag('dist') ?? DEFAULT_DIST; @@ -75,9 +98,46 @@ async function main(): Promise { console.log(`[deploy] wrote fixture dataset (${r.repoCount} repos) → ${out}`); break; } + case 'freshness': { + // OPS-A: compare the PUBLIC live dataset-meta.json against main HEAD's + // committed copy. Read-only; needs no secret. Exit 3 on drift so the + // scheduled run fails visibly (a one-off can be a deploy in flight; a + // persistent red is a silent freeze). Exit 1 (via main().catch) if the + // live site is unreachable/malformed — we cannot conclude "fresh". + const metaPath = resolve(data, DATASET_META_FILE); + if (!existsSync(metaPath)) throw new Error(`canonical dataset-meta not found in ${data}`); + const expectedSha = DatasetMetaSchema.parse( + JSON.parse(readFileSync(metaPath, 'utf8')), + ).stars_sha256; + const url = flag('url') ?? derivedLiveMetaUrl(); + if (!url) { + throw new Error('could not derive the live URL; pass --url '); + } + const result = await checkFreshness({ url, expectedSha }); + if (result.status === 'fresh') { + console.log( + `[deploy] freshness OK: live site matches main HEAD (${expectedSha.slice(0, 12)}…)`, + ); + break; + } + const detail = + `live site is serving ${result.liveSha.slice(0, 12)}… but main HEAD is ` + + `${expectedSha.slice(0, 12)}… — deploy drift (OPS-A)`; + console.error(`[deploy] DRIFT: ${detail}`); + appendStepSummary( + `### ⚠️ Deploy freshness drift (OPS-A)\n\n` + + `- live \`stars_sha256\`: \`${result.liveSha}\`\n` + + `- main HEAD \`stars_sha256\`: \`${expectedSha}\`\n\n` + + `The published site does not match main HEAD. A single occurrence can be a ` + + `deploy in flight; a persistent drift is a silent freeze — see the OPS-A ` + + `invariant in \`.github/workflows/sync-stars.yml\`.`, + ); + exit(3); + break; + } default: console.error( - 'usage: deploy [--data dir] [--dist dir] [--base /x/] [--out dir]', + 'usage: deploy [--data dir] [--dist dir] [--base /x/] [--out dir] [--url URL]', ); exit(2); } diff --git a/packages/deploy/src/freshness.ts b/packages/deploy/src/freshness.ts new file mode 100644 index 0000000..e53560a --- /dev/null +++ b/packages/deploy/src/freshness.ts @@ -0,0 +1,77 @@ +import { DatasetMetaSchema } from '@starred/schema'; + +/** + * Deploy-freshness guard (OPS-A). The daily sync commits fresh data to main and + * relies on a workflow-triggering push token to fire pages.yml. If that + * invariant ever breaks (e.g. someone swaps the App token for GITHUB_TOKEN), the + * commit still lands but NO deploy runs — the live site silently freezes on + * stale data with no failed run to notice. This guard makes that failure + * OBSERVABLE by comparing the live site's published fingerprint against main + * HEAD. It is strictly read-only: no writes, no secret, only the public artifact. + */ + +export interface FreshnessResult { + status: 'fresh' | 'drift'; + /** stars_sha256 the live site is currently serving. */ + liveSha: string; + /** stars_sha256 committed at main HEAD — what the site SHOULD be serving. */ + expectedSha: string; +} + +/** + * Parse a fetched dataset-meta.json body and return its stars_sha256, validated + * against the canonical schema. Throws (fail-closed) on malformed JSON or a body + * that does not satisfy the committed contract: a monitor that cannot read the + * live fingerprint must alarm, never silently conclude "fresh". + */ +export function parseLiveStarsSha(liveMetaText: string): string { + let raw: unknown; + try { + raw = JSON.parse(liveMetaText); + } catch { + throw new Error('live dataset-meta.json is not valid JSON'); + } + const parsed = DatasetMetaSchema.safeParse(raw); + if (!parsed.success) { + throw new Error('live dataset-meta.json failed dataset-meta schema validation'); + } + return parsed.data.stars_sha256; +} + +/** Pure comparison of the live fingerprint against the expected (main HEAD) one. */ +export function compareFreshness(liveSha: string, expectedSha: string): FreshnessResult { + return { status: liveSha === expectedSha ? 'fresh' : 'drift', liveSha, expectedSha }; +} + +export interface CheckFreshnessOptions { + /** Absolute URL of the live dataset-meta.json, e.g. https://.github.io//dataset-meta.json. */ + url: string; + /** The stars_sha256 committed at main HEAD — what the live site should be serving. */ + expectedSha: string; + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +} + +/** + * Fetch the live dataset-meta.json and compare its fingerprint to main HEAD. A + * network failure or non-200 THROWS — the monitor cannot conclude "fresh" from + * an unreachable site, and a false "fresh" would defeat the guard's whole point. + */ +export async function checkFreshness(opts: CheckFreshnessOptions): Promise { + const doFetch = opts.fetchImpl ?? fetch; + let res: Response; + try { + res = await doFetch(opts.url); + } catch (err) { + throw new Error( + `could not reach the live site (${opts.url}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (!res.ok) { + throw new Error(`live dataset-meta.json → HTTP ${res.status} (${opts.url})`); + } + const liveSha = parseLiveStarsSha(await res.text()); + return compareFreshness(liveSha, opts.expectedSha); +} diff --git a/packages/deploy/tests/freshness.test.ts b/packages/deploy/tests/freshness.test.ts new file mode 100644 index 0000000..cbaa7f8 --- /dev/null +++ b/packages/deploy/tests/freshness.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { checkFreshness, compareFreshness, parseLiveStarsSha } from '../src/freshness'; + +const SHA_A = 'a'.repeat(64); +const SHA_B = 'b'.repeat(64); + +const validLiveMeta = JSON.stringify({ + schema_version: '1.0', + dataset_generated_at: '2026-07-07T00:00:00.000Z', + stars_sha256: SHA_A, + repo_count: 550, +}); + +/** A fetch stub returning a fixed body/status; enough of Response for the guard. */ +function mockFetch(body: string, ok = true, status = 200): typeof fetch { + return (async () => ({ ok, status, text: async () => body })) as unknown as typeof fetch; +} + +const URL = 'https://example.github.io/repo/dataset-meta.json'; + +describe('deploy freshness guard (OPS-A)', () => { + it('MATCH: live fingerprint equals main HEAD → fresh', async () => { + const r = await checkFreshness({ + url: URL, + expectedSha: SHA_A, + fetchImpl: mockFetch(validLiveMeta), + }); + expect(r.status).toBe('fresh'); + expect(r.liveSha).toBe(SHA_A); + }); + + it('MISMATCH: live differs from main HEAD → drift (both fingerprints surfaced)', async () => { + const r = await checkFreshness({ + url: URL, + expectedSha: SHA_B, + fetchImpl: mockFetch(validLiveMeta), + }); + expect(r.status).toBe('drift'); + expect(r.liveSha).toBe(SHA_A); + expect(r.expectedSha).toBe(SHA_B); + }); + + it('MISSING live meta: HTTP 404 → throws (cannot conclude fresh)', async () => { + await expect( + checkFreshness({ + url: URL, + expectedSha: SHA_A, + fetchImpl: mockFetch('not found', false, 404), + }), + ).rejects.toThrow(/HTTP 404/); + }); + + it('MALFORMED live meta: invalid JSON → throws', async () => { + await expect( + checkFreshness({ url: URL, expectedSha: SHA_A, fetchImpl: mockFetch('{not json') }), + ).rejects.toThrow(/not valid JSON/); + }); + + it('MALFORMED live meta: valid JSON but fails the dataset-meta schema → throws', () => { + expect(() => parseLiveStarsSha(JSON.stringify({ stars_sha256: 'short' }))).toThrow( + /schema validation/, + ); + }); + + it('NETWORK failure: fetch rejects → throws (never silently "fresh")', async () => { + const failing = (async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + await expect( + checkFreshness({ url: URL, expectedSha: SHA_A, fetchImpl: failing }), + ).rejects.toThrow(/could not reach/); + }); + + it('compareFreshness is pure', () => { + expect(compareFreshness(SHA_A, SHA_A).status).toBe('fresh'); + expect(compareFreshness(SHA_A, SHA_B).status).toBe('drift'); + }); +}); diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts index c54aa27..763e4f0 100644 --- a/packages/deploy/tests/verify.test.ts +++ b/packages/deploy/tests/verify.test.ts @@ -8,7 +8,7 @@ import { staticSmoke, verifyBuiltArtifact } from '../src/verify'; // Mirror the production index.html CSP meta so the fixture exercises SEC-B. const CSP_META = - ""; + ""; function builtDist(base = '/repo/') { const root = mkdtempSync(join(tmpdir(), 'verify-')); From 9dc3e7d0c0cd7c3e38a91fee504c5560fb7d7fd4 Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Wed, 8 Jul 2026 04:12:15 +0800 Subject: [PATCH 2/6] fix(deploy): address Codex review round 1 on the freshness guard - freshness expected sha now comes from verifyDatasetIntegrity (re-hash stars.json + confirm dataset-meta agrees), so a stale/corrupt committed meta cannot self-certify the site as fresh (Codex: design-robustness). - extract the CLI flow into the pure, testable evaluateDeployFreshness + deriveLiveMetaUrl; add tests for fresh/drift/missing-data/underivable-url/ unreachable and URL derivation (Codex: CLI test gap). - deploy verify now rejects a reintroduced frame-ancestors directive (extracts the CSP from the content attribute so a comment can't trip it) + negative test (Codex: SEC-B not pinned). - reconcile docs/P3-ai-spec.md: coverage 492-at-the-time vs 550-today, visual confirmation marked DONE everywhere, live no-churn replay marked PENDING everywhere (Codex: 3 doc inconsistencies). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k4Jo11N3mwLtb3r4fMMJK --- docs/P3-ai-spec.md | 20 +++-- packages/deploy/src/cli.ts | 53 +++++--------- packages/deploy/src/freshness.ts | 64 ++++++++++++++++ packages/deploy/src/verify.ts | 26 ++++++- packages/deploy/tests/freshness.test.ts | 97 ++++++++++++++++++++++++- packages/deploy/tests/verify.test.ts | 18 +++++ 6 files changed, 231 insertions(+), 47 deletions(-) diff --git a/docs/P3-ai-spec.md b/docs/P3-ai-spec.md index 143b6b6..2ba0fd0 100644 --- a/docs/P3-ai-spec.md +++ b/docs/P3-ai-spec.md @@ -458,9 +458,10 @@ with no secret/backend and acceptable size + latency — see runs produced and merged PRs #17, #18, and #20. Each changed only the public artifact pair, passed CI plus the structural and provenance gates, and deployed through Pages. The public artifact bytes and metadata hashes were verified against -`main`; the current published coverage is five annotations out of 492 canonical -repositories. The third run exercised the configured `max_total_per_run: 3` -budget without exceeding it. +`main`; the published coverage was five annotations out of the 492 canonical +repositories present at the time — still five today, now against 550, as no +executor has run since. The third run exercised the configured +`max_total_per_run: 3` budget without exceeding it. **Operational closeout — status (updated 2026-07-08).** `verify-ai-provenance` remains required alongside `verify-agent-artifacts` and CI on `main`. Continue @@ -505,9 +506,11 @@ credentialed planner replay remains pending the operational closeout above. - the dashboard works fully without AI (fail-soft) and fails closed only on canonical data; - public AI artifact deployment is hash-verified against `main`; -- live AI facets, secondary summaries, and AI-aware search still need visual - confirmation on the public dashboard; -- an unchanged live run produces no artifact churn after bounded backfill; +- live AI facets, secondary summaries, and AI-aware search are visually confirmed + on the public dashboard (2026-07-08); +- an unchanged run produces no artifact churn: proven offline via the + deterministic assembler replay; the live credentialed planner replay is still + pending; - semantic search is explicitly deferred by ADR-001 (lexical search shipped). ## Subsequent milestones @@ -527,5 +530,6 @@ credentialed planner replay remains pending the operational closeout above. - **P3.5 (ADR and live artifact publication delivered; final closeout pending):** vector search is deferred and lexical search shipped. Three validated Claude Routine artifact PRs have merged and deployed five annotations under bounded - budgets. Public dashboard visual confirmation and the post-backfill no-churn - replay remain pending. + budgets. Public dashboard visual confirmation is DONE (2026-07-08) and the + deterministic no-churn replay is verified offline; only the live credentialed + planner replay remains pending. diff --git a/packages/deploy/src/cli.ts b/packages/deploy/src/cli.ts index 682be4c..5be0fc0 100644 --- a/packages/deploy/src/cli.ts +++ b/packages/deploy/src/cli.ts @@ -1,9 +1,8 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { argv, env, exit } from 'node:process'; -import { DatasetMetaSchema } from '@starred/schema'; import { verifyDatasetIntegrity } from './dataset'; -import { checkFreshness } from './freshness'; +import { evaluateDeployFreshness } from './freshness'; import { writeFixtureDataset } from './fixture'; import { DATASET_META_FILE, @@ -28,16 +27,6 @@ function derivedBase(): string { return env.GITHUB_ACTIONS && repo ? `/${repo}/` : '/'; } -/** Public URL of the live dataset-meta.json for a project Pages site, or undefined. */ -function derivedLiveMetaUrl(): string | undefined { - const slug = env.GITHUB_REPOSITORY; // "owner/repo" - if (!slug || !slug.includes('/')) return undefined; - const [owner, name] = slug.split('/'); - if (!owner || !name) return undefined; - // Pages subdomains are the owner login lowercased; the repo path keeps its case. - return `https://${owner.toLowerCase()}.github.io/${name}/${DATASET_META_FILE}`; -} - /** Best-effort append to the GitHub Actions run summary (no-op off CI). */ function appendStepSummary(markdown: string): void { const path = env.GITHUB_STEP_SUMMARY; @@ -100,34 +89,30 @@ async function main(): Promise { } case 'freshness': { // OPS-A: compare the PUBLIC live dataset-meta.json against main HEAD's - // committed copy. Read-only; needs no secret. Exit 3 on drift so the - // scheduled run fails visibly (a one-off can be a deploy in flight; a - // persistent red is a silent freeze). Exit 1 (via main().catch) if the - // live site is unreachable/malformed — we cannot conclude "fresh". - const metaPath = resolve(data, DATASET_META_FILE); - if (!existsSync(metaPath)) throw new Error(`canonical dataset-meta not found in ${data}`); - const expectedSha = DatasetMetaSchema.parse( - JSON.parse(readFileSync(metaPath, 'utf8')), - ).stars_sha256; - const url = flag('url') ?? derivedLiveMetaUrl(); - if (!url) { - throw new Error('could not derive the live URL; pass --url '); - } - const result = await checkFreshness({ url, expectedSha }); - if (result.status === 'fresh') { + // VERIFIED fingerprint (evaluateDeployFreshness re-hashes stars.json so a + // stale committed meta can't self-certify). Read-only; needs no secret. + // Exit 3 on drift so the scheduled run fails visibly (a one-off can be a + // deploy in flight; a persistent red is a silent freeze). Exit 1 (via + // main().catch) if the dataset/live site is unverifiable — never "fresh". + const outcome = await evaluateDeployFreshness({ + dataDir: data, + url: flag('url'), + repoSlug: env.GITHUB_REPOSITORY, + }); + if (outcome.status === 'fresh') { console.log( - `[deploy] freshness OK: live site matches main HEAD (${expectedSha.slice(0, 12)}…)`, + `[deploy] freshness OK: live site matches main HEAD (${outcome.expectedSha.slice(0, 12)}…)`, ); break; } - const detail = - `live site is serving ${result.liveSha.slice(0, 12)}… but main HEAD is ` + - `${expectedSha.slice(0, 12)}… — deploy drift (OPS-A)`; - console.error(`[deploy] DRIFT: ${detail}`); + console.error( + `[deploy] DRIFT: live site is serving ${outcome.liveSha.slice(0, 12)}… but main HEAD is ` + + `${outcome.expectedSha.slice(0, 12)}… — deploy drift (OPS-A)`, + ); appendStepSummary( `### ⚠️ Deploy freshness drift (OPS-A)\n\n` + - `- live \`stars_sha256\`: \`${result.liveSha}\`\n` + - `- main HEAD \`stars_sha256\`: \`${expectedSha}\`\n\n` + + `- live \`stars_sha256\`: \`${outcome.liveSha}\`\n` + + `- main HEAD \`stars_sha256\`: \`${outcome.expectedSha}\`\n\n` + `The published site does not match main HEAD. A single occurrence can be a ` + `deploy in flight; a persistent drift is a silent freeze — see the OPS-A ` + `invariant in \`.github/workflows/sync-stars.yml\`.`, diff --git a/packages/deploy/src/freshness.ts b/packages/deploy/src/freshness.ts index e53560a..5d2e5eb 100644 --- a/packages/deploy/src/freshness.ts +++ b/packages/deploy/src/freshness.ts @@ -1,4 +1,8 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { DatasetMetaSchema } from '@starred/schema'; +import { verifyDatasetIntegrity } from './dataset'; +import { DATASET_META_FILE, STARS_FILE } from './stage'; /** * Deploy-freshness guard (OPS-A). The daily sync commits fresh data to main and @@ -75,3 +79,63 @@ export async function checkFreshness(opts: CheckFreshnessOptions): Promise { + const starsPath = join(opts.dataDir, STARS_FILE); + const metaPath = join(opts.dataDir, DATASET_META_FILE); + if (!existsSync(starsPath) || !existsSync(metaPath)) { + throw new Error( + `canonical dataset (stars.json + dataset-meta.json) not found in ${opts.dataDir}`, + ); + } + const expectedSha = verifyDatasetIntegrity( + readFileSync(starsPath, 'utf8'), + readFileSync(metaPath, 'utf8'), + ).sha256; + const url = opts.url ?? deriveLiveMetaUrl(opts.repoSlug); + if (!url) { + throw new Error('could not derive the live URL; pass --url '); + } + const result = await checkFreshness({ url, expectedSha, fetchImpl: opts.fetchImpl }); + return { ...result, url }; +} diff --git a/packages/deploy/src/verify.ts b/packages/deploy/src/verify.ts index 698e606..f97c2e0 100644 --- a/packages/deploy/src/verify.ts +++ b/packages/deploy/src/verify.ts @@ -24,26 +24,44 @@ function assetUrls(html: string): string[] { /** Directives that must survive into the built index.html (SEC-B). */ const REQUIRED_CSP_DIRECTIVES = ["default-src 'none'", "script-src 'self'"]; +/** + * Directives that are IGNORED when a CSP is delivered via (they need a + * response header Pages cannot set) and only log a console error. They must not + * be reintroduced into the meta policy (SEC-B). + */ +const FORBIDDEN_CSP_DIRECTIVES = ['frame-ancestors']; /** * Assert the Content-Security-Policy meta survived the build (SEC-B). GitHub * Pages cannot send a CSP response header, so this meta is the only backstop; - * if a tooling change ever drops it (or weakens the load-bearing directives), - * the deploy must fail here rather than silently shipping an unprotected page. + * if a tooling change drops it, weakens a load-bearing directive, or reintroduces + * a meta-ineffective one, the deploy must fail here rather than silently shipping + * an unprotected — or console-erroring — page. The policy is extracted from the + * meta's `content` attribute so an explanatory HTML comment mentioning a + * directive name can neither satisfy nor trip these checks. */ function assertContentSecurityPolicy(html: string): void { - if (!/http-equiv=["']?Content-Security-Policy["']?/i.test(html)) { + const meta = html.match(/http-equiv=["']?Content-Security-Policy["']?[\s\S]*?content="([^"]*)"/i); + if (!meta) { throw new Error( 'index.html is missing the Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', ); } + const policy = meta[1] ?? ''; for (const directive of REQUIRED_CSP_DIRECTIVES) { - if (!html.includes(directive)) { + if (!policy.includes(directive)) { throw new Error( `Content-Security-Policy is missing the required "${directive}" directive (SEC-B)`, ); } } + for (const directive of FORBIDDEN_CSP_DIRECTIVES) { + if (policy.includes(directive)) { + throw new Error( + `Content-Security-Policy must not include "${directive}" (SEC-B): it is ignored in a and only logs a console error; framing protection needs a response header Pages cannot set`, + ); + } + } } /** diff --git a/packages/deploy/tests/freshness.test.ts b/packages/deploy/tests/freshness.test.ts index cbaa7f8..0cc5766 100644 --- a/packages/deploy/tests/freshness.test.ts +++ b/packages/deploy/tests/freshness.test.ts @@ -1,5 +1,15 @@ +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { checkFreshness, compareFreshness, parseLiveStarsSha } from '../src/freshness'; +import { writeFixtureDataset } from '../src/fixture'; +import { + checkFreshness, + compareFreshness, + deriveLiveMetaUrl, + evaluateDeployFreshness, + parseLiveStarsSha, +} from '../src/freshness'; const SHA_A = 'a'.repeat(64); const SHA_B = 'b'.repeat(64); @@ -76,3 +86,88 @@ describe('deploy freshness guard (OPS-A)', () => { expect(compareFreshness(SHA_A, SHA_B).status).toBe('drift'); }); }); + +describe('deriveLiveMetaUrl', () => { + it('derives a project Pages URL with the owner login lowercased', () => { + expect(deriveLiveMetaUrl('F-e-u-e-r/starledger')).toBe( + 'https://f-e-u-e-r.github.io/starledger/dataset-meta.json', + ); + }); + + it('returns undefined for a missing or malformed slug', () => { + expect(deriveLiveMetaUrl(undefined)).toBeUndefined(); + expect(deriveLiveMetaUrl('noslash')).toBeUndefined(); + expect(deriveLiveMetaUrl('/repo')).toBeUndefined(); + expect(deriveLiveMetaUrl('owner/')).toBeUndefined(); + }); +}); + +describe('evaluateDeployFreshness (CLI flow, verified main-HEAD fingerprint)', () => { + const url = 'https://example.github.io/repo/dataset-meta.json'; + + function fixtureDir(): { dir: string; sha: string } { + const dir = mkdtempSync(join(tmpdir(), 'freshness-cli-')); + writeFixtureDataset(dir); + const sha = ( + JSON.parse(readFileSync(join(dir, 'dataset-meta.json'), 'utf8')) as { + stars_sha256: string; + } + ).stars_sha256; + return { dir, sha }; + } + + function liveMeta(sha: string): string { + return JSON.stringify({ + schema_version: '1.0', + dataset_generated_at: '2026-07-07T00:00:00.000Z', + stars_sha256: sha, + repo_count: 1, + }); + } + + it('FRESH: live fingerprint equals the re-hashed main-HEAD dataset', async () => { + const { dir, sha } = fixtureDir(); + const outcome = await evaluateDeployFreshness({ + dataDir: dir, + url, + fetchImpl: mockFetch(liveMeta(sha)), + }); + expect(outcome.status).toBe('fresh'); + expect(outcome.expectedSha).toBe(sha); + expect(outcome.url).toBe(url); + }); + + it('DRIFT: live fingerprint differs from main HEAD', async () => { + const { dir, sha } = fixtureDir(); + const outcome = await evaluateDeployFreshness({ + dataDir: dir, + url, + fetchImpl: mockFetch(liveMeta('f'.repeat(64))), + }); + expect(outcome.status).toBe('drift'); + expect(outcome.expectedSha).toBe(sha); + expect(outcome.liveSha).toBe('f'.repeat(64)); + }); + + it('throws when the committed dataset is absent (fail-closed)', async () => { + const empty = mkdtempSync(join(tmpdir(), 'freshness-empty-')); + await expect(evaluateDeployFreshness({ dataDir: empty, url })).rejects.toThrow(/not found/); + }); + + it('throws when the URL cannot be derived and none is passed', async () => { + const { dir } = fixtureDir(); + await expect(evaluateDeployFreshness({ dataDir: dir, repoSlug: undefined })).rejects.toThrow( + /could not derive the live URL/, + ); + }); + + it('propagates an unreachable live site (never silently "fresh")', async () => { + const { dir } = fixtureDir(); + const failing = (async () => { + throw new Error('ECONNREFUSED'); + }) as unknown as typeof fetch; + await expect( + evaluateDeployFreshness({ dataDir: dir, url, fetchImpl: failing }), + ).rejects.toThrow(/could not reach/); + }); +}); diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts index 763e4f0..7c1a998 100644 --- a/packages/deploy/tests/verify.test.ts +++ b/packages/deploy/tests/verify.test.ts @@ -72,4 +72,22 @@ describe('verifyBuiltArtifact / staticSmoke (DEPLOY-1/2, PATH-2)', () => { /Content-Security-Policy/, ); }); + + it('SEC-B: a CSP that reintroduces the meta-ineffective frame-ancestors is rejected', () => { + const root = mkdtempSync(join(tmpdir(), 'verify-')); + const dataDir = join(root, 'data'); + const distDir = join(root, 'dist'); + mkdirSync(dataDir); + mkdirSync(join(distDir, 'assets'), { recursive: true }); + writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n'); + const cspWithFrameAncestors = + ""; + writeFileSync( + join(distDir, 'index.html'), + `${cspWithFrameAncestors}
`, + ); + writeFixtureDataset(dataDir); + stageDashboardData({ dataDir, distDir }); + expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(/frame-ancestors/); + }); }); From 737af3f149baa741a8f1a46da43492de60df5c0e Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Wed, 8 Jul 2026 04:46:30 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(deploy):=20address=20Codex=20review=20r?= =?UTF-8?q?ound=202=20=E2=80=94=20strip=20HTML=20comments=20before=20CSP?= =?UTF-8?q?=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex: the CSP assertion regex matched anywhere in the HTML, so a commented-out `` (which the browser does NOT enforce) could still pass verifyBuiltArtifact. Strip HTML comments before matching so a commented-out or inert CSP meta fails the gate. Adds a regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k4Jo11N3mwLtb3r4fMMJK --- packages/deploy/src/verify.ts | 13 +++++++++---- packages/deploy/tests/verify.test.ts | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/deploy/src/verify.ts b/packages/deploy/src/verify.ts index f97c2e0..20522b2 100644 --- a/packages/deploy/src/verify.ts +++ b/packages/deploy/src/verify.ts @@ -36,12 +36,17 @@ const FORBIDDEN_CSP_DIRECTIVES = ['frame-ancestors']; * Pages cannot send a CSP response header, so this meta is the only backstop; * if a tooling change drops it, weakens a load-bearing directive, or reintroduces * a meta-ineffective one, the deploy must fail here rather than silently shipping - * an unprotected — or console-erroring — page. The policy is extracted from the - * meta's `content` attribute so an explanatory HTML comment mentioning a - * directive name can neither satisfy nor trip these checks. + * an unprotected — or console-erroring — page. HTML comments are stripped first, + * so a commented-out CSP meta (which the browser does NOT enforce) cannot satisfy + * the required-directive check, and an explanatory comment mentioning a directive + * name can neither satisfy nor trip these checks. The policy is then extracted + * from the meta's `content` attribute. */ function assertContentSecurityPolicy(html: string): void { - const meta = html.match(/http-equiv=["']?Content-Security-Policy["']?[\s\S]*?content="([^"]*)"/i); + const active = html.replace(//g, ''); + const meta = active.match( + /http-equiv=["']?Content-Security-Policy["']?[\s\S]*?content="([^"]*)"/i, + ); if (!meta) { throw new Error( 'index.html is missing the Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts index 7c1a998..c75b4d9 100644 --- a/packages/deploy/tests/verify.test.ts +++ b/packages/deploy/tests/verify.test.ts @@ -90,4 +90,23 @@ describe('verifyBuiltArtifact / staticSmoke (DEPLOY-1/2, PATH-2)', () => { stageDashboardData({ dataDir, distDir }); expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(/frame-ancestors/); }); + + it('SEC-B: a commented-out CSP meta is rejected (the browser does not enforce it)', () => { + const root = mkdtempSync(join(tmpdir(), 'verify-')); + const dataDir = join(root, 'data'); + const distDir = join(root, 'dist'); + mkdirSync(dataDir); + mkdirSync(join(distDir, 'assets'), { recursive: true }); + writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n'); + // Well-formed except the CSP meta is commented out — an inert, unenforced policy. + writeFileSync( + join(distDir, 'index.html'), + `
`, + ); + writeFixtureDataset(dataDir); + stageDashboardData({ dataDir, distDir }); + expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow( + /Content-Security-Policy/, + ); + }); }); From fb52167f97c9cc640ec8f700d40d8b789320f9ac Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Wed, 8 Jul 2026 04:55:56 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(deploy):=20address=20Codex=20review=20r?= =?UTF-8?q?ound=203=20=E2=80=94=20bound=20CSP=20check=20to=20one=20?= =?UTF-8?q?=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex: the CSP regex `[\s\S]*?content=` could skip past `>` and borrow a content attribute from a LATER, unrelated meta tag, so a split-tag construct (``) would pass while the CSP meta itself is empty and enforces nothing. Now extract a single `` tag (attributes cannot cross the closing `>`) and read ITS content; an empty CSP meta is rejected. Adds a split-tag regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k4Jo11N3mwLtb3r4fMMJK --- packages/deploy/src/verify.ts | 21 ++++++++++++++------- packages/deploy/tests/verify.test.ts | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/deploy/src/verify.ts b/packages/deploy/src/verify.ts index 20522b2..c007f3f 100644 --- a/packages/deploy/src/verify.ts +++ b/packages/deploy/src/verify.ts @@ -38,21 +38,28 @@ const FORBIDDEN_CSP_DIRECTIVES = ['frame-ancestors']; * a meta-ineffective one, the deploy must fail here rather than silently shipping * an unprotected — or console-erroring — page. HTML comments are stripped first, * so a commented-out CSP meta (which the browser does NOT enforce) cannot satisfy - * the required-directive check, and an explanatory comment mentioning a directive - * name can neither satisfy nor trip these checks. The policy is then extracted - * from the meta's `content` attribute. + * the check, and an explanatory comment mentioning a directive name cannot trip + * it. The policy is read from a SINGLE `` tag (attributes cannot span the + * closing `>`), so directives can never be borrowed from a later unrelated tag's + * `content` attribute (a split-tag bypass). */ function assertContentSecurityPolicy(html: string): void { const active = html.replace(//g, ''); - const meta = active.match( - /http-equiv=["']?Content-Security-Policy["']?[\s\S]*?content="([^"]*)"/i, + const cspTag = (active.match(/]*>/gi) ?? []).find((tag) => + /http-equiv\s*=\s*["']?Content-Security-Policy["']?/i.test(tag), ); - if (!meta) { + if (!cspTag) { throw new Error( 'index.html is missing the Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', ); } - const policy = meta[1] ?? ''; + const content = cspTag.match(/content\s*=\s*"([^"]*)"/i); + if (!content) { + throw new Error( + 'the Content-Security-Policy meta has no content attribute (SEC-B): an empty policy enforces nothing', + ); + } + const policy = content[1] ?? ''; for (const directive of REQUIRED_CSP_DIRECTIVES) { if (!policy.includes(directive)) { throw new Error( diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts index c75b4d9..58788a2 100644 --- a/packages/deploy/tests/verify.test.ts +++ b/packages/deploy/tests/verify.test.ts @@ -109,4 +109,24 @@ describe('verifyBuiltArtifact / staticSmoke (DEPLOY-1/2, PATH-2)', () => { /Content-Security-Policy/, ); }); + + it('SEC-B: directives cannot be borrowed from a later meta tag (split-tag bypass)', () => { + const root = mkdtempSync(join(tmpdir(), 'verify-')); + const dataDir = join(root, 'data'); + const distDir = join(root, 'dist'); + mkdirSync(dataDir); + mkdirSync(join(distDir, 'assets'), { recursive: true }); + writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n'); + // The CSP meta itself has NO content; a later decoy meta carries directives. + // The browser enforces nothing from the empty CSP meta, so this must fail. + const splitTag = + ''; + writeFileSync( + join(distDir, 'index.html'), + `${splitTag}
`, + ); + writeFixtureDataset(dataDir); + stageDashboardData({ dataDir, distDir }); + expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(/content attribute/); + }); }); From 3d1f0eddeea4f987aaf8902c78ffef16175cac3a Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Wed, 8 Jul 2026 12:31:07 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(deploy):=20address=20Codex=20review=20r?= =?UTF-8?q?ound=204=20=E2=80=94=20pin=20the=20whole=20CSP,=20not=20substri?= =?UTF-8?q?ngs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex: the substring checks were too loose — the http-equiv regex matched the non-enforcing Content-Security-Policy-Report-Only, and includes("script-src 'self'") accepted a weakened script-src 'self' 'unsafe-inline'. Replace with a real CSP parser: require http-equiv to equal Content-Security-Policy exactly, parse directives into a map, and assert the WHOLE policy matches a pinned directive->sources map (exact source lists, no extra directives). Adds negative tests for report-only and unsafe-inline. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k4Jo11N3mwLtb3r4fMMJK --- packages/deploy/src/verify.ts | 85 +++++++++++++++++++--------- packages/deploy/tests/verify.test.ts | 34 +++++++++++ 2 files changed, 91 insertions(+), 28 deletions(-) diff --git a/packages/deploy/src/verify.ts b/packages/deploy/src/verify.ts index c007f3f..957c1b0 100644 --- a/packages/deploy/src/verify.ts +++ b/packages/deploy/src/verify.ts @@ -22,35 +22,56 @@ function assetUrls(html: string): string[] { .filter((u) => u.includes('/assets/')); } -/** Directives that must survive into the built index.html (SEC-B). */ -const REQUIRED_CSP_DIRECTIVES = ["default-src 'none'", "script-src 'self'"]; /** - * Directives that are IGNORED when a CSP is delivered via (they need a - * response header Pages cannot set) and only log a console error. They must not - * be reintroduced into the meta policy (SEC-B). + * The EXACT Content-Security-Policy the built index.html must ship (SEC-B). The + * deploy pins the WHOLE policy — every directive and its exact source list — so + * that a dropped directive, a weakened source (e.g. adding `'unsafe-inline'`), an + * extra/meta-ineffective directive (`frame-ancestors`), or a swap to the + * non-enforcing `Content-Security-Policy-Report-Only` all fail the gate rather + * than silently shipping. Change this map deliberately if the policy legitimately + * evolves. Source lists are compared order-insensitively. */ -const FORBIDDEN_CSP_DIRECTIVES = ['frame-ancestors']; +const EXPECTED_CSP: Record = { + 'default-src': ["'none'"], + 'script-src': ["'self'"], + 'style-src': ["'self'"], + 'img-src': ["'self'", 'data:'], + 'font-src': ["'self'"], + 'connect-src': ["'self'"], + 'base-uri': ["'none'"], + 'form-action': ["'none'"], +}; + +/** Parse a CSP policy string into a directive→sources map (directive names lowercased). */ +function parseCspPolicy(policy: string): Map { + const map = new Map(); + for (const part of policy.split(';')) { + const tokens = part.trim().split(/\s+/).filter(Boolean); + const name = tokens.shift(); + if (name) map.set(name.toLowerCase(), tokens); + } + return map; +} /** - * Assert the Content-Security-Policy meta survived the build (SEC-B). GitHub - * Pages cannot send a CSP response header, so this meta is the only backstop; - * if a tooling change drops it, weakens a load-bearing directive, or reintroduces - * a meta-ineffective one, the deploy must fail here rather than silently shipping - * an unprotected — or console-erroring — page. HTML comments are stripped first, - * so a commented-out CSP meta (which the browser does NOT enforce) cannot satisfy - * the check, and an explanatory comment mentioning a directive name cannot trip - * it. The policy is read from a SINGLE `` tag (attributes cannot span the - * closing `>`), so directives can never be borrowed from a later unrelated tag's - * `content` attribute (a split-tag bypass). + * Assert the built index.html ships EXACTLY the pinned Content-Security-Policy + * (SEC-B). GitHub Pages cannot send a CSP response header, so this meta is the + * only backstop; the deploy must fail here rather than ship a missing, weakened, + * report-only, or console-erroring policy. HTML comments are stripped first (a + * commented-out CSP is not browser-enforced); the policy is read from a SINGLE + * `` tag whose `http-equiv` equals `Content-Security-Policy` exactly (so + * a `-Report-Only` suffix or a later unrelated tag cannot satisfy the check), and + * every directive's source list must match the pin exactly. */ function assertContentSecurityPolicy(html: string): void { const active = html.replace(//g, ''); - const cspTag = (active.match(/]*>/gi) ?? []).find((tag) => - /http-equiv\s*=\s*["']?Content-Security-Policy["']?/i.test(tag), - ); + const cspTag = (active.match(/]*>/gi) ?? []).find((tag) => { + const equiv = tag.match(/http-equiv\s*=\s*["']?([^"'>\s]+)["']?/i); + return equiv !== null && equiv[1]!.toLowerCase() === 'content-security-policy'; + }); if (!cspTag) { throw new Error( - 'index.html is missing the Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', + 'index.html is missing an enforcing Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', ); } const content = cspTag.match(/content\s*=\s*"([^"]*)"/i); @@ -59,18 +80,26 @@ function assertContentSecurityPolicy(html: string): void { 'the Content-Security-Policy meta has no content attribute (SEC-B): an empty policy enforces nothing', ); } - const policy = content[1] ?? ''; - for (const directive of REQUIRED_CSP_DIRECTIVES) { - if (!policy.includes(directive)) { + const actual = parseCspPolicy(content[1] ?? ''); + + const extra = [...actual.keys()].filter((name) => !(name in EXPECTED_CSP)); + if (extra.length > 0) { + throw new Error( + `Content-Security-Policy has unexpected directive(s) "${extra.join(', ')}" (SEC-B): update the pinned policy deliberately (header-only directives such as frame-ancestors are ineffective in a )`, + ); + } + for (const [name, want] of Object.entries(EXPECTED_CSP)) { + const got = actual.get(name); + if (!got) { throw new Error( - `Content-Security-Policy is missing the required "${directive}" directive (SEC-B)`, + `Content-Security-Policy is missing the required "${name}" directive (SEC-B)`, ); } - } - for (const directive of FORBIDDEN_CSP_DIRECTIVES) { - if (policy.includes(directive)) { + const wantSorted = [...want].sort().join(' '); + const gotSorted = [...got].sort().join(' '); + if (wantSorted !== gotSorted) { throw new Error( - `Content-Security-Policy must not include "${directive}" (SEC-B): it is ignored in a and only logs a console error; framing protection needs a response header Pages cannot set`, + `Content-Security-Policy directive "${name}" must be exactly "${want.join(' ')}" but is "${got.join(' ')}" (SEC-B): no weakening (e.g. 'unsafe-inline') is allowed`, ); } } diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts index 58788a2..16cf6e4 100644 --- a/packages/deploy/tests/verify.test.ts +++ b/packages/deploy/tests/verify.test.ts @@ -129,4 +129,38 @@ describe('verifyBuiltArtifact / staticSmoke (DEPLOY-1/2, PATH-2)', () => { stageDashboardData({ dataDir, distDir }); expect(() => verifyBuiltArtifact({ distDir, base: '/repo/' })).toThrow(/content attribute/); }); + + // Build a staged dist whose contains exactly `headHtml`, for CSP tests. + function distWithHead(headHtml: string): string { + const root = mkdtempSync(join(tmpdir(), 'verify-')); + const dataDir = join(root, 'data'); + const distDir = join(root, 'dist'); + mkdirSync(dataDir); + mkdirSync(join(distDir, 'assets'), { recursive: true }); + writeFileSync(join(distDir, 'assets', 'index-abc.js'), 'console.log(1)\n'); + writeFileSync( + join(distDir, 'index.html'), + `${headHtml}
`, + ); + writeFixtureDataset(dataDir); + stageDashboardData({ dataDir, distDir }); + return distDir; + } + + it('SEC-B: a non-enforcing Content-Security-Policy-Report-Only meta is rejected', () => { + const reportOnly = CSP_META.replace( + 'http-equiv="Content-Security-Policy"', + 'http-equiv="Content-Security-Policy-Report-Only"', + ); + expect(() => + verifyBuiltArtifact({ distDir: distWithHead(reportOnly), base: '/repo/' }), + ).toThrow(/enforcing Content-Security-Policy/); + }); + + it("SEC-B: a weakened script-src (adds 'unsafe-inline') is rejected", () => { + const weakened = CSP_META.replace("script-src 'self'", "script-src 'self' 'unsafe-inline'"); + expect(() => verifyBuiltArtifact({ distDir: distWithHead(weakened), base: '/repo/' })).toThrow( + /unsafe-inline/, + ); + }); }); From 90cfe0021fae7a1471b04386b60cbfadf053116e Mon Sep 17 00:00:00 2001 From: F-e-u-e-r Date: Wed, 8 Jul 2026 12:45:34 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(deploy):=20address=20Codex=20review=20r?= =?UTF-8?q?ound=205=20=E2=80=94=20real=20attribute=20parsing=20+=20duplica?= =?UTF-8?q?te=20directives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex: (1) substring matching accepted decoy attributes — a `` passed while the browser enforced no CSP; (2) duplicate directives were overwritten (last wins), but browsers enforce the FIRST, so `script-src 'self' 'unsafe-inline'; script-src 'self'` slipped a weakened policy through. Now parse real `name="value"` attributes with proper name boundaries (data-* captured under its own name) and reject any duplicate directive. Adds decoy-attribute and duplicate-directive regression tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011k4Jo11N3mwLtb3r4fMMJK --- packages/deploy/src/verify.ts | 54 ++++++++++++++++++++-------- packages/deploy/tests/verify.test.ts | 20 +++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/deploy/src/verify.ts b/packages/deploy/src/verify.ts index 957c1b0..5adea8b 100644 --- a/packages/deploy/src/verify.ts +++ b/packages/deploy/src/verify.ts @@ -42,13 +42,39 @@ const EXPECTED_CSP: Record = { 'form-action': ["'none'"], }; -/** Parse a CSP policy string into a directive→sources map (directive names lowercased). */ +/** + * Parse `name="value"` attributes from a single tag string (lowercased names, + * first occurrence wins — the order browsers honor). The leading `(?:^|\s)` + * requires a real attribute-name boundary, so a decoy like `data-http-equiv` + * is captured under its own name and never mistaken for `http-equiv`. + */ +function parseTagAttributes(tag: string): Map { + const attrs = new Map(); + for (const m of tag.matchAll(/(?:^|\s)([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*"([^"]*)"/g)) { + const name = m[1]!.toLowerCase(); + if (!attrs.has(name)) attrs.set(name, m[2] ?? ''); + } + return attrs; +} + +/** + * Parse a CSP policy string into a directive→sources map (directive names + * lowercased). A DUPLICATE directive is rejected: browsers enforce only the + * first occurrence, so a duplicate could hide a weakened first directive behind + * a pinned-looking second one. + */ function parseCspPolicy(policy: string): Map { const map = new Map(); for (const part of policy.split(';')) { const tokens = part.trim().split(/\s+/).filter(Boolean); - const name = tokens.shift(); - if (name) map.set(name.toLowerCase(), tokens); + const name = tokens.shift()?.toLowerCase(); + if (!name) continue; + if (map.has(name)) { + throw new Error( + `Content-Security-Policy has a duplicate "${name}" directive (SEC-B): browsers enforce only the first, so a duplicate can hide a weakened policy`, + ); + } + map.set(name, tokens); } return map; } @@ -65,22 +91,20 @@ function parseCspPolicy(policy: string): Map { */ function assertContentSecurityPolicy(html: string): void { const active = html.replace(//g, ''); - const cspTag = (active.match(/]*>/gi) ?? []).find((tag) => { - const equiv = tag.match(/http-equiv\s*=\s*["']?([^"'>\s]+)["']?/i); - return equiv !== null && equiv[1]!.toLowerCase() === 'content-security-policy'; - }); - if (!cspTag) { - throw new Error( - 'index.html is missing an enforcing Content-Security-Policy meta (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', - ); + let policyText: string | undefined; + for (const tag of active.match(/]*>/gi) ?? []) { + const attrs = parseTagAttributes(tag); + if (attrs.get('http-equiv')?.toLowerCase() === 'content-security-policy') { + policyText = attrs.get('content'); // undefined ⇒ the CSP meta has no content + break; + } } - const content = cspTag.match(/content\s*=\s*"([^"]*)"/i); - if (!content) { + if (policyText === undefined) { throw new Error( - 'the Content-Security-Policy meta has no content attribute (SEC-B): an empty policy enforces nothing', + 'index.html is missing an enforcing Content-Security-Policy meta with a content attribute (SEC-B): Pages cannot set a CSP header, so this meta is the only backstop', ); } - const actual = parseCspPolicy(content[1] ?? ''); + const actual = parseCspPolicy(policyText); const extra = [...actual.keys()].filter((name) => !(name in EXPECTED_CSP)); if (extra.length > 0) { diff --git a/packages/deploy/tests/verify.test.ts b/packages/deploy/tests/verify.test.ts index 16cf6e4..7a1aaec 100644 --- a/packages/deploy/tests/verify.test.ts +++ b/packages/deploy/tests/verify.test.ts @@ -163,4 +163,24 @@ describe('verifyBuiltArtifact / staticSmoke (DEPLOY-1/2, PATH-2)', () => { /unsafe-inline/, ); }); + + it('SEC-B: decoy data-* attributes do not satisfy the CSP check', () => { + const decoy = CSP_META.replace('http-equiv=', 'data-http-equiv=').replace( + 'content=', + 'data-content=', + ); + expect(() => verifyBuiltArtifact({ distDir: distWithHead(decoy), base: '/repo/' })).toThrow( + /enforcing Content-Security-Policy/, + ); + }); + + it('SEC-B: a duplicate directive (weakened first, pinned second) is rejected', () => { + const dup = CSP_META.replace( + "script-src 'self'", + "script-src 'self' 'unsafe-inline'; script-src 'self'", + ); + expect(() => verifyBuiltArtifact({ distDir: distWithHead(dup), base: '/repo/' })).toThrow( + /duplicate "script-src"/, + ); + }); });