From 766968fa424dc33e6f8ea57234d66e6a51a18c95 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 17:40:16 +0200 Subject: [PATCH] ci: pin actions to commit SHAs, guard Docker jobs from forks, scan the image, gate publish on security (SEC-06, SEC-07, SEC-14, SEC-15, SEC-16) --- .gitea/workflows/ci.yml | 108 ++++++--- .gitea/workflows/publish.yml | 95 ++++++-- .gitea/workflows/scan.yml | 68 ++++++ docs/deployment.md | 22 ++ scripts/ci/cleanup_job_resources.sh | 30 ++- scripts/ci/install_trivy.sh | 46 ++++ scripts/ci/scan_image.sh | 39 ++++ security/trivyignore.txt | 5 + tests/security/test_ci_workflows.py | 326 ++++++++++++++++++++++++++++ 9 files changed, 679 insertions(+), 60 deletions(-) create mode 100644 .gitea/workflows/scan.yml create mode 100755 scripts/ci/install_trivy.sh create mode 100755 scripts/ci/scan_image.sh create mode 100644 security/trivyignore.txt create mode 100644 tests/security/test_ci_workflows.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 38226c0..6a669b7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -4,13 +4,21 @@ name: ci # # gitleaks secret scan over the full history # lint ruff check, ruff format --check, mypy -# test-unit pytest (coverage gate from pyproject.toml) +# test-unit pytest (coverage gate from pyproject.toml: 90 percent) # test-integration pytest against a throwaway Gitea in Docker # test-mcp MCP smoke tests, including against the freshly built image -# build-and-push build the image; push it on push events (needs lint + test-unit) +# build-and-push build and scan the image; push it on push events (needs lint + test-unit) # docs-check docs drift check # security security test suite, pip-audit, ruff bandit rules # +# Jobs that use the shared Docker daemon or secrets (test-integration, test-mcp, +# build-and-push) do not run for pull requests from forks: a fork controls the +# workflow and test code, and access to the daemon is root-equivalent on the +# host. Also enable "require approval for workflows from forks" in Gitea. +# +# Every third-party action is pinned to a full commit SHA (the version is in the +# trailing comment); bump deliberately. +# # Docker hygiene on the shared runner: every resource this workflow creates has # a per-run name (aegis-ci--...) and is removed by an `if: always()` # step that deletes exactly those names. Nothing here ever prunes. @@ -37,9 +45,10 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 + persist-credentials: false - name: Install gitleaks shell: bash @@ -60,13 +69,15 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -87,13 +98,15 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -105,7 +118,7 @@ jobs: # Best effort: some act_runner versions do not support the v4 artifact # backend, and the coverage gate itself already ran above. continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ github.run_id }} path: coverage.xml @@ -117,17 +130,20 @@ jobs: # cleanup step is the safety net for cancelled runs. # --------------------------------------------------------------------------- test-integration: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 40 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -151,19 +167,22 @@ jobs: # runners. # --------------------------------------------------------------------------- test-mcp: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 40 env: MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -196,6 +215,7 @@ jobs: # --------------------------------------------------------------------------- build-and-push: needs: [lint, test-unit] + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -203,7 +223,9 @@ jobs: digest: ${{ steps.digest.outputs.digest }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Compute image name and tags id: meta @@ -237,15 +259,36 @@ jobs: shell: bash run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version + # Vulnerability scan of the image that is about to be pushed: pinned Trivy + # (sha256 verified), fails on fixable HIGH and CRITICAL findings. Waivers live + # in security/trivyignore.txt and docs/security.md. + - name: Install Trivy + shell: bash + run: | + bin_dir="${RUNNER_TEMP:-/tmp}/trivy-bin" + bash scripts/ci/install_trivy.sh "${bin_dir}" + echo "${bin_dir}" >> "$GITHUB_PATH" + + - name: Scan image + shell: bash + run: bash scripts/ci/scan_image.sh "${{ steps.meta.outputs.sha_tag }}" + + # No third-party action sees the registry token: the CLI reads it from stdin. The + # credentials go to a per-job config directory (DOCKER_CONFIG), never into the shared + # credential store of the runner's Docker client. - name: Log in to the registry if: github.event_name == 'push' - uses: docker/login-action@v3 - with: - registry: git.hiddenden.cafe - username: ${{ github.actor }} + shell: bash + env: # PAT with write:package scope. The built-in token lacks package-write # on this instance. - password: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + REGISTRY_USER: ${{ github.actor }} + run: | + config_dir="$(mktemp -d)" + echo "DOCKER_CONFIG=${config_dir}" >> "$GITHUB_ENV" + printf '%s' "${REGISTRY_TOKEN}" | DOCKER_CONFIG="${config_dir}" \ + docker login git.hiddenden.cafe --username "${REGISTRY_USER}" --password-stdin - name: Tag and push if: github.event_name == 'push' @@ -284,7 +327,10 @@ jobs: if: always() shell: bash run: | - docker logout git.hiddenden.cafe || true + # Only the per-job credential directory created above is removed. + if [ -n "${DOCKER_CONFIG:-}" ]; then + rm -rf "${DOCKER_CONFIG}" + fi bash scripts/ci/cleanup_job_resources.sh \ "${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }} @@ -296,13 +342,15 @@ jobs: timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -321,13 +369,15 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 43a8f33..73c18e3 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -2,9 +2,10 @@ name: publish # Build the Python package with uv and publish it to the self-hosted Gitea PyPI # registry on merge. dev -> a dev package (aegis-gitea-mcp-dev, .devN versions); -# main -> the stable package (aegis-gitea-mcp). Gated on lint + tests so a release -# can never ship red. Reuses the REGISTRY_TOKEN package secret (same one ci.yml -# uses); if it is absent the job fails loudly instead of publishing anonymously. +# main -> the stable package (aegis-gitea-mcp). Gated on lint, tests and the security +# checks so a release can never ship red. Reuses the REGISTRY_TOKEN package secret +# (same one ci.yml uses); if it is absent the job fails loudly instead of publishing +# anonymously. on: push: branches: @@ -19,13 +20,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -44,13 +47,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -60,7 +65,46 @@ jobs: run: uv run pytest # --------------------------------------------------------------------------- - # 3. Build with uv and publish to the Gitea PyPI registry. + # 3. Security: the same gates as ci.yml (secret scan over the history, security + # tests, dependency audit, bandit rules). Nothing is published without them. + # --------------------------------------------------------------------------- + security: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: "0.12.5" + enable-cache: false + - name: Install dependencies + run: uv sync --locked + - name: Install gitleaks + shell: bash + run: | + bin_dir="${RUNNER_TEMP:-/tmp}/gitleaks-bin" + bash scripts/ci/install_gitleaks.sh "${bin_dir}" + echo "${bin_dir}" >> "$GITHUB_PATH" + - name: Scan history + shell: bash + run: gitleaks git --config .gitleaks.toml --redact --no-banner --verbose --exit-code 1 . + - name: Security tests + run: uv run pytest tests/security --no-cov + - name: Dependency audit + run: bash scripts/ci/pip_audit.sh + - name: Ruff security rules + run: uv run ruff check --select S . + + # --------------------------------------------------------------------------- + # 4. Build with uv and publish to the Gitea PyPI registry. # # dev -> aegis-gitea-mcp-dev X.Y.Z.dev (always unique) # main -> aegis-gitea-mcp X.Y.Z (no-op if already there) @@ -70,19 +114,21 @@ jobs: # and version "X.Y.Z". # --------------------------------------------------------------------------- publish: - needs: [lint, test] + needs: [lint, test, security] runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false @@ -135,7 +181,7 @@ jobs: # v4 artifact backend. The real deliverable is published to the registry # below, so a failed artifact upload must not fail the release. continue-on-error: true - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist-${{ steps.chan.outputs.channel }} path: dist/* @@ -144,25 +190,26 @@ jobs: shell: bash env: # Reuse the existing package secret (same one ci.yml uses). The - # token authenticates as its owning Gitea user, so GITHUB_ACTOR is the - # username and the token is the password. - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + # token authenticates as its owning Gitea user, so the actor is the + # username and the token is the password. uv reads the credentials from these variables, so the token never + # appears on a command line (process listings, shell traces). + UV_PUBLISH_USERNAME: ${{ github.actor }} + UV_PUBLISH_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} run: | # --check-url makes uv skip files already in the registry, so a main # push that did not bump the version is a clean no-op instead of a 409. uv publish \ --publish-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi \ - --check-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \ - --username "${GITHUB_ACTOR}" \ - --password "${REGISTRY_TOKEN}" + --check-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ # Optional second step to also publish to public PyPI lives behind its own - # secret. Intentionally left as a disabled stub — this pass does NOT push + # secret. Intentionally left as a disabled stub: this pass does NOT push # to public PyPI. # # - name: Publish to public PyPI # if: ${{ secrets.PYPI_TOKEN != '' }} # shell: bash # env: - # PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - # run: uv publish --username __token__ --password "${PYPI_TOKEN}" + # UV_PUBLISH_USERNAME: __token__ + # UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }} + # run: uv publish diff --git a/.gitea/workflows/scan.yml b/.gitea/workflows/scan.yml new file mode 100644 index 0000000..169c8a7 --- /dev/null +++ b/.gitea/workflows/scan.yml @@ -0,0 +1,68 @@ +name: scan + +# Weekly vulnerability check that does not depend on a push. The base image digests +# in docker/Dockerfile are pinned and refreshed by hand, so a new fixable HIGH or +# CRITICAL advisory in the pinned image, or a new advisory in the locked +# dependencies, turns this run red: the reminder to refresh the digests (see the +# comment at the top of docker/Dockerfile) or bump the dependency. +# +# Same rules as ci.yml: third-party actions pinned to a commit SHA, resources named +# per run and removed by name, no pruning. + +on: + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + +env: + UV_PYTHON_DOWNLOADS: never + UV_NO_PROGRESS: "1" + +jobs: + image-scan: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + SCAN_IMAGE: aegis-ci-${{ github.run_id }}-scan:test + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Build image + run: docker build -f docker/Dockerfile -t "${SCAN_IMAGE}" . + - name: Install Trivy + shell: bash + run: | + bin_dir="${RUNNER_TEMP:-/tmp}/trivy-bin" + bash scripts/ci/install_trivy.sh "${bin_dir}" + echo "${bin_dir}" >> "$GITHUB_PATH" + - name: Scan image + shell: bash + run: bash scripts/ci/scan_image.sh "${SCAN_IMAGE}" + - name: Cleanup + if: always() + shell: bash + run: bash scripts/ci/cleanup_job_resources.sh "${SCAN_IMAGE}" + + dependency-audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Set up uv + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: "0.12.5" + enable-cache: false + - name: Install dependencies + run: uv sync --locked + - name: Dependency audit + run: bash scripts/ci/pip_audit.sh diff --git a/docs/deployment.md b/docs/deployment.md index e52cf10..d5e42f6 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -132,3 +132,25 @@ The workflows in `.gitea/workflows/` lint, test and build. The `docs-check` job `uv run python scripts/check_docs.py`, which fails when `docs/tools.md` or the README tool summary is stale, or when a document names a tool that does not exist. `scripts/generate_catalog.py --check` fails when `tools.json` does not match the pinned swagger. + +Security rules of the pipeline: + +- Jobs that use the runner's Docker daemon or a secret (`test-integration`, `test-mcp`, + `build-and-push`) do not run for pull requests from forks. Access to the daemon is + root-equivalent on the host, and a fork controls the workflow and test code. Also enable + "require approval for workflows from forks" (or the equivalent runner setting) in Gitea, and + keep the daemon of a shared runner free of services you cannot afford to lose. +- Every third-party action is pinned to a full commit SHA with the version in a trailing + comment. Bump a pin deliberately: look up the SHA of the new tag with + `git ls-remote https://github.com// refs/tags/` (add `^{}` for annotated + tags). The registry token never reaches a third-party action: the CLI reads it from stdin and + `uv publish` reads `UV_PUBLISH_PASSWORD`. +- `build-and-push` scans the built image with a pinned Trivy (`scripts/ci/install_trivy.sh`, + sha256 verified) before anything is pushed, and fails on HIGH or CRITICAL findings that have a + fix. `scan.yml` repeats the scan and the dependency audit every week, so a new advisory in a + pinned base image turns it red: refresh the digests in `docker/Dockerfile`. Accepted findings + go in `security/trivyignore.txt` with a waiver row in [security.md](security.md). +- `publish.yml` needs the `security` job (secret scan, security tests, dependency audit, bandit + rules) as well as lint and tests. +- The cleanup script removes containers only for per-run image names (`aegis-ci--...`) + and immutable `:sha-` tags. Shared tags (`:latest`, `:main`, `:dev`) are only untagged. diff --git a/scripts/ci/cleanup_job_resources.sh b/scripts/ci/cleanup_job_resources.sh index e4b3cbd..8dbd5c5 100755 --- a/scripts/ci/cleanup_job_resources.sh +++ b/scripts/ci/cleanup_job_resources.sh @@ -8,8 +8,12 @@ # * per-run networks named aegis-it-* that the job container is attached to # (the integration harness joins the job container to its own per-run # network only), plus the containers still attached to those networks; -# * containers started from the exact image refs given as arguments; -# * the exact image refs given as arguments. +# * containers started from the per-run image refs given as arguments, and +# those refs. Per-run refs are names containing "aegis-ci-" (named after the +# run id) and immutable ":sha-" tags; +# * for any other ref (shared tags such as :latest, :main, :dev, which a +# deployment on the same daemon may use) only the tag is removed. Containers +# are never touched, and an image still used by a container simply stays. # Everything is best effort (a failing removal never fails the job). set -uo pipefail @@ -40,12 +44,24 @@ if [ -n "${job_ref}" ] && docker container inspect "${job_ref}" > /dev/null 2>&1 done fi +is_per_run_ref() { + case "$1" in + *aegis-ci-* | *:sha-*) return 0 ;; + *) return 1 ;; + esac +} + for image in "$@"; do - for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do - docker rm -f -v "${cid}" > /dev/null 2>&1 || true - done - docker rmi "${image}" > /dev/null 2>&1 || true - echo "removed image ${image}" + if is_per_run_ref "${image}"; then + for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do + docker rm -f -v "${cid}" > /dev/null 2>&1 || true + done + docker rmi "${image}" > /dev/null 2>&1 || true + echo "removed image ${image}" + else + docker image rm "${image}" > /dev/null 2>&1 || true + echo "untagged shared ref ${image}" + fi done exit 0 diff --git a/scripts/ci/install_trivy.sh b/scripts/ci/install_trivy.sh new file mode 100755 index 0000000..1c68b74 --- /dev/null +++ b/scripts/ci/install_trivy.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Install a pinned Trivy release into a directory, verifying its sha256. +# +# Usage: scripts/ci/install_trivy.sh +# +# The binary ends up at /trivy. To upgrade, bump TRIVY_VERSION and both +# digests from the release's trivy__checksums.txt. +set -euo pipefail + +TRIVY_VERSION="0.76.0" +SHA256_X64="e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7" +SHA256_ARM64="aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c" + +dest="${1:?usage: install_trivy.sh }" + +case "$(uname -m)" in + x86_64 | amd64) + arch="64bit" + expected="${SHA256_X64}" + ;; + aarch64 | arm64) + arch="ARM64" + expected="${SHA256_ARM64}" + ;; + *) + echo "unsupported architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +archive="trivy_${TRIVY_VERSION}_Linux-${arch}.tar.gz" +url="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/${archive}" + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +curl --fail --silent --show-error --location --retry 3 --output "${work}/${archive}" "${url}" +echo "${expected} ${work}/${archive}" | sha256sum --check --status || { + echo "sha256 mismatch for ${archive}" >&2 + exit 1 +} + +mkdir -p "${dest}" +tar -xzf "${work}/${archive}" -C "${dest}" trivy +chmod 0755 "${dest}/trivy" +"${dest}/trivy" version diff --git a/scripts/ci/scan_image.sh b/scripts/ci/scan_image.sh new file mode 100755 index 0000000..b2fff24 --- /dev/null +++ b/scripts/ci/scan_image.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Scan a locally built image for known vulnerabilities with Trivy. +# +# Usage: scripts/ci/scan_image.sh +# +# Fails (exit 1) on HIGH or CRITICAL findings that have a fixed version. Findings +# without a fix are reported by Trivy but do not fail the scan (nothing can be done +# about them yet). Accepted findings are listed in security/trivyignore.txt, one +# vulnerability id per line with a reason, and in docs/security.md. The image is +# exported with `docker save` so Trivy needs no access to the Docker socket. +# +# Trivy itself is pinned (scripts/ci/install_trivy.sh); its vulnerability database +# is fetched fresh on every run, which is the point of scanning. +set -euo pipefail + +image="${1:?usage: scan_image.sh }" +ignore_file="security/trivyignore.txt" + +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +docker save --output "${work}/image.tar" "${image}" + +args=( + image + --input "${work}/image.tar" + --cache-dir "${work}/cache" + --scanners vuln + --severity HIGH,CRITICAL + --ignore-unfixed + --exit-code 1 + --no-progress + --db-repository "ghcr.io/aquasecurity/trivy-db:2,public.ecr.aws/aquasecurity/trivy-db:2" +) +if [ -f "${ignore_file}" ]; then + args+=(--ignorefile "${ignore_file}") +fi + +trivy "${args[@]}" diff --git a/security/trivyignore.txt b/security/trivyignore.txt new file mode 100644 index 0000000..2bac54e --- /dev/null +++ b/security/trivyignore.txt @@ -0,0 +1,5 @@ +# Accepted container image vulnerabilities (Trivy ignore file). +# +# One vulnerability id per line (CVE-...). Every entry needs a comment above it with +# the reason and the date it must be reviewed, and a row in the waiver table of +# docs/security.md. Empty means nothing is ignored. diff --git a/tests/security/test_ci_workflows.py b/tests/security/test_ci_workflows.py new file mode 100644 index 0000000..a3a80c4 --- /dev/null +++ b/tests/security/test_ci_workflows.py @@ -0,0 +1,326 @@ +"""CI hygiene: pinned actions, fork guards, cleanup scope and the security gates (SEC-06/07/14/15/16). + +The workflow files are simple enough to read line by line, so no YAML library is needed +(none is a dependency of the project). +""" + +from __future__ import annotations + +import os +import re +import stat +import subprocess +from dataclasses import dataclass, field +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOWS = sorted((ROOT / ".gitea" / "workflows").glob("*.yml")) +FORK_GUARD = ( + "github.event_name != 'pull_request' || " + "github.event.pull_request.head.repo.full_name == github.repository" +) +PINNED = re.compile(r"^[\w.-]+/[\w.-]+@[0-9a-f]{40}$") +VERSION_COMMENT = re.compile(r"#\s*v\d+\.\d+\.\d+\s*$") +DOCKER_USE = re.compile( + r"\bdocker\s+(build|run|push|pull|login|save|tag|rmi|rm|image|ps|network|compose)\b" + r"|cleanup_job_resources\.sh|scan_image\.sh|tests/integration|tests/mcp" +) + + +@dataclass +class Step: + lines: list[str] = field(default_factory=list) + + @property + def text(self) -> str: + return "\n".join(self.lines) + + @property + def uses(self) -> str | None: + for line in self.lines: + match = re.match(r"\s*(?:-\s*)?uses:\s*(.*)$", line) + if match: + return match.group(1).strip() + return None + + def with_block(self) -> str: + collected: list[str] = [] + inside = False + for line in self.lines: + if re.match(r"^\s{8}with:\s*$", line): + inside = True + continue + if inside and re.match(r"^\s{8}\S", line): + break + if inside: + collected.append(line) + return "\n".join(collected) + + +@dataclass +class Job: + name: str + lines: list[str] + + @property + def text(self) -> str: + return "\n".join(self.lines) + + @property + def condition(self) -> str | None: + for line in self.lines: + match = re.match(r"^ {4}if:\s*(.*)$", line) + if match: + return match.group(1).strip() + return None + + @property + def needs(self) -> list[str]: + for line in self.lines: + match = re.match(r"^ {4}needs:\s*\[(.*)\]\s*$", line) + if match: + return [item.strip() for item in match.group(1).split(",")] + return [] + + def steps(self) -> list[Step]: + steps: list[Step] = [] + in_steps = False + for line in self.lines: + if re.match(r"^ {4}steps:\s*$", line): + in_steps = True + continue + if not in_steps: + continue + if re.match(r"^ {6}-\s", line): + steps.append(Step()) + if steps: + steps[-1].lines.append(line) + return steps + + +def jobs_of(path: Path) -> list[Job]: + jobs: list[Job] = [] + in_jobs = False + for line in path.read_text(encoding="utf-8").splitlines(): + if line.startswith("jobs:"): + in_jobs = True + continue + if not in_jobs: + continue + if line.lstrip().startswith("#"): + continue # comment lines (the banner of the next job sits at the end of a block) + match = re.match(r"^ {2}([\w-]+):\s*$", line) + if match: + jobs.append(Job(match.group(1), [])) + if jobs: + jobs[-1].lines.append(line) + return jobs + + +def has_pull_request_trigger(path: Path) -> bool: + return ( + re.search(r"^ {2}pull_request:", path.read_text(encoding="utf-8"), re.MULTILINE) is not None + ) + + +def test_there_are_workflows_to_check() -> None: + assert {path.name for path in WORKFLOWS} >= {"ci.yml", "publish.yml"} + for path in WORKFLOWS: + assert jobs_of(path), path.name + + +# SEC-07 + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_every_action_is_pinned_to_a_full_commit_sha_with_a_version_comment(path: Path) -> None: + seen = 0 + for line in path.read_text(encoding="utf-8").splitlines(): + match = re.match(r"^\s*(?:-\s*)?uses:\s*(\S+)(.*)$", line) + if match is None or line.lstrip().startswith("#"): + continue + seen += 1 + assert PINNED.match(match.group(1)), f"{path.name}: {line.strip()}" + assert VERSION_COMMENT.search(match.group(2)), f"{path.name}: no version comment: {line}" + assert seen, f"{path.name} uses no action, so the pin check proved nothing" + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_no_secret_is_handed_to_a_third_party_action(path: Path) -> None: + for job in jobs_of(path): + for step in job.steps(): + if step.uses is not None: + assert "secrets." not in step.with_block(), f"{path.name}/{job.name}" + assert "secrets." not in step.text, f"{path.name}/{job.name}: {step.text}" + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_checkout_does_not_persist_the_job_token(path: Path) -> None: + for job in jobs_of(path): + for step in job.steps(): + if step.uses and step.uses.startswith("actions/checkout@"): + assert "persist-credentials: false" in step.with_block(), f"{path.name}/{job.name}" + + +def test_the_registry_login_uses_the_cli_with_the_token_on_stdin() -> None: + text = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") + + assert "docker/login-action" not in text + assert "--password-stdin" in text + assert not re.search(r"docker login[^\n]*--password(?!-stdin)", text) + + +# SEC-06 + + +def test_docker_and_secret_jobs_do_not_run_for_fork_pull_requests() -> None: + path = ROOT / ".gitea" / "workflows" / "ci.yml" + assert has_pull_request_trigger(path) + guarded = [] + for job in jobs_of(path): + if DOCKER_USE.search(job.text) or "secrets." in job.text: + guarded.append(job.name) + assert job.condition == FORK_GUARD, f"job {job.name} runs docker or uses secrets" + assert {"test-integration", "test-mcp", "build-and-push"} <= set(guarded) + + +def test_workflows_without_a_pull_request_trigger_cannot_run_fork_code() -> None: + for path in WORKFLOWS: + if not has_pull_request_trigger(path): + assert "pull_request_target" not in path.read_text(encoding="utf-8") + + +# SEC-15 + + +def test_publish_needs_the_security_job_with_every_gate() -> None: + path = ROOT / ".gitea" / "workflows" / "publish.yml" + jobs = {job.name: job for job in jobs_of(path)} + + assert "security" in jobs["publish"].needs + security = jobs["security"].text + for gate in ( + "scripts/ci/install_gitleaks.sh", + "gitleaks git", + "tests/security", + "scripts/ci/pip_audit.sh", + "ruff check --select S", + ): + assert gate in security + assert "fetch-depth: 0" in security # the history scan needs it + + +def test_ci_security_job_runs_the_security_suite() -> None: + jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")} + + assert "uv run pytest tests/security --no-cov" in jobs["security"].text + + +# SEC-14 + + +def test_publish_passes_the_token_through_the_environment_not_the_command_line() -> None: + text = (ROOT / ".gitea" / "workflows" / "publish.yml").read_text(encoding="utf-8") + + assert "--password" not in text + assert "UV_PUBLISH_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}" in text + + +def test_the_build_job_logs_out_by_deleting_its_own_credential_directory() -> None: + text = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") + + assert "docker logout" not in text + assert "DOCKER_CONFIG" in text + + +FAKE_DOCKER = """#!/usr/bin/env bash +echo "$*" >> "$DOCKER_LOG" +if [ "$1" = "ps" ]; then + # one container per ancestor filter value + for arg in "$@"; do + case "$arg" in ancestor=*) echo "cid-of-${arg#ancestor=}" ;; esac + done +fi +exit 0 +""" + + +def run_cleanup(tmp_path: Path, *refs: str) -> list[str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "docker" + fake.write_text(FAKE_DOCKER) + fake.chmod(fake.stat().st_mode | stat.S_IXUSR) + log = tmp_path / "docker.log" + log.write_text("") + env = { + "PATH": f"{bin_dir}:/usr/bin:/bin", + "DOCKER_LOG": str(log), + "HOME": str(tmp_path), + } + work = tmp_path / "work" + work.mkdir() + subprocess.run( # noqa: S603 (fixed script path, fake docker on PATH) + ["bash", str(ROOT / "scripts" / "ci" / "cleanup_job_resources.sh"), *refs], # noqa: S607 + cwd=work, + env=env, + check=True, + capture_output=True, + timeout=30, + ) + return log.read_text().splitlines() + + +def test_cleanup_removes_containers_only_for_per_run_refs(tmp_path: Path) -> None: + registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp" + per_run = f"{registry}:sha-0123456789ab" + shared = [f"{registry}:latest", f"{registry}:main", f"{registry}:dev"] + + calls = run_cleanup(tmp_path, per_run, *shared) + + removed = [call for call in calls if call.startswith("rm -f")] + assert removed == [f"rm -f -v cid-of-{per_run}"] + queried = [call for call in calls if call.startswith("ps ")] + assert all("sha-0123456789ab" in call for call in queried) and len(queried) == 1 + # Shared tags are only untagged. + for ref in shared: + assert f"image rm {ref}" in calls + assert f"rmi {ref}" not in calls + + +def test_cleanup_treats_names_with_the_run_prefix_as_per_run(tmp_path: Path) -> None: + calls = run_cleanup(tmp_path, "aegis-ci-4242-mcp:test") + + assert "rm -f -v cid-of-aegis-ci-4242-mcp:test" in calls + assert "rmi aegis-ci-4242-mcp:test" in calls + + +def test_cleanup_without_arguments_touches_nothing(tmp_path: Path) -> None: + assert not any(call.startswith(("rm", "rmi", "image rm")) for call in run_cleanup(tmp_path)) + + +# SEC-16 + + +def test_the_image_scan_is_pinned_verified_and_gates_the_push() -> None: + install = (ROOT / "scripts" / "ci" / "install_trivy.sh").read_text(encoding="utf-8") + scan = (ROOT / "scripts" / "ci" / "scan_image.sh").read_text(encoding="utf-8") + ci = (ROOT / ".gitea" / "workflows" / "ci.yml").read_text(encoding="utf-8") + + assert re.search(r'^TRIVY_VERSION="\d+\.\d+\.\d+"$', install, re.MULTILINE) + assert len(re.findall(r'^SHA256_\w+="[0-9a-f]{64}"$', install, re.MULTILINE)) == 2 + assert "sha256sum --check" in install + assert "--severity HIGH,CRITICAL" in scan and "--exit-code 1" in scan + assert "--ignore-unfixed" in scan + jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")} + build = jobs["build-and-push"].text + assert build.index("scripts/ci/scan_image.sh") < build.index("docker push") + assert "scripts/ci/install_trivy.sh" in ci + + +def test_scripts_are_executable_and_parse() -> None: + for script in (ROOT / "scripts" / "ci").glob("*.sh"): + assert os.access(script, os.X_OK), script.name + subprocess.run(["bash", "-n", str(script)], check=True, timeout=30) # noqa: S603, S607