diff --git a/.github/workflows/soup-backstop.yml b/.github/workflows/soup-backstop.yml new file mode 100644 index 0000000..c9e6103 --- /dev/null +++ b/.github/workflows/soup-backstop.yml @@ -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 diff --git a/.github/workflows/soup-kev-monitor.yml b/.github/workflows/soup-kev-monitor.yml new file mode 100644 index 0000000..6322788 --- /dev/null +++ b/.github/workflows/soup-kev-monitor.yml @@ -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 diff --git a/.github/workflows/soup-sbom.yml b/.github/workflows/soup-sbom.yml new file mode 100644 index 0000000..ab229b1 --- /dev/null +++ b/.github/workflows/soup-sbom.yml @@ -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