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:
1 parent
6f7a3c0e5b
commit
766968fa42
9 files changed
+674
-55
No files matched your search
+79
-29
@@ -4,13 +4,21 @@ name: ci
|
|||||||
#
|
#
|
||||||
# gitleaks secret scan over the full history
|
# gitleaks secret scan over the full history
|
||||||
# lint ruff check, ruff format --check, mypy
|
# 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-integration pytest against a throwaway Gitea in Docker
|
||||||
# test-mcp MCP smoke tests, including against the freshly built image
|
# 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
|
# docs-check docs drift check
|
||||||
# security security test suite, pip-audit, ruff bandit rules
|
# 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
|
# 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()`
|
# 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.
|
# step that deletes exactly those names. Nothing here ever prunes.
|
||||||
@@ -37,9 +45,10 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install gitleaks
|
- name: Install gitleaks
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -60,13 +69,15 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -87,13 +98,15 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -105,7 +118,7 @@ jobs:
|
|||||||
# Best effort: some act_runner versions do not support the v4 artifact
|
# Best effort: some act_runner versions do not support the v4 artifact
|
||||||
# backend, and the coverage gate itself already ran above.
|
# backend, and the coverage gate itself already ran above.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: coverage-${{ github.run_id }}
|
name: coverage-${{ github.run_id }}
|
||||||
path: coverage.xml
|
path: coverage.xml
|
||||||
@@ -117,17 +130,20 @@ jobs:
|
|||||||
# cleanup step is the safety net for cancelled runs.
|
# cleanup step is the safety net for cancelled runs.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
test-integration:
|
test-integration:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -151,19 +167,22 @@ jobs:
|
|||||||
# runners.
|
# runners.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
test-mcp:
|
test-mcp:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
env:
|
env:
|
||||||
MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test
|
MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -196,6 +215,7 @@ jobs:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: [lint, test-unit]
|
needs: [lint, test-unit]
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
@@ -203,7 +223,9 @@ jobs:
|
|||||||
digest: ${{ steps.digest.outputs.digest }}
|
digest: ${{ steps.digest.outputs.digest }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Compute image name and tags
|
- name: Compute image name and tags
|
||||||
id: meta
|
id: meta
|
||||||
@@ -237,15 +259,36 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version
|
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
|
- name: Log in to the registry
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
uses: docker/login-action@v3
|
shell: bash
|
||||||
with:
|
env:
|
||||||
registry: git.hiddenden.cafe
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
# PAT with write:package scope. The built-in token lacks package-write
|
# PAT with write:package scope. The built-in token lacks package-write
|
||||||
# on this instance.
|
# 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
|
- name: Tag and push
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
@@ -284,7 +327,10 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
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 \
|
bash scripts/ci/cleanup_job_resources.sh \
|
||||||
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }}
|
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }}
|
||||||
|
|
||||||
@@ -296,13 +342,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -321,13 +369,15 @@ jobs:
|
|||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ name: publish
|
|||||||
|
|
||||||
# Build the Python package with uv and publish it to the self-hosted Gitea PyPI
|
# 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);
|
# 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
|
# main -> the stable package (aegis-gitea-mcp). Gated on lint, tests and the security
|
||||||
# can never ship red. Reuses the REGISTRY_TOKEN package secret (same one ci.yml
|
# checks so a release can never ship red. Reuses the REGISTRY_TOKEN package secret
|
||||||
# uses); if it is absent the job fails loudly instead of publishing anonymously.
|
# (same one ci.yml uses); if it is absent the job fails loudly instead of publishing
|
||||||
|
# anonymously.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -19,13 +20,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -44,13 +47,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -60,7 +65,46 @@ jobs:
|
|||||||
run: uv run pytest
|
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)
|
# 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)
|
# main -> aegis-gitea-mcp X.Y.Z (no-op if already there)
|
||||||
@@ -70,19 +114,21 @@ jobs:
|
|||||||
# and version "X.Y.Z".
|
# and version "X.Y.Z".
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
publish:
|
publish:
|
||||||
needs: [lint, test]
|
needs: [lint, test, security]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||||
with:
|
with:
|
||||||
version: "0.12.5"
|
version: "0.12.5"
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -135,7 +181,7 @@ jobs:
|
|||||||
# v4 artifact backend. The real deliverable is published to the registry
|
# v4 artifact backend. The real deliverable is published to the registry
|
||||||
# below, so a failed artifact upload must not fail the release.
|
# below, so a failed artifact upload must not fail the release.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: dist-${{ steps.chan.outputs.channel }}
|
name: dist-${{ steps.chan.outputs.channel }}
|
||||||
path: dist/*
|
path: dist/*
|
||||||
@@ -144,25 +190,26 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# Reuse the existing package secret (same one ci.yml uses). The
|
# Reuse the existing package secret (same one ci.yml uses). The
|
||||||
# token authenticates as its owning Gitea user, so GITHUB_ACTOR is the
|
# token authenticates as its owning Gitea user, so the actor is the
|
||||||
# username and the token is the password.
|
# username and the token is the password. uv reads the credentials from these variables, so the token never
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
# appears on a command line (process listings, shell traces).
|
||||||
|
UV_PUBLISH_USERNAME: ${{ github.actor }}
|
||||||
|
UV_PUBLISH_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# --check-url makes uv skip files already in the registry, so a main
|
# --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.
|
# push that did not bump the version is a clean no-op instead of a 409.
|
||||||
uv publish \
|
uv publish \
|
||||||
--publish-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi \
|
--publish-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi \
|
||||||
--check-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/ \
|
--check-url https://git.hiddenden.cafe/api/packages/Hiddenden/pypi/simple/
|
||||||
--username "${GITHUB_ACTOR}" \
|
|
||||||
--password "${REGISTRY_TOKEN}"
|
|
||||||
|
|
||||||
# Optional second step to also publish to public PyPI lives behind its own
|
# 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.
|
# to public PyPI.
|
||||||
#
|
#
|
||||||
# - name: Publish to public PyPI
|
# - name: Publish to public PyPI
|
||||||
# if: ${{ secrets.PYPI_TOKEN != '' }}
|
# if: ${{ secrets.PYPI_TOKEN != '' }}
|
||||||
# shell: bash
|
# shell: bash
|
||||||
# env:
|
# env:
|
||||||
# PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
# UV_PUBLISH_USERNAME: __token__
|
||||||
# run: uv publish --username __token__ --password "${PYPI_TOKEN}"
|
# UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||||
|
# run: uv publish
|
||||||
@@ -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
|
||||||
@@ -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
|
`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
|
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.
|
--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/<owner>/<action> refs/tags/<tag>` (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-<run id>-...`)
|
||||||
|
and immutable `:sha-` tags. Shared tags (`:latest`, `:main`, `:dev`) are only untagged.
|
||||||
@@ -8,8 +8,12 @@
|
|||||||
# * per-run networks named aegis-it-* that the job container is attached to
|
# * 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
|
# (the integration harness joins the job container to its own per-run
|
||||||
# network only), plus the containers still attached to those networks;
|
# network only), plus the containers still attached to those networks;
|
||||||
# * containers started from the exact image refs given as arguments;
|
# * containers started from the per-run image refs given as arguments, and
|
||||||
# * the exact image refs given as arguments.
|
# those refs. Per-run refs are names containing "aegis-ci-" (named after the
|
||||||
|
# run id) and immutable ":sha-<commit>" 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).
|
# Everything is best effort (a failing removal never fails the job).
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -40,12 +44,24 @@ if [ -n "${job_ref}" ] && docker container inspect "${job_ref}" > /dev/null 2>&1
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
is_per_run_ref() {
|
||||||
|
case "$1" in
|
||||||
|
*aegis-ci-* | *:sha-*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
for image in "$@"; do
|
for image in "$@"; do
|
||||||
|
if is_per_run_ref "${image}"; then
|
||||||
for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do
|
for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do
|
||||||
docker rm -f -v "${cid}" > /dev/null 2>&1 || true
|
docker rm -f -v "${cid}" > /dev/null 2>&1 || true
|
||||||
done
|
done
|
||||||
docker rmi "${image}" > /dev/null 2>&1 || true
|
docker rmi "${image}" > /dev/null 2>&1 || true
|
||||||
echo "removed image ${image}"
|
echo "removed image ${image}"
|
||||||
|
else
|
||||||
|
docker image rm "${image}" > /dev/null 2>&1 || true
|
||||||
|
echo "untagged shared ref ${image}"
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
Executable
+46
@@ -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 <dest-dir>
|
||||||
|
#
|
||||||
|
# The binary ends up at <dest-dir>/trivy. To upgrade, bump TRIVY_VERSION and both
|
||||||
|
# digests from the release's trivy_<version>_checksums.txt.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TRIVY_VERSION="0.76.0"
|
||||||
|
SHA256_X64="e24b1c51293c1b15e4ba8d45bbc7891ba053f90278ec2698f3562c993e145fe7"
|
||||||
|
SHA256_ARM64="aab21e677a7b7071fc79f1711480840754c18a1b9d9cefa7ccd06af63a0e8f4c"
|
||||||
|
|
||||||
|
dest="${1:?usage: install_trivy.sh <dest-dir>}"
|
||||||
|
|
||||||
|
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
|
||||||
Executable
+39
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Scan a locally built image for known vulnerabilities with Trivy.
|
||||||
|
#
|
||||||
|
# Usage: scripts/ci/scan_image.sh <image-ref>
|
||||||
|
#
|
||||||
|
# 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 <image-ref>}"
|
||||||
|
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[@]}"
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user