diff --git a/.github/workflows/README.adoc b/.github/workflows/README.adoc index 298de36..b8d6c14 100644 --- a/.github/workflows/README.adoc +++ b/.github/workflows/README.adoc @@ -2,31 +2,169 @@ = GitHub Workflows — what runs, and why some look duplicated This directory holds the repo's CI/CD. A few workflows *look* like they overlap -but are deliberately distinct — documented here so the apparent duplication is -not "tidied away" into a real coverage gap. +but are deliberately distinct — documented here so apparent duplication is not +"tidied away" into a real coverage gap. == Hypatia runs in two places (on purpose) * `hypatia-scan.yml` — the **standalone security scan** (push / PR / weekly). Independent; this is the repo's own Hypatia coverage. * `static-analysis-gate.yml` -> `hypatia-scan` job — runs Hypatia as part of the - **gate**, then the `deposit-findings` job hands the results to the - **gitbot-fleet learning** pipeline. This job is also a *required status check*. + **gate**, then the `deposit-findings` job hands results to the gitbot-fleet + learning pipeline. Its live required-check status needs owner verification. -Same tool, two different consumers (security scan vs fleet learning). Removing -either loses real function — keep both. +Same tool, two consumers. Removing either loses real function — keep both. == Secret scanning is single-sourced -`secret-scanner.yml` calls the standards reusable (gitleaks + a Rust-secrets -check). An inline TruffleHog job was removed: the reusable deliberately retired -TruffleHog as redundant with gitleaks, so re-adding it was duplicated work, not -extra coverage. +`secret-scanner.yml` calls the standards reusable, which runs gitleaks, a Rust +secret check, and a shell secret check. Its `scan` job preserves the required +GitHub Actions context `scan / gitleaks` (app ID `15368`). TruffleHog was +removed from the reusable as redundant; do not re-add an overlapping scan +without identifying coverage it supplies. + +The runs through PR #48 head `f6e6731` failed before any jobs were created. +The https://github.com/hyperpolymath/vocarium/actions/runs/41199621947[run annotation] +says the called workflow was not found. `hyperpolymath/standards` redirects to +`metadatastician/standards`, but GitHub Actions does not follow redirects for +reusable workflows. After the owner permitted the canonical name at the old +commit, the next run created the required context but all three scanning jobs +failed at setup: the old reusable's *own* lockfile records the checkout ref as +its commit SHA, which GitHub rejects as an invalid lockfile pin. The caller's +successful lock verification does not cover the remote reusable's dependencies. + +The caller now pins a newer standards commit with checkout's lock ref recorded +as `v7.0.1` against the same checkout SHA. The newer reusable needs only +`contents: read`, has no secret expressions, gates both a working-tree and a +git-history scan, and fetches its estate baseline at the reusable's pinned SHA +rather than `main`. Its baseline includes upstream allowlist rules, and deleted +AsciiDoc content is not covered by the separate AsciiDoc working-tree pass. +The repository's Actions allowlist must permit the *new exact pin* as well. +The https://github.com/hyperpolymath/vocarium/actions/runs/41220900235[first run] +using it was rejected by the effective Actions policy; recheck the saved pattern +and allow time for settings to propagate. A real hosted run must confirm the +canonical reference, upstream lock, and actual scans work; `merge_group` is +wired in preparation for a later queue canary, not yet proven. + +The same retired `hyperpolymath/standards` owner appears in other reusable +callers (`governance`, `hypatia-scan`, `mirror`, `rust-ci`, and `scorecard`). +Those workflows need separate review and hosted verification; fixing the +secret scanner does not repair them. + +== SonarCloud: automatic analysis only + +The project is `hyperpolymath_vocarium` in organization `hyperpolymath`. +On 2026-10-31 the public +https://sonarcloud.io/api/settings/values?component=hyperpolymath_vocarium&keys=sonar.autoscan.enabled,sonar.project.monorepo.enabled[project settings] +reported `sonar.autoscan.enabled=true` and monorepo mode disabled. The official +GitHub app is `sonarqubecloud` (app ID `12526`). SonarCloud documents automatic +analysis for the default branch and PRs, including forks: +https://docs.sonarsource.com/sonarqube-cloud/analyzing-source-code/automatic-analysis[automatic analysis] and +https://docs.sonarsource.com/sonarqube-cloud/analyzing-source-code/ci-based-analysis/github-actions-for-sonarcloud[fork PRs]. + +The official check for PR #47, commit +`748cf83de60de9cc9b968d38c187a7330183b2e2`, was +https://github.com/hyperpolymath/vocarium/runs/124289772397[124289772397]: +`SonarCloud Code Analysis`, success, zero new issues. The separate Actions +scanner https://github.com/hyperpolymath/vocarium/actions/runs/41121896614/job/124289694405[124289694405] +failed with exit 3 and a missing-`SONAR_TOKEN` warning; its complete final +scanner error was not retrieved. These are duplicate *attempts/reporting*, not +proof that two analyses completed or that the scanner failed due to findings. +The official app also analyzed `main` commit +`297bdd98156f621ee95aa57e20290fbe948a7027` while the Actions scanner had a +startup failure. Real findings must not be ignored: the official gate on +https://github.com/hyperpolymath/vocarium/runs/117203725307[an earlier main commit] +failed its new-code security rating; the 2026-10-27 main analysis reported +21 vulnerabilities, independently of the PR #47 result. + +[cols="2,3,3",options="header"] +|=== +| Event | Route in `sonarqube.yml` | Coverage / review boundary + +| PR targeting `main` (including forks) +| The `SonarQube` Actions job verifies the official app's exact *PR head* SHA; + it never receives a scanner secret or checks out PR code. +| Same-repository PRs are observed. SonarCloud documents fork support, but + no vocarium fork PR canary has been observed. The verifier fails closed if + the result or permission is absent; qualify a real fork before rollout. + +| Push to `main` +| Verify the official app's exact push SHA. +| Observed on main, including independent success and failure results. + +| Other branches and manual dispatch +| No trigger or CI scanner. +| The owner selected `main` only and retired the legacy manual / non-main + route. A dispatch would not itself trigger a fresh automatic analysis. + +| Merge queue (`merge_group`) +| No trigger, as before. +| Neither a synthetic merge-group analysis nor its official receipt has been + qualified. Do not substitute a PR-head result or a skipped/success job for + the queue SHA; qualify an actual queue candidate before requiring this check. +|=== + +The pinned `cicd-suite` verifier checks the latest official app check for the +exact SHA and rejects missing, pending, skipped, stale, wrong-app, and failing +results. The consumer also checks the official receipt's project and PR/main +URL, preventing a successful result for a different Sonar project or PR from +being substituted. Its start time must be strictly after the PR update or push +event timestamp, with a valid completion at or after its start; missing +timestamps and same-second starts fail closed. This prevents an older same-SHA +check, including one still running, from passing before a new check appears. +GitHub timestamps have only second precision and do not prove which base +revision or server-side configuration Sonar analyzed; a check that starts too +close to the event may need a fresh analysis and workflow rerun. Neither check +proves rules, coverage, current base-branch analysis, or entitlement. A new run +on the same SHA can supersede a prior result; a newer pending run is not a pass. +The five-minute wait is bounded and an unavailable result fails rather than +launching an extra scanner or becoming green. + +`sonar-project.properties` is retained as dormant CI-scanner configuration. +Automatic analysis ignores it; its exclusions are *not* evidence of the active +automatic-analysis scope. Review the SonarCloud project settings and actual +findings before changing scope. In particular, do not copy those exclusions into +`.sonarcloud.properties` merely to make a check pass. Automatic analysis does +not import coverage or external reports. SonarCloud explicitly +https://docs.sonarsource.com/sonarqube-cloud/analyzing-source-code/ci-based-analysis/overview-of-integrated-cis[does not support simultaneous automatic and CI-based analysis] +of the same project. If CI analysis is ever reintroduced, first choose a +compatible analysis method and confirm main/PR coverage is not lost. Do not +enable a privileged `pull_request_target` scan of untrusted PR code. + +=== Rollout, branch protection, and organization / queue plan + +* The Actions job keeps its existing `SonarQube` context and the official + `SonarCloud Code Analysis` app check remains separate. The visible active + rules for `main` required `scan / gitleaks` (GitHub Actions app `15368`), + not a Sonar context, on 2026-10-31. The branch-protection API returned 403, + so the owner must also inspect any classic/organization protection and + app-bound required contexts before merging or changing them. The checked-in + `.github/settings.yml` declares other contexts and is not proof of live state. +* Before qualifying forks, open a real fork PR against `main` containing this + candidate workflow. Confirm that both the official check and `SonarQube` + refer to the fork PR's exact head SHA and this project, without a secret; + push a second commit and confirm the older success cannot satisfy it. + Record the check IDs. Repeat when project permissions or analysis mode change. +* GitHub's native https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue[merge queue] + is available for organization-owned public repositories. `hyperpolymath/vocarium` + is currently user-owned. The owner prefers a future organization move; this + PR does not transfer the repository or enable a queue. Plan the organization, + access policies, GitHub Apps, branch rules, and project migration separately. + The verifier deliberately binds `GITHUB_REPOSITORY` to `hyperpolymath/vocarium`: + after any transfer, update and retest that binding and the Sonar project/app + identity rather than assuming old receipts or project keys remain valid. +* Before enabling a queue, verify all required check identities and run a real + `merge_group` canary. Confirm `scan / gitleaks` checks out and scans the exact + synthetic SHA with a completed, non-skipped result. The trigger alone is not + evidence. If a Sonar context is required for queue candidates, first obtain + an appropriately scoped analysis and official receipt for the exact queue + SHA, or explicitly review a different analysis design; the current automatic + route has not been qualified for merge groups. == SAST tools are complementary, not redundant * `codeql.yml` — CodeQL (matrix defaults to `actions`; every repo has workflows). -* `sonarqube.yml` — SonarCloud (shell / JS surface; see `sonar-project.properties`). +* `sonarqube.yml` — verifies SonarCloud automatic analysis for main / PRs. * `static-analysis-gate.yml` — panic-attack + Hypatia gate (see above). Each targets a different language/surface; together they cover what no single diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 81c6816..ae970d2 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -45,12 +45,14 @@ workflows: - 'actions/checkout@v7.0.1' '.github/workflows/rust-ci.yml': [] '.github/workflows/scorecard.yml': [] - '.github/workflows/secret-scanner.yml': [] + '.github/workflows/secret-scanner.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/security-policy.yml': - 'actions/checkout@v7.0.1' '.github/workflows/sonarqube.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/checkout@v7.0.1' - - 'sonarsource/sonarqube-scan-action@v8.2.1' + - 'hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113' '.github/workflows/static-analysis-gate.yml': - 'actions/checkout@v7.0.1' - 'actions/download-artifact@v8.0.2' @@ -76,6 +78,11 @@ dependencies: commit: 'sha1-508db95dd578ae2727ebd6217d5ba78e4fbda05d' owner_id: 44036562 repo_id: 760701061 + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: 'v7.0.1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'actions/checkout@v7.0.1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -128,6 +135,11 @@ dependencies: commit: 'sha1-6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee' owner_id: 9919 repo_id: 259445878 + 'hyperpolymath/cicd-suite@4404c3c0474e17753fe7689f3485c02890aac113': + ref: 'main' + commit: 'sha1-4404c3c0474e17753fe7689f3485c02890aac113' + owner_id: 6759885 + repo_id: 1326697643 'mlugg/setup-zig@v2.2.1': ref: 'v2.2.1' commit: 'sha1-d1434d08867e3ee9daa34448df10607b98908d29' @@ -143,8 +155,3 @@ dependencies: commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64' owner_id: 2242 repo_id: 204253808 - 'sonarsource/sonarqube-scan-action@v8.2.1': - ref: 'v8.2.1' - commit: 'sha1-22918119ff8e1ca75a623e15c8296b6ea4fbe28f' - owner_id: 545988 - repo_id: 366408409 diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index 39883a4..5782f81 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,4 +1,3 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. name: Secret Scanner @@ -6,23 +5,31 @@ on: pull_request: push: branches: [main] + # Preparation only: a real merge-group canary is required before rollout. + merge_group: + types: [checks_requested] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - actions: read - contents: read -# Single secret scanner. The standards reusable runs gitleaks (+ a Rust-secrets -# check). An inline TruffleHog job previously lived here, but the reusable -# DELIBERATELY retired TruffleHog as redundant (gitleaks gives sufficient -# coverage at lower cost — see the reusable's header). Re-adding it was -# duplicated work, not extra coverage, so it has been removed. See -# .github/workflows/README.adoc. +permissions: {} jobs: + # Preserve the required GitHub Actions context `scan / gitleaks`. + # The old owner redirects, which Actions does not support for reusables. + # The old commit's own lock rejects checkout at job setup; this newer pin has + # a valid checkout lock and retains gating scans, including full git history. scan: permissions: contents: read - pull-requests: write - actions: read - uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@84355587cb2a1f86e6882de83514a32db2646e7a - secrets: inherit + uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc + scanner-contract: + name: Secret scanner contract tests + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test required context and event wiring + run: bash tests/workflows/secret_scanner_test.sh diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 351737d..3fe1624 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,34 +1,122 @@ -# This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 # This workflow is managed by gh actions-lock. -# SonarQube Cloud (SonarCloud) static analysis. Analysis scope + exclusions live -# in sonar-project.properties. Requires the SONAR_TOKEN repository secret -# (Settings -> Secrets and variables -> Actions) and a SonarCloud project: -# https://sonarcloud.io/project/overview?id=hyperpolymath_rsr-template-repo -# Mirrors the boj-server arrangement. +# Automatic analysis owns main/PR results; the Actions job verifies its receipt. +# No CI scanner, manual dispatch, non-main route, or merge-queue analysis is +# configured. See .github/workflows/README.adoc before changing coverage. name: SonarQube on: push: - branches: [main, master] + branches: [main] pull_request: - branches: [main, master] - workflow_dispatch: + branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read +permissions: {} jobs: sonarqube: + # Preserve the existing Actions check context even though its main/PR + # meaning changes from "scanner ran" to "official quality gate passed". name: SonarQube + permissions: + contents: read + checks: read runs-on: ubuntu-latest timeout-minutes: 15 steps: - - name: Checkout - uses: actions/checkout@v7.0.1 + - name: Select analysis route + id: route + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + if [[ "$GITHUB_REPOSITORY" != 'hyperpolymath/vocarium' ]]; then + echo '::error::This workflow is bound to the vocarium SonarCloud project.' + exit 1 + fi + case "$EVENT_NAME:$EVENT_REF:$BASE_REF" in + pull_request:*:main|push:refs/heads/main:) + route=official ;; + *) + echo '::error::No qualified Sonar analysis route for this event.' + exit 1 ;; + esac + echo "route=$route" >> "$GITHUB_OUTPUT" + - name: Verify official same-commit quality gate + id: official + if: steps.route.outputs.route == 'official' + uses: hyperpolymath/cicd-suite/actions/sonarcloud-result-check@4404c3c0474e17753fe7689f3485c02890aac113 with: - fetch-depth: 0 # full history for accurate new-code detection - - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@v8.3.0 + repository: ${{ github.repository }} + sha: ${{ github.event.pull_request.head.sha || github.sha }} + wait-seconds: '300' + - name: Verify project and analysis context + if: steps.route.outputs.route == 'official' + shell: bash env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GH_TOKEN: ${{ github.token }} + CHECK_RUN_ID: ${{ steps.official.outputs.check-run-id }} + SUBJECT_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + EVENT_NAME: ${{ github.event_name }} + EVENT_TIME: ${{ github.event.pull_request.updated_at || github.event.repository.pushed_at }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [[ ! "$CHECK_RUN_ID" =~ ^[1-9][0-9]*$ || ! "$SUBJECT_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo '::error::Invalid check-run ID or subject SHA.' + exit 1 + fi + # A prior check on the same SHA, including one still in progress, + # must not satisfy a later event. GitHub push payloads use epoch + # seconds; PR updated_at uses ISO 8601. + if [[ "$EVENT_TIME" =~ ^[0-9]{1,12}$ ]]; then + event_epoch=$((10#$EVENT_TIME)) + elif [[ "$EVENT_TIME" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then + event_epoch=$(date -u -d "$EVENT_TIME" +%s) || exit 1 + else + echo '::error::Missing or invalid event timestamp; no fresh receipt can be verified.' + exit 1 + fi + case "$EVENT_NAME" in + pull_request) + if [[ ! "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo '::error::Invalid PR number.' + exit 1 + fi + expected_url="https://sonarcloud.io/dashboard?id=hyperpolymath_vocarium&pullRequest=$PR_NUMBER" ;; + push) + expected_url='https://sonarcloud.io/dashboard?id=hyperpolymath_vocarium&branch=main' ;; + *) + echo '::error::Unexpected receipt event.' + exit 1 ;; + esac + if ! gh api "repos/$GITHUB_REPOSITORY/check-runs/$CHECK_RUN_ID" | jq -e \ + --arg id "$CHECK_RUN_ID" --arg sha "$SUBJECT_SHA" --arg url "$expected_url" \ + --argjson event_epoch "$event_epoch" ' + .id == ($id | tonumber) and .head_sha == $sha and + .name == "SonarCloud Code Analysis" and + .app.id == 12526 and .app.slug == "sonarqubecloud" and + .status == "completed" and .conclusion == "success" and + .details_url == $url and + ((try (.started_at | fromdateiso8601) catch 0) > $event_epoch) and + ((try (.completed_at | fromdateiso8601) catch 0) >= + (try (.started_at | fromdateiso8601) catch 0)) + ' >/dev/null; then + echo '::error::Official receipt is unavailable, stale, or belongs to a different project, PR, branch, commit, or quality gate.' + exit 1 + fi + sonar-contract: + name: SonarQube contract tests + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test routing and receipt context + run: bash tests/workflows/sonarqube_test.sh diff --git a/sonar-project.properties b/sonar-project.properties index 924e294..71d91ba 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,7 +1,9 @@ # SPDX-License-Identifier: MPL-2.0 -# SonarQube Cloud (SonarCloud) configuration. +# Dormant SonarQube Cloud CI-scanner configuration (not automatic-analysis settings). # Project: https://sonarcloud.io/project/overview?id=hyperpolymath_vocarium -# Requires the SONAR_TOKEN repository secret + a SonarCloud project (owner setup). +# No CI scanner is configured in GitHub Actions. Automatic analysis ignores this +# file; do not assume these exclusions apply to the official app. Reintroducing +# a CI scan requires a compatible analysis method; see .github/workflows/README.adoc. sonar.organization=hyperpolymath sonar.projectKey=hyperpolymath_vocarium diff --git a/tests/workflows/secret_scanner_test.sh b/tests/workflows/secret_scanner_test.sh new file mode 100755 index 0000000..1541bb3 --- /dev/null +++ b/tests/workflows/secret_scanner_test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Static contract for the required secret-scanning context and its triggers. +set -euo pipefail + +workflow=${1:-.github/workflows/secret-scanner.yml} +fail() { echo "FAIL: $*" >&2; exit 1; } + +for trigger in pull_request push merge_group; do + grep -Fxq " $trigger:" "$workflow" || fail "missing $trigger trigger" +done +grep -Fxq ' branches: [main]' "$workflow" || fail 'main push trigger missing' +grep -Fxq ' types: [checks_requested]' "$workflow" || fail 'merge-group checks_requested missing' +grep -Fxq ' group: ${{ github.workflow }}-${{ github.ref }}' "$workflow" || fail 'concurrency is not scoped by ref' +grep -Fxq 'permissions: {}' "$workflow" || fail 'workflow-wide permissions are not disabled' + +# The job ID and reusable subjob name together produce `scan / gitleaks`, the +# app-bound required status context. The pinned reusable was inspected at this +# exact SHA; it runs a real scan of actions/checkout's default event tree. +awk ' + /^ scan:$/ { in_scan=1; next } + in_scan && /^ [a-zA-Z0-9_-]+:$/ { exit } + in_scan && $0 == " contents: read" { read_contents=1 } + in_scan && $0 == " uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@8d1a2aab4211493cf42f147f2080f024605797dc" { pinned=1 } + END { if (!in_scan || !read_contents || !pinned) exit 1 } +' "$workflow" || fail 'required scan job, minimal permissions, or pinned canonical reusable changed' + +if grep -Eq '^[[:space:]]*(secrets|continue-on-error|pull_request_target):|\$\{\{[[:space:]]*secrets\.' "$workflow"; then + fail 'inherited secrets, bypass, or privileged PR trigger present' +fi +if grep -Eq '^[[:space:]]*(actions|pull-requests):[[:space:]]*(read|write)' "$workflow"; then + fail 'unneeded Actions or PR permissions present' +fi + +echo 'Secret scanner contract passed: PR, main push and merge-group wiring; required job; pinned reusable; no inherited secrets.' +echo 'A real merge-group event and scan still require a hosted canary.' + +# Exercise the checker itself against unsafe mutations. Passing a workflow +# path is the recursive fixture mode, which does not make further fixtures. +if [[ $# -eq 0 ]]; then + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + expect_reject() { + local label=$1 candidate=$2 + if bash "$0" "$candidate" > "$tmp/fixture.log" 2>&1; then + fail "$label mutation unexpectedly passed" + fi + echo "ok: $label mutation rejected" + } + cp "$workflow" "$tmp/inherit.yml" + sed -i '/^ scanner-contract:/i\ secrets: inherit' "$tmp/inherit.yml" + expect_reject 'inherited secrets' "$tmp/inherit.yml" + cp "$workflow" "$tmp/privileged-pr.yml" + sed -i '/^ pull_request:$/a\ pull_request_target:' "$tmp/privileged-pr.yml" + expect_reject 'privileged PR trigger' "$tmp/privileged-pr.yml" + cp "$workflow" "$tmp/bypass.yml" + sed -i '/^ scanner-contract:/i\ continue-on-error: true' "$tmp/bypass.yml" + expect_reject 'failure bypass' "$tmp/bypass.yml" + cp "$workflow" "$tmp/redirect.yml" + sed -i 's/uses: metadatastician\/standards/uses: hyperpolymath\/standards/' "$tmp/redirect.yml" + expect_reject 'redirecting reusable owner' "$tmp/redirect.yml" + cp "$workflow" "$tmp/old-pin.yml" + sed -i 's/8d1a2aab4211493cf42f147f2080f024605797dc/84355587cb2a1f86e6882de83514a32db2646e7a/' "$tmp/old-pin.yml" + expect_reject 'upstream commit with invalid checkout lock' "$tmp/old-pin.yml" + cp "$workflow" "$tmp/permission.yml" + sed -i '/^ contents: read$/a\ pull-requests: write' "$tmp/permission.yml" + expect_reject 'excess PR permissions' "$tmp/permission.yml" + cp "$workflow" "$tmp/context.yml" + sed -i 's/^ scan:$/ renamed:/' "$tmp/context.yml" + expect_reject 'renamed required job' "$tmp/context.yml" + cp "$workflow" "$tmp/group.yml" + sed -i '/^ merge_group:/d' "$tmp/group.yml" + expect_reject 'missing merge-group trigger' "$tmp/group.yml" +fi diff --git a/tests/workflows/sonarqube_test.sh b/tests/workflows/sonarqube_test.sh new file mode 100755 index 0000000..edfcfa0 --- /dev/null +++ b/tests/workflows/sonarqube_test.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Exercise the actual inline workflow scripts, not a copy of their logic. +set -euo pipefail + +workflow=${1:-.github/workflows/sonarqube.yml} +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +extract_run() { + local step=$1 dest=$2 + awk -v target=" - name: $step" ' + $0 == target { found = 1; next } + found && /^ run: \|$/ { in_run = 1; next } + in_run && /^ / { sub(/^ /, ""); print; next } + in_run && /^$/ { print; next } + in_run { exit } + END { if (!in_run) exit 1 } + ' "$workflow" > "$dest" +} +extract_run 'Select analysis route' "$tmp/route.sh" +extract_run 'Verify project and analysis context' "$tmp/context.sh" +bash -n "$tmp/route.sh" +bash -n "$tmp/context.sh" + +fail() { echo "FAIL: $*" >&2; exit 1; } +route_case() { + local label=$1 event=$2 ref=$3 base=$4 repo=$5 expected=$6 rc=0 + : > "$tmp/output" + GITHUB_OUTPUT="$tmp/output" GITHUB_REPOSITORY="$repo" \ + EVENT_NAME="$event" EVENT_REF="$ref" BASE_REF="$base" \ + bash "$tmp/route.sh" > "$tmp/log" 2>&1 || rc=$? + if [[ $expected == failure ]]; then + [[ $rc -ne 0 && ! -s "$tmp/output" ]] || fail "$label unexpectedly passed" + else + [[ $rc == 0 ]] || { cat "$tmp/log"; fail "$label exited $rc"; } + grep -qx "route=$expected" "$tmp/output" || fail "$label chose the wrong route" + fi + echo "ok: $label" +} +repo=hyperpolymath/vocarium +route_case 'same-repo PR to main' pull_request refs/pull/47/merge main "$repo" official +route_case 'fork PR to main follows the same secret-free route' pull_request refs/pull/48/merge main "$repo" official +route_case 'main push' push refs/heads/main '' "$repo" official +route_case 'master push is not an analysis route' push refs/heads/master '' "$repo" failure +route_case 'master PR is not an analysis route' pull_request refs/pull/49/merge master "$repo" failure +route_case 'manual main dispatch is not an analysis route' workflow_dispatch refs/heads/main '' "$repo" failure +route_case 'manual PR ref is not an analysis route' workflow_dispatch refs/pull/47/merge '' "$repo" failure +route_case 'manual feature branch is not an analysis route' workflow_dispatch refs/heads/feature '' "$repo" failure +route_case 'merge-group SHA is not treated as a PR head' merge_group refs/heads/gh-readonly-queue/main/pr-1 main "$repo" failure +route_case 'unknown branch is not silently accepted' push refs/heads/other '' "$repo" failure +route_case 'different repository is not this project' push refs/heads/main '' other/vocarium failure + +# Check the wiring around the scripts as well as their runtime behaviour. +grep -Fq 'uses: hyperpolymath/cicd-suite/actions/sonarcloud-result-check@4404c3c0474e17753fe7689f3485c02890aac113' "$workflow" || fail 'verifier is not pinned' +grep -Fq 'sha: ${{ github.event.pull_request.head.sha || github.sha }}' "$workflow" || fail 'subject is not the PR head / push SHA' +grep -Fxq 'permissions: {}' "$workflow" || fail 'workflow-wide token permissions are not disabled' +grep -Fxq ' checks: read' "$workflow" || fail 'job-scoped checks: read is missing' +if grep -Eq '^[[:space:]]*(continue-on-error|pull_request_target|workflow_dispatch):|SONAR_TOKEN|sonarqube-scan-action' "$workflow"; then + fail 'unexpected bypass, privileged/manual trigger, scanner, or scanner secret present' +fi +[[ $(grep -Fxc ' branches: [main]' "$workflow") -eq 2 ]] || fail 'Sonar triggers are not limited to main' +echo 'ok: verifier pin, main-only triggers, permissions and secret-free quality-gate wiring' + +mkdir -p "$tmp/bin" +cat > "$tmp/bin/gh" <<'MOCK' +#!/usr/bin/env bash +[[ ${MOCK_ERROR:-0} == 0 ]] || exit 1 +cat "$MOCK_RESPONSE" +MOCK +chmod +x "$tmp/bin/gh" +export PATH="$tmp/bin:$PATH" MOCK_RESPONSE="$tmp/response.json" +sha=0123456789abcdef0123456789abcdef01234567 +jq -n --arg sha "$sha" '{id:10,head_sha:$sha,name:"SonarCloud Code Analysis",app:{id:12526,slug:"sonarqubecloud"},status:"completed",conclusion:"success",started_at:"2026-10-30T20:16:50Z",completed_at:"2026-10-30T20:17:00Z",details_url:"https://sonarcloud.io/dashboard?id=hyperpolymath_vocarium&pullRequest=47"}' > "$tmp/base.json" +context_case() { + local label=$1 expected=$2 filter=$3 event=${4:-pull_request} pr=${5:-47} id=${6:-10} + local event_time=${7-2026-10-30T20:16:44Z} rc=0 + jq "$filter" "$tmp/base.json" > "$MOCK_RESPONSE" + GITHUB_REPOSITORY="$repo" CHECK_RUN_ID="$id" SUBJECT_SHA="$sha" \ + EVENT_NAME="$event" EVENT_TIME="$event_time" PR_NUMBER="$pr" \ + bash "$tmp/context.sh" > "$tmp/log" 2>&1 || rc=$? + if [[ $expected == success ]]; then + [[ $rc == 0 ]] || { cat "$tmp/log"; fail "$label exited $rc"; } + else + [[ $rc -ne 0 ]] || fail "$label unexpectedly passed" + fi + echo "ok: $label" +} +context_case 'matching PR project and subject' success '.' +context_case 'matching main project and subject' success '.details_url="https://sonarcloud.io/dashboard?id=hyperpolymath_vocarium&branch=main"' push '' +push_epoch=$(date -u -d '2026-10-30T20:16:44Z' +%s) +context_case 'numeric push timestamp accepted' success '.details_url="https://sonarcloud.io/dashboard?id=hyperpolymath_vocarium&branch=main"' push '' 10 "$push_epoch" +context_case 'old same-SHA receipt from a prior event refused' failure '.started_at="2026-10-29T20:16:50Z" | .completed_at="2026-10-29T20:17:00Z"' +context_case 'old in-progress check finishing after a new event refused' failure '.started_at="2026-10-30T20:16:40Z"' +context_case 'same-second check is not provably fresh' failure '.started_at="2026-10-30T20:16:44Z"' +context_case 'missing start time refused' failure 'del(.started_at)' +context_case 'missing completion time refused' failure 'del(.completed_at)' +context_case 'completion before start refused' failure '.completed_at="2026-10-30T20:16:49Z"' +context_case 'missing event timestamp refused' failure '.' pull_request 47 10 '' +context_case 'invalid event timestamp refused' failure '.' pull_request 47 10 'yesterday;false' +context_case 'other project refused' failure '.details_url="https://sonarcloud.io/dashboard?id=other_project&pullRequest=47"' +context_case 'other PR refused' failure '.details_url="https://sonarcloud.io/dashboard?id=hyperpolymath_vocarium&pullRequest=48"' +context_case 'other branch refused for main push' failure '.' push '' +context_case 'wrong check ID refused' failure '.id=11' +context_case 'wrong commit / stale receipt refused' failure '.head_sha="ffffffffffffffffffffffffffffffffffffffff"' +context_case 'wrong check name refused' failure '.name="SonarQube"' +context_case 'wrong app ID refused' failure '.app.id=15368' +context_case 'wrong app slug refused' failure '.app.slug="impostor"' +context_case 'pending receipt refused' failure '.status="in_progress" | .conclusion=null' +context_case 'skipped receipt refused' failure '.conclusion="skipped"' +context_case 'stale conclusion refused' failure '.conclusion="stale"' +context_case 'genuine quality-gate failure refused' failure '.conclusion="failure"' +context_case 'malformed check identity refused' failure '.' pull_request 47 '10;touch /tmp/sonar-injection' +context_case 'untrusted PR number refused' failure '.' pull_request '47;false' +MOCK_ERROR=1 context_case 'check API denial fails closed' failure '.' +echo 'SonarQube consumer contract tests passed.'