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)

This commit is contained in:
Latte committed 2026-10-14 17:40:16 +02:00
1 parent 6f7a3c0e5b
commit 766968fa42
9 files changed
+679 -60

No files matched your search

+79 -29
View File
@@ -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-<run id>-...) 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
+71 -24
View File
@@ -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<run_number> (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
+68
View File
@@ -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