Skip to content
Open
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
101 changes: 101 additions & 0 deletions .github/workflows/soup-backstop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Periodic backstop reconciliation (WI §7).
#
# The one procedure that reads the whole evidence history rather than producing a record. It needs
# the durable store, not the workflow artifacts: those are kept for 90 days, and an annual backstop
# has to reconcile 365.
#
# The product list lives here on purpose. A product that was never scanned cannot report its own
# absence, so the expectation has to come from outside the products.

# No trigger here on purpose — see soup-kev-monitor.yml. The product caller owns the schedule
# (annually for tier Basic, quarterly for Extended) and the manual trigger.

name: SOUP Backstop

on:
workflow_call:
inputs:
products:
description: 'Comma-separated products that must have records. A product missing entirely is the finding this catches.'
required: true
type: string
window:
description: 'Days of history to reconcile. 366 for tier Basic, 92 for Extended.'
required: false
type: number
default: 366
runs-on:
description: 'Runner label(s). JSON array or a single label.'
required: false
type: string
default: '"ubuntu-latest"'
secrets:
DO_ACCESS_KEY:
required: true
DO_SECRET_KEY:
required: true

jobs:
backstop:
runs-on: ${{ fromJSON(inputs.runs-on) }}
steps:
- uses: actions/checkout@v4

- name: Fetch the evidence history
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DO_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SECRET_KEY }}
run: |
mkdir -p evidence
# One prefix per product per year. Syncing the whole space would pull the mobile build
# artifacts as well, which are large and irrelevant here.
YEARS=$(python3 -c "
import datetime
d = datetime.date.today()
span = int(${{ inputs.window }}) // 365 + 2
print(' '.join(str(d.year - i) for i in range(span)))")
for product in $(echo '${{ inputs.products }}' | tr ',' ' '); do
for year in $YEARS; do
aws s3 sync \
"s3://quickbird-artifacts/$product/soup-evidence/$year/" "evidence/" \
--endpoint-url https://fra1.digitaloceanspaces.com \
--only-show-errors || true
done
done
echo "fetched $(ls -1 evidence | wc -l) run record(s)"

- name: Resolve the report path
id: path
run: echo "out-dir=${{ github.event.repository.name }}/soup-backstop/$(date +%Y)" >> "$GITHUB_OUTPUT"

- name: Reconcile
id: backstop
run: |
python3 .github/soup/backstop-report.py evidence \
--products '${{ inputs.products }}' \
--window '${{ inputs.window }}' \
--out backstop-report.json
continue-on-error: true

# The report is the record of the review, so it is stored like any other.
- name: Store the report
if: always()
uses: BetaHuhn/do-spaces-action@v2.0.146
with:
access_key: ${{ secrets.DO_ACCESS_KEY }}
secret_key: ${{ secrets.DO_SECRET_KEY }}
space_name: quickbird-artifacts
space_region: fra1
source: backstop-report.json
out_dir: ${{ steps.path.outputs.out-dir }}

- name: Fail on an action-required verdict
run: |
VERDICT=$(jq -r '.verdict' backstop-report.json)
jq -r '"backstop: " + (.summary | tostring)' backstop-report.json
if [ "$VERDICT" != "clean" ]; then
echo "::error::backstop verdict: $VERDICT — see backstop-report.json"
jq -r '.coverage[] | select(.status != "ok") | " \(.product): \(.status) — \(.detail)"' backstop-report.json
jq -r '.determination_drift[]? | " \(.product): \(.field) changed — \(.detail)"' backstop-report.json
exit 1
fi
106 changes: 106 additions & 0 deletions .github/workflows/soup-kev-monitor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Reusable workflow.
#
# Daily check of whether anything a product currently runs contains a vulnerability that is
# known to be actively exploited. From 11 September 2026 that question carries a 24-hour
# reporting clock for CRA-scoped products, and it is the only question this workflow asks.
# Severity grading, deadlines and the finding lifecycle are handled by the assessment, not here.

# No trigger here on purpose. `workflow_dispatch` on a reusable workflow gets its inputs from the
# dispatch form, not from workflow_call, so `product` would arrive empty and `runs-on` would arrive
# as "" — fromJSON("") then fails the job before anything runs. The product caller owns both the
# schedule and the manual trigger, which is also how soup-version-check.yml is wired.

name: SOUP - KEV monitor

on:
workflow_call:
inputs:
product:
description: 'Product name, used in the evidence record and the alert'
required: true
type: string
cra-scope:
description: >-
true | false | unknown. Whether this product is in scope of the Cyber Resilience Act.
Left at "unknown" the alert says so — assuming "false" is the one wrong answer with a
legal consequence attached.
required: false
type: string
default: 'unknown'
slack-channel-id:
description: 'Channel for alerts. Omitted, the run still writes its record; only the notification is skipped.'
required: false
type: string
default: ''
runs-on:
description: 'Runner label(s). JSON array or a single label.'
required: false
type: string
default: '["self-hosted", "Linux"]'
secrets:
SLACK_BOT_TOKEN:
required: false
GH_API_TOKEN:
required: false
# Without these the dated record exists only as a 90-day workflow artifact, and an annual
# backstop cannot reconcile its own review period. Declared as not required so the workflow
# still runs without them, but the backstop then reports the coverage it cannot see.
DO_ACCESS_KEY:
required: false
DO_SECRET_KEY:
required: false

jobs:
kev-check:
runs-on: ${{ fromJSON(inputs.runs-on) }}
steps:
- uses: actions/checkout@v4

# gh is required here and the self-hosted runners do not ship it (proven on the first
# e2e run). Fetched once into the tool cache, which persists on a self-hosted runner —
# after the first run this is a no-op. Provisioning gh on the runner image makes this
# a no-op from the start; nothing breaks either way.
- name: Ensure gh
shell: bash
run: |
command -v gh >/dev/null 2>&1 && exit 0
V=2.63.2
case "$(uname -m)" in aarch64|arm64) A=arm64;; *) A=amd64;; esac
CACHE="${RUNNER_TOOL_CACHE:-$RUNNER_TEMP}/gh-cli/$V-$A"
if [ ! -x "$CACHE/bin/gh" ]; then
mkdir -p "$CACHE"
curl -sSL --fail --retry 3 \
"https://github.com/cli/cli/releases/download/v$V/gh_${V}_linux_$A.tar.gz" \
| tar -xz -C "$CACHE" --strip-components=1
fi
echo "$CACHE/bin" >> "$GITHUB_PATH"

- name: KEV monitor
id: monitor
uses: QuickBirdEng/actions/kev-monitor@main
with:
product: ${{ inputs.product }}
cra-scope: ${{ inputs.cra-scope }}
slack-channel-id: ${{ inputs.slack-channel-id }}
slack-bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
gh-token: ${{ secrets.GH_API_TOKEN || github.token }}
do-access-key: ${{ secrets.DO_ACCESS_KEY }}
do-secret-key: ${{ secrets.DO_SECRET_KEY }}

# The job fails on a KEV finding so it is visible without reading Slack, and on an
# incomplete run so that "could not check" is never mistaken for "nothing found".
# An all-clear passes and leaves its dated record behind as the evidence of monitoring.
- name: Verdict
if: always()
shell: bash
run: |
case "${{ steps.monitor.outputs.verdict }}" in
all-clear)
echo "all clear — record retained as evidence that the product was monitored today" ;;
kev-findings)
echo "::error::actively exploited vulnerability present in the running version"; exit 1 ;;
incomplete)
echo "::error::the check could not be completed — this is not an all-clear"; exit 1 ;;
*)
echo "::error::no verdict produced"; exit 1 ;;
esac
138 changes: 138 additions & 0 deletions .github/workflows/soup-sbom.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Reusable workflow.
#
# WI §7 stage #3: produce the inventory of a build and assess it. Called by a thin workflow in
# the product repository, which owns the trigger — same pattern as soup-version-check.yml.
#
# Must run AFTER the release workflow has pushed the images of this tag. The pipeline scans an
# image by pulling it and records the digest of what it pulled; an image that is not in the
# registry yet becomes a gap, and a gap is what an incomplete inventory looks like. The product
# caller therefore triggers on the release workflow completing, not on the tag push.

name: SOUP - SBOM

on:
workflow_call:
inputs:
product:
description: 'Product name. Becomes the subject of the document.'
required: true
type: string
version:
description: >-
The version this inventory describes. Use the release tag. A placeholder here produces a
document that cannot be resolved back to a build, which is the one thing it exists for.
required: true
type: string
ref:
description: 'Ref to check out. Defaults to the calling ref.'
required: false
type: string
default: ''
scope-file:
description: 'Scope declaration. Every discovered candidate must be classified in it.'
required: false
type: string
default: '.soup-scope.yml'
publish-to-release:
description: >-
Attach the bundle to the release of this tag. The fixed asset name is what lets the daily
monitor resolve a deployed version to its inventory.
required: false
type: boolean
default: true
render-pdf:
description: 'Also render the readable version.'
required: false
type: boolean
default: true
runs-on:
description: 'Runner label(s). JSON array or a single label.'
required: false
type: string
default: '["self-hosted", "Linux"]'
secrets:
REGISTRY_USERNAME:
required: false
REGISTRY_PASSWORD:
required: false
# Without these, private images cannot be pulled and are recorded as gaps rather than
# scanned. The run does not fail; the inventory says it is incomplete.
GH_API_TOKEN:
required: false
DO_ACCESS_KEY:
required: false
DO_SECRET_KEY:
required: false
outputs:
complete:
description: 'false if any in-scope candidate could not be scanned'
value: ${{ jobs.sbom.outputs.complete }}
gaps:
description: 'ids of in-scope candidates that produced no BOM'
value: ${{ jobs.sbom.outputs.gaps }}

jobs:
sbom:
runs-on: ${{ fromJSON(inputs.runs-on) }}
outputs:
complete: ${{ steps.discovery.outputs.complete }}
gaps: ${{ steps.discovery.outputs.gaps }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}

# gh is required here and the self-hosted runners do not ship it (proven on the first
# e2e run). Fetched once into the tool cache, which persists on a self-hosted runner —
# after the first run this is a no-op. Provisioning gh on the runner image makes this
# a no-op from the start; nothing breaks either way.
- name: Ensure gh
shell: bash
run: |
command -v gh >/dev/null 2>&1 && exit 0
V=2.63.2
case "$(uname -m)" in aarch64|arm64) A=arm64;; *) A=amd64;; esac
CACHE="${RUNNER_TOOL_CACHE:-$RUNNER_TEMP}/gh-cli/$V-$A"
if [ ! -x "$CACHE/bin/gh" ]; then
mkdir -p "$CACHE"
curl -sSL --fail --retry 3 \
"https://github.com/cli/cli/releases/download/v$V/gh_${V}_linux_$A.tar.gz" \
| tar -xz -C "$CACHE" --strip-components=1
fi
echo "$CACHE/bin" >> "$GITHUB_PATH"

- name: Discover, scan, assess, publish
id: discovery
uses: QuickBirdEng/actions/soup-discovery@main
with:
product: ${{ inputs.product }}
version: ${{ inputs.version }}
# Also passed as the release tag. Under workflow_run — the normal trigger — the
# ref is the default branch, so the action cannot derive the tag itself; without
# this line the bundle would be tiered `branch`, never attached to the release,
# and stored under the branch prefix. `version` is documented as the release tag,
# which is what makes this pass-through correct.
release-tag: ${{ inputs.version }}
scope-file: ${{ inputs.scope-file }}
render-pdf: ${{ inputs.render-pdf }}
publish-to-release: ${{ inputs.publish-to-release }}
registry-username: ${{ secrets.REGISTRY_USERNAME }}
registry-password: ${{ secrets.REGISTRY_PASSWORD }}
repo-token: ${{ secrets.GH_API_TOKEN || github.token }}
do-access-key: ${{ secrets.DO_ACCESS_KEY }}
do-secret-key: ${{ secrets.DO_SECRET_KEY }}

# An incomplete inventory is a finding, not a failure: the document states which artefacts
# are missing and why, and that is the record WI §7 stage #3 asks for. Failing the job here
# would delete the evidence of the gap along with the run.
- name: Verdict
if: always()
shell: bash
run: |
if [ "${{ steps.discovery.outputs.complete }}" = "true" ]; then
echo "inventory complete — ${{ steps.discovery.outputs.component-count }} components"
else
echo "::warning::inventory incomplete, gaps: ${{ steps.discovery.outputs.gaps }}"
echo "::warning:: the document records each gap and its reason; resolve them before"
echo "::warning:: this version is used as release evidence"
fi