diff --git a/.github/actions/docker-auth-setup/action.yaml b/.github/actions/docker-auth-setup/action.yaml new file mode 100644 index 00000000000..83d6de05237 --- /dev/null +++ b/.github/actions/docker-auth-setup/action.yaml @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +name: docker-auth-setup +description: > + Isolate a job-scoped Docker config and optionally authenticate to Docker Hub. + Credential predicates and secret mapping stay in the calling workflow YAML. + +runs: + using: composite + steps: + - name: Authenticate to Docker Hub + shell: bash + run: bash "${{ github.action_path }}/setup.sh" diff --git a/.github/actions/docker-auth-setup/setup.sh b/.github/actions/docker-auth-setup/setup.sh new file mode 100755 index 00000000000..a63f48efc5f --- /dev/null +++ b/.github/actions/docker-auth-setup/setup.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")" +chmod 700 "${docker_config}" +export DOCKER_CONFIG="${docker_config}" +printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}" + +if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then + echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls." + exit 0 +fi +if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::error::Docker Hub credentials are required for trusted runs." + exit 1 +fi + +auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted" +: > "${auth_marker}" +chmod 600 "${auth_marker}" +login_succeeded=0 +for attempt in 1 2 3; do + if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then + login_succeeded=1 + break + fi + if [[ "${attempt}" -lt 3 ]]; then + echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." + sleep 5 + fi +done +if [[ "${login_succeeded}" -ne 1 ]]; then + echo "::error::Docker Hub login failed after 3 attempts." + exit 1 +fi diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index 36a0e276688..7aa389ad0c2 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -209,45 +209,11 @@ jobs: # explicit because strict YAML decoders reject 100 or more aliases here. - &dockerhub-auth name: Authenticate to Docker Hub + uses: NVIDIA/NemoClaw/.github/actions/docker-auth-setup@66cf71b3af6c32ab972a1ce0e505499da0b495f2 env: DOCKERHUB_AUTH_REQUIRED: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '1' || '0' }} DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_USERNAME || '' }} DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_TOKEN || '' }} - shell: bash - run: | - set -euo pipefail - docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")" - chmod 700 "${docker_config}" - export DOCKER_CONFIG="${docker_config}" - printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}" - - if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then - echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls." - exit 0 - fi - if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then - echo "::error::Docker Hub credentials are required for trusted E2E runs." - exit 1 - fi - - auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted" - : > "${auth_marker}" - chmod 600 "${auth_marker}" - login_succeeded=0 - for attempt in 1 2 3; do - if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then - login_succeeded=1 - break - fi - if [[ "${attempt}" -lt 3 ]]; then - echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." - sleep 5 - fi - done - if [[ "${login_succeeded}" -ne 1 ]]; then - echo "::error::Docker Hub login failed after 3 attempts." - exit 1 - fi - name: Configure live E2E trace directory env: diff --git a/.github/workflows/sandbox-images-and-e2e.yaml b/.github/workflows/sandbox-images-and-e2e.yaml index b714522b769..2187a59dbe0 100644 --- a/.github/workflows/sandbox-images-and-e2e.yaml +++ b/.github/workflows/sandbox-images-and-e2e.yaml @@ -42,45 +42,11 @@ jobs: # receive Docker Hub credentials (this workflow has no schedule trigger). - &dockerhub-auth name: Authenticate to Docker Hub + uses: NVIDIA/NemoClaw/.github/actions/docker-auth-setup@66cf71b3af6c32ab972a1ce0e505499da0b495f2 env: DOCKERHUB_AUTH_REQUIRED: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '1' || '0' }} DOCKERHUB_USERNAME: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_USERNAME || '' }} DOCKERHUB_TOKEN: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && secrets.DOCKERHUB_TOKEN || '' }} - shell: bash - run: | - set -euo pipefail - docker_config="$(mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX")" - chmod 700 "${docker_config}" - export DOCKER_CONFIG="${docker_config}" - printf 'DOCKER_CONFIG=%s\n' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}" - - if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then - echo "::notice::Docker Hub credentials are withheld for this ref; continuing with anonymous pulls." - exit 0 - fi - if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then - echo "::error::Docker Hub credentials are required for trusted image builds." - exit 1 - fi - - auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted" - : > "${auth_marker}" - chmod 600 "${auth_marker}" - login_succeeded=0 - for attempt in 1 2 3; do - if printf '%s' "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then - login_succeeded=1 - break - fi - if [[ "${attempt}" -lt 3 ]]; then - echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." - sleep 5 - fi - done - if [[ "${login_succeeded}" -ne 1 ]]; then - echo "::error::Docker Hub login failed after 3 attempts." - exit 1 - fi - name: Resolve sandbox base image uses: ./.github/actions/resolve-sandbox-base-image diff --git a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts index 12852e7e5c3..eeaeded4bb9 100644 --- a/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts +++ b/test/e2e/support/dockerhub-auth-workflow-boundary.test.ts @@ -9,7 +9,11 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; - +import { + CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION, + DOCKER_AUTH_SETUP_ACTION, + validateDockerAuthSetupAction, +} from "../../../tools/e2e/docker-auth-setup-workflow-boundary.mts"; import { validateE2eWorkflowBoundary } from "../../../tools/e2e/workflow-boundary.mts"; import { readWorkflow } from "../../helpers/e2e-workflow-contract"; @@ -19,6 +23,13 @@ const CLEANUP_STEP_NAME = "Clean up Docker auth"; const CLEANUP_HELPER_RUN = "bash .github/scripts/docker-auth-cleanup.sh"; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const CLEANUP_HELPER_PATH = path.join(REPO_ROOT, ".github", "scripts", "docker-auth-cleanup.sh"); +const SETUP_HELPER_PATH = path.join( + REPO_ROOT, + ".github", + "actions", + "docker-auth-setup", + "setup.sh", +); type WorkflowStep = Record & { env?: Record; @@ -146,7 +157,7 @@ describe("shared Docker Hub authentication workflow boundary", () => { ); }); - it("rejects trust, isolation, retry, password, and cleanup mapping drift", () => { + it("rejects trust, isolation, pin, and cleanup mapping drift", () => { const errors = validateMutation((workflow) => { const auth = namedStep(workflow.jobs.live, AUTH_STEP_NAME); const cleanup = namedStep(workflow.jobs.live, CLEANUP_STEP_NAME); @@ -158,20 +169,9 @@ describe("shared Docker Hub authentication workflow boundary", () => { ...auth!.env, DOCKERHUB_USERNAME: "${{ secrets.DOCKERHUB_USERNAME }}", }; - auth!.run = String(auth!.run) - .replace( - "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX", - "${GITHUB_WORKSPACE}/docker-config", - ) - .replace("for attempt in 1 2 3; do", "for attempt in 1 2; do") - .replace( - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - 'auth_marker="${GITHUB_WORKSPACE}/login-attempted"', - ) - .replace(': > "${auth_marker}"', 'touch "${auth_marker}"') - .replace('chmod 600 "${auth_marker}"', 'chmod 644 "${auth_marker}"') - .replace("--password-stdin", '--password "${DOCKERHUB_TOKEN}"') - .replaceAll("exit 1", "exit 0"); + auth!.uses = CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION; + auth!.run = "echo inline login"; + auth!.shell = "bash"; cleanup!.if = "success()"; cleanup!.run = `${String(cleanup!.run)} || true`; @@ -189,17 +189,10 @@ describe("shared Docker Hub authentication workflow boundary", () => { expect.arrayContaining([ "canonical Docker Hub auth step must always run so untrusted refs receive an isolated empty Docker config", "canonical Docker Hub auth must gate DOCKERHUB_USERNAME on the trusted repository, main ref, and scheduled/manual events", - 'canonical Docker Hub auth run script must include mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - "canonical Docker Hub auth directory must not use the checkout workspace", - "canonical Docker Hub auth run script must include for attempt in 1 2 3; do", - 'canonical Docker Hub auth run script must include auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - 'canonical Docker Hub auth run script must include : > "${auth_marker}"', - 'canonical Docker Hub auth run script must include chmod 600 "${auth_marker}"', - "canonical Docker Hub auth must create and protect its login-attempt marker after trusted credential validation and before login", - "canonical Docker Hub auth run script must include --password-stdin", - "canonical Docker Hub auth must pass the token only through --password-stdin", - "canonical Docker Hub auth must fail when trusted credentials are missing", - "canonical Docker Hub auth must fail after exhausting login retries", + "canonical Docker Hub auth must not load docker-auth-setup from the target checkout", + "canonical Docker Hub auth must use the reviewed immutable docker-auth-setup action", + "canonical Docker Hub auth step must not embed an inline login script; use the pinned docker-auth-setup action", + "canonical Docker Hub auth step must contain exactly name, uses, and env", "live Docker Hub cleanup step must contain exactly name, if, shell, and run", "live Docker Hub cleanup step must always run", `live Docker Hub cleanup step must run only ${CLEANUP_HELPER_RUN}`, @@ -245,9 +238,14 @@ describe("shared Docker Hub authentication workflow boundary", () => { ); }); + it("keeps the pinned docker-auth-setup action and setup script under provenance review", () => { + expect(validateDockerAuthSetupAction()).toEqual([]); + expect(namedStep(loadWorkflow().jobs.live, AUTH_STEP_NAME)?.uses).toBe( + DOCKER_AUTH_SETUP_ACTION, + ); + }); + it("executes the shared auth script with isolated config and bounded fail-closed retries", () => { - const workflow = loadWorkflow(); - const authScript = String(namedStep(workflow.jobs.live, AUTH_STEP_NAME)?.run ?? ""); const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-docker-auth-script-")); const fakeBin = path.join(directory, "bin"); const runnerTemp = path.join(directory, "runner-temp"); @@ -284,7 +282,7 @@ fi fs.rmSync(callsPath, { force: true }); fs.rmSync(tokensPath, { force: true }); fs.rmSync(githubEnv, { force: true }); - return spawnSync("bash", ["-c", authScript], { + return spawnSync("bash", [SETUP_HELPER_PATH], { encoding: "utf8", env: { ...process.env, @@ -349,7 +347,7 @@ fi expect(missing.status).toBe(1); expect(fs.existsSync(callsPath)).toBe(false); expect(`${missing.stdout}${missing.stderr}`).toContain( - "Docker Hub credentials are required for trusted E2E runs", + "Docker Hub credentials are required for trusted runs", ); } finally { fs.rmSync(directory, { force: true, recursive: true }); diff --git a/test/e2e/support/hermes-secret-boundary-workflow.test.ts b/test/e2e/support/hermes-secret-boundary-workflow.test.ts index f965bdc6c4a..1f0cd73a0b8 100644 --- a/test/e2e/support/hermes-secret-boundary-workflow.test.ts +++ b/test/e2e/support/hermes-secret-boundary-workflow.test.ts @@ -60,7 +60,8 @@ describe("Hermes image workflow secret boundary", () => { ...auth!.env, DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}", }; - auth!.run = auth!.run?.replaceAll("exit 1", "exit 0"); + auth!.run = "echo inline login"; + auth!.shell = "bash"; mainWorkflow.jobs["sandbox-images-and-e2e"].secrets = { inherit: true, }; @@ -68,7 +69,8 @@ describe("Hermes image workflow secret boundary", () => { expect(validateSandboxImagesWorkflow(imageWorkflow, mainWorkflow)).toEqual( expect.arrayContaining([ "sandbox image Docker Hub credentials must be gated to trusted main push/manual runs", - "sandbox image Docker Hub auth must fail closed on missing credentials and retries", + "sandbox image Docker Hub auth step must expose only name, uses, and env", + "sandbox image Docker Hub auth must not embed an inline login script; use the pinned docker-auth-setup action", "main sandbox image caller must map only the optional Docker Hub secrets explicitly", ]), ); diff --git a/test/helpers/vitest-watch-triggers.ts b/test/helpers/vitest-watch-triggers.ts index b4b7fea2db9..259152ccf4f 100644 --- a/test/helpers/vitest-watch-triggers.ts +++ b/test/helpers/vitest-watch-triggers.ts @@ -83,6 +83,13 @@ export const vitestWatchTriggerPatterns: VitestWatchTriggerPattern[] = [ pattern: /(?:^|\/)test\/e2e\/docs\/parity-inventory\.generated\.json$/, testsToRun: runTests("test/e2e/support/e2e-migration-policy.test.ts"), }, + { + pattern: /(?:^|\/)\.github\/actions\/docker-auth-setup\/(?:action\.yaml|setup\.sh)$/, + testsToRun: runTests( + "test/e2e/support/dockerhub-auth-workflow-boundary.test.ts", + "test/e2e/support/sandbox-images-workflow-boundary.test.ts", + ), + }, { pattern: /(?:^|\/)\.github\/workflows\/e2e\.yaml$/, testsToRun: runTests(...E2E_WORKFLOW_CONTRACTS), diff --git a/test/pr-risk-plan.test.ts b/test/pr-risk-plan.test.ts index daa119e1401..027ba5f9485 100644 --- a/test/pr-risk-plan.test.ts +++ b/test/pr-risk-plan.test.ts @@ -224,6 +224,8 @@ describe("deterministic PR risk plan", () => { ".github/workflows/pr.yaml", ".github/actions/prepare-e2e/action.yaml", ".github/actions/upload-e2e-artifacts/action.yaml", + ".github/actions/docker-auth-setup/action.yaml", + ".github/actions/docker-auth-setup/setup.sh", "package-lock.json", "package.json", "vitest.config.ts", diff --git a/test/vitest-watch-triggers.test.ts b/test/vitest-watch-triggers.test.ts index cc2c0c85573..ac30470fc67 100644 --- a/test/vitest-watch-triggers.test.ts +++ b/test/vitest-watch-triggers.test.ts @@ -58,6 +58,8 @@ const OPAQUE_INPUTS = [ ".github/workflows/pr-e2e-gate.yaml", ".github/workflows/platform-vitest-main.yaml", "ci/platform-vitest-macos-requirements.lock", + ".github/actions/docker-auth-setup/action.yaml", + ".github/actions/docker-auth-setup/setup.sh", ] as const; function triggeredBy(relativePath: string): string[] { @@ -112,6 +114,14 @@ describe("Vitest opaque-input watch triggers", () => { expect(triggeredBy("ci/platform-vitest-macos-requirements.lock")).toEqual([ "test/platform-vitest-main-workflow.test.ts", ]); + expect(triggeredBy(".github/actions/docker-auth-setup/action.yaml")).toEqual([ + "test/e2e/support/dockerhub-auth-workflow-boundary.test.ts", + "test/e2e/support/sandbox-images-workflow-boundary.test.ts", + ]); + expect(triggeredBy(".github/actions/docker-auth-setup/setup.sh")).toEqual([ + "test/e2e/support/dockerhub-auth-workflow-boundary.test.ts", + "test/e2e/support/sandbox-images-workflow-boundary.test.ts", + ]); }); it("returns only concrete test files that exist (#6692)", () => { diff --git a/tools/advisors/e2e-recommendations.mts b/tools/advisors/e2e-recommendations.mts index 62bde0712f0..fd86fe86ac2 100644 --- a/tools/advisors/e2e-recommendations.mts +++ b/tools/advisors/e2e-recommendations.mts @@ -33,7 +33,7 @@ const CLOUD_ONBOARD_E2E_PATTERNS: readonly RegExp[] = [ /^scripts\/scorecard\/analyze-trace-timing\.ts$/, /^ci\/onboard-performance-budget\.json$/, /^scripts\/e2e\/sanitize-trace-timing\.py$/, - /^\.github\/actions\/(?:prepare-e2e|upload-e2e-artifacts)\//, + /^\.github\/actions\/(?:prepare-e2e|upload-e2e-artifacts|docker-auth-setup)\//, /^\.github\/workflows\/e2e\.yaml$/, /^test\/e2e\/live\/cloud-onboard\.test\.ts$/, ]; diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index 1896381315f..627d0257c01 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -236,6 +236,7 @@ export const RISK_RULES: readonly RiskRule[] = [ file === "ci/platform-matrix.json" || file === ".github/workflows/e2e.yaml" || file.startsWith(".github/actions/prepare-e2e/") || + file.startsWith(".github/actions/docker-auth-setup/") || file === "src/lib/trace.ts" || file === "scripts/scorecard/analyze-trace-timing.mts" || file === "scripts/e2e/sanitize-trace-timing.py" || @@ -276,7 +277,8 @@ export const RISK_RULES: readonly RiskRule[] = [ file.startsWith("tools/e2e/") || file.startsWith("test/e2e/") || file.startsWith(".github/actions/prepare-e2e/") || - file.startsWith(".github/actions/upload-e2e-artifacts/"), + file.startsWith(".github/actions/upload-e2e-artifacts/") || + file.startsWith(".github/actions/docker-auth-setup/"), }, { id: "sandbox-boundary", diff --git a/tools/e2e/docker-auth-setup-workflow-boundary.mts b/tools/e2e/docker-auth-setup-workflow-boundary.mts new file mode 100644 index 00000000000..c1f52c3686a --- /dev/null +++ b/tools/e2e/docker-auth-setup-workflow-boundary.mts @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import YAML from "yaml"; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const DEFAULT_ACTION_DIR = join(REPO_ROOT, ".github", "actions", "docker-auth-setup"); +const DEFAULT_ACTION_PATH = join(DEFAULT_ACTION_DIR, "action.yaml"); +const DEFAULT_SETUP_PATH = join(DEFAULT_ACTION_DIR, "setup.sh"); + +const DOCKER_AUTH_SETUP_ACTION_PROVENANCE = { + reference: + "NVIDIA/NemoClaw/.github/actions/docker-auth-setup@66cf71b3af6c32ab972a1ce0e505499da0b495f2", + actionYamlSha256: "fc1b656caaa96da3b179b7f2f81d53a8459d5f4d477070c1538bc2384e7f5c1a", + setupShSha256: "7e4eba935281957447a41a3227aeb9a30f242fc549bcac2d87e53667f1379f7b", +} as const; + +export const DOCKER_AUTH_SETUP_ACTION = DOCKER_AUTH_SETUP_ACTION_PROVENANCE.reference; +export const DOCKER_AUTH_SETUP_STEP = "Authenticate to Docker Hub"; +export const CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION = "./.github/actions/docker-auth-setup"; + +type WorkflowRecord = Record; + +function record(value: unknown): WorkflowRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as WorkflowRecord) + : {}; +} + +function sha256(filePath: string): string { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +export function validateDockerAuthSetupAction( + actionPath = DEFAULT_ACTION_PATH, + setupPath = DEFAULT_SETUP_PATH, +): string[] { + const errors: string[] = []; + const actionSource = readFileSync(actionPath, "utf8"); + const setupSource = readFileSync(setupPath, "utf8"); + + if (sha256(actionPath) !== DOCKER_AUTH_SETUP_ACTION_PROVENANCE.actionYamlSha256) { + errors.push( + "docker-auth-setup action.yaml must match the content reviewed at its immutable commit pin", + ); + } + if (sha256(setupPath) !== DOCKER_AUTH_SETUP_ACTION_PROVENANCE.setupShSha256) { + errors.push( + "docker-auth-setup setup.sh must match the content reviewed at its immutable commit pin", + ); + } + + const action = record(YAML.parse(actionSource)); + if (action.name !== "docker-auth-setup") { + errors.push("docker-auth-setup action identity must remain canonical"); + } + if (Object.keys(record(action.inputs)).length !== 0) { + errors.push("docker-auth-setup must not expose inputs; credential mapping stays in YAML"); + } + + const runs = record(action.runs); + if (runs.using !== "composite") { + errors.push("docker-auth-setup must be a composite action"); + } + const expectedSteps = [ + { + name: "Authenticate to Docker Hub", + shell: "bash", + run: 'bash "${{ github.action_path }}/setup.sh"', + }, + ]; + if (!isDeepStrictEqual(runs.steps, expectedSteps)) { + errors.push("docker-auth-setup must run only the reviewed setup.sh from github.action_path"); + } + + for (const fragment of [ + 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', + 'chmod 700 "${docker_config}"', + 'export DOCKER_CONFIG="${docker_config}"', + 'if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then', + "continuing with anonymous pulls", + 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then', + "Docker Hub credentials are required for trusted runs", + 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', + ': > "${auth_marker}"', + 'chmod 600 "${auth_marker}"', + "for attempt in 1 2 3; do", + "timeout 30s docker login docker.io", + '--username "${DOCKERHUB_USERNAME}"', + "--password-stdin", + "Docker Hub login failed after 3 attempts", + ]) { + if (!setupSource.includes(fragment)) { + errors.push(`docker-auth-setup setup.sh must include ${fragment}`); + } + } + if ( + !setupSource.includes("printf 'DOCKER_CONFIG=%s\\n'") || + !setupSource.includes('"${DOCKER_CONFIG}"') || + !setupSource.includes('>> "${GITHUB_ENV}"') + ) { + errors.push( + "docker-auth-setup setup.sh must persist the isolated DOCKER_CONFIG through GITHUB_ENV", + ); + } + if (setupSource.includes("${{ github.workspace }}") || setupSource.includes("GITHUB_WORKSPACE")) { + errors.push("docker-auth-setup setup.sh must not use the checkout workspace"); + } + if (/--password(?:=|\s)(?!-stdin\b)/u.test(setupSource)) { + errors.push("docker-auth-setup must pass the token only through --password-stdin"); + } + + const configIndex = setupSource.indexOf( + 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', + ); + const trustIndex = setupSource.indexOf('if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then'); + const loginIndex = setupSource.indexOf("docker login docker.io"); + if (configIndex < 0 || trustIndex <= configIndex || loginIndex <= trustIndex) { + errors.push( + "docker-auth-setup must isolate Docker config before evaluating trust and authenticating", + ); + } + const missingCredentialsIndex = setupSource.indexOf( + 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then', + ); + const missingCredentialsEndIndex = setupSource.indexOf("\nfi", missingCredentialsIndex); + const markerPathIndex = setupSource.indexOf( + 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', + ); + const markerCreateIndex = setupSource.indexOf(': > "${auth_marker}"'); + const markerChmodIndex = setupSource.indexOf('chmod 600 "${auth_marker}"'); + const retryIndex = setupSource.indexOf("for attempt in 1 2 3; do"); + const missingCredentialsBlock = + missingCredentialsIndex >= 0 && retryIndex > missingCredentialsIndex + ? setupSource.slice(missingCredentialsIndex, retryIndex) + : ""; + if (!missingCredentialsBlock.includes("exit 1")) { + errors.push("docker-auth-setup must fail when trusted credentials are missing"); + } + if ( + missingCredentialsEndIndex < 0 || + markerPathIndex <= missingCredentialsEndIndex || + markerCreateIndex <= markerPathIndex || + markerChmodIndex <= markerCreateIndex || + retryIndex <= markerChmodIndex || + loginIndex <= retryIndex + ) { + errors.push( + "docker-auth-setup must create and protect its login-attempt marker after trusted credential validation and before login", + ); + } + const exhaustedLoginIndex = setupSource.indexOf("Docker Hub login failed after 3 attempts"); + if (exhaustedLoginIndex < 0 || !setupSource.slice(exhaustedLoginIndex).includes("exit 1")) { + errors.push("docker-auth-setup must fail after exhausting login retries"); + } + if ((setupSource.match(/\bexit 1\b/gu) ?? []).length !== 2) { + errors.push( + "docker-auth-setup must fail closed on missing credentials and exhausted login retries", + ); + } + + return errors; +} + +export function validateDockerAuthSetupWorkflowBoundary( + actionPath = DEFAULT_ACTION_PATH, + setupPath = DEFAULT_SETUP_PATH, +): string[] { + return validateDockerAuthSetupAction(actionPath, setupPath); +} diff --git a/tools/e2e/sandbox-images-workflow-boundary.mts b/tools/e2e/sandbox-images-workflow-boundary.mts index 9961e046917..520b2549b8d 100644 --- a/tools/e2e/sandbox-images-workflow-boundary.mts +++ b/tools/e2e/sandbox-images-workflow-boundary.mts @@ -7,6 +7,12 @@ import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import YAML from "yaml"; +import { + CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION, + DOCKER_AUTH_SETUP_ACTION, + validateDockerAuthSetupWorkflowBoundary, +} from "./docker-auth-setup-workflow-boundary.mts"; + const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const DEFAULT_WORKFLOW_PATH = join( REPO_ROOT, @@ -189,50 +195,28 @@ function validateMainCaller(errors: string[], mainWorkflow: SandboxImagesWorkflo } function validateCanonicalAuth(errors: string[], auth: SandboxImagesWorkflowStep): void { - if (!isDeepStrictEqual(sortedKeys(auth), ["env", "name", "run", "shell"])) { - errors.push("sandbox image Docker Hub auth step must expose only name, env, shell, and run"); + if (!isDeepStrictEqual(sortedKeys(auth), ["env", "name", "uses"])) { + errors.push("sandbox image Docker Hub auth step must expose only name, uses, and env"); } - if (auth.shell !== "bash") errors.push("sandbox image Docker Hub auth step must use bash"); - if (!isDeepStrictEqual(record(auth.env), EXPECTED_AUTH_ENV)) { + if (auth.shell !== undefined || auth.run !== undefined) { errors.push( - "sandbox image Docker Hub credentials must be gated to trusted main push/manual runs", + "sandbox image Docker Hub auth must not embed an inline login script; use the pinned docker-auth-setup action", ); } - - const run = typeof auth.run === "string" ? auth.run : ""; - const requiredFragments = [ - 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - 'chmod 700 "${docker_config}"', - 'printf \'DOCKER_CONFIG=%s\\n\' "${DOCKER_CONFIG}" >> "${GITHUB_ENV}"', - 'if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]', - 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]', - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - ': > "${auth_marker}"', - 'chmod 600 "${auth_marker}"', - "for attempt in 1 2 3; do", - `if printf '%s' "\${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "\${DOCKERHUB_USERNAME}" --password-stdin; then`, - "Docker Hub login failed after 3 attempts", - ]; - for (const fragment of requiredFragments) { - if (!run.includes(fragment)) { - errors.push(`sandbox image Docker Hub auth script must include ${fragment}`); - } - } - if (run.includes("GITHUB_WORKSPACE")) { - errors.push("sandbox image Docker Hub auth directory must not use the checkout workspace"); - } - if (/--password(?:[=\s]|$)/u.test(run)) { - errors.push("sandbox image Docker Hub token must be passed only through --password-stdin"); + if (auth.uses === CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION) { + errors.push( + "sandbox image Docker Hub auth must not load docker-auth-setup from the target checkout", + ); } - if ((run.match(/\bexit 1\b/gu) ?? []).length !== 2) { + if (auth.uses !== DOCKER_AUTH_SETUP_ACTION) { errors.push( - "sandbox image Docker Hub auth must fail closed on missing credentials and retries", + "sandbox image Docker Hub auth must use the reviewed immutable docker-auth-setup action", ); } - const isolateIndex = run.indexOf("mktemp -d"); - const trustIndex = run.indexOf('if [[ "${DOCKERHUB_AUTH_REQUIRED}"'); - if (isolateIndex < 0 || trustIndex < 0 || isolateIndex >= trustIndex) { - errors.push("sandbox image Docker config must be isolated before the trust decision"); + if (!isDeepStrictEqual(record(auth.env), EXPECTED_AUTH_ENV)) { + errors.push( + "sandbox image Docker Hub credentials must be gated to trusted main push/manual runs", + ); } } @@ -824,6 +808,7 @@ export function validateSandboxImagesWorkflow( mainWorkflow: SandboxImagesWorkflow, ): string[] { const errors: string[] = []; + errors.push(...validateDockerAuthSetupWorkflowBoundary()); validateTriggersAndPermissions(errors, workflow); validateMainCaller(errors, mainWorkflow); diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index b48767be5a5..60d2d679060 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -10,6 +10,11 @@ import { discoverCredentialFreeTests, SHARED_E2E_JOB_ID, } from "./credential-free-tests.mts"; +import { + CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION, + DOCKER_AUTH_SETUP_ACTION, + validateDockerAuthSetupWorkflowBoundary, +} from "./docker-auth-setup-workflow-boundary.mts"; import { type HermesDashboardWorkflow, validateHermesDashboardWorkflow, @@ -1994,17 +1999,35 @@ function requireCanonicalDockerHubAuthRun( "canonical Docker Hub auth step must always run so untrusted refs receive an isolated empty Docker config", ); } - if (authStep.shell !== "bash") { - errors.push("canonical Docker Hub auth step must use bash"); + if (authStep.shell !== undefined || authStep.run !== undefined) { + errors.push( + "canonical Docker Hub auth step must not embed an inline login script; use the pinned docker-auth-setup action", + ); + } + if (authStep.uses === CHECKOUT_LOCAL_DOCKER_AUTH_SETUP_ACTION) { + errors.push( + "canonical Docker Hub auth must not load docker-auth-setup from the target checkout", + ); } - if (authStep.uses !== undefined) { - errors.push("canonical Docker Hub auth step must use the audited inline retry script"); + if (authStep.uses !== DOCKER_AUTH_SETUP_ACTION) { + errors.push( + "canonical Docker Hub auth must use the reviewed immutable docker-auth-setup action", + ); } if (authStep["continue-on-error"] !== undefined) { errors.push( "canonical Docker Hub auth step must fail closed when trusted authentication fails", ); } + const authKeys = Object.keys(authStep).sort(); + if ( + authKeys.length !== 3 || + authKeys[0] !== "env" || + authKeys[1] !== "name" || + authKeys[2] !== "uses" + ) { + errors.push("canonical Docker Hub auth step must contain exactly name, uses, and env"); + } const authEnv = asRecord(authStep.env); if (authEnv.DOCKERHUB_AUTH_REQUIRED !== GUARDED_DOCKER_HUB_AUTH_REQUIRED) { @@ -2028,87 +2051,6 @@ function requireCanonicalDockerHubAuthRun( if (unexpectedEnv.length > 0) { errors.push("canonical Docker Hub auth step must expose only its three guarded inputs"); } - - const runScript = stringValue(authStep.run); - for (const fragment of [ - 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - 'chmod 700 "${docker_config}"', - 'export DOCKER_CONFIG="${docker_config}"', - 'if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then', - "continuing with anonymous pulls", - 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then', - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - ': > "${auth_marker}"', - 'chmod 600 "${auth_marker}"', - "for attempt in 1 2 3; do", - "timeout 30s docker login docker.io", - '--username "${DOCKERHUB_USERNAME}"', - "--password-stdin", - "Docker Hub login failed after 3 attempts", - ]) { - if (!runScript.includes(fragment)) { - errors.push(`canonical Docker Hub auth run script must include ${fragment}`); - } - } - if ( - !runScript.includes("printf 'DOCKER_CONFIG=%s\\n'") || - !runScript.includes('"${DOCKER_CONFIG}"') || - !runScript.includes('>> "${GITHUB_ENV}"') - ) { - errors.push( - "canonical Docker Hub auth run script must persist the isolated DOCKER_CONFIG through GITHUB_ENV", - ); - } - if (runScript.includes("${{ github.workspace }}") || runScript.includes("GITHUB_WORKSPACE")) { - errors.push("canonical Docker Hub auth directory must not use the checkout workspace"); - } - if (/--password(?:=|\s)(?!-stdin\b)/u.test(runScript)) { - errors.push("canonical Docker Hub auth must pass the token only through --password-stdin"); - } - - const configIndex = runScript.indexOf( - 'mktemp -d "${RUNNER_TEMP}/docker-config-${GITHUB_JOB}-XXXXXX"', - ); - const trustIndex = runScript.indexOf('if [[ "${DOCKERHUB_AUTH_REQUIRED}" != "1" ]]; then'); - const loginIndex = runScript.indexOf("docker login docker.io"); - if (configIndex < 0 || trustIndex <= configIndex || loginIndex <= trustIndex) { - errors.push( - "canonical Docker Hub auth must isolate Docker config before evaluating trust and authenticating", - ); - } - const missingCredentialsIndex = runScript.indexOf( - 'if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then', - ); - const missingCredentialsEndIndex = runScript.indexOf("\nfi", missingCredentialsIndex); - const markerPathIndex = runScript.indexOf( - 'auth_marker="${DOCKER_CONFIG}/.nemoclaw-docker-login-attempted"', - ); - const markerCreateIndex = runScript.indexOf(': > "${auth_marker}"'); - const markerChmodIndex = runScript.indexOf('chmod 600 "${auth_marker}"'); - const retryIndex = runScript.indexOf("for attempt in 1 2 3; do"); - const missingCredentialsBlock = - missingCredentialsIndex >= 0 && retryIndex > missingCredentialsIndex - ? runScript.slice(missingCredentialsIndex, retryIndex) - : ""; - if (!missingCredentialsBlock.includes("exit 1")) { - errors.push("canonical Docker Hub auth must fail when trusted credentials are missing"); - } - if ( - missingCredentialsEndIndex < 0 || - markerPathIndex <= missingCredentialsEndIndex || - markerCreateIndex <= markerPathIndex || - markerChmodIndex <= markerCreateIndex || - retryIndex <= markerChmodIndex || - loginIndex <= retryIndex - ) { - errors.push( - "canonical Docker Hub auth must create and protect its login-attempt marker after trusted credential validation and before login", - ); - } - const exhaustedLoginIndex = runScript.indexOf("Docker Hub login failed after 3 attempts"); - if (exhaustedLoginIndex < 0 || !runScript.slice(exhaustedLoginIndex).includes("exit 1")) { - errors.push("canonical Docker Hub auth must fail after exhausting login retries"); - } } function requireCanonicalDockerHubCleanupRun( @@ -2140,6 +2082,7 @@ function requireCanonicalDockerHubCleanupRun( } function validateDockerHubAuthBoundary(errors: string[], jobs: WorkflowRecord): void { + errors.push(...validateDockerAuthSetupWorkflowBoundary()); const e2eJobNames = Object.entries(jobs) .filter(([jobName, rawJob]) => { const env = asRecord(asRecord(rawJob).env);