Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/actions/suite/action.yml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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: ["*"]
Expand All @@ -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
29 changes: 29 additions & 0 deletions .github/scripts/provide-base.sh
Original file line number Diff line number Diff line change
@@ -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 <base image:tag>
#
# 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 <base image:tag>" >&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"
29 changes: 29 additions & 0 deletions .github/scripts/pull-verified.sh
Original file line number Diff line number Diff line change
@@ -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 <image:tag> <workflow file>
#
# 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 <image:tag> <workflow file>" >&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"
62 changes: 46 additions & 16 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,34 +87,37 @@ 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
done
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: |
Expand All @@ -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" \
Expand Down
35 changes: 34 additions & 1 deletion .github/workflows/base-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Loading
Loading