diff --git a/.github/workflows/container-ci.yml b/.github/workflows/container-ci.yml index 1dee6839a..ee0d244aa 100644 --- a/.github/workflows/container-ci.yml +++ b/.github/workflows/container-ci.yml @@ -11,6 +11,7 @@ on: - compose.yaml - compose.apparmor.yaml - compose.findings.yaml + - compose.runner.yaml - docker/** - plugins/codex-security/** - sdk/typescript/** @@ -23,6 +24,7 @@ on: - compose.yaml - compose.apparmor.yaml - compose.findings.yaml + - compose.runner.yaml - docker/** - plugins/codex-security/** - sdk/typescript/** @@ -109,6 +111,8 @@ jobs: bun-version: "1.3.14" - name: Verify findings service and persistent SQLite storage + env: + CODEX_SECURITY_IMAGE: codex-security:ci run: bun sdk/typescript/scripts/smoke-findings-service.ts - name: Validate hardened customer Compose configuration diff --git a/.github/workflows/container-release-image.yml b/.github/workflows/container-release-image.yml new file mode 100644 index 000000000..054bbe770 --- /dev/null +++ b/.github/workflows/container-release-image.yml @@ -0,0 +1,653 @@ +name: container-release-image + +on: + workflow_call: + inputs: + target: + required: true + type: string + package: + required: true + type: string + +permissions: + contents: read + +jobs: + validate: + name: validate-linux-${{ matrix.architecture }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - architecture: amd64 + runner: ubuntu-24.04 + - architecture: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build native customer image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + env: + DOCKER_BUILD_RECORD_UPLOAD: "false" + with: + context: . + target: ${{ inputs.target }} + load: true + platforms: linux/${{ matrix.architecture }} + push: false + tags: codex-security:release-candidate + cache-from: type=gha,scope=${{ inputs.package }}-${{ matrix.architecture }} + cache-to: ${{ github.event_name != 'pull_request' && format('type=gha,mode=max,scope={0}-{1}', inputs.package, matrix.architecture) || '' }} + + - name: Verify native image + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + TARGET: ${{ inputs.target }} + shell: bash + run: | + set -euo pipefail + actual_architecture="$(docker image inspect --format '{{.Architecture}}' codex-security:release-candidate)" + if [[ "$actual_architecture" != "$EXPECTED_ARCHITECTURE" ]]; then + echo "Expected a native $EXPECTED_ARCHITECTURE image; found $actual_architecture." >&2 + exit 1 + fi + if [[ "$TARGET" == scanner ]]; then + docker run --rm codex-security:release-candidate --version + docker run --rm codex-security:release-candidate bulk-scan --help + docker run --rm codex-security:release-candidate info --json + fi + [[ "$(docker run --rm --entrypoint id codex-security:release-candidate -u)" == 10001 ]] + + - name: Verify host-aware AppArmor sandbox selection + if: inputs.target == 'scanner' + shell: bash + run: | + set -euo pipefail + docker run --rm --entrypoint /bin/sh codex-security:release-candidate -ec ' + command_directory="$(mktemp -d)" + trap '\''rm -rf "$command_directory"'\'' EXIT + printf "%s\\n" "#!/bin/sh" '\''printf "%s\\n" "$@"'\'' > "$command_directory/codex-security" + chmod 755 "$command_directory/codex-security" + + actual="$( + PATH="$command_directory:$PATH" \ + /usr/local/bin/codex-security-entrypoint \ + bulk-scan /input/repositories.csv --output-dir /output + )" + restricted_user_namespaces= + if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + IFS= read -r restricted_user_namespaces \ + < /proc/sys/kernel/apparmor_restrict_unprivileged_userns || true + fi + + apparmor_profile= + if [ -r /proc/self/attr/current ]; then + IFS= read -r apparmor_profile < /proc/self/attr/current || true + fi + + if [ "$restricted_user_namespaces" = 1 ] && + [ "$apparmor_profile" != "codex-security-container (enforce)" ]; then + printf "%s\\n" "$actual" | grep -Fxq features.use_legacy_landlock=true + elif printf "%s\\n" "$actual" | grep -Fxq features.use_legacy_landlock=true; then + printf "%s\\n" "Landlock must not be forced when the preferred sandbox is available." >&2 + exit 1 + fi + ' + + - name: Verify hardened Codex command sandbox + if: inputs.target == 'scanner' + shell: bash + run: | + set -euo pipefail + command=( + docker run --rm + --cap-drop ALL + --security-opt no-new-privileges + --security-opt "seccomp=$GITHUB_WORKSPACE/docker/codex-security-seccomp.json" + --entrypoint node + codex-security:release-candidate + /usr/local/lib/node_modules/@openai/codex-security/node_modules/@openai/codex/bin/codex.js + ) + + if output="$("${command[@]}" sandbox /usr/bin/true 2>&1)"; then + printf '%s\n' "$output" + elif grep -Eq 'bwrap: (Failed to make / slave: Permission denied|loopback: Failed RTM_NEW(ADDR|LINK): Operation not permitted|setting up uid map: Permission denied|No permissions to create a new namespace)' <<< "$output"; then + echo '::notice::This Docker host blocks nested Bubblewrap namespaces; verifying the supported Landlock fallback.' + "${command[@]}" sandbox --enable use_legacy_landlock /usr/bin/true + else + printf 'The hardened Codex sandbox failed unexpectedly:\n%s\n' "$output" >&2 + exit 1 + fi + + - name: Verify host-scoped Git credentials + if: inputs.target == 'scanner' + shell: bash + run: | + set -euo pipefail + docker run --rm \ + --entrypoint /bin/sh \ + --env GH_TOKEN=SYNTHETIC_GITHUB_TOKEN \ + codex-security:release-candidate \ + -ec 'actual="$(printf "protocol=https\nhost=github.com\n\n" | /usr/local/bin/codex-security-git-credential get)"; test "$actual" = "$(printf "username=x-access-token\npassword=SYNTHETIC_GITHUB_TOKEN")"; test -z "$(printf "protocol=https\nhost=untrusted.example\n\n" | /usr/local/bin/codex-security-git-credential get)"' + + - name: Verify hardened customer Compose configuration + if: inputs.target == 'scanner' + env: + CODEX_SECURITY_IMAGE: codex-security:release-candidate + shell: bash + run: | + set -euo pipefail + mkdir -p results state + chmod 700 results state + printf 'id,repository,revision\n' > repositories.csv + CODEX_SECURITY_USER="$(id -u):$(id -g)" + export CODEX_SECURITY_USER + docker compose config --quiet + docker compose run --rm codex-security --version + if output="$(docker compose run --rm codex-security 2>&1)"; then + echo 'An empty repository CSV must not start a security scan.' >&2 + exit 1 + else + status=$? + fi + if [[ "$status" -ne 2 ]] || ! grep -Fq 'Multiscan CSV must contain at least one repository.' <<< "$output"; then + printf 'Unexpected empty-repository scan behavior:\n%s\n' "$output" >&2 + exit 1 + fi + + - name: Verify optional hardened AppArmor Compose override + if: inputs.target == 'scanner' + env: + CODEX_SECURITY_IMAGE: codex-security:release-candidate + shell: bash + run: | + set -euo pipefail + CODEX_SECURITY_USER="$(id -u):$(id -g)" + export CODEX_SECURITY_USER + compose=(docker compose -f compose.yaml -f compose.apparmor.yaml) + + "${compose[@]}" config --format json | + jq --exit-status ' + .services["codex-security"].security_opt as $options | + ($options | index("apparmor=codex-security-container")) != null and + ($options | index("no-new-privileges:true")) != null and + any($options[]; startswith("seccomp=")) + ' > /dev/null + + if ! docker info --format '{{json .SecurityOptions}}' | + grep -Fq '"name=apparmor"'; then + echo '::notice::This Docker host does not expose AppArmor; the default customer workflow remains available.' + exit 0 + fi + + sudo install -m 0644 docker/codex-security.apparmor \ + /etc/apparmor.d/codex-security-container + sudo apparmor_parser -r -W /etc/apparmor.d/codex-security-container + sudo grep -Fxq 'codex-security-container (enforce)' \ + /sys/kernel/security/apparmor/profiles + + # The single-quoted program is evaluated inside the customer container. + # shellcheck disable=SC2016 + "${compose[@]}" run --rm --entrypoint /bin/sh codex-security -ec ' + test "$(cat /proc/self/attr/current)" = "codex-security-container (enforce)" + command_directory="$(mktemp -d)" + trap '\''rm -rf "$command_directory"'\'' EXIT + printf "%s\\n" "#!/bin/sh" '\''printf "%s\\n" "$@"'\'' \ + > "$command_directory/codex-security" + chmod 755 "$command_directory/codex-security" + actual="$( + PATH="$command_directory:$PATH" \ + /usr/local/bin/codex-security-entrypoint \ + bulk-scan /input/repositories.csv --output-dir /output + )" + if printf "%s\\n" "$actual" | + grep -Fxq features.use_legacy_landlock=true; then + printf "%s\\n" "The AppArmor profile must retain the preferred Codex sandbox." >&2 + exit 1 + fi + ' + + "${compose[@]}" run --rm --entrypoint node codex-security \ + /usr/local/lib/node_modules/@openai/codex-security/node_modules/@openai/codex/bin/codex.js \ + sandbox /usr/bin/true + + - name: Set up Bun for findings service verification + if: inputs.target == 'findings-service' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + + - name: Verify findings API and persistent storage through consumer Compose + if: inputs.target == 'findings-service' + env: + IMAGE: codex-security:release-candidate + run: bun sdk/typescript/scripts/smoke-findings-service.ts "$IMAGE" + + authorize: + if: github.event_name != 'pull_request' + name: authorize-container-publication + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: container + permissions: + contents: read + packages: read + outputs: + image: ${{ steps.release.outputs.image }} + version: ${{ steps.release.outputs.version }} + + steps: + - name: Checkout release source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate protected release source and version + id: release + env: + PACKAGE: ${{ inputs.package }} + shell: bash + run: | + set -euo pipefail + package_version="$(node -p 'require("./sdk/typescript/package.json").version')" + + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + if [[ "$GITHUB_REF" != refs/heads/main ]]; then + echo 'Manual image releases must use the protected main branch.' >&2 + exit 1 + fi + version="$package_version" + elif [[ "$GITHUB_REF_NAME" =~ ^container-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + version="${GITHUB_REF_NAME#container-v}" + else + echo 'Container release tags must identify a stable version such as container-v0.1.0.' >&2 + exit 1 + fi + + if [[ "$version" != "$package_version" ]]; then + echo "Container version $version must match the CLI package version $package_version." >&2 + exit 1 + fi + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then + echo 'Container releases must be built from a commit on the protected main branch.' >&2 + exit 1 + fi + + printf 'image=ghcr.io/%s\n' "${GITHUB_REPOSITORY_OWNER,,}/$PACKAGE" >> "$GITHUB_OUTPUT" + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + + - name: Preflight public package and immutable release version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE: ${{ inputs.package }} + VERSION: ${{ steps.release.outputs.version }} + shell: bash + run: | + set -euo pipefail + owner="${GITHUB_REPOSITORY_OWNER,,}" + endpoint="orgs/$owner/packages/container/$PACKAGE" + + if ! metadata="$(gh api "$endpoint" 2>/dev/null)"; then + echo "::error::A repository administrator must bootstrap ghcr.io/$owner/$PACKAGE, make the package public, and grant this repository package access before approving publication." + exit 1 + fi + + if [[ "$(jq -r '.visibility' <<< "$metadata")" != public ]]; then + echo "::error::ghcr.io/$owner/$PACKAGE must be public before any release image is pushed." + exit 1 + fi + + sh docker/verify-container-release-version.sh "$endpoint" "$VERSION" + + publish-platform: + name: publish-linux-${{ matrix.architecture }} + needs: authorize + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - architecture: amd64 + runner: ubuntu-24.04 + - architecture: arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout approved release source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Sign in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish native image by immutable digest + id: build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + target: ${{ inputs.target }} + platforms: linux/${{ matrix.architecture }} + outputs: type=image,name=${{ needs.authorize.outputs.image }},push-by-digest=true,name-canonical=true,push=true + provenance: mode=max + sbom: true + cache-from: type=gha,scope=${{ inputs.package }}-${{ matrix.architecture }} + cache-to: type=gha,mode=max,scope=${{ inputs.package }}-${{ matrix.architecture }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ needs.authorize.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + + - name: Verify the exact published native image + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + TARGET: ${{ inputs.target }} + IMAGE: ${{ needs.authorize.outputs.image }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + shell: bash + run: | + set -euo pipefail + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then + echo 'The registry did not return a valid immutable platform digest.' >&2 + exit 1 + fi + + reference="$IMAGE@$IMAGE_DIGEST" + docker logout ghcr.io + docker pull "$reference" + + actual_architecture="$(docker image inspect --format '{{.Architecture}}' "$reference")" + if [[ "$actual_architecture" != "$EXPECTED_ARCHITECTURE" ]]; then + echo "Expected a native $EXPECTED_ARCHITECTURE image; found $actual_architecture." >&2 + exit 1 + fi + + if [[ "$TARGET" == scanner ]]; then + docker run --rm "$reference" --version + docker run --rm "$reference" bulk-scan --help + docker run --rm "$reference" info --json + fi + [[ "$(docker run --rm --entrypoint id "$reference" -u)" == 10001 ]] + + - name: Set up Bun for findings service verification + if: inputs.target == 'findings-service' + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: "1.3.14" + + - name: Verify findings API and persistent storage through consumer Compose + if: inputs.target == 'findings-service' + env: + IMAGE: ${{ needs.authorize.outputs.image }}@${{ steps.build.outputs.digest }} + run: bun sdk/typescript/scripts/smoke-findings-service.ts "$IMAGE" + + - name: Record verified platform digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + shell: bash + run: | + set -euo pipefail + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then + echo 'The registry did not return a valid immutable platform digest.' >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/${{ inputs.package }}-platform-digests" + touch "$RUNNER_TEMP/${{ inputs.package }}-platform-digests/${IMAGE_DIGEST#sha256:}" + + - name: Upload platform digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.package }}-image-digest-${{ matrix.architecture }} + path: ${{ runner.temp }}/${{ inputs.package }}-platform-digests/* + if-no-files-found: error + retention-days: 7 + compression-level: 0 + + manifest: + name: publish-and-verify-multiarchitecture-candidate + needs: + - authorize + - publish-platform + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.manifest.outputs.digest }} + candidate: ${{ steps.manifest.outputs.candidate }} + + steps: + - name: Checkout approved customer configuration + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Download verified platform digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: ${{ runner.temp }}/${{ inputs.package }}-platform-digests + pattern: ${{ inputs.package }}-image-digest-* + merge-multiple: true + + - name: Sign in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish provisional multiarchitecture candidate + id: manifest + env: + IMAGE: ${{ needs.authorize.outputs.image }} + shell: bash + run: | + set -euo pipefail + mapfile -t digest_files < <( + find "$RUNNER_TEMP/${{ inputs.package }}-platform-digests" \ + -maxdepth 1 -type f -printf '%f\n' | sort + ) + + if [[ "${#digest_files[@]}" -ne 2 ]]; then + echo 'A release must contain exactly one verified amd64 and arm64 image.' >&2 + exit 1 + fi + + references=() + for digest in "${digest_files[@]}"; do + if [[ ! "$digest" =~ ^[[:xdigit:]]{64}$ ]]; then + echo 'The release contains an invalid platform digest.' >&2 + exit 1 + fi + references+=("$IMAGE@sha256:$digest") + done + + candidate="$IMAGE:release-candidate-$GITHUB_SHA" + docker buildx imagetools create \ + --tag "$candidate" \ + "${references[@]}" + + manifest_digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$candidate")" + if [[ ! "$manifest_digest" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then + echo 'The registry did not return a valid multiarchitecture image digest.' >&2 + exit 1 + fi + printf 'digest=%s\n' "$manifest_digest" >> "$GITHUB_OUTPUT" + printf 'candidate=%s\n' "$candidate" >> "$GITHUB_OUTPUT" + + docker buildx imagetools inspect "$candidate" --raw | + jq --exit-status ' + [.manifests[] | select(.platform.os == "linux") | .platform.architecture] + | (index("amd64") != null and index("arm64") != null) + ' > /dev/null + + - name: Verify customers can pull the candidate without GitHub credentials + env: + CANDIDATE: ${{ steps.manifest.outputs.candidate }} + TARGET: ${{ inputs.target }} + shell: bash + run: | + set -euo pipefail + docker logout ghcr.io + if ! docker pull "$CANDIDATE"; then + echo '::error::The verified candidate cannot be pulled anonymously; no stable release tags have been published.' + exit 1 + fi + if [[ "$TARGET" == scanner ]]; then + docker run --rm "$CANDIDATE" --version + docker run --rm "$CANDIDATE" bulk-scan --help + docker run --rm "$CANDIDATE" info --json + fi + + - name: Verify hardened customer Compose against the public candidate + if: inputs.target == 'scanner' + env: + CODEX_SECURITY_IMAGE: ${{ steps.manifest.outputs.candidate }} + shell: bash + run: | + set -euo pipefail + mkdir -p results state + chmod 700 results state + printf 'id,repository,revision\n' > repositories.csv + CODEX_SECURITY_USER="$(id -u):$(id -g)" + export CODEX_SECURITY_USER + docker compose config --quiet + docker compose run --rm codex-security --version + if output="$(docker compose run --rm codex-security 2>&1)"; then + echo 'An empty repository CSV must not start a security scan.' >&2 + exit 1 + else + status=$? + fi + if [[ "$status" -ne 2 ]] || ! grep -Fq 'Multiscan CSV must contain at least one repository.' <<< "$output"; then + printf 'Unexpected empty-repository scan behavior:\n%s\n' "$output" >&2 + exit 1 + fi + + attest: + name: attest-verified-multiarchitecture-candidate + needs: + - authorize + - manifest + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + attestations: write + contents: read + id-token: write + packages: write + + steps: + - name: Sign in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Sign verified multiarchitecture candidate provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + push-to-registry: true + subject-name: ${{ needs.authorize.outputs.image }} + subject-digest: ${{ needs.manifest.outputs.digest }} + + promote: + name: promote-verified-and-attested-release + needs: + - authorize + - manifest + - attest + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + packages: write + + steps: + - name: Checkout approved immutable-version verifier + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Sign in to GitHub Container Registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Promote the verified, attested, immutable image digest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE: ${{ inputs.package }} + IMAGE: ${{ needs.authorize.outputs.image }} + VERSION: ${{ needs.authorize.outputs.version }} + MANIFEST_DIGEST: ${{ needs.manifest.outputs.digest }} + shell: bash + run: | + set -euo pipefail + if [[ ! "$MANIFEST_DIGEST" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then + echo 'The verified release did not provide a valid immutable digest.' >&2 + exit 1 + fi + + owner="${GITHUB_REPOSITORY_OWNER,,}" + endpoint="orgs/$owner/packages/container/$PACKAGE" + sh docker/verify-container-release-version.sh "$endpoint" "$VERSION" + + docker buildx imagetools create \ + --tag "$IMAGE:$VERSION" \ + --tag "$IMAGE:sha-$GITHUB_SHA" \ + --tag "$IMAGE:latest" \ + "$IMAGE@$MANIFEST_DIGEST" + + actual_digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$IMAGE:$VERSION")" + if [[ "$actual_digest" != "$MANIFEST_DIGEST" ]]; then + echo '::error::The promoted stable tag does not reference the verified and attested candidate digest.' + exit 1 + fi + + - name: Verify the stable release is publicly pullable + env: + IMAGE: ${{ needs.authorize.outputs.image }} + VERSION: ${{ needs.authorize.outputs.version }} + shell: bash + run: | + set -euo pipefail + docker logout ghcr.io + docker pull "$IMAGE:$VERSION" + docker run --rm --entrypoint codex-security "$IMAGE:$VERSION" --version diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 0537549b4..20c573e43 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - .dockerignore - - .github/workflows/container-release.yml + - .github/workflows/container-release*.yml - Dockerfile - Dockerfile.dockerignore - compose.yaml - compose.apparmor.yaml + - compose.findings.yaml + - compose.runner.yaml - docker/** - plugins/codex-security/** - sdk/typescript/** @@ -25,598 +27,22 @@ permissions: contents: read jobs: - validate: + release: if: github.repository == 'openai/codex-security' - name: validate-linux-${{ matrix.architecture }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 strategy: fail-fast: false matrix: include: - - architecture: amd64 - runner: ubuntu-24.04 - - architecture: arm64 - runner: ubuntu-24.04-arm - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Build native customer image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - env: - DOCKER_BUILD_RECORD_UPLOAD: "false" - with: - context: . - load: true - platforms: linux/${{ matrix.architecture }} - push: false - tags: codex-security:release-candidate - cache-from: type=gha,scope=codex-security-${{ matrix.architecture }} - cache-to: ${{ github.event_name != 'pull_request' && format('type=gha,mode=max,scope=codex-security-{0}', matrix.architecture) || '' }} - - - name: Verify native image and bundled scanner - env: - EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} - shell: bash - run: | - set -euo pipefail - actual_architecture="$(docker image inspect --format '{{.Architecture}}' codex-security:release-candidate)" - if [[ "$actual_architecture" != "$EXPECTED_ARCHITECTURE" ]]; then - echo "Expected a native $EXPECTED_ARCHITECTURE image; found $actual_architecture." >&2 - exit 1 - fi - docker run --rm codex-security:release-candidate --version - docker run --rm codex-security:release-candidate bulk-scan --help - docker run --rm codex-security:release-candidate info --json - [[ "$(docker run --rm --entrypoint id codex-security:release-candidate -u)" == 10001 ]] - - - name: Verify host-aware AppArmor sandbox selection - shell: bash - run: | - set -euo pipefail - docker run --rm --entrypoint /bin/sh codex-security:release-candidate -ec ' - command_directory="$(mktemp -d)" - trap '\''rm -rf "$command_directory"'\'' EXIT - printf "%s\\n" "#!/bin/sh" '\''printf "%s\\n" "$@"'\'' > "$command_directory/codex-security" - chmod 755 "$command_directory/codex-security" - - actual="$( - PATH="$command_directory:$PATH" \ - /usr/local/bin/codex-security-entrypoint \ - bulk-scan /input/repositories.csv --output-dir /output - )" - restricted_user_namespaces= - if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then - IFS= read -r restricted_user_namespaces \ - < /proc/sys/kernel/apparmor_restrict_unprivileged_userns || true - fi - - apparmor_profile= - if [ -r /proc/self/attr/current ]; then - IFS= read -r apparmor_profile < /proc/self/attr/current || true - fi - - if [ "$restricted_user_namespaces" = 1 ] && - [ "$apparmor_profile" != "codex-security-container (enforce)" ]; then - printf "%s\\n" "$actual" | grep -Fxq features.use_legacy_landlock=true - elif printf "%s\\n" "$actual" | grep -Fxq features.use_legacy_landlock=true; then - printf "%s\\n" "Landlock must not be forced when the preferred sandbox is available." >&2 - exit 1 - fi - ' - - - name: Verify hardened Codex command sandbox - shell: bash - run: | - set -euo pipefail - command=( - docker run --rm - --cap-drop ALL - --security-opt no-new-privileges - --security-opt "seccomp=$GITHUB_WORKSPACE/docker/codex-security-seccomp.json" - --entrypoint node - codex-security:release-candidate - /usr/local/lib/node_modules/@openai/codex-security/node_modules/@openai/codex/bin/codex.js - ) - - if output="$("${command[@]}" sandbox /usr/bin/true 2>&1)"; then - printf '%s\n' "$output" - elif grep -Eq 'bwrap: (Failed to make / slave: Permission denied|loopback: Failed RTM_NEW(ADDR|LINK): Operation not permitted|setting up uid map: Permission denied|No permissions to create a new namespace)' <<< "$output"; then - echo '::notice::This Docker host blocks nested Bubblewrap namespaces; verifying the supported Landlock fallback.' - "${command[@]}" sandbox --enable use_legacy_landlock /usr/bin/true - else - printf 'The hardened Codex sandbox failed unexpectedly:\n%s\n' "$output" >&2 - exit 1 - fi - - - name: Verify host-scoped Git credentials - shell: bash - run: | - set -euo pipefail - docker run --rm \ - --entrypoint /bin/sh \ - --env GH_TOKEN=SYNTHETIC_GITHUB_TOKEN \ - codex-security:release-candidate \ - -ec 'actual="$(printf "protocol=https\nhost=github.com\n\n" | /usr/local/bin/codex-security-git-credential get)"; test "$actual" = "$(printf "username=x-access-token\npassword=SYNTHETIC_GITHUB_TOKEN")"; test -z "$(printf "protocol=https\nhost=untrusted.example\n\n" | /usr/local/bin/codex-security-git-credential get)"' - - - name: Verify hardened customer Compose configuration - env: - CODEX_SECURITY_IMAGE: codex-security:release-candidate - shell: bash - run: | - set -euo pipefail - mkdir -p results state - chmod 700 results state - printf 'id,repository,revision\n' > repositories.csv - CODEX_SECURITY_USER="$(id -u):$(id -g)" - export CODEX_SECURITY_USER - docker compose config --quiet - docker compose run --rm codex-security --version - if output="$(docker compose run --rm codex-security 2>&1)"; then - echo 'An empty repository CSV must not start a security scan.' >&2 - exit 1 - else - status=$? - fi - if [[ "$status" -ne 2 ]] || ! grep -Fq 'Multiscan CSV must contain at least one repository.' <<< "$output"; then - printf 'Unexpected empty-repository scan behavior:\n%s\n' "$output" >&2 - exit 1 - fi - - - name: Verify optional hardened AppArmor Compose override - env: - CODEX_SECURITY_IMAGE: codex-security:release-candidate - shell: bash - run: | - set -euo pipefail - CODEX_SECURITY_USER="$(id -u):$(id -g)" - export CODEX_SECURITY_USER - compose=(docker compose -f compose.yaml -f compose.apparmor.yaml) - - "${compose[@]}" config --format json | - jq --exit-status ' - .services["codex-security"].security_opt as $options | - ($options | index("apparmor=codex-security-container")) != null and - ($options | index("no-new-privileges:true")) != null and - any($options[]; startswith("seccomp=")) - ' > /dev/null - - if ! docker info --format '{{json .SecurityOptions}}' | - grep -Fq '"name=apparmor"'; then - echo '::notice::This Docker host does not expose AppArmor; the default customer workflow remains available.' - exit 0 - fi - - sudo install -m 0644 docker/codex-security.apparmor \ - /etc/apparmor.d/codex-security-container - sudo apparmor_parser -r -W /etc/apparmor.d/codex-security-container - sudo grep -Fxq 'codex-security-container (enforce)' \ - /sys/kernel/security/apparmor/profiles - - # The single-quoted program is evaluated inside the customer container. - # shellcheck disable=SC2016 - "${compose[@]}" run --rm --entrypoint /bin/sh codex-security -ec ' - test "$(cat /proc/self/attr/current)" = "codex-security-container (enforce)" - command_directory="$(mktemp -d)" - trap '\''rm -rf "$command_directory"'\'' EXIT - printf "%s\\n" "#!/bin/sh" '\''printf "%s\\n" "$@"'\'' \ - > "$command_directory/codex-security" - chmod 755 "$command_directory/codex-security" - actual="$( - PATH="$command_directory:$PATH" \ - /usr/local/bin/codex-security-entrypoint \ - bulk-scan /input/repositories.csv --output-dir /output - )" - if printf "%s\\n" "$actual" | - grep -Fxq features.use_legacy_landlock=true; then - printf "%s\\n" "The AppArmor profile must retain the preferred Codex sandbox." >&2 - exit 1 - fi - ' - - "${compose[@]}" run --rm --entrypoint node codex-security \ - /usr/local/lib/node_modules/@openai/codex-security/node_modules/@openai/codex/bin/codex.js \ - sandbox /usr/bin/true - - authorize: - if: github.event_name != 'pull_request' - name: authorize-container-publication - needs: validate - runs-on: ubuntu-24.04 - timeout-minutes: 10 - environment: container - permissions: - contents: read - packages: read - outputs: - image: ${{ steps.release.outputs.image }} - version: ${{ steps.release.outputs.version }} - - steps: - - name: Checkout release source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Validate protected release source and version - id: release - shell: bash - run: | - set -euo pipefail - package_version="$(node -p 'require("./sdk/typescript/package.json").version')" - - if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then - if [[ "$GITHUB_REF" != refs/heads/main ]]; then - echo 'Manual image releases must use the protected main branch.' >&2 - exit 1 - fi - version="$package_version" - elif [[ "$GITHUB_REF_NAME" =~ ^container-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - version="${GITHUB_REF_NAME#container-v}" - else - echo 'Container release tags must identify a stable version such as container-v0.1.0.' >&2 - exit 1 - fi - - if [[ "$version" != "$package_version" ]]; then - echo "Container version $version must match the CLI package version $package_version." >&2 - exit 1 - fi - - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main; then - echo 'Container releases must be built from a commit on the protected main branch.' >&2 - exit 1 - fi - - printf 'image=ghcr.io/%s\n' "${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - - - name: Preflight public package and immutable release version - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.release.outputs.version }} - shell: bash - run: | - set -euo pipefail - owner="${GITHUB_REPOSITORY_OWNER,,}" - package="${GITHUB_REPOSITORY#*/}" - endpoint="orgs/$owner/packages/container/$package" - - if ! metadata="$(gh api "$endpoint" 2>/dev/null)"; then - echo "::error::A repository administrator must bootstrap ghcr.io/$owner/$package, make the package public, and grant this repository package access before approving publication." - exit 1 - fi - - if [[ "$(jq -r '.visibility' <<< "$metadata")" != public ]]; then - echo "::error::ghcr.io/$owner/$package must be public before any release image is pushed." - exit 1 - fi - - sh docker/verify-container-release-version.sh "$endpoint" "$VERSION" - - publish-platform: - name: publish-linux-${{ matrix.architecture }} - needs: authorize - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - permissions: - contents: read - packages: write - strategy: - fail-fast: false - matrix: - include: - - architecture: amd64 - runner: ubuntu-24.04 - - architecture: arm64 - runner: ubuntu-24.04-arm - - steps: - - name: Checkout approved release source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish native image by immutable digest - id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - platforms: linux/${{ matrix.architecture }} - outputs: type=image,name=${{ needs.authorize.outputs.image }},push-by-digest=true,name-canonical=true,push=true - provenance: mode=max - sbom: true - cache-from: type=gha,scope=codex-security-${{ matrix.architecture }} - cache-to: type=gha,mode=max,scope=codex-security-${{ matrix.architecture }} - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.version=${{ needs.authorize.outputs.version }} - org.opencontainers.image.revision=${{ github.sha }} - - - name: Verify the exact published native image - env: - EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} - IMAGE: ${{ needs.authorize.outputs.image }} - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - shell: bash - run: | - set -euo pipefail - if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then - echo 'The registry did not return a valid immutable platform digest.' >&2 - exit 1 - fi - - reference="$IMAGE@$IMAGE_DIGEST" - docker logout ghcr.io - docker pull "$reference" - - actual_architecture="$(docker image inspect --format '{{.Architecture}}' "$reference")" - if [[ "$actual_architecture" != "$EXPECTED_ARCHITECTURE" ]]; then - echo "Expected a native $EXPECTED_ARCHITECTURE image; found $actual_architecture." >&2 - exit 1 - fi - - docker run --rm "$reference" --version - docker run --rm "$reference" bulk-scan --help - docker run --rm "$reference" info --json - [[ "$(docker run --rm --entrypoint id "$reference" -u)" == 10001 ]] - - - name: Record verified platform digest - env: - IMAGE_DIGEST: ${{ steps.build.outputs.digest }} - shell: bash - run: | - set -euo pipefail - if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then - echo 'The registry did not return a valid immutable platform digest.' >&2 - exit 1 - fi - mkdir -p "$RUNNER_TEMP/codex-security-platform-digests" - touch "$RUNNER_TEMP/codex-security-platform-digests/${IMAGE_DIGEST#sha256:}" - - - name: Upload platform digest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: codex-security-image-digest-${{ matrix.architecture }} - path: ${{ runner.temp }}/codex-security-platform-digests/* - if-no-files-found: error - retention-days: 7 - compression-level: 0 - - manifest: - name: publish-and-verify-multiarchitecture-candidate - needs: - - authorize - - publish-platform - runs-on: ubuntu-24.04 - timeout-minutes: 30 - permissions: - contents: read - packages: write - outputs: - digest: ${{ steps.manifest.outputs.digest }} - candidate: ${{ steps.manifest.outputs.candidate }} - - steps: - - name: Checkout approved customer configuration - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Download verified platform digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: ${{ runner.temp }}/codex-security-platform-digests - pattern: codex-security-image-digest-* - merge-multiple: true - - - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish provisional multiarchitecture candidate - id: manifest - env: - IMAGE: ${{ needs.authorize.outputs.image }} - shell: bash - run: | - set -euo pipefail - mapfile -t digest_files < <( - find "$RUNNER_TEMP/codex-security-platform-digests" \ - -maxdepth 1 -type f -printf '%f\n' | sort - ) - - if [[ "${#digest_files[@]}" -ne 2 ]]; then - echo 'A release must contain exactly one verified amd64 and arm64 image.' >&2 - exit 1 - fi - - references=() - for digest in "${digest_files[@]}"; do - if [[ ! "$digest" =~ ^[[:xdigit:]]{64}$ ]]; then - echo 'The release contains an invalid platform digest.' >&2 - exit 1 - fi - references+=("$IMAGE@sha256:$digest") - done - - candidate="$IMAGE:release-candidate-$GITHUB_SHA" - docker buildx imagetools create \ - --tag "$candidate" \ - "${references[@]}" - - manifest_digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$candidate")" - if [[ ! "$manifest_digest" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then - echo 'The registry did not return a valid multiarchitecture image digest.' >&2 - exit 1 - fi - printf 'digest=%s\n' "$manifest_digest" >> "$GITHUB_OUTPUT" - printf 'candidate=%s\n' "$candidate" >> "$GITHUB_OUTPUT" - - docker buildx imagetools inspect "$candidate" --raw | - jq --exit-status ' - [.manifests[] | select(.platform.os == "linux") | .platform.architecture] - | (index("amd64") != null and index("arm64") != null) - ' > /dev/null - - - name: Verify customers can pull the candidate without GitHub credentials - env: - CANDIDATE: ${{ steps.manifest.outputs.candidate }} - shell: bash - run: | - set -euo pipefail - docker logout ghcr.io - if ! docker pull "$CANDIDATE"; then - echo '::error::The verified candidate cannot be pulled anonymously; no stable release tags have been published.' - exit 1 - fi - docker run --rm "$CANDIDATE" --version - docker run --rm "$CANDIDATE" bulk-scan --help - docker run --rm "$CANDIDATE" info --json - - - name: Verify hardened customer Compose against the public candidate - env: - CODEX_SECURITY_IMAGE: ${{ steps.manifest.outputs.candidate }} - shell: bash - run: | - set -euo pipefail - mkdir -p results state - chmod 700 results state - printf 'id,repository,revision\n' > repositories.csv - CODEX_SECURITY_USER="$(id -u):$(id -g)" - export CODEX_SECURITY_USER - docker compose config --quiet - docker compose run --rm codex-security --version - if output="$(docker compose run --rm codex-security 2>&1)"; then - echo 'An empty repository CSV must not start a security scan.' >&2 - exit 1 - else - status=$? - fi - if [[ "$status" -ne 2 ]] || ! grep -Fq 'Multiscan CSV must contain at least one repository.' <<< "$output"; then - printf 'Unexpected empty-repository scan behavior:\n%s\n' "$output" >&2 - exit 1 - fi - - attest: - name: attest-verified-multiarchitecture-candidate - needs: - - authorize - - manifest - runs-on: ubuntu-24.04 - timeout-minutes: 10 + - target: scanner + package: codex-security + - target: findings-service + package: codex-security-findings permissions: attestations: write contents: read id-token: write packages: write - - steps: - - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Sign verified multiarchitecture candidate provenance - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - push-to-registry: true - subject-name: ${{ needs.authorize.outputs.image }} - subject-digest: ${{ needs.manifest.outputs.digest }} - - promote: - name: promote-verified-and-attested-release - needs: - - authorize - - manifest - - attest - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read - packages: write - - steps: - - name: Checkout approved immutable-version verifier - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Sign in to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Promote the verified, attested, immutable image digest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - IMAGE: ${{ needs.authorize.outputs.image }} - VERSION: ${{ needs.authorize.outputs.version }} - MANIFEST_DIGEST: ${{ needs.manifest.outputs.digest }} - shell: bash - run: | - set -euo pipefail - if [[ ! "$MANIFEST_DIGEST" =~ ^sha256:[[:xdigit:]]{64}$ ]]; then - echo 'The verified release did not provide a valid immutable digest.' >&2 - exit 1 - fi - - owner="${GITHUB_REPOSITORY_OWNER,,}" - package="${GITHUB_REPOSITORY#*/}" - endpoint="orgs/$owner/packages/container/$package" - sh docker/verify-container-release-version.sh "$endpoint" "$VERSION" - - docker buildx imagetools create \ - --tag "$IMAGE:$VERSION" \ - --tag "$IMAGE:sha-$GITHUB_SHA" \ - --tag "$IMAGE:latest" \ - "$IMAGE@$MANIFEST_DIGEST" - - actual_digest="$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$IMAGE:$VERSION")" - if [[ "$actual_digest" != "$MANIFEST_DIGEST" ]]; then - echo '::error::The promoted stable tag does not reference the verified and attested candidate digest.' - exit 1 - fi - - - name: Verify the stable release is publicly pullable - env: - IMAGE: ${{ needs.authorize.outputs.image }} - VERSION: ${{ needs.authorize.outputs.version }} - shell: bash - run: | - set -euo pipefail - docker logout ghcr.io - docker pull "$IMAGE:$VERSION" - docker run --rm "$IMAGE:$VERSION" --version + uses: ./.github/workflows/container-release-image.yml + with: + target: ${{ matrix.target }} + package: ${{ matrix.package }} diff --git a/README.md b/README.md index fd829cd9a..f97bd07de 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,15 @@ await security.close(); Use the included Docker Compose configuration for scans of many repositories. See the [container quick start](sdk/typescript/README.md#containerized-bulk-scans) for more detail. +For individual CLI stages with durable state and access to a separately deployed +findings service, use the same scanner image with the +[workflow runner Compose example](docker/README.md#workflow-runner). + ## Findings service (preview) The [findings service](sdk/typescript/README.md#findings-service-preview) runs -from the SDK in Docker, stores findings and embeddings in SQLite, and lists +from the separate `ghcr.io/openai/codex-security-findings` image (or a local +source build), stores findings and embeddings in SQLite, and lists findings with pagination. Its read-only dashboard at `/dashboard` refreshes every five seconds and shows stored findings and duplicate groups from the service's database. It also returns potential duplicates by embedding similarity within a diff --git a/compose.findings.yaml b/compose.findings.yaml index 229a7a460..d9da02eef 100644 --- a/compose.findings.yaml +++ b/compose.findings.yaml @@ -1,10 +1,7 @@ services: findings: - build: - context: . - target: findings-service + image: ${CODEX_SECURITY_FINDINGS_IMAGE:-ghcr.io/openai/codex-security-findings:latest} init: true - env_file: docker/findings.env environment: OPENAI_API_KEY: CODEX_API_KEY: diff --git a/compose.runner.yaml b/compose.runner.yaml new file mode 100644 index 000000000..f2ff2204e --- /dev/null +++ b/compose.runner.yaml @@ -0,0 +1,28 @@ +services: + codex-security: + image: ${CODEX_SECURITY_IMAGE:-ghcr.io/openai/codex-security:latest} + init: true + user: ${CODEX_SECURITY_USER:-10001:10001} + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + - seccomp=${CODEX_SECURITY_SECCOMP:-./docker/codex-security-seccomp.json} + environment: + CODEX_API_KEY: + CODEX_SECURITY_GIT_HOST: + GH_TOKEN: + GITHUB_TOKEN: + OPENAI_API_KEY: + volumes: + - type: bind + source: ${CODEX_SECURITY_RESULTS:-./results} + target: /output + bind: + create_host_path: false + - type: bind + source: ${CODEX_SECURITY_STATE:-./state} + target: /state + bind: + create_host_path: false + command: ["--help"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..5f075b538 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,178 @@ +# Container releases + +`container-release` uses one release pipeline for both images: + +| Docker target | GHCR image | +| ------------------- | ---------------------------------------- | +| `scanner` (default) | `ghcr.io/openai/codex-security` | +| `findings-service` | `ghcr.io/openai/codex-security-findings` | + +Both use the SDK package version, native Linux `amd64`/`arm64` builds, BuildKit +SBOMs and maximum-mode provenance, and a GitHub provenance attestation. Native +images are tested before publishing the multiarchitecture manifest. Anonymous +pulls and attestation must succeed before promoting version, `sha-`, and +`latest` tags. Stable version tags cannot be overwritten. + +## GHCR administrator setup + +Before the first release, an administrator must prepare each package: + +1. Allow organization package creation and bootstrap missing packages with a + reviewed image and a non-release tag. For the findings image: + + ```bash + docker build --target findings-service -t ghcr.io/openai/codex-security-findings:bootstrap . + printf '%s' "$CR_PAT" | docker login ghcr.io --username YOUR_GITHUB_USER --password-stdin + docker push ghcr.io/openai/codex-security-findings:bootstrap + docker logout ghcr.io + ``` + + Use a personal access token (classic) with `write:packages`, authorized for SSO + if required; never commit it or pass it into the build. For the scanner, use + target `scanner` and image `ghcr.io/openai/codex-security:bootstrap`. + +2. In each package's settings, link `openai/codex-security`, set visibility to + **Public**, and grant the repository **Write** under **Manage Actions access**. + The workflow uses `GITHUB_TOKEN` and refuses missing, private, or unreadable + packages. Verify `docker pull` works after logging out of GHCR. +3. Protect the repository's `container` environment with required reviewers and + deployment rules for protected `main` and approved `container-v*` tags. + Allow the workflow's pinned actions, package writes, and OIDC attestations. + Update branch-protection check names if they reference the old release jobs. + +See GitHub's [registry authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) +and [package access settings](https://docs.github.com/en/packages/learn-github-packages/configuring-a-packages-access-control-and-visibility). + +## Publishing + +After merging to `main`, push `container-v` matching the SDK package +version or run `container-release` manually on `main`. Releases require a commit +on protected `main`; pull requests only build and test. + +The images release independently. If one fails, fix the cause and rerun only +failed jobs; do not overwrite an existing stable version. `bootstrap` and +`release-candidate-` tags are not consumer releases. + +See the [findings service guide](../sdk/typescript/README.md#findings-service-preview) +for image selection, source builds, storage, backups, and upgrades. + +## Workflow runner + +`compose.runner.yaml` runs the packaged CLI from the **scanner** image. It does +not start a findings service or implement another workflow engine. It passes +commands, output, and exit codes through the existing scanner entrypoint. +The shared Dockerfile and the two image releases above are unchanged. + +Run these commands from the repository root. After the selected scanner release +is available, prepare private directories and choose the host user's UID/GID so +the runner can write its bind mounts: + +```bash +mkdir -p results state +chmod 700 results state +export CODEX_SECURITY_USER="$(id -u):$(id -g)" +export CODEX_SECURITY_IMAGE=ghcr.io/openai/codex-security:latest +docker compose -f compose.runner.yaml pull +docker compose -f compose.runner.yaml run --rm codex-security login --device-auth +``` + +For unattended use, provide `OPENAI_API_KEY` or `CODEX_API_KEY` instead of login. +Git authentication uses the existing `GH_TOKEN`/`GITHUB_TOKEN` and optional +`CODEX_SECURITY_GIT_HOST` settings. Pass only the credentials the runner needs; +the findings service's embedding credentials are configured separately. +Use a version or digest in `CODEX_SECURITY_IMAGE` for repeatable deployments. +To test an unreleased checkout, build the same scanner target locally instead +of pulling: + +```bash +docker build --target scanner -t codex-security:local . +export CODEX_SECURITY_IMAGE=codex-security:local +``` + +The existing `CODEX_SECURITY_RESULTS` and `CODEX_SECURITY_STATE` settings select +the host directories (default `./results` and `./state`): + +| Container path | Durable contents | +| ------------------------------- | --------------------------------------------------- | +| `/output` | Scan artifacts and any source checkouts stored here | +| `/output/.codex-security-state` | CLI scan history and workbench database | +| `/state` | Codex sign-in and configuration | + +Keep all three across runner replacements. Keep the approved source checkout +available at the same container path for later source reviews. For example, +place a checkout under `results/repository`, then scan it with artifacts outside +the checkout: + +```bash +docker compose -f compose.runner.yaml run --rm codex-security \ + scan /output/repository --output-dir /output/scans/run-001 --headless +``` + +An existing checkout elsewhere can instead be bind-mounted with +`run --volume /absolute/repository:/input/repository`; repeat that mount on each +stage that needs the source. Moving a host scan's files into these directories +does not rewrite absolute paths in its saved state. Run the scan in the runner +or preserve its original paths. Never share the runner's workbench database or +Codex home with the findings service's `/state` volume. + +### Connecting to the findings service + +For an independently hosted service, pass its reachable base URL through the +existing `--findings-url` flag. Container loopback addresses refer to the runner, +not the Docker host or another container. The findings API has no authentication; +use a private network or an authenticated TLS proxy appropriate to the deployment. +Do not expose the unauthenticated API publicly. + +For a service on the same Docker engine, start it as a separate Compose project: + +```bash +docker compose -p findings -f compose.findings.yaml up -d +``` + +Save this network-only override as `compose.runner.local.yaml`: + +```yaml +networks: + default: + external: true + name: findings_default +``` + +Then run the runner as a different project on that existing network. The service +is reachable by its Compose DNS name even though its published host port remains +loopback-only: + +```bash +docker compose -p runner -f compose.runner.yaml -f compose.runner.local.yaml \ + run --rm codex-security dedupe --scan SCAN_ID \ + --findings-url http://findings:3000 --json +``` + +Use the scan ID from the completed scan and first import its findings into the +service with the matching repository ID, as described in the +[findings API guide](../sdk/typescript/README.md#findings-service-preview). +For a remote service, omit the network override and supply its URL instead. +Stopping or replacing the runner does not stop the service or remove its volume. + +Only commands supported by the selected image are available. Workflow resumption, +custom publication, and dedupe write-back require a release containing those +SDK/CLI capabilities; durable mounts alone do not add them. The runner does not +schedule, retry, or skip stages on its own. + +### Sandbox and lifecycle + +The runner retains the scanner's nonroot user, dropped capabilities, +no-new-privileges, and seccomp profile. It does not override Codex approval or +filesystem settings. On hosts that restrict nested user namespaces, install the +existing [AppArmor profile](../sdk/typescript/README.md#containerized-bulk-scans) +and append `-f compose.apparmor.yaml` to the runner Compose commands. This override +works because both examples use the `codex-security` service name. The entrypoint's +bulk-scan-specific Landlock selection remains unchanged; it is not applied to +other commands. Source inspection needs a host that supports the selected Codex +sandbox; do not disable sandboxing to work around host restrictions. + +`run --rm` removes only the finished runner container. Preserve its host mounts +for later stages and retries; use the same image version and source paths. +Stop active runners before backing up the entire results and state directories, +and back up the findings service separately. No service ports or Docker socket +are exposed by the runner example. diff --git a/docker/findings.env b/docker/findings.env deleted file mode 100644 index c4f8a8a9e..000000000 --- a/docker/findings.env +++ /dev/null @@ -1,3 +0,0 @@ -HOST=0.0.0.0 -PORT=3000 -CODEX_SECURITY_STATE_DIR=/state diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 681f17cc4..aa23bc506 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -912,23 +912,38 @@ cancel active scans. ## Findings service (preview) +The findings API is distributed separately from the scanner as +`ghcr.io/openai/codex-security-findings`, for Linux `amd64` and `arm64`. +Once a release is published, it can be pulled without a GitHub login. See +[container release setup](../../docker/README.md) for the required maintainer +setup and publication process. + From the repository root, copy the example if you do not already have a `.env`: ```bash cp .env.example .env ``` -Set `OPENAI_API_KEY` in `.env`, then build and start the findings API: +Set `OPENAI_API_KEY` in `.env`, then pull and start the findings API: ```bash -docker compose -f compose.findings.yaml up --build -d +docker compose -f compose.findings.yaml pull +docker compose -f compose.findings.yaml up --no-build -d curl -i http://127.0.0.1:3000/v1/findings ``` -The `findings-service` Docker target starts the compiled SDK server used by the -packaged `start:server` script, without invoking the CLI. Docker runs Node -directly so stop signals reach the server. The existing default Docker target -and bulk-scan Compose configuration are unchanged. +You can deploy with just `compose.findings.yaml` and a private `.env`; no source +checkout or Node.js installation is required. `CODEX_SECURITY_FINDINGS_IMAGE` +defaults to `ghcr.io/openai/codex-security-findings:latest`. Set it to a published +version, `sha-` tag, or digest for repeatable deployments. + +To build from a source checkout instead: + +```bash +docker build --target findings-service -t codex-security-findings:local . +export CODEX_SECURITY_FINDINGS_IMAGE=codex-security-findings:local +docker compose -f compose.findings.yaml up --no-build -d +``` ### Read-only dashboard @@ -1344,16 +1359,44 @@ use the same finding upsert operation. Changing a stored document invalidates its old embedding so later matching cannot use a stale vector. Historical findings are not automatically embedded; submit them to a bulk endpoint first. -The `findings-state` named volume -persists that database across container restarts. Stop the service with +The `findings-state` named volume persists `/state`, including the database, +across container replacements. Keep the same Compose project name to reuse it. +The image runs as UID/GID `10001:10001`; a bind mount must be writable by that +UID/GID if used instead of the named volume. + +Stop the service with `docker compose -f compose.findings.yaml down`; add `--volumes` only when you intend to delete the stored data. -`docker/findings.env` contains non-secret container defaults: `HOST=0.0.0.0`, -`PORT=3000`, and `CODEX_SECURITY_STATE_DIR=/state`. Compose publishes the port -only on the host's loopback interface. There is no API authentication in this -preview. Do not expose it to an untrusted network; use an authenticated proxy -before sharing access. +The image defaults to `HOST=0.0.0.0`, `PORT=3000`, and +`CODEX_SECURITY_STATE_DIR=/state`. Keep port and volume mappings aligned if +changing these settings. Compose binds only to host loopback; the API has no +authentication. Use an authenticated TLS proxy before sharing access. Finding +JSON is sent to `api.openai.com` over HTTPS for embeddings; the database and +generated embeddings stay in the local volume. + +### Upgrades and backups + +Read the release notes and stop the service before backing up the entire +`/state` directory. For the published-image Compose configuration: + +```bash +docker compose -f compose.findings.yaml stop findings +mkdir -p backups +chmod 700 backups +docker compose -f compose.findings.yaml run --rm --no-deps --user 0:0 \ + --entrypoint tar -T findings -C /state -czf - . > backups/findings-state.tgz +chmod 600 backups/findings-state.tgz +``` + +Keep backups separately; this command overwrites an existing backup of the same +name. Set `CODEX_SECURITY_FINDINGS_IMAGE` to the new version or digest and repeat +the pull/start commands above, retaining the volume. Startup applies SQLite +migrations automatically. To roll back, stop the service, restore the pre-upgrade +backup, and select the previous image digest; an older image may not support the +migrated database. + +### Running without Docker To run locally, use Node.js and Python 3 as described in the prerequisites. Export the API key in your shell; the server does not load `.env` automatically. diff --git a/sdk/typescript/scripts/fixtures/findings-service-sqlite.ts b/sdk/typescript/scripts/fixtures/findings-service-sqlite.ts index 326b2412a..be423d1c1 100644 --- a/sdk/typescript/scripts/fixtures/findings-service-sqlite.ts +++ b/sdk/typescript/scripts/fixtures/findings-service-sqlite.ts @@ -1,6 +1,6 @@ // Assert persisted findings and embeddings in the smoke-test container. import assert from "node:assert/strict"; -import { chmodSync, cpSync, mkdirSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; import type { ScanManifest } from "../../src/models.js"; @@ -22,6 +22,10 @@ try { ], ) > 0, ); + assert.equal( + db.prepare("SELECT COUNT(*) AS count FROM scans").get()!["count"], + 0, + ); const findingIds = db .prepare("SELECT id FROM findings ORDER BY id") .all() @@ -75,78 +79,6 @@ try { members.map((row) => row["finding_id"]), importedIds.slice(0, 3).sort(), ); - const workflows = db - .prepare("SELECT dedupe_status, results_json FROM finding_workflows") - .all(); - assert.equal(workflows.length, 2); - for (const row of workflows) { - const results = JSON.parse(row["results_json"] as string); - assert.equal(row["dedupe_status"], "completed"); - assert.deepEqual(results.dedupe.duplicateGroups, [ - importedIds.slice(0, 3), - ]); - assert.ok(!("dedupePendingWrite" in results)); - } - const reviews = db - .prepare( - "SELECT model, source_content_digest, prompt_digest, contract_digest, result_json FROM finding_workflow_reviews", - ) - .all(); - const models = new Set(); - const decisions = new Set(); - for (const row of reviews) { - const result = JSON.parse(row["result_json"] as string); - models.add(row["model"] as string); - assert.ok(row["source_content_digest"]); - assert.ok(row["prompt_digest"] && row["contract_digest"]); - for (const decision of "decisions" in result - ? result.decisions - : [result]) { - decisions.add(decision.decision); - if (decision.decision === "SAME") { - assert.equal(typeof decision.canonicalFindingId, "string"); - assert.equal( - decision.mergedFinding.findingId, - decision.canonicalFindingId, - ); - assert.ok( - decision.mergedFinding.extensions.mergedOriginals.length > 0, - ); - } - } - } - assert.deepEqual(models, new Set(["gpt-5.6-luna", "gpt-5.6-sol"])); - assert.deepEqual(decisions, new Set(["SAME", "DISTINCT"])); - } - - if (process.argv.includes("--prepare-scan")) { - const sourceDir = "/state/smoke-source"; - mkdirSync(sourceDir, { recursive: true }); - const scanDir = "/state/smoke-scan"; - cpSync("_bundled_plugin/examples/completed-scan", scanDir, { - recursive: true, - }); - chmodSync(scanDir, 0o700); - const timestamp = scan.completedAt!; - db.exec("BEGIN"); - db.prepare( - "INSERT OR IGNORE INTO workspaces (id, created_at, updated_at) VALUES ('00000000-0000-4000-8000-000000000001', ?, ?)", - ).run(timestamp, timestamp); - db.prepare( - "INSERT OR IGNORE INTO scans (id, workspace_id, target_path, target_revision, scope, mode, scan_dir, status, phase, started_at, completed_at, created_at, updated_at) VALUES (?, '00000000-0000-4000-8000-000000000001', ?, 'revision', '.', 'standard', ?, 'complete', 'reporting', ?, ?, ?, ?)", - ).run( - scan.id, - sourceDir, - scanDir, - timestamp, - timestamp, - timestamp, - timestamp, - ); - db.prepare( - "INSERT OR IGNORE INTO scan_progress (scan_id, updated_at) VALUES (?, ?)", - ).run(scan.id, timestamp); - db.exec("COMMIT"); } } finally { db.close(); diff --git a/sdk/typescript/scripts/fixtures/prepare-runner-scan.ts b/sdk/typescript/scripts/fixtures/prepare-runner-scan.ts new file mode 100644 index 000000000..504a7897e --- /dev/null +++ b/sdk/typescript/scripts/fixtures/prepare-runner-scan.ts @@ -0,0 +1,110 @@ +// Prepare a synthetic runner scan or check its saved workflows and reviews. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import type { ScanManifest } from "../../src/models.js"; + +const packageRoot = "/usr/local/lib/node_modules/@openai/codex-security"; +const exampleDir = join(packageRoot, "_bundled_plugin/examples/completed-scan"); +const { scan } = JSON.parse( + readFileSync(join(exampleDir, "scan-manifest.json"), "utf8"), +) as ScanManifest; +// Keep database initialization and migrations in the existing workbench. +const { databasePath } = JSON.parse( + execFileSync( + "python3", + [ + "-I", + "-B", + join(packageRoot, "_bundled_plugin/scripts/workbench_db.py"), + "database-info", + ], + { encoding: "utf8" }, + ), +) as { databasePath: string }; +const db = new DatabaseSync(databasePath); +try { + db.exec("PRAGMA busy_timeout = 5000"); + if (process.argv[2] === "--check-workflows") { + const importedIds = JSON.parse(process.argv[3]!) as string[]; + const workflows = db + .prepare("SELECT dedupe_status, results_json FROM finding_workflows") + .all(); + assert.equal(workflows.length, 2); + for (const row of workflows) { + const results = JSON.parse(row["results_json"] as string); + assert.equal(row["dedupe_status"], "completed"); + assert.deepEqual(results.dedupe.duplicateGroups, [ + importedIds.slice(0, 3), + ]); + assert.ok(!("dedupePendingWrite" in results)); + } + const reviews = db + .prepare( + "SELECT model, source_content_digest, prompt_digest, contract_digest, result_json FROM finding_workflow_reviews", + ) + .all(); + const models = new Set(); + const decisions = new Set(); + for (const row of reviews) { + const result = JSON.parse(row["result_json"] as string); + models.add(row["model"] as string); + assert.ok(row["source_content_digest"]); + assert.ok(row["prompt_digest"] && row["contract_digest"]); + for (const decision of "decisions" in result + ? result.decisions + : [result]) { + decisions.add(decision.decision); + if (decision.decision === "SAME") { + assert.equal(typeof decision.canonicalFindingId, "string"); + assert.equal( + decision.mergedFinding.findingId, + decision.canonicalFindingId, + ); + assert.ok( + decision.mergedFinding.extensions.mergedOriginals.length > 0, + ); + } + } + } + assert.deepEqual(models, new Set(["gpt-5.6-luna", "gpt-5.6-sol"])); + assert.deepEqual(decisions, new Set(["SAME", "DISTINCT"])); + } else { + writeFileSync("/state/runner-marker", "synthetic runner state\n"); + const sourceDir = "/output/repository"; + mkdirSync(sourceDir, { recursive: true }); + const scanDir = "/output/smoke-scan"; + cpSync(exampleDir, scanDir, { recursive: true }); + chmodSync(scanDir, 0o700); + const timestamp = scan.completedAt!; + db.exec("BEGIN"); + db.prepare( + "INSERT OR IGNORE INTO workspaces (id, created_at, updated_at) VALUES ('00000000-0000-4000-8000-000000000001', ?, ?)", + ).run(timestamp, timestamp); + db.prepare( + "INSERT OR IGNORE INTO scans (id, workspace_id, target_path, target_revision, scope, mode, scan_dir, status, phase, started_at, completed_at, created_at, updated_at) VALUES (?, '00000000-0000-4000-8000-000000000001', ?, 'revision', '.', 'standard', ?, 'complete', 'reporting', ?, ?, ?, ?)", + ).run( + scan.id, + sourceDir, + scanDir, + timestamp, + timestamp, + timestamp, + timestamp, + ); + db.prepare( + "INSERT OR IGNORE INTO scan_progress (scan_id, updated_at) VALUES (?, ?)", + ).run(scan.id, timestamp); + db.exec("COMMIT"); + } +} finally { + db.close(); +} diff --git a/sdk/typescript/scripts/smoke-findings-service.ts b/sdk/typescript/scripts/smoke-findings-service.ts index c565c217d..80a83bd1f 100644 --- a/sdk/typescript/scripts/smoke-findings-service.ts +++ b/sdk/typescript/scripts/smoke-findings-service.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmod, cp, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout } from "node:timers/promises"; @@ -12,9 +12,22 @@ import type { FindingDedupeGroup } from "../src/finding-dedupe-groups.js"; import type { DashboardSnapshot } from "../src/server/dashboard-types.js"; const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); -const container = "findings-ci"; +const container = `findings-ci-${process.pid}`; +const image = process.argv[2] ?? "codex-security-findings:local"; +const runnerImage = + process.env["CODEX_SECURITY_IMAGE"] ?? "codex-security:runner-smoke"; const compose = ["compose", "-p", container, "-f", "compose.findings.yaml"]; -const localRoot = await mkdtemp(join(tmpdir(), "findings-host-publish-")); +const runnerRoot = await mkdtemp(join(tmpdir(), "codex-security-runner-")); +const runnerCompose = [ + "compose", + "-p", + `${container}-runner`, + "-f", + "compose.runner.yaml", + "-f", + join(runnerRoot, "network.json"), +]; +const runner = [...runnerCompose, "run", "--rm", "-T"]; let base: string; const document: FindingsDocument = JSON.parse( await readFile( @@ -61,16 +74,36 @@ const findings: Finding[] = [ ]; const ids = findings.map((finding) => finding.findingId); -function docker(args: string[], { check = true } = {}): string { +function docker(args: string[], { check = true, status = 0 } = {}): string { const result = spawnSync("docker", args, { cwd: repositoryRoot, + env: { + ...process.env, + CODEX_SECURITY_FINDINGS_IMAGE: image, + CODEX_SECURITY_IMAGE: runnerImage, + CODEX_SECURITY_USER: `${process.getuid!()}:${process.getgid!()}`, + CODEX_SECURITY_RESULTS: join(runnerRoot, "results"), + CODEX_SECURITY_STATE: join(runnerRoot, "state"), + CODEX_SECURITY_SECCOMP: join( + repositoryRoot, + "docker/codex-security-seccomp.json", + ), + OPENAI_API_KEY: "synthetic-container-key", + CODEX_API_KEY: "", + GH_TOKEN: "", + GITHUB_TOKEN: "", + }, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], }); if (result.stdout) process.stdout.write(result.stdout); if (check) { if (result.error) throw result.error; - assert.equal(result.status, 0, `docker ${args.join(" ")} failed`); + assert.equal( + result.status, + status, + `docker ${args.join(" ")} returned an unexpected exit code`, + ); } return result.stdout?.trim() ?? ""; } @@ -80,22 +113,20 @@ async function startService(): Promise { ...compose, "run", "--detach", + "--use-aliases", "--publish", "127.0.0.1::3000", "--name", container, "--env", "OPENAI_API_KEY=synthetic-container-key", + "--env", + "NODE_OPTIONS=--import=/test/mock-embeddings.mjs", "--volume", `${join(repositoryRoot, "docker/fixtures/mock-embeddings.mjs")}:/test/mock-embeddings.mjs:ro`, "--volume", - `${join(repositoryRoot, "docker/fixtures/mock-reviews.mjs")}:/test/mock-reviews.mjs:ro`, - "--volume", `${fileURLToPath(new URL("fixtures/findings-service-sqlite.ts", import.meta.url))}:/test/findings-service-sqlite.ts:ro`, "findings", - "--import", - "/test/mock-embeddings.mjs", - "dist/server/index.js", ]); base = `http://${docker(["port", container, "3000/tcp"])}`; for (let attempt = 0; ; attempt++) { @@ -113,62 +144,6 @@ async function startService(): Promise { } } -async function checkHostPublication(): Promise { - const installed = join(localRoot, "package"); - docker([ - "cp", - `${container}:/usr/local/lib/node_modules/@openai/codex-security`, - installed, - ]); - const scanDir = join(localRoot, "completed-scan"); - await cp( - join(installed, "_bundled_plugin/examples/completed-scan"), - scanDir, - { recursive: true }, - ); - if (process.platform !== "win32") await chmod(scanDir, 0o700); - const result = spawnSync( - process.execPath, - [ - join(installed, "bin/codex-security.mjs"), - "publish", - "scan", - "--scan-dir", - scanDir, - "--to", - "custom", - "--findings-url", - base, - "--json", - ], - { - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - env: { ...process.env, CODEX_SECURITY_NO_UPDATE_NOTICE: "1" }, - }, - ); - if (result.error) throw result.error; - assert.equal( - result.status, - 0, - "The installed CLI must publish from the host to Docker", - ); - assert.deepEqual(JSON.parse(result.stdout), { - scanId: manifest.scan.id, - repositoryId, - findingIds: [ids[0]], - findingCount: 1, - }); - const response = await fetch( - `${base}/v1/finding/${ids[0]}/potential-duplicates?repositoryId=${repositoryId}`, - ); - assert.equal(response.status, 200); - assert.deepEqual(await response.json(), { - finding: example, - potentialDuplicates: [], - }); -} - async function checkDashboard(): Promise { for (const [path, type] of [ ["/dashboard", "text/html"], @@ -227,31 +202,22 @@ async function checkCandidates(): Promise { } } -function checkCliDeduplication(): void { - docker([ - "exec", - container, - "node", - "--experimental-strip-types", - "/test/findings-service-sqlite.ts", - JSON.stringify(ids), - "--prepare-scan", - ]); +async function checkCliDeduplication(): Promise { for (const allRepositories of [false, true]) { const command = [ - "exec", - container, - "node", - "--import", - "/test/mock-reviews.mjs", - "dist/cli.js", + ...runner, + "--env", + "NODE_OPTIONS=--import=/test/mock-reviews.mjs", + "--volume", + `${join(repositoryRoot, "docker/fixtures/mock-reviews.mjs")}:/test/mock-reviews.mjs:ro`, + "codex-security", "dedupe", "--scan", manifest.scan.id, "--workflow-id", allRepositories ? "smoke-all" : "smoke-repository", "--findings-url", - "http://127.0.0.1:3000", + "http://findings:3000", "--json", ...(allRepositories ? ["--all-repositories"] : []), ]; @@ -263,17 +229,13 @@ function checkCliDeduplication(): void { deduplicationStatus: "completed", }; assert.deepEqual(actual, expected); - const calls = docker([ - "exec", - container, - "cat", - "/state/review-calls.jsonl", - ]); - assert.deepEqual(JSON.parse(docker(command)), expected); - assert.equal( - docker(["exec", container, "cat", "/state/review-calls.jsonl"]), - calls, + const callsPath = join( + runnerRoot, + "results/.codex-security-state/review-calls.jsonl", ); + const calls = await readFile(callsPath, "utf8"); + assert.deepEqual(JSON.parse(docker(command)), expected); + assert.equal(await readFile(callsPath, "utf8"), calls); } findings[0] = example!; } @@ -307,6 +269,21 @@ function checkStorage(expectGroups = false): void { ]); } +function checkWorkflowStorage(): void { + docker([ + ...runner, + "--entrypoint", + "node", + "--volume", + `${fileURLToPath(new URL("fixtures/prepare-runner-scan.ts", import.meta.url))}:/test/prepare-runner-scan.ts:ro`, + "codex-security", + "--experimental-strip-types", + "/test/prepare-runner-scan.ts", + "--check-workflows", + JSON.stringify(ids), + ]); +} + async function checkStoredGroups(): Promise { let stored: FindingDedupeGroup[] = []; for (const [index, id] of ids.entries()) { @@ -324,8 +301,14 @@ async function checkStoredGroups(): Promise { return stored; } -function checkReviews(): void { - const calls = docker(["exec", container, "cat", "/state/review-calls.jsonl"]) +async function checkReviews(): Promise { + const calls = ( + await readFile( + join(runnerRoot, "results/.codex-security-state/review-calls.jsonl"), + "utf8", + ) + ) + .trim() .split("\n") .map( (line) => @@ -366,31 +349,87 @@ function stopService(): void { let passed = false; try { - docker([...compose, "build"]); + for (const directory of ["results", "state"]) + await mkdir(join(runnerRoot, directory), { mode: 0o700 }); + await writeFile( + join(runnerRoot, "network.json"), + JSON.stringify({ + networks: { default: { external: true, name: `${container}_default` } }, + }), + ); + if (!process.argv[2]) + docker(["build", "--target", "findings-service", "--tag", image, "."]); + if (!process.env["CODEX_SECURITY_IMAGE"]) + docker(["build", "--target", "scanner", "--tag", runnerImage, "."]); await startService(); - await checkHostPublication(); + docker([...runnerCompose, "config", "--quiet"]); + docker([ + ...runnerCompose, + "-f", + "compose.apparmor.yaml", + "config", + "--quiet", + ]); + docker([...runner, "codex-security", "dedupe", "--help"]); + docker([ + ...runner, + "--entrypoint", + "node", + "--volume", + `${fileURLToPath(new URL("fixtures/prepare-runner-scan.ts", import.meta.url))}:/test/prepare-runner-scan.ts:ro`, + "codex-security", + "--experimental-strip-types", + "/test/prepare-runner-scan.ts", + ]); + assert.equal( + docker( + [ + ...runner, + "codex-security", + "dedupe", + "--scan", + "missing-scan", + "--findings-url", + "http://findings:3000", + "--json", + ], + { status: 2 }, + ), + "", + ); await checkInsertions(); await checkDashboard(); await checkCandidates(); await checkPages(); checkStorage(); - checkCliDeduplication(); + await checkCliDeduplication(); checkStorage(true); + checkWorkflowStorage(); const storedGroups = await checkStoredGroups(); - checkReviews(); + await checkReviews(); stopService(); docker(["rm", container]); await startService(); checkStorage(true); assert.deepEqual(await checkStoredGroups(), storedGroups); + await checkDashboard(); await checkPages(); await checkCandidates(); + await checkCliDeduplication(); + checkWorkflowStorage(); + await checkReviews(); stopService(); + assert.equal( + await readFile(join(runnerRoot, "state/runner-marker"), "utf8"), + "synthetic runner state\n", + ); passed = true; - console.log("Findings service Docker smoke test passed."); + console.log( + "Findings service and separate scanner runner Docker smoke test passed.", + ); } finally { if (!passed) docker(["logs", container], { check: false }); docker(["rm", "--force", container], { check: false }); docker([...compose, "down", "--volumes"], { check: passed }); - await rm(localRoot, { recursive: true, force: true }); + await rm(runnerRoot, { recursive: true, force: true }); } diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 97ce7ad63..18454c78e 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -6,7 +6,8 @@ "dashboard/**/*.tsx", "tests-ts/**/*.ts", "scripts/smoke-findings-service.ts", - "scripts/fixtures/findings-service-sqlite.ts" + "scripts/fixtures/findings-service-sqlite.ts", + "scripts/fixtures/prepare-runner-scan.ts" ], "exclude": ["dist", "node_modules", "tests-ts/package.test.ts"], "compilerOptions": {