diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 7569708e7d..e9a66c55a3 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -1108,11 +1108,12 @@ jobs: if: always() run: | set -euo pipefail - if ! command -v convert >/dev/null 2>&1; then + if ! command -v convert >/dev/null 2>&1 || ! command -v tesseract >/dev/null 2>&1; then sudo apt-get update -qq - sudo apt-get install --no-install-recommends -y imagemagick + sudo apt-get install --no-install-recommends -y imagemagick tesseract-ocr fi convert -version + tesseract --version - name: Prepare public history bundle with redacted layout previews id: prepare_public_history diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index af3c18f381..56ceb3772d 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -208,8 +208,9 @@ 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 +24 colors, and removes metadata. It then runs OCR and rejects the whole public +bundle if any letter or digit remains readable. 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`, @@ -384,8 +385,9 @@ 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 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. +previews with an empty OCR result 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 diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 1e5e2a1e85..ac76ff74d0 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -199,11 +199,13 @@ 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. +and metadata, and validates the new PNG again. Tesseract must then find no +readable letter or digit. OCR output is never logged. Any readable text or OCR +failure blocks the whole publication. This is a layout preview, not diagnostic +evidence. The job 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 diff --git a/tests/runner-e2e/history-public-bundle.ts b/tests/runner-e2e/history-public-bundle.ts index 0508f8062c..b8a5ef0db9 100644 --- a/tests/runner-e2e/history-public-bundle.ts +++ b/tests/runner-e2e/history-public-bundle.ts @@ -263,6 +263,24 @@ async function validatePublicPreview(file: string) { }); } +async function assertPreviewHasNoReadableText(file: string) { + const { stdout } = await execFileAsync( + process.env.RUNNER_E2E_TESSERACT_BINARY ?? "tesseract", + [file, "stdout", "--psm", "11", "-l", "eng"], + { + timeout: 30_000, + maxBuffer: 1024 * 1024, + env: { ...process.env, OMP_THREAD_LIMIT: "1" }, + }, + ); + // The report job no longer has provider secrets, so it cannot compare OCR + // output with exact credential values. Reject every readable letter or digit + // instead. Do not include OCR output in the error because it is untrusted. + if (/[\p{L}\p{N}]/u.test(stdout)) { + throw new Error("Public layout preview still contains OCR-readable text"); + } +} + export async function createPublicLayoutPreview( source: string, destination: string, @@ -313,6 +331,7 @@ export async function createPublicLayoutPreview( { timeout: 45_000, maxBuffer: 1024 * 1024 }, ); await validatePublicPreview(output); + await assertPreviewHasNoReadableText(output); await copyFile(output, destination, fsConstants.COPYFILE_EXCL); } finally { await rm(temporary, { recursive: true, force: true }); diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 3e39c0639f..1321c8f00b 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -690,6 +690,8 @@ describe("public repository paid workflow security", () => { expect(workflow).toContain("unexpected_png="); expect(workflow).toContain("passed_count="); expect(workflow).toContain("pnpm test:e2e:runner:history:prepare"); + expect(report).toContain("tesseract-ocr"); + expect(report).toContain("tesseract --version"); expect(report).not.toContain("id-token: write"); expect(report).not.toMatch( /(?:OPENAI|ANTHROPIC|OPENROUTER|DAYTONA)_API_KEY/,