diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index bbedec9..4dfbc14 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -248,6 +248,7 @@ jobs: if: steps.read-model-policy.outputs.evidence_required == 'false' env: STAGED_TARGET_URL: ${{ steps.staged-deployment.outputs.target_url }} + VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }} run: | node --input-type=module <<'NODE' const origin = new URL(process.env.STAGED_TARGET_URL); @@ -260,6 +261,24 @@ jobs: ) { throw new Error("legacy smoke target is not an exact Vercel origin"); } + const automationBypassSecret = + process.env.VERCEL_AUTOMATION_BYPASS_SECRET; + const automationBypassSecretLength = Buffer.byteLength( + automationBypassSecret ?? "", + "utf8", + ); + if ( + typeof automationBypassSecret !== "string" || + automationBypassSecretLength < 32 || + automationBypassSecretLength > 512 || + /[\r\n]/.test(automationBypassSecret) + ) { + throw new Error("legacy smoke automation bypass is unavailable"); + } + const legacySmokeRequestHeaders = Object.freeze({ + Accept: "application/json", + "x-vercel-protection-bypass": automationBypassSecret, + }); const requestJson = async (path) => { let lastError; @@ -267,7 +286,7 @@ jobs: try { const response = await fetch(new URL(path, origin), { redirect: "error", - headers: { Accept: "application/json" }, + headers: legacySmokeRequestHeaders, signal: AbortSignal.timeout(30_000), }); const text = await response.text(); diff --git a/scripts/perf/read-model-ops-source-contracts.mjs b/scripts/perf/read-model-ops-source-contracts.mjs index 0d15dc5..7c288f5 100644 --- a/scripts/perf/read-model-ops-source-contracts.mjs +++ b/scripts/perf/read-model-ops-source-contracts.mjs @@ -969,6 +969,51 @@ export function evaluateReadModelOperationsSourceContracts( stagedWakeGateBlock.includes('--target-url "$STAGED_TARGET_URL"'), "an active fast lane must pass the exact unaliased staged wake canary before attestation", ); + const stagedLegacySmoke = deployWorkflow.indexOf( + "Smoke legacy staged public APIs", + ); + const stagedLegacySmokeEnd = deployWorkflow.indexOf( + "Record legacy-only read path", + ); + const stagedLegacySmokeBlock = + stagedLegacySmoke >= 0 && stagedLegacySmokeEnd > stagedLegacySmoke + ? deployWorkflow.slice(stagedLegacySmoke, stagedLegacySmokeEnd) + : ""; + check( + "ops-protected-legacy-stage-smoke", + stagedLegacySmoke > stagedWakeGateEnd && + stagedLegacySmokeBlock.includes( + "if: steps.read-model-policy.outputs.evidence_required == 'false'", + ) && + stagedLegacySmokeBlock.includes( + "VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}", + ) && + stagedLegacySmokeBlock.includes( + "process.env.VERCEL_AUTOMATION_BYPASS_SECRET", + ) && + stagedLegacySmokeBlock.includes( + 'Buffer.byteLength(\n automationBypassSecret ?? "",\n "utf8",\n )', + ) && + stagedLegacySmokeBlock.includes("automationBypassSecretLength < 32") && + stagedLegacySmokeBlock.includes("automationBypassSecretLength > 512") && + stagedLegacySmokeBlock.includes( + "/[\\r\\n]/.test(automationBypassSecret)", + ) && + stagedLegacySmokeBlock.includes( + '"x-vercel-protection-bypass": automationBypassSecret', + ) && + stagedLegacySmokeBlock.includes("headers: legacySmokeRequestHeaders") && + stagedLegacySmokeBlock.includes( + "STAGED_TARGET_URL: ${{ steps.staged-deployment.outputs.target_url }}", + ) && + (stagedLegacySmokeBlock.match(/\bfetch\(/gu) ?? []).length === 1 && + !stagedLegacySmokeBlock.includes( + "NEXT_PUBLIC_VERCEL_AUTOMATION_BYPASS_SECRET", + ) && + !stagedLegacySmokeBlock.includes("${automationBypassSecret}") && + !stagedLegacySmokeBlock.includes("console."), + "the legacy staged API smoke uses the protected deployment bypass only inside its exact step without exposing it", + ); check( "ops-exact-release-dependency", deployWorkflow.includes("needs: release-gate") && diff --git a/tests/data-pipeline/read-model-deploy-policy.test.ts b/tests/data-pipeline/read-model-deploy-policy.test.ts index 821550b..4970a42 100644 --- a/tests/data-pipeline/read-model-deploy-policy.test.ts +++ b/tests/data-pipeline/read-model-deploy-policy.test.ts @@ -401,6 +401,10 @@ describe("read-model production deploy policy", () => { expect(workflow).toContain("staged-release-attestation.json"); expect(workflow).toContain("attestation_sha256"); expect(workflow).toContain("Smoke legacy staged public APIs"); + expect(workflow).toContain( + '"x-vercel-protection-bypass": automationBypassSecret', + ); + expect(workflow).toContain("headers: legacySmokeRequestHeaders"); expect(workflow).toContain('"/api/ops/health"'); expect(workflow).toContain('"/api/indexers/v1/token-list"'); expect(workflow).toContain("/api/explore/token?address="); diff --git a/tests/data-pipeline/read-model-ops-contract.test.ts b/tests/data-pipeline/read-model-ops-contract.test.ts index 92ff31d..d9b7203 100644 --- a/tests/data-pipeline/read-model-ops-contract.test.ts +++ b/tests/data-pipeline/read-model-ops-contract.test.ts @@ -220,6 +220,91 @@ describe("read-model operations source contract", () => { ); }); + it("fails closed when the protected legacy staged smoke bypass is missing", () => { + const workflowPath = ".github/workflows/deploy-production.yml"; + const legacyStep = + " - name: Smoke legacy staged public APIs\n" + + " if: steps.read-model-policy.outputs.evidence_required == 'false'\n" + + " env:\n" + + " STAGED_TARGET_URL: ${{ steps.staged-deployment.outputs.target_url }}\n" + + " VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}\n"; + const unsafeWorkflow = readFileSync(resolve(ROOT, workflowPath), "utf8").replace( + legacyStep, + legacyStep.replace( + " VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}\n", + "", + ), + ); + expect(unsafeWorkflow).not.toBe( + readFileSync(resolve(ROOT, workflowPath), "utf8"), + ); + const result = evaluateReadModelOperationsSourceContracts(ROOT, { + sourceOverrides: { + ...integratedOverrides(), + [workflowPath]: unsafeWorkflow, + }, + expectedSha256Overrides: fixtureDigests(), + }); + expect(result.failures.map(({ id }: { id: string }) => id)).toContain( + "ops-protected-legacy-stage-smoke", + ); + }); + + it("rejects a legacy staged smoke bypass relocated to another workflow step", () => { + const workflowPath = ".github/workflows/deploy-production.yml"; + const secretLine = + " VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}\n"; + const workflow = readFileSync(resolve(ROOT, workflowPath), "utf8"); + const legacyStepStart = workflow.indexOf( + " - name: Smoke legacy staged public APIs", + ); + const legacyStepEnd = workflow.indexOf( + " - name: Record legacy-only read path", + ); + expect(legacyStepStart).toBeGreaterThanOrEqual(0); + expect(legacyStepEnd).toBeGreaterThan(legacyStepStart); + const legacyStep = workflow.slice(legacyStepStart, legacyStepEnd); + expect(legacyStep).toContain(secretLine); + const unsafeLegacyStep = legacyStep.replace(secretLine, ""); + const unsafeWorkflow = + workflow.slice(0, legacyStepStart) + + unsafeLegacyStep + + workflow.slice(legacyStepEnd).replace( + " - name: Record legacy-only read path\n", + ` - name: Record legacy-only read path\n env:\n${secretLine}`, + ); + const result = evaluateReadModelOperationsSourceContracts(ROOT, { + sourceOverrides: { + ...integratedOverrides(), + [workflowPath]: unsafeWorkflow, + }, + expectedSha256Overrides: fixtureDigests(), + }); + expect(result.failures.map(({ id }: { id: string }) => id)).toContain( + "ops-protected-legacy-stage-smoke", + ); + }); + + it("fails closed when the legacy staged smoke drops the bypass header", () => { + const workflowPath = ".github/workflows/deploy-production.yml"; + const workflow = readFileSync(resolve(ROOT, workflowPath), "utf8"); + const unsafeWorkflow = workflow.replace( + ' "x-vercel-protection-bypass": automationBypassSecret,\n', + "", + ); + expect(unsafeWorkflow).not.toBe(workflow); + const result = evaluateReadModelOperationsSourceContracts(ROOT, { + sourceOverrides: { + ...integratedOverrides(), + [workflowPath]: unsafeWorkflow, + }, + expectedSha256Overrides: fixtureDigests(), + }); + expect(result.failures.map(({ id }: { id: string }) => id)).toContain( + "ops-protected-legacy-stage-smoke", + ); + }); + it("rejects comment-only controls and jointly drifted manifests", () => { const operations = JSON.parse( readFileSync(resolve(ROOT, "config/read-model-operations.v1.json"), "utf8"),