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())