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..2ba0fd0 100644 --- a/docs/P3-ai-spec.md +++ b/docs/P3-ai-spec.md @@ -458,17 +458,32 @@ 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. - -**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. +`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 +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 +493,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; @@ -490,9 +506,11 @@ replay remain 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 @@ -512,5 +530,6 @@ replay remain 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 6e945a5..5be0fc0 100644 --- a/packages/deploy/src/cli.ts +++ b/packages/deploy/src/cli.ts @@ -1,7 +1,8 @@ -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 { verifyDatasetIntegrity } from './dataset'; +import { evaluateDeployFreshness } from './freshness'; import { writeFixtureDataset } from './fixture'; import { DATASET_META_FILE, @@ -26,6 +27,17 @@ function derivedBase(): string { return env.GITHUB_ACTIONS && repo ? `/${repo}/` : '/'; } +/** 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 +87,42 @@ 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 + // 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 (${outcome.expectedSha.slice(0, 12)}…)`, + ); + break; + } + 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\`: \`${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\`.`, + ); + 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..5d2e5eb --- /dev/null +++ b/packages/deploy/src/freshness.ts @@ -0,0 +1,141 @@ +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 + * 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); +} + +/** + * Public URL of the live dataset-meta.json for a project Pages site, or undefined + * when the slug is missing/malformed. Pages subdomains are the owner login + * lowercased; the repo path keeps its case. Pure so it is unit-testable without + * the environment. + */ +export function deriveLiveMetaUrl(repoSlug: string | undefined): string | undefined { + if (!repoSlug || !repoSlug.includes('/')) return undefined; + const [owner, name] = repoSlug.split('/'); + if (!owner || !name) return undefined; + return `https://${owner.toLowerCase()}.github.io/${name}/${DATASET_META_FILE}`; +} + +export interface EvaluateFreshnessOptions { + /** Directory holding main HEAD's stars.json + dataset-meta.json. */ + dataDir: string; + /** Live URL override; when absent it is derived from repoSlug. */ + url?: string; + /** Repo slug ("owner/repo") for URL derivation when url is absent. */ + repoSlug?: string; + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +} + +export interface FreshnessOutcome extends FreshnessResult { + /** The live URL that was actually checked. */ + url: string; +} + +/** + * The full freshness evaluation used by the CLI/workflow. It derives main HEAD's + * VERIFIED fingerprint by re-hashing stars.json and confirming dataset-meta + * agrees (verifyDatasetIntegrity) — so a stale or corrupt committed meta cannot + * "self-certify" the site as fresh against itself — then resolves the live URL, + * fetches, and compares. Every failure mode (missing data, an inconsistent local + * dataset, an underivable URL, an unreachable/malformed live artifact) THROWS, + * fail-closed: the monitor never reports "fresh" from a state it could not verify. + */ +export async function evaluateDeployFreshness( + opts: EvaluateFreshnessOptions, +): 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..5adea8b 100644 --- a/packages/deploy/src/verify.ts +++ b/packages/deploy/src/verify.ts @@ -22,25 +22,108 @@ 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'"]; +/** + * 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 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 `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()?.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; +} /** - * 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. + * 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 { - if (!/http-equiv=["']?Content-Security-Policy["']?/i.test(html)) { + const active = html.replace(//g, ''); + 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; + } + } + if (policyText === undefined) { + throw new Error( + '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(policyText); + + const extra = [...actual.keys()].filter((name) => !(name in EXPECTED_CSP)); + if (extra.length > 0) { 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', + `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 directive of REQUIRED_CSP_DIRECTIVES) { - if (!html.includes(directive)) { + 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 "${name}" directive (SEC-B)`, + ); + } + const wantSorted = [...want].sort().join(' '); + const gotSorted = [...got].sort().join(' '); + if (wantSorted !== gotSorted) { throw new Error( - `Content-Security-Policy is missing the required "${directive}" directive (SEC-B)`, + `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/freshness.test.ts b/packages/deploy/tests/freshness.test.ts new file mode 100644 index 0000000..0cc5766 --- /dev/null +++ b/packages/deploy/tests/freshness.test.ts @@ -0,0 +1,173 @@ +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +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); + +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'); + }); +}); + +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 c54aa27..7a1aaec 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-')); @@ -72,4 +72,115 @@ 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/); + }); + + 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/, + ); + }); + + 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/); + }); + + // 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/, + ); + }); + + 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"/, + ); + }); });