diff --git a/.github/actions/suite/action.yml b/.github/actions/suite/action.yml new file mode 100644 index 0000000..f29746b --- /dev/null +++ b/.github/actions/suite/action.yml @@ -0,0 +1,56 @@ +# The unit and integration suites, inside the base image: run by ci.yml's +# `test` job on every change and by release.yml's before anything is published, +# so a release is the commit the suite ran on and not merely one it ran near. +# An action rather than a reusable workflow so each caller's job keeps the name +# `test`: a required status check is matched by that name, and a reusable +# workflow reports as `test / test`. +# +# The base image already carries a compiled liboqs and every dependency +# (docker/Dockerfile.base, base-image.yml), so the suite runs on the runtime +# the app ships with. The published base is used only once its provenance is +# verified; otherwise it is built here (.github/scripts/provide-base.sh). +# The caller checks the tree out first. +name: suite +description: Unit and integration suites inside the verified base image + +inputs: + token: + description: the job's GITHUB_TOKEN, to verify the base and pull it on main + required: true + +runs: + using: composite + steps: + - name: Base image name (lowercased) + id: img + shell: bash + run: echo "base=ghcr.io/${GITHUB_REPOSITORY,,}-base" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.token }} + + - name: Provide the base image (published and verified, else built here) + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + BASE_NAME: ${{ steps.img.outputs.base }} + run: | + .github/scripts/provide-base.sh "$BASE_NAME:latest" > "$RUNNER_TEMP/base.ref" + + - name: Unit tests (in the base image — liboqs already compiled) + shell: bash + run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" + docker run --rm -v "$PWD:/app" -w /app "$BASE" \ + sh -c "python -c 'import oqs; print(\"liboqs ready (prebuilt)\")' && pytest -q" + + - name: Integration tests + shell: bash + run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" + docker run --rm -v "$PWD:/app" -w /app "$BASE" pytest tests/integration -q diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d406340..7fd765f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,23 @@ # Workflow actions are pinned to a commit SHA (tests/test_workflows_pinned.py); # Dependabot is what moves the pin, as a reviewable PR, when an action releases. +# +# A release is offered only once it is a week old (`cooldown`): a hijacked +# publisher's release is usually found and pulled within days, and a pin moved +# the day it appeared is how it reaches the jobs that publish. Security updates +# are not held back. +# +# The pip entry reads requirements.txt. The hash-locked set the image installs +# (docker/requirements.lock) is in a format Dependabot does not update; it is +# moved with `uv pip compile --generate-hashes`, and audit.yml's pip-audit fails +# on a known vulnerability in it, on every change and every week. version: 2 updates: - package-ecosystem: github-actions directory: / schedule: interval: weekly + cooldown: + default-days: 7 groups: actions: patterns: ["*"] @@ -14,9 +26,13 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 open-pull-requests-limit: 5 - package-ecosystem: docker directory: /docker schedule: interval: weekly + cooldown: + default-days: 7 diff --git a/.github/scripts/provide-base.sh b/.github/scripts/provide-base.sh new file mode 100755 index 0000000..5d3f32f --- /dev/null +++ b/.github/scripts/provide-base.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# The base image (Python + a compiled liboqs) a job runs or builds on: +# +# .github/scripts/provide-base.sh +# +# The published one when base-image.yml on `main` is proven to have built it, +# pinned by digest; otherwise one built here from docker/Dockerfile.base, under +# the given tag. Never a published image that cannot be traced to a build: +# every test, and the image every container node pulls, sit on top of it. +# Prints the reference to use. +set -euo pipefail + +[ $# -eq 1 ] || { echo "usage: provide-base.sh " >&2; exit 2; } +base="$1" +[[ "$base" =~ ^[a-z0-9][a-z0-9._/-]*:[A-Za-z0-9._-]+$ ]] \ + || { echo "provide-base: not an image reference: $base" >&2; exit 2; } +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" + +if pinned="$("$here/pull-verified.sh" "$base" base-image.yml)"; then + echo "using the published base image, verified: $pinned" >&2 + echo "$pinned" + exit 0 +fi +echo "the published base image is unreachable or unproven — building it here" >&2 +docker rmi "$base" >/dev/null 2>&1 || true +docker build -f "$root/docker/Dockerfile.base" -t "$base" "$root" >&2 +echo "$base" diff --git a/.github/scripts/pull-verified.sh b/.github/scripts/pull-verified.sh new file mode 100755 index 0000000..7e6dcde --- /dev/null +++ b/.github/scripts/pull-verified.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Pull an image this repository publishes, and prove which workflow built it: +# +# .github/scripts/pull-verified.sh +# +# Prints the image pinned by digest (`name@sha256:…`) on success, the one +# reference a later step should use: a tag can be moved between this check and +# the next pull, a digest cannot. Fails unless the digest carries a provenance +# attestation signed by that workflow of this repository, run from `main`. +# Needs GH_TOKEN and GITHUB_REPOSITORY. +set -euo pipefail + +[ $# -eq 2 ] || { echo "usage: pull-verified.sh " >&2; exit 2; } +image="$1" +workflow="$2" + +docker pull --quiet "$image" >&2 +pinned="$(docker inspect --format '{{index .RepoDigests 0}}' "$image")" +# The reference goes on to other steps and into `docker run`: nothing but a +# lowercase registry path and a digest, so it cannot carry a second line or an +# option. +[[ "$pinned" =~ ^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$ ]] \ + || { echo "pull-verified: $image has no registry digest" >&2; exit 1; } +gh attestation verify "oci://$pinned" \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/$workflow" \ + --source-ref refs/heads/main >&2 +echo "$pinned" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 65a3e0c..bfb3110 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -29,25 +29,51 @@ jobs: inputs: docker/requirements.lock require-hashes: true + # liboqs is C compiled from source (scripts/liboqs.lock): no package index + # lists it, so nothing above sees it. Its advisories are on its own + # repository; one that names the locked version fails, unless + # scripts/liboqs.accepted says why it cannot reach a node. + liboqs: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Advisories against the locked liboqs + env: + GH_TOKEN: ${{ github.token }} + run: python3 -m tests.tools.liboqs_advisories + # The image already published: a fix released this week for a package in it - # is a reason to rebuild, and nothing in the tree says so. + # is a reason to rebuild, and nothing in the tree says so. Scanned once it is + # proven to be the one ci.yml built, by digest, and never with the socket. published-image: # Not on a pull request: no change in one can fix an image already out. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest permissions: + contents: read packages: read + attestations: read steps: - - name: Image name (lowercased) - id: img - run: echo "app=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Pull it and check its provenance + env: + GH_TOKEN: ${{ github.token }} + run: | + .github/scripts/pull-verified.sh "ghcr.io/${GITHUB_REPOSITORY,,}:latest" ci.yml > "$RUNNER_TEMP/app.ref" - name: Scan it run: | - docker pull '${{ steps.img.outputs.app }}:latest' - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + APP="$(cat "$RUNNER_TEMP/app.ref")" + docker run --rm \ aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ - image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \ - --exit-code 1 --no-progress '${{ steps.img.outputs.app }}:latest' + image --image-src remote --scanners vuln,secret \ + --severity HIGH,CRITICAL --ignore-unfixed \ + --exit-code 1 --no-progress "$APP" # The console as a browser and an attacker meet it, scanned by OWASP ZAP # against a throwaway node: the public pages first, then every API route the @@ -61,25 +87,27 @@ jobs: permissions: contents: read packages: read + attestations: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Image names (lowercased) - id: img + - name: The published image, proven, and a base to run the tools in + env: + GH_TOKEN: ${{ github.token }} run: | - repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]') - echo "app=ghcr.io/$repo" >> "$GITHUB_OUTPUT" - echo "base=ghcr.io/$repo-base" >> "$GITHUB_OUTPUT" + .github/scripts/pull-verified.sh "ghcr.io/${GITHUB_REPOSITORY,,}:latest" ci.yml > "$RUNNER_TEMP/app.ref" + .github/scripts/provide-base.sh "ghcr.io/${GITHUB_REPOSITORY,,}-base:latest" > "$RUNNER_TEMP/base.ref" - name: A node to scan run: | + APP="$(cat "$RUNNER_TEMP/app.ref")" docker network create scan password="$(head -c 18 /dev/urandom | base64 | tr -d '/+=')" echo "::add-mask::$password" echo "CONSOLE_PASSWORD=$password" >> "$GITHUB_ENV" docker run -d --name target --network scan \ -e NMESH_CONSOLE_PASSWORD="$password" -e NMESH_FLEET=1 \ - '${{ steps.img.outputs.app }}:latest' + "$APP" for _ in $(seq 1 60); do docker logs target 2>&1 | grep -q "Web console" && exit 0 sleep 1 @@ -87,8 +115,9 @@ jobs: docker logs target; exit 1 - name: Describe its API run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" mkdir -p zap && chmod 777 zap - docker run --rm -v "$PWD:/app" -w /app '${{ steps.img.outputs.base }}:latest' \ + docker run --rm -v "$PWD:/app" -w /app "$BASE" \ python -m tests.zap.console_openapi https://target:8787 > zap/openapi.json - name: Scan the public pages run: | @@ -97,8 +126,9 @@ jobs: zap-baseline.py -t https://target:8787/ -I -J baseline.json - name: Scan the API, signed in run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" token="$(docker run --rm --network scan -e P="$CONSOLE_PASSWORD" \ - '${{ steps.img.outputs.base }}:latest' python -c \ + "$BASE" python -c \ 'import json,os,ssl,urllib.request as u; r=u.Request("https://target:8787/api/login", json.dumps({"password":os.environ["P"]}).encode(), {"Content-Type":"application/json"}); print(json.load(u.urlopen(r, context=ssl._create_unverified_context()))["token"])' 2>/dev/null)" echo "::add-mask::$token" docker run --rm --network scan -v "$PWD/zap:/zap/wrk:rw" \ diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml index e10c2f9..103d249 100644 --- a/.github/workflows/base-image.yml +++ b/.github/workflows/base-image.yml @@ -29,18 +29,27 @@ permissions: {} jobs: build-base: + # A dispatch from another branch would publish `:latest` from it; and + # .github/scripts/pull-verified.sh trusts only a base built from main. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read packages: write + id-token: write + attestations: write steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Base image name (lowercased) id: img - run: echo "base=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')-base" >> "$GITHUB_OUTPUT" + run: echo "base=ghcr.io/${GITHUB_REPOSITORY,,}-base" >> "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 @@ -52,9 +61,33 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push base image + id: push uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: docker/Dockerfile.base push: true tags: ${{ steps.img.outputs.base }}:latest + + # Every test runs in this image and the image every node pulls is built + # on it: CI uses it only once this attestation proves base-image.yml on + # main built it (.github/scripts/pull-verified.sh). + - name: Attest the base image's provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ steps.img.outputs.base }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + # Listed, not gating: the base carries the compiler liboqs was built + # with and is never what a node runs (the application image is a fresh + # stage, scanned with a gate by ci.yml). + - name: List the base image's known vulnerabilities + continue-on-error: true + env: + BASE: ${{ steps.img.outputs.base }}@${{ steps.push.outputs.digest }} + run: | + docker run --rm \ + aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ + image --image-src remote --scanners vuln --severity HIGH,CRITICAL \ + --ignore-unfixed --no-progress "$BASE" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 874c46f..388f33e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,62 +8,28 @@ on: permissions: {} jobs: - # Run the suite inside the published base image, which already carries a - # compiled liboqs + all deps (see docker/Dockerfile.base, base-image.yml). That - # avoids recompiling the heavy C library on every run and tests on the exact - # runtime the app ships with. If the base isn't reachable (first bootstrap, or - # a fork PR without package read) we build it once locally so CI still goes - # green — same pull-or-build fallback as the docker job below. test: runs-on: ubuntu-latest permissions: contents: read packages: read + attestations: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - - name: Base image name (lowercased) - id: img - run: echo "base=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')-base" >> "$GITHUB_OUTPUT" - - - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + - uses: $/.github/actions/suite with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Provide the base image (prebuilt liboqs; build once if unreachable) - run: | - BASE='${{ steps.img.outputs.base }}:latest' - if docker pull "$BASE" 2>/dev/null; then - echo "using published base image with prebuilt liboqs: $BASE" - else - echo "base image not reachable — building it locally" - docker build -f docker/Dockerfile.base -t "$BASE" . - fi - echo "BASE=$BASE" >> "$GITHUB_ENV" - - - name: Unit tests (in the base image — liboqs already compiled) - run: | - docker run --rm -v "$PWD:/app" -w /app "$BASE" \ - sh -c "python -c 'import oqs; print(\"liboqs ready (prebuilt)\")' && pytest -q" - - - name: Integration tests - run: docker run --rm -v "$PWD:/app" -w /app "$BASE" pytest tests/integration -q + token: ${{ secrets.GITHUB_TOKEN }} # Build the application image on every run so a broken Dockerfile fails CI, # and publish it to GHCR (ghcr.io//nmesh) only on pushes to main — PRs # (including from forks) build but never push, since they lack write creds. # # The heavy liboqs layer lives in a separate base image built by - # base-image.yml. Here we reuse the published base (pulling it) and only when - # it isn't reachable — first bootstrap, or a fork PR without package access — - # build it locally so CI still goes green. The app image then layers only the - # NMesh code on top, so this job is fast whenever the base is available. + # base-image.yml, reused here once its provenance is verified, and built here + # otherwise (.github/scripts/provide-base.sh). The app image layers only the + # NMesh code on top of exactly the base that was verified. docker: needs: test runs-on: ubuntu-latest @@ -77,6 +43,12 @@ jobs: id-token: write attestations: write steps: + # Outbound connections of a job that can publish the image every node + # pulls, listed in the run's summary (audit: recorded, not yet refused). + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -84,61 +56,83 @@ jobs: - name: Image names (lowercased) id: img run: | - repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]') - echo "app=ghcr.io/$repo" >> "$GITHUB_OUTPUT" - echo "base=ghcr.io/$repo-base" >> "$GITHUB_OUTPUT" + echo "app=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + echo "base=ghcr.io/${GITHUB_REPOSITORY,,}-base" >> "$GITHUB_OUTPUT" - - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Provide the base image (pull if published, else build locally) + - name: Provide the base image (published and verified, else built here) + env: + GH_TOKEN: ${{ github.token }} + BASE_NAME: ${{ steps.img.outputs.base }} run: | - BASE='${{ steps.img.outputs.base }}:latest' - if docker pull "$BASE" 2>/dev/null; then - echo "using published base image $BASE" - else - echo "base image not reachable — building it locally" - docker build -f docker/Dockerfile.base -t "$BASE" . - fi + .github/scripts/provide-base.sh "$BASE_NAME:latest" > "$RUNNER_TEMP/base.ref" - name: Build the application image + env: + APP: ${{ steps.img.outputs.app }} run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" docker build -f docker/Dockerfile \ - --build-arg BASE_IMAGE='${{ steps.img.outputs.base }}:latest' \ - -t '${{ steps.img.outputs.app }}:latest' \ - -t '${{ steps.img.outputs.app }}:${{ github.sha }}' . - - # A vulnerability the distribution or a package has already fixed, left in - # the image a node runs, fails the build. One with no fix yet is listed - # and let through: nothing here could do better than wait for it. - # Trivy runs from its image pinned by digest (v0.75.0), not an action. - - name: Scan the application image + --build-arg BASE_IMAGE="$BASE" \ + -t "$APP:latest" -t "$APP:$GITHUB_SHA" . + + # A vulnerability the distribution or a package has already fixed, or a + # secret, left in the image a node runs, fails the build. A vulnerability + # with no fix yet is listed and let through: nothing here could do better + # than wait for it. Trivy runs from its image pinned by digest (v0.75.0), + # on a saved copy of the image and never with the Docker socket: the + # socket is root on the runner, and the registry login comes later. + - name: Scan the application image, and list what it carries + env: + APP: ${{ steps.img.outputs.app }} run: | - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \ + mkdir -p "$RUNNER_TEMP/scan" + docker save "$APP:latest" -o "$RUNNER_TEMP/scan/image.tar" + docker run --rm -v "$RUNNER_TEMP/scan:/scan" \ + aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ + image --input /scan/image.tar --cache-dir /scan/cache --scanners vuln,secret \ + --severity HIGH,CRITICAL --ignore-unfixed \ + --exit-code 1 --no-progress + docker run --rm -v "$RUNNER_TEMP/scan:/scan" \ aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ - image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \ - --exit-code 1 --no-progress '${{ steps.img.outputs.app }}:latest' + image --input /scan/image.tar --cache-dir /scan/cache --format cyclonedx \ + --output /scan/sbom.cdx.json --no-progress + rm -f "$RUNNER_TEMP/scan/image.tar" + + - name: Log in to GHCR (main only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Push the application image (main only) id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + APP: ${{ steps.img.outputs.app }} run: | - docker push '${{ steps.img.outputs.app }}:latest' - docker push '${{ steps.img.outputs.app }}:${{ github.sha }}' - digest="$(docker inspect --format '{{index .RepoDigests 0}}' '${{ steps.img.outputs.app }}:latest' | cut -d@ -f2)" + docker push "$APP:latest" + docker push "$APP:$GITHUB_SHA" + digest="$(docker inspect --format '{{index .RepoDigests 0}}' "$APP:latest" | cut -d@ -f2)" echo "digest=$digest" >> "$GITHUB_OUTPUT" # The image every container node pulls, attested: which commit and which - # workflow built it (`gh attestation verify oci:// --repo /NMesh`). - - name: Attest the application image (main only) + # workflow built it (`gh attestation verify oci:// --repo /NMesh`), + # and what it carries (the SBOM the scan above produced). + - name: Attest the application image's provenance (main only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-name: ${{ steps.img.outputs.app }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + + - name: Attest the application image's SBOM (main only) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-name: ${{ steps.img.outputs.app }} + subject-digest: ${{ steps.push.outputs.digest }} + sbom-path: ${{ runner.temp }}/scan/sbom.cdx.json + push-to-registry: true diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ca02e4f..0398990 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -21,19 +21,23 @@ jobs: permissions: contents: read packages: read + attestations: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Base image name (lowercased) - id: img - run: echo "base=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')-base" >> "$GITHUB_OUTPUT" + - name: Provide the base image (published and verified, else built here) + env: + GH_TOKEN: ${{ github.token }} + run: | + .github/scripts/provide-base.sh "ghcr.io/${GITHUB_REPOSITORY,,}-base:latest" > "$RUNNER_TEMP/base.ref" - name: Fuzz every target env: SECONDS_PER_TARGET: ${{ inputs.seconds || '30' }} run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" case "$SECONDS_PER_TARGET" in ''|*[!0-9]*) echo "seconds must be a number"; exit 2;; esac - docker run --rm -v "$PWD:/app" -w /app '${{ steps.img.outputs.base }}:latest' \ + docker run --rm -v "$PWD:/app" -w /app "$BASE" \ python -m tests.fuzz -s "$SECONDS_PER_TARGET" \ --corpus .fuzz/corpus --findings .fuzz/findings - name: Keep what it found diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b422faa..c157937 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,10 @@ name: Release -# Builds distributable artifacts on a version tag (e.g. v0.1.0): +# Builds distributable artifacts on a version tag (e.g. v0.1.0), once the suite +# has passed on that very commit: # - nmesh.pyz attached to the GitHub release -# - Docker image pushed to GHCR (ghcr.io//nmesh) +# - Docker image pushed to GHCR as ghcr.io//nmesh: +# `:latest` belongs to ci.yml alone (one publisher, one order: BUGSVULNS 111). on: push: tags: [ "v*" ] @@ -10,13 +12,31 @@ on: permissions: {} jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + attestations: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: $/.github/actions/suite + with: + token: ${{ secrets.GITHUB_TOKEN }} + pyz: + needs: test runs-on: ubuntu-latest permissions: contents: write id-token: write attestations: write steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -35,44 +55,94 @@ jobs: with: name: nmesh-pyz path: nmesh.pyz + # The runner's own `gh`, rather than a third-party action holding a token + # that can write releases. - name: Attach to release - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 - with: - files: nmesh.pyz + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" nmesh.pyz --clobber --repo "$GITHUB_REPOSITORY" + else + gh release create "$GITHUB_REF_NAME" nmesh.pyz --verify-tag \ + --title "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" + fi docker: + needs: test runs-on: ubuntu-latest + concurrency: + group: release-docker-${{ github.ref }} + cancel-in-progress: false permissions: contents: read packages: write id-token: write attestations: write steps: + - uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Lowercase image name + - name: Image names (lowercased) id: img - run: echo "name=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" - - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + run: | + echo "app=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + echo "base=ghcr.io/${GITHUB_REPOSITORY,,}-base" >> "$GITHUB_OUTPUT" + - name: Provide the base image (published and verified, else built here) + env: + GH_TOKEN: ${{ github.token }} + BASE_NAME: ${{ steps.img.outputs.base }} + run: | + .github/scripts/provide-base.sh "$BASE_NAME:latest" > "$RUNNER_TEMP/base.ref" + - name: Build the application image + env: + APP: ${{ steps.img.outputs.app }} + run: | + BASE="$(cat "$RUNNER_TEMP/base.ref")" + docker build -f docker/Dockerfile --build-arg BASE_IMAGE="$BASE" \ + -t "$APP:$GITHUB_REF_NAME" . + # The same gate as ci.yml's, on the image this tag publishes. + - name: Scan the image, and list what it carries + env: + APP: ${{ steps.img.outputs.app }} + run: | + mkdir -p "$RUNNER_TEMP/scan" + docker save "$APP:$GITHUB_REF_NAME" -o "$RUNNER_TEMP/scan/image.tar" + docker run --rm -v "$RUNNER_TEMP/scan:/scan" \ + aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ + image --input /scan/image.tar --cache-dir /scan/cache --scanners vuln,secret \ + --severity HIGH,CRITICAL --ignore-unfixed \ + --exit-code 1 --no-progress + docker run --rm -v "$RUNNER_TEMP/scan:/scan" \ + aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ + image --input /scan/image.tar --cache-dir /scan/cache --format cyclonedx \ + --output /scan/sbom.cdx.json --no-progress + rm -f "$RUNNER_TEMP/scan/image.tar" - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push + - name: Push id: push - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - with: - context: . - file: docker/Dockerfile - push: true - tags: | - ${{ steps.img.outputs.name }}:latest - ${{ steps.img.outputs.name }}:${{ github.ref_name }} + env: + APP: ${{ steps.img.outputs.app }} + run: | + docker push "$APP:$GITHUB_REF_NAME" + digest="$(docker inspect --format '{{index .RepoDigests 0}}' "$APP:$GITHUB_REF_NAME" | cut -d@ -f2)" + echo "digest=$digest" >> "$GITHUB_OUTPUT" - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-name: ${{ steps.img.outputs.name }} + subject-name: ${{ steps.img.outputs.app }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + - uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-name: ${{ steps.img.outputs.app }} subject-digest: ${{ steps.push.outputs.digest }} + sbom-path: ${{ runner.temp }}/scan/sbom.cdx.json push-to-registry: true diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml new file mode 100644 index 0000000..a52d1aa --- /dev/null +++ b/.github/workflows/repo-checks.yml @@ -0,0 +1,80 @@ +# What builds and ships the code, checked like the code: the workflows (zizmor), +# the whole history for secrets (gitleaks), and the Dockerfiles (hadolint, and +# Trivy's misconfiguration rules). Each tool runs from its image pinned by +# digest, with no token it does not need and no Docker socket. +name: repo-checks + +on: + push: + branches: [ main ] + pull_request: + # zizmor's online audits learn of an action found vulnerable after it was + # pinned; nothing in the tree changes when they do. + schedule: + - cron: "29 5 * * 1" + workflow_dispatch: + +permissions: {} + +jobs: + # Template injection, excessive permissions, a pin whose version comment + # lies, an action known to be vulnerable or an impostor commit. Any finding + # fails; a deliberate one is suppressed in .github/zizmor.yml with its reason. + workflows: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: zizmor + env: + GH_TOKEN: ${{ github.token }} + run: | + docker run --rm -e GH_TOKEN -v "$PWD:/repo:ro" -w /repo \ + ghcr.io/zizmorcore/zizmor@sha256:a2eb396d886c053073405c7a980f2139ba2248ec172243cfa3841e57196e8101 \ + --config .github/zizmor.yml .github/workflows .github/actions + + # Every commit, not only the tip: a key committed and then deleted is still + # in the history anyone can clone. GitHub's secret scanning knows providers' + # formats; this also knows private keys and generic credentials. A test + # fixture that looks like one is listed in .gitleaksignore. + secrets: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + - name: gitleaks + run: | + docker run --rm -v "$PWD:/repo:ro" -w /repo \ + -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/repo \ + ghcr.io/gitleaks/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ + git --no-banner --redact --exit-code 1 \ + --gitleaks-ignore-path /repo/.gitleaksignore /repo + + # The images' recipes. A rule set aside is set aside in .hadolint.yaml or + # .trivyignore, with its reason. + dockerfiles: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: hadolint + run: | + docker run --rm -v "$PWD:/repo:ro" -w /repo \ + ghcr.io/hadolint/hadolint@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d \ + /bin/hadolint docker/Dockerfile docker/Dockerfile.base + - name: Trivy misconfiguration rules + run: | + docker run --rm -v "$PWD:/repo:ro" -w /repo \ + aquasec/trivy@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa \ + config --severity HIGH,CRITICAL --ignorefile .trivyignore \ + --exit-code 1 docker/ diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000..6a89601 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,3 @@ +# zizmor (repo-checks.yml). A finding set aside goes here, with its reason; an +# empty rule set means every default audit applies. +rules: {} diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..af122c5 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,6 @@ +# gitleaks (repo-checks.yml): findings that are not secrets, by fingerprint. +# Truncated OpenSSH key headers the fleet tests use as fixtures; none is a key. +89c8d6d6aa4989e9a3e12f081c7fc8405a5815f6:tests/test_fleet_provision.py:private-key:657 +89c8d6d6aa4989e9a3e12f081c7fc8405a5815f6:tests/test_fleet_state.py:private-key:277 +89c8d6d6aa4989e9a3e12f081c7fc8405a5815f6:tests/test_console_fleet.py:private-key:409 +7f03db166e6b88f111ef1a12582a68fcd47830cc:tests/test_fleet_provision.py:private-key:223 diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..b1d3d4a --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,7 @@ +# hadolint (repo-checks.yml). Each rule set aside, with its reason. +ignored: + # Pin versions in apt-get install. Debian removes a superseded package from + # its archive, so a pinned version stops installing at the next security + # update; the image pins its base by digest and takes the distribution's + # fixes on purpose (`apt-get upgrade`), and Trivy gates what it ends up with. + - DL3008 diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..d4d9e91 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,8 @@ +# Trivy (repo-checks.yml, `trivy config docker/`). Each rule set aside, with +# its reason. + +# Both images run as root. The application image's /data volume, the +# documented /root/.ssh mount and the fleet app's Docker socket all assume it; +# moving to an unprivileged user is tracked as BUGSVULNS 116, and this line +# goes with it. +DS-0002 diff --git a/AGENTS.md b/AGENTS.md index a6e4524..7e2dc9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,7 +160,12 @@ Index: [`Docs/Architecture/README.md`](Docs/Architecture/README.md). the wrapper is never allowed to fetch it itself (`src/liboqs_guard.py`). The base image installs `docker/requirements.lock` under `--require-hashes`, and `.github/workflows/audit.yml` checks that set against known advisories every - week. A version bump moves the lock, its hashes and its digest together. + week, and liboqs against its own advisories. A version bump moves the lock, + its hashes and its digest together. +- **CI tools are dependencies too.** Every action is pinned by commit and every + tool image by digest; none is handed the Docker socket, and an image this + repository published is used only once its provenance attestation checks out + (`.github/scripts/pull-verified.sh`). ## Contribution discipline @@ -498,6 +503,24 @@ scope. Add yours. `cd`'d, which under the test harness was `/usr/bin`; every stamp then looked wrong. → A file that decides trust is read from the script's own tree (`BASH_SOURCE`), never from the current directory. +- **An absence asserted on the file's text instead of on what it means.** A + test that the release no longer pushes `:latest` was written as `":latest" + not in text`; the comment explaining the rule and the base image's tag both + contain it, so the test failed on correct code. Caught by the run. → Assert on + the thing parsed (here, the tags given to `-t` and `docker push`), and run the + new test against both trees before trusting either verdict. +- **A check whose failure an `echo` swallowed.** Workflows were written as + `echo "BASE=$(provide-base.sh …)" >> "$GITHUB_ENV"`: the step's status is + `echo`'s, so a failed provenance check would have left `BASE` empty and the + job going on. Caught on the reread before commit. → Assign a command's output + to a variable first (`x="$(cmd)"` fails under `bash -e`), then use it; + `tests/test_workflows_pinned.py` refuses the other form. +- **A required check renamed without asking what required it.** The suite was + moved into a reusable workflow, and `ci.yml`'s `test` check became `test / + test`; branch protection waits for `test` by name, so the PR sat blocked with + every check green until the operator noticed the check never reported. → + Before renaming or moving a job, ask whether branch protection names it; + a check's name is an interface. - **A rate called "at rest" that was a mesh still settling.** About 30 packets per second per node was published as idle traffic; it was taken 25 s after start, with searches and handshakes still running, and the same mesh sent diff --git a/BUGSVULNS.MD b/BUGSVULNS.MD index e7ec540..256151f 100644 --- a/BUGSVULNS.MD +++ b/BUGSVULNS.MD @@ -4226,3 +4226,122 @@ so the only symptom was that later builds broke. **Fixed** — both publishers run in a concurrency group that never cancels a run in progress, so they finish in the order they were queued and the newest commit publishes last. The base was republished from `main` by hand. + +## 112 — medium — The image scanner held the Docker socket on a runner about to push + +> **Status — fixed.** Tests: `tests/test_workflows_pinned.py` +> (`test_no_tool_is_handed_the_docker_socket`, +> `test_a_scan_runs_before_the_registry_login`). + +**Where** — the `docker` job of `.github/workflows/ci.yml`; `audit.yml`'s +`published-image` the same way. + +**What happens** — Trivy ran as `docker run -v /var/run/docker.sock:…`, after +the job had logged in to GHCR on `main`. The socket is root on the runner: a +container holding it can start another that mounts the host, and read the +login the runner had just stored. The scanner is pinned by digest, so a moved +tag cannot reach it, but a digest is chosen by whoever accepts the Dependabot +update — and in March 2026 the Trivy project's own release and images were +replaced with credential stealers for several days. A poisoned scanner pinned +that week would have been able to push `:latest`, the image every container +node pulls. + +**Fixed** — the image is saved to a file and Trivy reads the file +(`--input`); the published image is read from the registry by digest +(`--image-src remote`). No workflow hands the socket to anything, and the +registry login comes after the scan. + +## 113 — medium — A release published an image no test and no scan had seen + +> **Status — fixed.** Tests: `tests/test_workflows_pinned.py` +> (`test_a_release_publishes_only_what_the_suite_passed`, +> `test_a_release_scans_its_image_and_leaves_latest_to_ci`). + +**Where** — `.github/workflows/release.yml`. + +**What happens** — a `v*` tag built and pushed the zipapp and the image +straight away: no suite ran on the tagged commit, no Trivy gate ran on the +image, and the image was built on whatever `nmesh-base:latest` held that +minute. It also pushed `:latest`, from a workflow outside the concurrency group +that orders `ci.yml`'s pushes (111), so a tag on an older commit could put an +older image back under `:latest`. + +**Fixed** — the suite is one action (`.github/actions/suite`) that the `test` +job of both `ci.yml` and `release.yml` runs; every release job waits for it. The release image is built +on a verified base, scanned with the same gate as `ci.yml`'s, given an attested +SBOM, and pushed under its tag only. The zipapp is attached with the runner's +own `gh` instead of a third-party action holding a token that writes releases. + +## 114 — low — The base image every test ran in was never traced to a build + +> **Status — fixed.** Tests: `tests/test_ci_scripts.py`, +> `tests/test_workflows_pinned.py` +> (`test_an_image_of_this_repository_is_pulled_only_once_proven`, +> `test_the_base_image_is_published_from_main_only_and_attested`). + +**Where** — `.github/workflows/base-image.yml`, and every job that pulled +`nmesh-base:latest` (`ci.yml`, `fuzz.yml`, `audit.yml`). + +**What happens** — the base was published without an attestation and pulled by +tag. Whoever could write to the package — or run `base-image.yml` from any +branch, which `workflow_dispatch` allowed — chose the interpreter the suite ran +in and the `site-packages` and liboqs the application image copies. + +**Fixed** — `base-image.yml` publishes from `main` only and attests the image's +provenance. Jobs take it through `.github/scripts/provide-base.sh`, which uses +the published base only once `gh attestation verify` proves `base-image.yml` +on `main` built it, by the digest it checked, and builds the base locally +otherwise. The published application image is verified the same way (signed by +`ci.yml`) before the weekly scan and the console scan use it. + +## 115 — medium — liboqs 0.16.0 carries two advisories, in code a node never calls + +> **Status — fixed for new builds; open for libraries already built.** Tests: +> `tests/test_liboqs_lock.py` (`TestWhatIsCompiled`), +> `tests/test_liboqs_advisories.py`. + +**Where** — `scripts/build_liboqs.sh`, `scripts/liboqs.lock`. + +**What happens** — GHSA-wh5q-mpc8-67wf (an out-of-bounds read in LMS/HSS +verification) and GHSA-xv6c-h66p-6mfq (LM-OTS key reuse and further memory +errors in LMS signing) affect liboqs 0.11.0 to 0.16.0, built with +`OQS_ENABLE_SIG_STFL_LMS` — and the build turned on LMS, XMSS and +`OQS_HAZARDOUS_EXPERIMENTAL_ENABLE_SIG_STFL_KEY_SIG_GEN`, on the belief that the +wrapper would not load without them. No node calls a stateful signature, so the +code was linked and unreachable. Nothing reported it either: liboqs is compiled +from source, so it is in no package index, and none of Dependabot, pip-audit or +Trivy can see it. The fix is in 0.17.0, not released when this was written. + +**Fixed** — the wrapper loads without them (0.16.0 compiles the stateful +signature API with no scheme behind it), so the build now compiles only +ML-KEM-768 and ML-DSA-65 (`OQS_MINIMAL_BUILD`), with LMS, XMSS and the hazardous +signing off by name: 26 MB of library becomes 600 KB, and the unit suite passes +on it. `audit.yml` now reads liboqs's advisories against the lock every week; +these two are listed in `scripts/liboqs.accepted` with that reason. + +**Still open** — a library a host built before this change (`start.sh` reuses +one whose stamp matches the lock) keeps its LMS code until the lock moves; it +stays unreachable from a node. Moving the lock to 0.17.0 rebuilds it, and the +audit will then report both exceptions as stale. + +## 116 — low — The container runs as root + +> **Status — open.** + +**Where** — `docker/Dockerfile`, `docker/entrypoint.sh`. + +**What happens** — no `USER` is set, so the node, and any shell the fleet app +opens, run as root inside the container. That is not root on the host under +Docker's defaults, but it is one kernel bug or one careless mount (the Docker +socket, which the fleet app can use) away from it, where an unprivileged user +would also need a privilege escalation. A host install already runs as its own +account (`install.sh`). + +**Why it is not one line** — three things a deployment relies on assume root: +the `/data` volume of every existing node is owned by root; the documented key +mount is `/root/.ssh`, and a key kept `0600` by the host's user cannot be read +by another account in the container; and the fleet app's Docker management talks to a +mounted socket owned by the host's `docker` group. Dropping privileges in the +entrypoint (take ownership of `/data`, then `setpriv` to an `nmesh` account +with the socket's group) has to move all three without breaking a node that +updates. Trivy's rule DS-0002 is set aside in `.trivyignore` until it does. diff --git a/Docs/Architecture/gotchas.md b/Docs/Architecture/gotchas.md index ce1deed..e1b007b 100644 --- a/Docs/Architecture/gotchas.md +++ b/Docs/Architecture/gotchas.md @@ -2496,6 +2496,26 @@ running X. interpreter that may read it, the audit runner's included. The base build needs `make` → `build-essential`, not `gcc` alone (otherwise CMake: "CMAKE_MAKE_PROGRAM is not set"). +- liboqs is built with **only ML-KEM-768 and ML-DSA-65** (`OQS_MINIMAL_BUILD` + in `scripts/build_liboqs.sh`). The build used to turn on LMS, XMSS and the + hazardous stateful signing because "the wrapper fails to load without the + stateful symbols" — true of the *API* symbols (`OQS_SIG_STFL_*`), which + 0.16.0 compiles with no scheme behind them, not of the schemes. With them on, + the library carried two advisories in code no node calls (BUGSVULNS 115). A + new algorithm in `src/crypto.py` needs its name added there: + `tests/test_liboqs_lock.py` derives the list from `KEM_ALG`/`DSA_ALG`, and + the wrapper raises `MechanismNotEnabledError` for anything not built. +- A **required status check is matched by its name**, and a job run through a + reusable workflow reports as ` / `. Moving `ci.yml`'s + `test` job into a reusable workflow turned the check into `test / test`: + every check went green and the PR stayed blocked, waiting for a `test` that + no run would ever report. The suite is a composite action instead + (`.github/actions/suite`), so the job keeps its name; + `tests/test_workflows_pinned.py` holds it. +- CI never hands the Docker socket to a tool, and logs in to the registry only + after the scan (BUGSVULNS 112): the socket is root on the runner, and the + login it would find is the one that pushes `:latest`. A scanner reads a + `docker save` file (`trivy image --input`) or the registry by digest. - Docker: the application image is a **fresh `python:*-slim` stage** that copies `/opt/liboqs` and `site-packages` out of the base — the base's compiler, CMake, git and headers never reach a running node. Two consequences: both diff --git a/Docs/Setup/guide b/Docs/Setup/guide index 26a7aa1..d5d3043 100644 --- a/Docs/Setup/guide +++ b/Docs/Setup/guide @@ -553,6 +553,17 @@ must hash to the locked SHA-256. Anything else stops the install — no other tag, never `main`. A tag is a pointer its owner can move; this is the post-quantum half of the node's crypto. +Only the two schemes a node uses are compiled: ML-KEM-768 and ML-DSA-65 +(`OQS_MINIMAL_BUILD`), with the stateful signatures (LMS, XMSS) off by name. +Every other scheme is code an advisory can land in and no node calls — liboqs +0.16.0 carries two advisories in its LMS code (BUGSVULNS 115) — and the library +shrinks from about 26 MB to under 1 MB. The wrapper still loads: the stateful +signature API it binds to is compiled with no scheme behind it. A library built +before this change keeps its other schemes until the lock next moves; none of +them is reachable from a node. `audit.yml` reads liboqs's own advisories every +week (`tests/tools/liboqs_advisories.py`); one that names the locked version +fails it unless `scripts/liboqs.accepted` says why it cannot reach a node. + The wrapper has its own answer to a missing library — clone the tag and compile it on the spot, unchecked — so the node never lets it get that far: `src/liboqs_guard.py` refuses to import it when there is no library where it diff --git a/SECURITY.md b/SECURITY.md index e271fd4..3a298eb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,6 +30,14 @@ gh attestation verify nmesh.pyz --repo fnyaker/NMesh gh attestation verify oci://ghcr.io/fnyaker/nmesh:latest --repo fnyaker/NMesh ``` +Each image also carries an attested SBOM (CycloneDX), the list of what it +contains: + +``` +gh attestation verify oci://ghcr.io/fnyaker/nmesh:latest --repo fnyaker/NMesh \ + --predicate-type https://cyclonedx.org/bom +``` + That proves where a file came from, not that its code is right; what a node installs from the mesh is decided by the publishers its operator pinned ([`Docs/Updates/guide`](Docs/Updates/guide)). @@ -45,14 +53,28 @@ installs from the mesh is decided by the publishers its operator pinned credential format is refused. - **Dependabot** alerts and security updates for the dependencies of `requirements.txt`, the base image and the workflow actions. -- Every workflow action is pinned to a full commit SHA, and each workflow starts - with no token permission, granting each job only what it uses. +- Every workflow action is pinned to a full commit SHA, Dependabot offers a new + one only once it is a week old, and each workflow starts with no token + permission, granting each job only what it uses. **zizmor** checks the + workflows themselves on every change and weekly. +- **gitleaks** scans the whole history for secrets, beyond the provider formats + GitHub's scanning knows; **hadolint** and Trivy's misconfiguration rules check + both Dockerfiles. +- An image of this repository is used by CI — to run the suite, to build on, + to scan — only once its provenance attestation proves which workflow on + `main` built it, and then by that digest. A release publishes only a commit + the suite passed on. - liboqs is compiled only from the commit and archive digest in - `scripts/liboqs.lock`, and a node with no liboqs refuses to start rather than - let the wrapper fetch one; the base image installs a hash-locked package set - (`docker/requirements.lock`) that `pip-audit` checks every week. + `scripts/liboqs.lock`, with only the two schemes a node uses (ML-KEM-768, + ML-DSA-65), and a node with no liboqs refuses to start rather than let the + wrapper fetch one. liboqs's own advisories are checked against the locked + version on every change and every week. The base image installs a + hash-locked package set (`docker/requirements.lock`) that `pip-audit` checks + the same way. - The image a node runs carries no compiler or build tools, and is scanned by - Trivy on every build and every week: a fixable HIGH or CRITICAL fails it. + Trivy on every build, every release and every week: a fixable HIGH or + CRITICAL, or a secret, fails it. The scanner reads a saved copy of the image + and never holds the Docker socket or a registry login. - The console is scanned by OWASP ZAP every week, its API included and signed in, against a throwaway node. - **OpenSSF Scorecard** grades how the repository itself is kept, weekly, diff --git a/TEST.md b/TEST.md index fdab5c1..69494ab 100644 --- a/TEST.md +++ b/TEST.md @@ -152,12 +152,28 @@ python tests/tools/api_fuzz.py https://127.0.0.1:8787 --rounds 3 GitHub CI (`.github/workflows/ci.yml`) runs the unit tests and then the integration tests on every push to `main` and every pull request. +The suite itself is one composite action, `.github/actions/suite`: the `test` +job of `ci.yml` runs it on every change, and the `test` job of `release.yml` +before anything is published, so a release is a commit the suite passed on. +It is an action and not a reusable workflow because branch protection requires +the status check `test` by name, and a reusable workflow reports its job as +`test / test` — the required check then never arrives and the PR stays blocked +with every check green. + The tests run **inside the base image** (`docker/Dockerfile.base`, published by `base-image.yml`), which already carries a **compiled liboqs** and every -dependency. So the heavy C library is no longer rebuilt on every run, and we -test on the exact runtime the app ships with (Python 3.14). If the base image is -not reachable (first bootstrap, or a fork PR with no package access), CI builds -it once locally to stay green — the same fallback as the `docker` job. +dependency. So the C library is not rebuilt on every run, and we test on the +exact runtime the app ships with (Python 3.14). The published base is used only +once its provenance checks out — attested by `base-image.yml`, run from `main` +(`.github/scripts/pull-verified.sh`) — and then by the digest that was checked. +If it is unreachable or unproven (first bootstrap, a fork PR with no package +access, a base published before it was attested), CI builds it here +(`.github/scripts/provide-base.sh`); every job that uses the base, the fuzzer +and the console scan included, goes through that script. The reference a +script prints is checked to be a plain lowercase image name and digest (or +tag), and reaches the job's later steps through a file in `$RUNNER_TEMP`, +never `$GITHUB_ENV`, where a value with a newline would define a second +variable. Alongside it, GitHub runs **CodeQL** (`codeql.yml`: extended queries on Python, JavaScript and the workflows, plus `.github/codeql/nmesh`, the same @@ -166,7 +182,29 @@ security queries with mesh input as their source), **secret scanning** with push action in a workflow is pinned to a full commit SHA and every workflow starts from `permissions: {}`, granting each job its own; `tests/test_workflows_pinned.py` refuses a tag reference or a missing `permissions: {}`. A new action goes in by -SHA with its version as a comment (`@ # v4.4.0`); Dependabot moves the pin. +SHA with its version as a comment (`@ # v4.4.0`); Dependabot moves the pin, +and offers a release only once it is a week old (`cooldown`). A workflow of +this repository is called as `$/.github/workflows/…` (the self-repository +form), never `./…`, which is read from whatever the workspace holds by then. + +`repo-checks.yml` checks what builds and ships the code, on every change and +weekly, each tool from its image pinned by digest: + +- **zizmor** over the workflows: template injection, excessive permissions, a + pin whose version comment lies, an action found vulnerable after it was + pinned. Any finding fails; one set aside goes in `.github/zizmor.yml` with its + reason. Run it locally with `uvx zizmor .github/workflows`. +- **gitleaks** over the whole history, not only the tip: a key committed and + deleted is still in every clone. A fixture that looks like a key is listed by + fingerprint in `.gitleaksignore`. +- **hadolint** and **Trivy's misconfiguration rules** over both Dockerfiles; a + rule set aside is in `.hadolint.yaml` or `.trivyignore`, with its reason. + +The publishing jobs (`base-image.yml`, `ci.yml`'s `docker`, `release.yml`) +start with StepSecurity's Harden-Runner in `audit` mode: every outbound +connection the job makes is listed in the run's summary. That is a record, not +a barrier; `block` with an allow-list is the next step, once the lists from a +few runs say what the jobs legitimately reach. `.github/codeql/nmesh/NMeshSources.qll` is where the mesh is declared as remote input: bytes an asyncio protocol or stream hands over, a spooled frame, @@ -182,11 +220,22 @@ codeql database analyze /tmp/nmesh-db .github/codeql/nmesh/queries --format=sari ``` The `docker` job scans the image it built with Trivy (pinned by digest) and -fails on a HIGH or CRITICAL vulnerability that has a fix; one with none yet is -listed and let through. `audit.yml` scans the published image the same way -every Monday, since a fix released after the last build is news nothing in the -tree carries. `tests/test_workflows_pinned.py` refuses a `docker run` of a -registry image not pinned by digest. +fails on a HIGH or CRITICAL vulnerability that has a fix, or on a secret; a +vulnerability with none yet is listed and let through. Trivy reads a saved copy +of the image (`docker save`, `--input`) and is never given the Docker socket — +the socket is root on the runner — and the registry login comes only after the +scan, so nothing the scanner runs could reach a credential that pushes. The +same scan writes the image's SBOM (CycloneDX), attested beside its provenance +(`actions/attest-sbom`). `release.yml` does the same for the image a tag +publishes, and leaves `:latest` to `ci.yml`. `audit.yml` scans the published +image every Monday, once its provenance is checked, since a fix released after +the last build is news nothing in the tree carries; `base-image.yml` lists the +base's vulnerabilities without gating on them (no node runs the base). +`tests/test_workflows_pinned.py` refuses a `docker run` of a registry image not +pinned by digest, a `docker pull` outside the verifying scripts, the socket +handed to anything, a login before a scan, and a release job that does not +wait for the suite; `tests/test_ci_scripts.py` drives the verifying scripts +with a `docker` and a `gh` that answer each way. ### The repository itself (`scorecard.yml`) and what it ships @@ -240,10 +289,19 @@ peer input gets a target in the same commit. `audit.yml` runs `pip-audit` over `docker/requirements.lock`, the hash-locked set the base image installs, on every change and every Monday. +It also reads **liboqs's own advisories** (`tests/tools/liboqs_advisories.py`): +liboqs is C built from source, so no package index — and so neither +Dependabot, nor pip-audit, nor an image scanner — ever sees it. An advisory +whose range holds the locked version fails, unless `scripts/liboqs.accepted` +names it with the reason it cannot reach a node; an exception the lock has +outgrown is reported for removal. `tests/test_liboqs_advisories.py` holds the +range reading offline. `tests/test_liboqs_lock.py` holds `scripts/liboqs.lock`, `requirements.txt` and that lock together, and runs `scripts/build_liboqs.sh` offline against a moved tag and a tampered archive; `NMESH_NETWORK_TESTS=1` also checks the -locked digest against the archive GitHub serves today. +locked digest against the archive GitHub serves today. The same file checks +that only the two schemes a node uses are compiled (`OQS_MINIMAL_BUILD`), with +the stateful signatures off. ### `[gwN] node down: Not properly terminated` diff --git a/pyproject.toml b/pyproject.toml index d6e6e27..f5ba731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends.legacy:build" [project] name = "nmesh" -version = "0.4.113" +version = "0.4.115" license = "GPL-3.0-only" [tool.pytest.ini_options] diff --git a/scripts/build_liboqs.sh b/scripts/build_liboqs.sh index 79e1b00..2435832 100755 --- a/scripts/build_liboqs.sh +++ b/scripts/build_liboqs.sh @@ -88,16 +88,20 @@ fi GENERATOR=() command -v ninja >/dev/null 2>&1 && GENERATOR=(-GNinja) -# The wrapper's own feature set (the stateful signature symbols must exist or -# it fails to load), minus OpenSSL so no dev headers are needed. +# The two algorithms a node uses (src/crypto.py) and nothing else: every other +# scheme is code an advisory can land in and no node can call. The stateful +# signature API the wrapper loads is compiled without any scheme behind it; +# the schemes themselves stay off, by name, should a release turn one on. +# No OpenSSL, so no dev headers are needed. cmake -S "$SRC" -B "$WORK/build" "${GENERATOR[@]}" \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=ON \ -DOQS_BUILD_ONLY_LIB=ON \ -DOQS_USE_OPENSSL=OFF \ - -DOQS_ENABLE_SIG_STFL_LMS=ON \ - -DOQS_ENABLE_SIG_STFL_XMSS=ON \ - -DOQS_HAZARDOUS_EXPERIMENTAL_ENABLE_SIG_STFL_KEY_SIG_GEN=ON \ + "-DOQS_MINIMAL_BUILD=KEM_ml_kem_768;SIG_ml_dsa_65" \ + -DOQS_ENABLE_SIG_STFL_LMS=OFF \ + -DOQS_ENABLE_SIG_STFL_XMSS=OFF \ + -DOQS_HAZARDOUS_EXPERIMENTAL_ENABLE_SIG_STFL_KEY_SIG_GEN=OFF \ -DCMAKE_INSTALL_PREFIX="$PREFIX" \ || die "cmake configure failed" cmake --build "$WORK/build" --parallel "$JOBS" || die "build failed" diff --git a/scripts/liboqs.accepted b/scripts/liboqs.accepted new file mode 100644 index 0000000..5f65876 --- /dev/null +++ b/scripts/liboqs.accepted @@ -0,0 +1,9 @@ +# Advisories published against the liboqs in scripts/liboqs.lock that cannot +# reach a node, each with the reason. Read by tests/tools/liboqs_advisories.py +# (audit.yml): an advisory naming the locked version and not listed here fails +# the audit. Review this file whenever the lock moves; the audit names an entry +# that no longer applies. +# +# GHSA id why a node cannot reach it +GHSA-xv6c-h66p-6mfq LMS/HSS signing; LMS is not compiled (build_liboqs.sh builds ML-KEM-768 and ML-DSA-65 only) and no node calls OQS_SIG_STFL_*. Fixed in 0.17.0. +GHSA-wh5q-mpc8-67wf LMS/HSS verification; LMS is not compiled (build_liboqs.sh builds ML-KEM-768 and ML-DSA-65 only) and no node calls OQS_SIG_STFL_*. Fixed in 0.17.0. diff --git a/src/version.py b/src/version.py index bd13785..8bfc972 100644 --- a/src/version.py +++ b/src/version.py @@ -10,7 +10,7 @@ import re -__version__ = "0.4.113" +__version__ = "0.4.115" # Releases are tagged ``vX.Y.Z`` (see .github/workflows/release.yml). Anything # after the numbers (``-rc1``, ``+build``) is kept but only used to break ties diff --git a/tests/test_ci_scripts.py b/tests/test_ci_scripts.py new file mode 100644 index 0000000..ecce57a --- /dev/null +++ b/tests/test_ci_scripts.py @@ -0,0 +1,140 @@ +""" +The scripts the workflows use to take an image of this repository +(`.github/scripts`): the published one only when its attestation checks out, +by the digest that was checked, and a local build otherwise. Driven offline, +with a `docker` and a `gh` that answer as each test needs. +""" +import pathlib +import shutil +import stat +import subprocess + +import pytest + +SCRIPTS = pathlib.Path(__file__).resolve().parent.parent / ".github" / "scripts" +DIGEST = "sha256:" + "ab" * 32 +BASE = "ghcr.io/owner/repo-base:latest" + + +@pytest.fixture +def sandbox(tmp_path): + tools = tmp_path / "bin" + tools.mkdir() + for name in ("bash", "dirname", "cat"): + (tools / name).symlink_to(shutil.which(name)) + scripts = tmp_path / ".github" / "scripts" + scripts.mkdir(parents=True) + for script in SCRIPTS.glob("*.sh"): + shutil.copy(script, scripts / script.name) + return tmp_path + + +def _tool(sandbox, name, body): + path = sandbox / "bin" / name + path.write_text("#!/bin/sh\n" + body) + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def _docker(sandbox, pull_ok=True, digest=f"ghcr.io/owner/repo-base@{DIGEST}"): + log = sandbox / "docker.log" + _tool(sandbox, "docker", + f'echo "$@" >> "{log}"\n' + 'case "$1" in\n' + f' pull) exit {0 if pull_ok else 1};;\n' + f' inspect) echo "{digest}";;\n' + "esac\nexit 0\n") + return log + + +def _gh(sandbox, verified=True): + log = sandbox / "gh.log" + _tool(sandbox, "gh", f'echo "$@" >> "{log}"\nexit {0 if verified else 1}\n') + return log + + +def _run(sandbox, script, *args): + return subprocess.run( + [shutil.which("bash"), str(sandbox / ".github" / "scripts" / script), *args], + capture_output=True, text=True, timeout=30, cwd=sandbox, + env={"PATH": str(sandbox / "bin"), "GITHUB_REPOSITORY": "owner/Repo", + "GH_TOKEN": "t"}) + + +class TestPullVerified: + def test_a_proven_image_comes_back_by_digest(self, sandbox): + _docker(sandbox) + gh = _gh(sandbox) + result = _run(sandbox, "pull-verified.sh", BASE, "base-image.yml") + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == f"ghcr.io/owner/repo-base@{DIGEST}" + asked = gh.read_text() + assert f"oci://ghcr.io/owner/repo-base@{DIGEST}" in asked + assert "--signer-workflow owner/Repo/.github/workflows/base-image.yml" in asked + assert "--source-ref refs/heads/main" in asked + + def test_an_unproven_image_is_refused(self, sandbox): + _docker(sandbox) + _gh(sandbox, verified=False) + result = _run(sandbox, "pull-verified.sh", BASE, "base-image.yml") + assert result.returncode != 0 + assert not result.stdout.strip() + + def test_an_image_without_a_registry_digest_is_refused(self, sandbox): + _docker(sandbox, digest="") + gh = _gh(sandbox) + result = _run(sandbox, "pull-verified.sh", BASE, "base-image.yml") + assert result.returncode != 0 + assert not gh.exists() + + + @pytest.mark.parametrize("digest", [ + f"ghcr.io/owner/repo-base@{DIGEST}\nPATH=/tmp", + f"--privileged ghcr.io/owner/repo-base@{DIGEST}", + f"ghcr.io/Owner/repo-base@{DIGEST}", + "ghcr.io/owner/repo-base@sha256:abc", + ]) + def test_only_a_plain_pinned_reference_comes_back(self, sandbox, digest): + _docker(sandbox, digest=digest) + gh = _gh(sandbox) + result = _run(sandbox, "pull-verified.sh", BASE, "base-image.yml") + assert result.returncode != 0 + assert not result.stdout.strip() + assert not gh.exists() + + +class TestProvideBase: + def test_the_published_base_when_proven(self, sandbox): + docker = _docker(sandbox) + _gh(sandbox) + result = _run(sandbox, "provide-base.sh", BASE) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == f"ghcr.io/owner/repo-base@{DIGEST}" + assert "build" not in docker.read_text() + + @pytest.mark.parametrize("pull_ok, verified", [(True, False), (False, True)]) + def test_built_here_when_unproven_or_unreachable(self, sandbox, pull_ok, verified): + docker = _docker(sandbox, pull_ok=pull_ok) + _gh(sandbox, verified=verified) + result = _run(sandbox, "provide-base.sh", BASE) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == BASE + built = [line for line in docker.read_text().splitlines() if line.startswith("build")] + assert len(built) == 1 + assert f"-t {BASE}" in built[0] + assert built[0].split()[2].endswith("docker/Dockerfile.base") + + @pytest.mark.parametrize("name", ["", "-t evil", "ghcr.io/x-base", "a b:latest", + "ghcr.io/x-base:latest\nX=1"]) + def test_a_name_that_is_not_an_image_reference_is_refused(self, sandbox, name): + docker = _docker(sandbox) + _gh(sandbox) + result = _run(sandbox, "provide-base.sh", name) + assert result.returncode != 0 + assert not docker.exists() + + def test_a_failed_build_fails(self, sandbox): + _tool(sandbox, "docker", 'case "$1" in pull) exit 1;; build) exit 3;; esac\nexit 0\n') + _gh(sandbox) + result = _run(sandbox, "provide-base.sh", BASE) + assert result.returncode != 0 + assert not result.stdout.strip() diff --git a/tests/test_liboqs_advisories.py b/tests/test_liboqs_advisories.py new file mode 100644 index 0000000..67b6db6 --- /dev/null +++ b/tests/test_liboqs_advisories.py @@ -0,0 +1,84 @@ +""" +The weekly check of liboqs's own advisories (`tests/tools/liboqs_advisories.py`) +reads ranges, exceptions and verdicts correctly — offline, on advisories shaped +like the ones GitHub's API returns. +""" +import pytest + +from tests.tools import liboqs_advisories as check + + +def _advisory(ghsa, *ranges, withdrawn=None): + return {"ghsa_id": ghsa, "severity": "moderate", "summary": "s", + "withdrawn_at": withdrawn, + "vulnerabilities": [{"package": {"ecosystem": "other", "name": "liboqs"}, + "vulnerable_version_range": r} for r in ranges]} + + +class TestRanges: + @pytest.mark.parametrize("spec, hit", [ + (">= 0.11.0, <= 0.16.0", True), + ("<= 0.15.0", False), + ("< 0.16.0", False), + ("< 0.16.1", True), + ("0.15.0", False), + ("0.16.0", True), + ("= 0.16", True), + ("> 0.16.0", False), + (">= 0.17.0", False), + ]) + def test_the_bounds_are_read(self, spec, hit): + assert check.in_range("0.16.0", spec) is hit + + @pytest.mark.parametrize("spec", [None, "", "all versions", "<= 0.16.x", 7]) + def test_a_range_nobody_can_read_is_affected(self, spec): + assert check.in_range("0.16.0", spec) is True + + +class TestAdvisories: + def test_one_affected_entry_is_enough(self): + assert check.affects(_advisory("GHSA-a", "<= 0.10.0", ">= 0.16.0"), "0.16.0") + + def test_a_withdrawn_advisory_affects_nothing(self): + assert not check.affects(_advisory("GHSA-a", "<= 0.16.0", withdrawn="2026-01-01"), "0.16.0") + + @pytest.mark.parametrize("vulnerabilities", [None, [], "x", [None]]) + def test_an_advisory_that_names_no_range_is_affected(self, vulnerabilities): + advisory = _advisory("GHSA-a") + advisory["vulnerabilities"] = vulnerabilities + assert check.affects(advisory, "0.16.0") + + +class TestVerdict: + def test_open_excused_and_stale(self): + advisories = [_advisory("GHSA-open", "<= 0.16.0"), + _advisory("GHSA-ok", "<= 0.16.0"), + _advisory("GHSA-old", "<= 0.15.0"), + "not an advisory"] + still_open, excused, stale = check.verdict( + advisories, "0.16.0", {"GHSA-ok": "why", "GHSA-gone": "why"}) + assert [a["ghsa_id"] for a in still_open] == ["GHSA-open"] + assert [a["ghsa_id"] for a in excused] == ["GHSA-ok"] + assert stale == ["GHSA-gone"] + + +class TestFiles: + def test_the_locked_version_is_read(self): + assert check.version(check.locked_version()) + + def test_every_accepted_entry_says_why(self): + assert all(reason for reason in check.accepted().values()) + + @pytest.mark.parametrize("line", ["GHSA-xv6c-h66p-6mfq", "GHSA-nope reason", + "CVE-2026-1 reason"]) + def test_an_entry_without_an_id_and_a_reason_is_refused(self, tmp_path, line): + path = tmp_path / "accepted" + path.write_text(f"# header\n{line}\n") + with pytest.raises(ValueError): + check.accepted(path) + + def test_a_lock_without_a_version_is_refused(self, tmp_path): + path = tmp_path / "lock" + path.write_text("LIBOQS_COMMIT=abc\n") + with pytest.raises(ValueError): + check.locked_version(path) diff --git a/tests/test_liboqs_lock.py b/tests/test_liboqs_lock.py index 9ee3375..7482a37 100644 --- a/tests/test_liboqs_lock.py +++ b/tests/test_liboqs_lock.py @@ -183,3 +183,40 @@ def test_a_refused_build_leaves_none(self, sandbox): (sandbox / "prefix").mkdir() assert _run(sandbox).returncode != 0 assert not (sandbox / "prefix" / "nmesh-liboqs.lock").exists() + + +class TestWhatIsCompiled: + """Only the two schemes a node uses are built. liboqs 0.16.0 carries two + advisories in its LMS code (BUGSVULNS 115); a scheme that is not compiled + is one no advisory can reach.""" + + def _configure_args(self, sandbox) -> list: + args = sandbox / "cmake-args" + _tool(sandbox, "git", 'case "$1" in\n' + ' clone) for last; do :; done; mkdir -p "$last";;\n' + f' -C) echo {_lock()["LIBOQS_COMMIT"]};;\n' + "esac\n") + _tool(sandbox, "cmake", f'[ "$1" = -S ] && printf "%s\\n" "$@" > "{args}"\nexit 0\n') + _tool(sandbox, "grep", f'exec {shutil.which("grep")} "$@"\n') + (sandbox / "prefix").mkdir() + result = _run(sandbox) + assert result.returncode == 0, result.stderr + return args.read_text().splitlines() + + def test_only_the_schemes_the_node_uses(self, sandbox): + crypto = (ROOT / "src" / "crypto.py").read_text() + kem = re.search(r'^KEM_ALG = "([^"]+)"', crypto, re.M).group(1) + dsa = re.search(r'^DSA_ALG = "([^"]+)"', crypto, re.M).group(1) + wanted = {"KEM_" + kem.lower().replace("-", "_"), + "SIG_" + dsa.lower().replace("-", "_")} + minimal = [arg for arg in self._configure_args(sandbox) + if arg.startswith("-DOQS_MINIMAL_BUILD=")] + assert len(minimal) == 1 + assert set(minimal[0].split("=", 1)[1].split(";")) == wanted + + def test_no_stateful_scheme_and_no_hazardous_signing(self, sandbox): + args = self._configure_args(sandbox) + for option in ("OQS_ENABLE_SIG_STFL_LMS", "OQS_ENABLE_SIG_STFL_XMSS", + "OQS_HAZARDOUS_EXPERIMENTAL_ENABLE_SIG_STFL_KEY_SIG_GEN"): + assert f"-D{option}=OFF" in args, option + assert not [arg for arg in args if arg.endswith("=ON") and "STFL" in arg] diff --git a/tests/test_workflows_pinned.py b/tests/test_workflows_pinned.py index 437defc..dc19195 100644 --- a/tests/test_workflows_pinned.py +++ b/tests/test_workflows_pinned.py @@ -6,7 +6,13 @@ whatever the tag names on the day of the run, with the job's token in reach — the release job's token can publish an image every node pulls. A full commit SHA cannot be moved; Dependabot (`.github/dependabot.yml`) moves the pin -instead, as a pull request somebody reads. +instead, as a pull request somebody reads. A workflow of this repository is +named with the self-repository form (`$/…`), which is resolved from the commit +the run is for; `./…` is read from whatever the job's workspace holds by then. + +And what the publishing jobs do with their token is held here too: no tool +gets the Docker socket, a scan comes before the registry login, an image of +this repository is used only once its provenance is checked. """ import pathlib import re @@ -15,18 +21,27 @@ WORKFLOWS = pathlib.Path(__file__).resolve().parent.parent / ".github" / "workflows" _USES = re.compile(r"^\s*-?\s*uses:\s*(\S+)", re.M) -_PINNED = re.compile(r"^[\w.-]+/[\w./-]+@[0-9a-f]{40}$") +_PINNED = re.compile(r"^(?:[\w.-]+/[\w./-]+@[0-9a-f]{40}|\$/[\w./-]+)$") def _workflows() -> list[pathlib.Path]: return sorted(WORKFLOWS.glob("*.y*ml")) +def _runs_steps() -> list[pathlib.Path]: + """Every file that runs steps: the workflows and this tree's own actions.""" + return _workflows() + sorted((WORKFLOWS.parent / "actions").glob("*/action.y*ml")) + + +def _name(path: pathlib.Path) -> str: + return path.name if path.parent == WORKFLOWS else f"{path.parent.name}/{path.name}" + + def test_there_are_workflows_to_check(): assert _workflows() -@pytest.mark.parametrize("path", _workflows(), ids=lambda p: p.name) +@pytest.mark.parametrize("path", _runs_steps(), ids=_name) def test_every_action_is_pinned_to_a_commit(path): text = path.read_text() refs = _USES.findall(text) @@ -59,7 +74,7 @@ def _images_run(text: str) -> list[str]: return images -@pytest.mark.parametrize("path", _workflows(), ids=lambda p: p.name) +@pytest.mark.parametrize("path", _runs_steps(), ids=_name) def test_a_third_party_image_is_pinned_by_digest(path): """An image built in the job is named through a step output (``$…`` or ``'${{ … }}'``); anything else comes from a registry, where a tag moves.""" @@ -82,11 +97,125 @@ def test_a_tag_reference_is_refused(): assert _PINNED.match("actions/checkout@" + "a" * 40) +def test_a_workspace_relative_reference_is_refused(): + assert not _PINNED.match("./.github/actions/suite") + assert _PINNED.match("$/.github/actions/suite") + + @pytest.mark.parametrize("name", ["base-image.yml", "ci.yml"]) def test_what_publishes_latest_does_it_one_run_at_a_time(name): """Two merges a minute apart once let the older build overwrite the newer - `:latest` (BUGSVULNS 100).""" + `:latest` (BUGSVULNS 111).""" text = (WORKFLOWS / name).read_text() assert re.search(r"concurrency:\s*\n\s+group: ", text), name assert "cancel-in-progress: false" in text + + +def _jobs(text: str) -> dict: + """Each job's text, by name: the two-space keys under ``jobs:``.""" + body = text.split("\njobs:\n", 1)[1] + parts = re.split(r"^ ([\w-]+):\n", body, flags=re.M) + return dict(zip(parts[1::2], parts[2::2])) + + +def test_the_job_splitter_finds_every_job(): + assert set(_jobs((WORKFLOWS / "ci.yml").read_text())) == {"test", "docker"} + + +@pytest.mark.parametrize("path", _runs_steps(), ids=_name) +def test_no_tool_is_handed_the_docker_socket(path): + """The socket is root on the runner, and the runner of a publishing job + holds the registry login: a scanner image pinned on the wrong day could + push. Trivy reads a saved image (`--input`) or the registry instead.""" + assert "docker.sock" not in path.read_text(), path.name + + +@pytest.mark.parametrize("path", _workflows(), ids=lambda p: p.name) +def test_a_scan_runs_before_the_registry_login(path): + for name, job in _jobs(path.read_text()).items(): + if "trivy" in job and "docker/login-action" in job and "--input" in job: + assert job.index("trivy") < job.index("docker/login-action"), \ + f"{path.name}/{name}: scan first, log in after" + + +@pytest.mark.parametrize("path", _runs_steps(), ids=_name) +def test_an_image_of_this_repository_is_pulled_only_once_proven(path): + """A pull goes through `.github/scripts/pull-verified.sh` (or + `provide-base.sh`, which calls it), which checks the attestation and hands + back the digest it checked.""" + assert "docker pull" not in path.read_text(), path.name + + +def test_the_scripts_check_the_signer_and_the_branch(): + text = (WORKFLOWS.parent / "scripts" / "pull-verified.sh").read_text() + assert "gh attestation verify" in text + assert "--signer-workflow" in text and "--source-ref refs/heads/main" in text + + +def test_the_base_image_is_published_from_main_only_and_attested(): + text = (WORKFLOWS / "base-image.yml").read_text() + assert "if: github.ref == 'refs/heads/main'" in text + assert "actions/attest-build-provenance@" in text + + +@pytest.mark.parametrize("name", ["ci.yml", "release.yml"]) +def test_the_suite_runs_in_a_job_named_test(name): + """Branch protection requires the status check ``test`` by name. A + reusable workflow would report as ``test / test`` and the required check + would never arrive, so the suite is an action each caller's ``test`` job + runs.""" + jobs = _jobs((WORKFLOWS / name).read_text()) + assert "uses: $/.github/actions/suite" in jobs["test"] + assert "runs-on:" in jobs["test"] + + +def test_a_release_publishes_only_what_the_suite_passed(): + jobs = _jobs((WORKFLOWS / "release.yml").read_text()) + assert "$/.github/actions/suite" in jobs["test"] + for name, job in jobs.items(): + if name != "test": + assert re.search(r"^ needs: test$", job, re.M), name + + +def test_a_release_scans_its_image_and_leaves_latest_to_ci(): + text = (WORKFLOWS / "release.yml").read_text() + assert "--exit-code 1" in text and "--scanners vuln,secret" in text + tagged = re.findall(r"(?:-t|docker push) (\S+)", text) + assert tagged and not [tag for tag in tagged if "latest" in tag], tagged + + +@pytest.mark.parametrize("name", ["ci.yml", "release.yml"]) +def test_a_published_image_carries_its_sbom(name): + text = (WORKFLOWS / name).read_text() + assert "actions/attest-build-provenance@" in text + assert "actions/attest-sbom@" in text and "--format cyclonedx" in text + + +def test_every_dependabot_update_waits_a_week(): + text = (WORKFLOWS.parent / "dependabot.yml").read_text() + ecosystems = re.findall(r"package-ecosystem: (\S+)", text) + waits = [int(days) for days in re.findall(r"default-days: (\d+)", text)] + assert ecosystems and len(waits) == len(ecosystems) + assert min(waits) >= 7 + + +@pytest.mark.parametrize("path", _runs_steps(), ids=_name) +def test_a_failing_command_cannot_hide_inside_an_echo(path): + """``echo "X=$(check)" >> "$GITHUB_ENV"`` exits with echo's status, so a + failed provenance check would pass on with ``X`` empty. Assign first — + ``x="$(check)"`` fails the step under ``bash -e`` — then echo.""" + hiding = re.findall(r'^.*echo "[^"\n]*\$\([^\n]*>> "\$GITHUB_\w+".*$', path.read_text(), re.M) + assert not hiding, f"{path.name}: {hiding}" + + +@pytest.mark.parametrize("path", _workflows(), ids=lambda p: p.name) +def test_an_image_reference_never_goes_through_the_job_environment(path): + """What a script prints reaches later steps through a file in + ``$RUNNER_TEMP``, never ``$GITHUB_ENV``: a value written there with a + newline in it defines whatever variable its second line names.""" + for name, job in _jobs(path.read_text()).items(): + if ".github/scripts/" in job and "GITHUB_ENV" in job: + for line in job.splitlines(): + assert not ("GITHUB_ENV" in line and re.search(r"\b(APP|BASE)=", line)), \ + f"{path.name}/{name}: {line.strip()}" diff --git a/tests/tools/liboqs_advisories.py b/tests/tools/liboqs_advisories.py new file mode 100644 index 0000000..1b8b9d8 --- /dev/null +++ b/tests/tools/liboqs_advisories.py @@ -0,0 +1,157 @@ +""" +Is the liboqs a node compiles named by a published advisory? + +liboqs is C, built from source (`scripts/liboqs.lock`): no package index lists +it, so neither Dependabot, nor pip-audit, nor an image scanner ever sees it. +Its advisories are published on its own repository, and this reads them: + + python -m tests.tools.liboqs_advisories + +An advisory whose affected range holds the locked version fails, unless +`scripts/liboqs.accepted` names it with the reason it cannot reach a node. An +accepted id the locked version is no longer affected by is reported, so the +list shrinks when the lock moves. A range this cannot read counts as affected. + +`.github/workflows/audit.yml` runs it on every change and every week. +""" +from __future__ import annotations + +import json +import os +import pathlib +import re +import sys +import urllib.request + +ROOT = pathlib.Path(__file__).resolve().parent.parent.parent +LOCK = ROOT / "scripts" / "liboqs.lock" +ACCEPTED = ROOT / "scripts" / "liboqs.accepted" +API = ("https://api.github.com/repos/open-quantum-safe/liboqs/security-advisories" + "?state=published&per_page=100&page={page}") +_MAX_PAGES = 10 +_BOUND = re.compile(r"^(<=|>=|<|>|=)?\s*v?(\d+(?:\.\d+)*)$") + + +def version(text: str) -> tuple: + return tuple(int(part) for part in text.split(".")) + + +def _compare(left: tuple, right: tuple) -> int: + width = max(len(left), len(right)) + left += (0,) * (width - len(left)) + right += (0,) * (width - len(right)) + return (left > right) - (left < right) + + +def in_range(locked: str, spec) -> bool: + """GitHub's ``vulnerable_version_range``: comma-separated bounds, every one + of which must hold (``">= 0.11.0, <= 0.16.0"``). Anything else is read as + affected: a range nobody can parse is not a range anybody is outside of.""" + if not isinstance(spec, str) or not spec.strip(): + return True + have = version(locked) + for bound in spec.split(","): + match = _BOUND.match(bound.strip()) + if match is None: + return True + operator, number = match.group(1) or "=", version(match.group(2)) + order = _compare(have, number) + holds = {"<": order < 0, "<=": order <= 0, ">": order > 0, + ">=": order >= 0, "=": order == 0}[operator] + if not holds: + return False + return True + + +def affects(advisory: dict, locked: str) -> bool: + if advisory.get("withdrawn_at"): + return False + vulnerabilities = advisory.get("vulnerabilities") + if not isinstance(vulnerabilities, list) or not vulnerabilities: + return True + return any(not isinstance(entry, dict) + or in_range(locked, entry.get("vulnerable_version_range")) + for entry in vulnerabilities) + + +def locked_version(path: pathlib.Path = LOCK) -> str: + match = re.search(r"^LIBOQS_VERSION=(\d+(?:\.\d+)*)$", path.read_text(), re.M) + if match is None: + raise ValueError(f"no LIBOQS_VERSION in {path}") + return match.group(1) + + +def accepted(path: pathlib.Path = ACCEPTED) -> dict: + """``GHSA-xxxx-xxxx-xxxx why it cannot reach a node``, one per line. An + entry without its reason is refused: an exception nobody can read back is + one nobody will ever remove.""" + entries = {} + for number, line in enumerate(path.read_text().splitlines(), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + ghsa, _, reason = line.partition(" ") + if not re.fullmatch(r"GHSA(-[23456789cfghjmpqrvwx]{4}){3}", ghsa) or not reason.strip(): + raise ValueError(f"{path.name}:{number}: expected 'GHSA-… reason'") + entries[ghsa] = reason.strip() + return entries + + +def fetch(token: str | None = None) -> list: + advisories: list = [] + for page in range(1, _MAX_PAGES + 1): + request = urllib.request.Request(API.format(page=page), headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"}) + if token: + request.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(request, timeout=30) as response: + batch = json.load(response) + if not isinstance(batch, list): + raise ValueError("the advisory list is not a list") + advisories.extend(batch) + if len(batch) < 100: + return advisories + raise ValueError(f"more than {_MAX_PAGES} pages of advisories") + + +def verdict(advisories: list, locked: str, exceptions: dict) -> tuple: + """(open, excused, stale): the advisories that fail the check, those an + exception covers, and the exceptions nothing needs any more.""" + hits = [a for a in advisories if isinstance(a, dict) and affects(a, locked)] + names = {a.get("ghsa_id") for a in hits} + still_open = [a for a in hits if a.get("ghsa_id") not in exceptions] + excused = [a for a in hits if a.get("ghsa_id") in exceptions] + stale = sorted(ghsa for ghsa in exceptions if ghsa not in names) + return still_open, excused, stale + + +def _line(advisory: dict) -> str: + return (f"{advisory.get('ghsa_id')} ({advisory.get('severity')}) " + f"{advisory.get('summary', '')[:100]}") + + +def main() -> int: + locked = locked_version() + exceptions = accepted() + still_open, excused, stale = verdict( + fetch(os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")), + locked, exceptions) + print(f"liboqs {locked}") + for advisory in excused: + print(f"accepted: {_line(advisory)}\n {exceptions[advisory['ghsa_id']]}") + for ghsa in stale: + print(f"warning: {ghsa} is accepted in {ACCEPTED.name} but no longer " + f"affects {locked}: remove it") + for advisory in still_open: + print(f"failed: {_line(advisory)}") + if still_open: + print(f"{len(still_open)} advisory(ies) name liboqs {locked}: move the lock, " + f"or say in {ACCEPTED.name} why a node cannot reach the code") + return 1 + print("ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main())