diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 1150d96d54..7569708e7d 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -1104,14 +1104,44 @@ jobs: retention-days: 30 if-no-files-found: error - - name: Verify history source report and private screenshot evidence + - name: Qualify public preview raster sanitizer + if: always() + run: | + set -euo pipefail + if ! command -v convert >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install --no-install-recommends -y imagemagick + fi + convert -version + + - name: Prepare public history bundle with redacted layout previews + id: prepare_public_history + if: always() + run: | + set -euo pipefail + pnpm test:e2e:runner:history:prepare -- \ + "$GITHUB_WORKSPACE/runner-e2e-merged-report/normalized" \ + "$GITHUB_WORKSPACE/runner-e2e-public-report/normalized" + + - name: Upload prepared public history source + if: always() && steps.prepare_public_history.outcome == 'success' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: runner-e2e-public-history-source-${{ github.run_id }}-${{ github.run_attempt }} + path: runner-e2e-public-report/ + retention-days: 1 + if-no-files-found: error + + - name: Verify prepared history source and public layout previews id: history_source_ready if: always() run: | set -euo pipefail - dashboard_root="runner-e2e-merged-report/normalized" - private_screenshot="$(find "$dashboard_root" -type f -name '*.png' -print -quit 2>/dev/null || true)" - if [ -f "$dashboard_root/index.html" ] && [ -n "$private_screenshot" ]; then + dashboard_root="runner-e2e-public-report/normalized" + public_preview="$(find "$dashboard_root/evidence" -type f -path '*/public-visuals/*.png' -print -quit 2>/dev/null || true)" + unexpected_png="$(find "$dashboard_root/evidence" -type f -name '*.png' ! -path '*/public-visuals/*.png' -print -quit 2>/dev/null || true)" + passed_count="$(jq '[.results[] | select(.status == "passed")] | length' "$dashboard_root/normalized-results.json" 2>/dev/null || echo invalid)" + if [ "${{ steps.prepare_public_history.outcome }}" = "success" ] && [ -f "$dashboard_root/index.html" ] && [ -z "$unexpected_png" ] && { [ "$passed_count" = "0" ] || [ -n "$public_preview" ]; }; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" @@ -1154,10 +1184,10 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Download access-controlled normalized campaign + - name: Download prepared public campaign uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - name: runner-e2e-report-${{ github.run_id }}-${{ github.run_attempt }} + name: runner-e2e-public-history-source-${{ github.run_id }}-${{ github.run_attempt }} path: runner-e2e-merged-report - name: Exchange GitHub OIDC identity for scoped AWS credentials diff --git a/package.json b/package.json index 0a7fb82bab..e8aed3cb59 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "test:e2e:runner:dashboard": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/dashboard-regenerate.ts", "test:e2e:runner:models:update": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/openrouter-models-update.ts", "test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts", + "test:e2e:runner:history:prepare": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-public-bundle.ts", "test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts", "test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json", "test:e2e:runner:report": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/report.ts", diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index ad62738f60..af3c18f381 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -204,16 +204,18 @@ usage is labeled `unavailable` or `unpriced`; it is never presented as zero cost. The CI report job stages the same portable site at `normalized/index.html` inside the access-controlled merged report artifact. -Permanent public history has a narrower boundary. Before uploading to S3 or -packaging the optional GitHub Pages artifact, the publisher removes raster and -video evidence, archives, and the generated Playwright/blob/HTML report trees. -It then regenerates the dashboard against only the remaining allowlisted, -inert structured per-attempt evidence (`.json`, `.log`, `.md`, and `.txt`). -Per-attempt XML is excluded because browsers can process XML/XSLT. The root -`junit.xml` remains public because the report aggregator builds it from fixed -markup and XML-escaped fields. Public dashboards therefore contain results and -accounting but no attempt screenshots, videos, traces, or generated Playwright -reports. +Permanent public history has a narrower boundary. A trusted job without +provider or AWS credentials copies successful declared PNG screenshots into a +separate publication tree. It validates the PNG container, reduces each image +to at most 160 pixels on either edge, applies a strong blur, limits the image to +24 colors, and removes metadata. These layout previews preserve coarse UI +state while making rendered task and provider text unreadable. The job then +removes the full-resolution raster files, failure screenshots, video, archives, +SVG, and generated Playwright/blob/HTML report trees. Only the derived +`public-visuals/*.png` previews and allowlisted inert evidence (`.json`, `.log`, +`.md`, and `.txt`) remain. Per-attempt XML is excluded because browsers can +process XML/XSLT. The root `junit.xml` remains public because the report +aggregator builds it from fixed markup and XML-escaped fields. ### Billing interpretation @@ -380,11 +382,12 @@ bundle digest fails closed. GitHub Pages remains the stable latest dashboard. Enable Pages with GitHub Actions as its source and set `RUNNER_FULL_STACK_E2E_PUBLISH_PAGES=true`. -The publisher prunes screenshots, video, archives, and generated report trees, -then regenerates the public dashboard before either the CloudFront-backed S3 -history or optional Pages artifact is created. Public per-attempt evidence is -limited to allowlisted inert structured text. Databases, Paperclip homes, -workspaces, raw/unredacted logs, credentials, and visual evidence are never +The trusted report job creates a separate public bundle before the AWS role is +available. It publishes only blurred, low-resolution, metadata-free layout +previews for successful declared screenshots and allowlisted inert structured +text. The S3/CloudFront history and optional Pages mirror use this same bundle. +Full-resolution and failure screenshots, video, archives, generated reports, +databases, Paperclip homes, workspaces, raw logs, and credentials are never published. Sanitized allowlisted `.log` copies may be public only after exact-value/key-shape scanning and redaction. diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 518e9f1a47..1e5e2a1e85 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -179,8 +179,9 @@ latest pointers are mutable, and S3 versioning makes those updates recoverable. ## Public evidence boundary CloudFront and GitHub Pages are public. Fixture identifiers, timing, token -usage, costs, normalized results, and allowlisted inert structured per-attempt -evidence are expected public data. Screenshots, video, archives, generated +usage, costs, normalized results, allowlisted inert structured per-attempt +evidence, and deliberately degraded layout previews are expected public data. +Full-resolution and failure screenshots, video, archives, generated Playwright/blob/HTML report trees, credentials, Paperclip homes, databases, workspaces, master keys, raw/unredacted logs, and unallowlisted files are not. Only allowlisted `.log` copies that passed exact-value/key-shape scanning and @@ -189,20 +190,29 @@ redaction may cross the public boundary. The packaged evidence uploaded as a 30-day GitHub Actions artifact has a different, access-controlled boundary. Text is exact-value and key-shape scanned and redacted. PNG and WebM are raw-byte scanned but cannot be inspected -for credentials rendered as pixels, so they remain only in local evidence and -the access-controlled artifact. SVG is rejected during packaging because it is -active content. +for credentials rendered as pixels, so full-resolution files remain only in +local evidence and the access-controlled artifact. SVG is rejected during +packaging because it is active content. -Before permanent publication, the campaign publisher prunes raster/video -files, archives, and generated report trees. It then regenerates the dashboard -from the remaining allowlisted `.json`, `.log`, `.md`, and `.txt` evidence and -accepts only that dashboard, normalized JSON/JUnit/summary, fixed -branding assets, and the inert structured evidence paths. Per-attempt XML is -excluded because browsers can process XML/XSLT; the only public XML is the -root `junit.xml`, which the report aggregator constructs from fixed markup and -XML-escaped fields. The same pruned tree feeds both S3/CloudFront history and -the optional GitHub Pages artifact. A leak fails the cell and withholds the -unsafe file. +Before permanent publication, the trusted report job creates a separate tree. +It accepts only declared screenshots from passing results. It validates a +bounded, non-interlaced PNG container before invoking ImageMagick with strict +memory, disk, thread, and time limits. It reduces each image to at most 160 +pixels on either edge, applies a strong blur, limits the palette, removes alpha +and metadata, and validates the new PNG again. This is a layout preview, not +diagnostic evidence. It then prunes full-resolution raster files, all failure +images, video, archives, active SVG, and generated reports. This transformation +runs before AWS credentials are available. The AWS job downloads only the +prepared public tree. + +The remaining allowlist contains `public-visuals/*.png`, `.json`, `.log`, `.md`, +and `.txt` evidence, plus the generated dashboard, normalized +JSON/JUnit/summary, and fixed branding assets. Per-attempt XML is excluded +because browsers can process XML/XSLT. The only public XML is the root +`junit.xml`, which the report aggregator constructs from fixed markup and +XML-escaped fields. The same prepared tree feeds S3/CloudFront history and the +optional GitHub Pages artifact. Any malformed image, transformation error, +unexpected file, or scan failure withholds publication. Rotate the affected credential immediately if a secret-scanning failure or unexpected public object is observed. Preserve the access-controlled Actions diff --git a/tests/runner-e2e/dashboard.ts b/tests/runner-e2e/dashboard.ts index 23f80352f7..5580888c80 100644 --- a/tests/runner-e2e/dashboard.ts +++ b/tests/runner-e2e/dashboard.ts @@ -889,7 +889,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {

Full-stack acceptance campaign

${html(input.title)}

-

A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Visual evidence is retained in the access-controlled workflow artifact; public history contains inert structured evidence only.

+

A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Full-resolution visual evidence stays in the access-controlled workflow artifact. Public history includes blurred low-resolution layout previews and inert structured evidence.

@@ -897,7 +897,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {
${failed}Failed
${html(durationLabel(totalDuration))}Test time
- +
@@ -918,7 +918,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) { ${suiteSections} ${historySection} - +