diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 8f31a5a4..1c425eac 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -35,9 +35,9 @@ jobs: id: check run: | if [ -f ".hypatia-baseline.json" ]; then - echo "has_baseline=true" >> $GITHUB_OUTPUT + echo "has_baseline=true" >> "$GITHUB_OUTPUT" else - echo "has_baseline=false" >> $GITHUB_OUTPUT + echo "has_baseline=false" >> "$GITHUB_OUTPUT" fi - name: Checkout pinned standards history @@ -45,13 +45,13 @@ jobs: with: repository: hyperpolymath/standards # The gate needs the full graph for ancestry and age checks, but its - # script must come from the exact reusable commit rather than moving - # main. This also lets a Standards PR test its own validator change. - ref: ${{ job.workflow_sha }} + # script comes from a reviewed immutable helper revision. GitHub does + # not expose job.workflow_sha; an absent property silently floats. + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-history fetch-depth: 0 filter: tree:0 - persist-credentials: false - name: Run staleness check env: @@ -85,9 +85,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - # Exact commit of this reusable workflow. This preserves consumer - # pin semantics and lets a standards PR test its own scripts. - ref: ${{ job.workflow_sha }} + # Immutable helper revision paired with this workflow. Standards PRs + # test their own scripts through the bootstrap rule below. + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -123,7 +124,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -258,16 +260,16 @@ jobs: # A reusable workflow only auto-checks-out its own YAML, not sibling # scripts. Sparse-check-out standards' scripts/ to get apply-baseline.sh, - # mirroring the language-policy job below. `job.workflow_sha` is the exact - # commit of this called workflow, so the filter and schema semantics cannot - # drift from the reusable workflow that invoked them. This also lets a - # standards PR test its own script change instead of silently using main. + # mirroring the language-policy job below. The explicit immutable helper + # commit below keeps the filter and schema semantics reviewable. Update + # that pin when changing the helpers; the job context has no workflow_sha. - name: Check out standards for the baseline filter if: needs.workflow-staleness.outputs.has_baseline == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-checkout sparse-checkout: | scripts @@ -1107,15 +1109,25 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - # Execute helper code from the same immutable Standards revision as - # this reusable workflow. A moving `main` checkout would let a caller - # pinned to workflow revision X execute implementation revision Y. - ref: ${{ job.workflow_sha }} + # Execute helpers from this reviewed immutable revision, which includes + # the parser and descriptile-policy checks. Bump this helper pin when + # changing the implementation; job.workflow_sha is not a GitHub context. + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh + scripts/check-descriptile-policy.sh tools/policy/check-workflows-parse.sh sparse-checkout-cone-mode: false + - name: Check descriptile policy consistency + run: | + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then + SCRIPT=scripts/check-descriptile-policy.sh + else + SCRIPT=.standards-dupkey/scripts/check-descriptile-policy.sh + fi + bash "$SCRIPT" - name: Parse every tracked workflow run: | if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then @@ -1223,9 +1235,8 @@ jobs: # refs are red now, all-pinned-but-no-lock is a ::warning until # ENFORCE_ACTIONS_LOCK_FROM (2026-10-01), red after. # - # Standards is checked out at `job.workflow_sha` = the SHA of THIS reusable - # as pinned by the caller (verified 2026-09-02 on consumer run logs), so the - # gate script and the YAML move together — one speed, no `ref: main` float. + # Standards helpers use the explicit immutable commit below. Update that + # helper pin with helper changes; job.workflow_sha is not a GitHub context. actions-lock-verify: name: Actions lockfile verify runs-on: ${{ inputs.runs-on }} @@ -1242,9 +1253,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-lock + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false + path: .standards-lock sparse-checkout: | scripts/check-actions-lock-gate.sh scripts/update-actions-lock.sh @@ -1266,7 +1277,7 @@ jobs: fi for f in check-actions-lock-gate.sh update-actions-lock.sh; do if [ ! -f "$SRC/$f" ]; then - echo "::error::actions-lock gate: $f not found in $SRC (standards checkout at job.workflow_sha failed?)" + echo "::error::actions-lock gate: $f not found in $SRC (standards checkout at the explicit helper pin failed?)" exit 1 fi cp "$SRC/$f" "$RUNNER_TEMP/$f" diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 913d0fc2..95a6dfd9 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -41,7 +41,7 @@ jobs: # cache step because cache restore happens before the clone, so the key # cannot hash a not-yet-cloned tree — it must hash the remote ref. sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) - if [ -z "$sha" ]; then + if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then echo "ERROR: could not resolve hypatia HEAD via git ls-remote" >&2 exit 1 fi @@ -62,15 +62,25 @@ jobs: # SD022, hypatia#545) never reached CI. No restore-keys on purpose — # a partial restore would repopulate ~/hypatia and the guards below # would then skip the rebuild, reintroducing the staleness. - key: hypatia-scanner-v3-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} + key: hypatia-scanner-v4-${{ runner.os }}-${{ steps.hypatia-rev.outputs.sha }} - - name: Clone Hypatia + - name: Check out resolved Hypatia commit + env: + HYPATIA_SHA: ${{ steps.hypatia-rev.outputs.sha }} run: | - # On a cache miss ~/hypatia is absent, so this clones the SHA the key - # was computed from. On a cache hit it is present (correct SHA) and we - # skip — the guard is now safe because the key is source-pinned. + set -euo pipefail if [ ! -d "$HOME/hypatia" ]; then - git clone --depth 1 https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" + git init "$HOME/hypatia" + git -C "$HOME/hypatia" remote add origin https://github.com/hyperpolymath/hypatia.git + git -C "$HOME/hypatia" fetch --depth 1 origin "$HYPATIA_SHA" + git -C "$HOME/hypatia" checkout --detach FETCH_HEAD + fi + # A cache is usable only when its source matches the key, including + # on cache hits. v4 invalidates caches populated by the moving clone. + ACTUAL_SHA=$(git -C "$HOME/hypatia" rev-parse HEAD) + if [ "$ACTUAL_SHA" != "$HYPATIA_SHA" ]; then + echo "::error::Hypatia cached source does not match the resolved commit" + exit 1 fi - name: Build Hypatia scanner @@ -118,7 +128,7 @@ jobs: if [ ! -s hypatia-findings.json ] || ! jq -e -s ' length == 1 and (.[0] | type == "array" and all(.[]; type == "object" and (.severity as $s | - ["critical", "high", "medium", "low", "info", "informational"] | index($s) != null))) + ["critical", "high", "medium", "warn", "low", "info", "informational"] | index($s) != null))) ' hypatia-findings.json >/dev/null; then echo "::error::Hypatia did not produce one valid findings array" exit 2 @@ -127,7 +137,8 @@ jobs: FINDING_COUNT=$(jq '. | length' hypatia-findings.json) CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) - MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) + # Hypatia's research rules use "warn" at the same rank as "medium". + MEDIUM=$(jq '[.[] | select(.severity == "medium" or .severity == "warn")] | length' hypatia-findings.json) { echo "findings_count=$FINDING_COUNT" diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 1ccafb23..34176e2a 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -13,10 +13,10 @@ # `vars._MIRROR_ENABLED == 'true'`, so forge selection is # configured per-repo via Actions vars — no per-call inputs required. # -# Caller (one-line wrapper) MUST use `secrets: inherit` so the reusable +# Callers pass explicit secrets (preferred) or `secrets: inherit` so the reusable # can read the per-forge SSH keys (GITLAB_SSH_KEY, BITBUCKET_SSH_KEY, # CODEBERG_SSH_KEY, SOURCEHUT_SSH_KEY, DISROOT_SSH_KEY, GITEA_SSH_KEY) -# and RADICLE_KEY from the caller repo. Without `secrets: inherit`, +# and RADICLE_KEY from the caller repo. Without a passed secret, # `${{ secrets.X }}` inside the reusable evaluates to empty. # # Caller example (wrapper): @@ -44,12 +44,30 @@ on: required: false default: ubuntu-latest + secrets: + GITLAB_SSH_KEY: + required: false + BITBUCKET_SSH_KEY: + required: false + CODEBERG_SSH_KEY: + required: false + SOURCEHUT_SSH_KEY: + required: false + DISROOT_SSH_KEY: + required: false + GITEA_SSH_KEY: + required: false + RADICLE_KEY: + required: false + permissions: actions: read contents: read jobs: mirror-gitlab: + # Advisory mirror: verification still fails closed and skips the push. + continue-on-error: true timeout-minutes: 20 runs-on: ${{ inputs.runs-on }} if: vars.GITLAB_MIRROR_ENABLED == 'true' @@ -67,6 +85,27 @@ jobs: if: ${{ env.GITLAB_KEY != '' }} with: ssh-private-key: ${{ secrets.GITLAB_SSH_KEY }} + - name: Verify gitlab SSH host + if: ${{ env.GITLAB_KEY != '' }} + env: + MIRROR_HOST: gitlab.com + APPROVED_FINGERPRINT: ${{ vars.GITLAB_SSH_FINGERPRINT || 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid GITLAB_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-gitlab-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to GitLab # continue-on-error: GitLab branch protection on the mirror repo may block # force-push even for a deploy key. Owner action required: in GitLab go to @@ -75,9 +114,12 @@ jobs: # Until then this step is advisory-only; failures do not red main. continue-on-error: true if: ${{ env.GITLAB_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.GITLAB_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: gitlab.com run: | - ssh-keyscan -t ed25519 gitlab.com >> ~/.ssh/known_hosts - git remote add gitlab git@gitlab.com:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add gitlab "git@gitlab.com:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force gitlab main - name: Skipped (GITLAB_SSH_KEY not configured) if: ${{ env.GITLAB_KEY == '' }} @@ -100,11 +142,35 @@ jobs: if: ${{ env.BITBUCKET_KEY != '' }} with: ssh-private-key: ${{ secrets.BITBUCKET_SSH_KEY }} + - name: Verify bitbucket SSH host + if: ${{ env.BITBUCKET_KEY != '' }} + env: + MIRROR_HOST: bitbucket.org + APPROVED_FINGERPRINT: ${{ vars.BITBUCKET_SSH_FINGERPRINT || 'SHA256:ybgmFkzwOSotHTHLJgHO0QN8L0xErw6vd0VhFA9m3SM' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/ + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid BITBUCKET_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-bitbucket-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Bitbucket if: ${{ env.BITBUCKET_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.BITBUCKET_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: bitbucket.org run: | - ssh-keyscan -t ed25519 bitbucket.org >> ~/.ssh/known_hosts - git remote add bitbucket git@bitbucket.org:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add bitbucket "git@bitbucket.org:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force bitbucket main - name: Skipped (BITBUCKET_SSH_KEY not configured) if: ${{ env.BITBUCKET_KEY == '' }} @@ -127,11 +193,35 @@ jobs: if: ${{ env.CODEBERG_KEY != '' }} with: ssh-private-key: ${{ secrets.CODEBERG_SSH_KEY }} + - name: Verify codeberg SSH host + if: ${{ env.CODEBERG_KEY != '' }} + env: + MIRROR_HOST: codeberg.org + APPROVED_FINGERPRINT: ${{ vars.CODEBERG_SSH_FINGERPRINT || 'SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://docs.codeberg.org/security/ssh-fingerprint/ + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid CODEBERG_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-codeberg-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Codeberg if: ${{ env.CODEBERG_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.CODEBERG_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: codeberg.org run: | - ssh-keyscan -t ed25519 codeberg.org >> ~/.ssh/known_hosts - git remote add codeberg git@codeberg.org:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add codeberg "git@codeberg.org:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force codeberg main - name: Skipped (CODEBERG_SSH_KEY not configured) if: ${{ env.CODEBERG_KEY == '' }} @@ -154,11 +244,35 @@ jobs: if: ${{ env.SOURCEHUT_KEY != '' }} with: ssh-private-key: ${{ secrets.SOURCEHUT_SSH_KEY }} + - name: Verify sourcehut SSH host + if: ${{ env.SOURCEHUT_KEY != '' }} + env: + MIRROR_HOST: git.sr.ht + APPROVED_FINGERPRINT: ${{ vars.SOURCEHUT_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://man.sr.ht/ + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid SOURCEHUT_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-sourcehut-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to SourceHut if: ${{ env.SOURCEHUT_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.SOURCEHUT_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: git.sr.ht run: | - ssh-keyscan -t ed25519 git.sr.ht >> ~/.ssh/known_hosts - git remote add sourcehut git@git.sr.ht:~hyperpolymath/${{ github.event.repository.name }} || true + git remote add sourcehut "git@git.sr.ht:~${MIRROR_ORG}/${REPO_NAME}" || true git push --force sourcehut main - name: Skipped (SOURCEHUT_SSH_KEY not configured) if: ${{ env.SOURCEHUT_KEY == '' }} @@ -181,11 +295,35 @@ jobs: if: ${{ env.DISROOT_KEY != '' }} with: ssh-private-key: ${{ secrets.DISROOT_SSH_KEY }} + - name: Verify disroot SSH host + if: ${{ env.DISROOT_KEY != '' }} + env: + MIRROR_HOST: git.disroot.org + APPROVED_FINGERPRINT: ${{ vars.DISROOT_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Caller must approve this independently of the ssh-keyscan result. + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid DISROOT_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-disroot-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Disroot if: ${{ env.DISROOT_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.DISROOT_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: git.disroot.org run: | - ssh-keyscan -t ed25519 git.disroot.org >> ~/.ssh/known_hosts - git remote add disroot git@git.disroot.org:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add disroot "git@git.disroot.org:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force disroot main - name: Skipped (DISROOT_SSH_KEY not configured) if: ${{ env.DISROOT_KEY == '' }} @@ -208,11 +346,35 @@ jobs: if: ${{ env.GITEA_KEY != '' }} with: ssh-private-key: ${{ secrets.GITEA_SSH_KEY }} + - name: Verify gitea SSH host + if: ${{ env.GITEA_KEY != '' }} + env: + MIRROR_HOST: ${{ vars.GITEA_HOST }} + APPROVED_FINGERPRINT: ${{ vars.GITEA_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: caller-controlled instance + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid GITEA_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-gitea-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Gitea if: ${{ env.GITEA_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.GITEA_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: ${{ vars.GITEA_HOST }} run: | - ssh-keyscan -t ed25519 ${{ vars.GITEA_HOST }} >> ~/.ssh/known_hosts - git remote add gitea git@${{ vars.GITEA_HOST }}:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add gitea "git@${MIRROR_HOST}:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force gitea main - name: Skipped (GITEA_SSH_KEY not configured) if: ${{ env.GITEA_KEY == '' }} @@ -252,12 +414,12 @@ jobs: if: ${{ env.RADICLE_KEY != '' }} run: | cargo install radicle-cli --locked - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Mirror to Radicle if: ${{ env.RADICLE_KEY != '' }} run: | mkdir -p ~/.radicle/keys - echo "${{ secrets.RADICLE_KEY }}" > ~/.radicle/keys/radicle + printf "%s\n" "$RADICLE_KEY" > ~/.radicle/keys/radicle chmod 600 ~/.radicle/keys/radicle rad sync --announce || echo "Radicle sync attempted" - name: Skipped (RADICLE_KEY not configured) diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 8750b94a..f77501af 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -45,16 +45,36 @@ jobs: # estate froze: measured in this repo on 2026-08-25, all 12 Scorecard # alerts were stuck at 2026-06-03 while Hypatia's updated 2026-08-24. # The badge kept working, which is why it went unnoticed. + - name: Checkout native-lock reconciliation helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + ref: 591cea1b074b56367f25ec0084a0c4ab66b42a7e + path: .standards-scorecard + persist-credentials: false + sparse-checkout: scripts/reconcile-scorecard-actions-lock.rb + sparse-checkout-cone-mode: false + - name: Reconcile native action pins + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh extension install github/gh-actions-lock --pin v0.1.6 + ruby .standards-scorecard/scripts/reconcile-scorecard-actions-lock.rb \ + results.sarif results.reconciled.sarif actions-lock-audit.json - name: Upload SARIF to code scanning uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v3 with: - sarif_file: results.sarif + sarif_file: results.reconciled.sarif - name: Upload results artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: scorecard-results - path: results.sarif + path: | + results.sarif + results.reconciled.sarif + actions-lock-audit.json retention-days: 90 pull-request: @@ -126,14 +146,34 @@ jobs: jq -r '.runs[].automationDetails.id' results.sarif n=$(jq '[.runs[].automationDetails.id | split("/")[0:2] | join("/")] | unique | length' results.sarif) test "$n" -eq 3 || { echo "expected 3 Scorecard categories, got $n" >&2; exit 1; } + - name: Checkout native-lock reconciliation helper + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + ref: 591cea1b074b56367f25ec0084a0c4ab66b42a7e + path: .standards-scorecard + persist-credentials: false + sparse-checkout: scripts/reconcile-scorecard-actions-lock.rb + sparse-checkout-cone-mode: false + - name: Reconcile native action pins + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh extension install github/gh-actions-lock --pin v0.1.6 + ruby .standards-scorecard/scripts/reconcile-scorecard-actions-lock.rb \ + results.sarif results.reconciled.sarif actions-lock-audit.json - name: Upload SARIF to code scanning uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: - sarif_file: results.sarif + sarif_file: results.reconciled.sarif - name: Retain scan evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: scorecard-pr-results - path: results.sarif + path: | + results.sarif + results.reconciled.sarif + actions-lock-audit.json if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 8f717239..77a863c7 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -41,7 +41,7 @@ jobs: - name: Install test dependencies run: | sudo apt-get update -qq - sudo apt-get install -y --no-install-recommends ripgrep libxml2-utils + sudo apt-get install -y --no-install-recommends ripgrep libxml2-utils ruby ruby-minitest python3 -m pip install --user --quiet pyyaml - name: Run tests/*.sh and scripts/tests/*.sh diff --git a/docs/MIRROR-HOST-VERIFICATION.adoc b/docs/MIRROR-HOST-VERIFICATION.adoc new file mode 100644 index 00000000..67a054c9 --- /dev/null +++ b/docs/MIRROR-HOST-VERIFICATION.adoc @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 += Mirror SSH host verification + +The reusable mirror workflow verifies the remote Ed25519 host fingerprint +before exporting a strict SSH configuration or pushing any refs. An empty, +malformed, changed, or unapproved host key fails that mirror job. The fetched +key is never trusted merely because `ssh-keyscan` returned it. The GitLab +mirror remains advisory at job level: a verification failure skips the push +and records the job failure without failing the overall workflow. + +Callers enable each mirror with its optional secret: `GITLAB_SSH_KEY`, +`BITBUCKET_SSH_KEY`, `CODEBERG_SSH_KEY`, `SOURCEHUT_SSH_KEY`, `DISROOT_SSH_KEY`, +`GITEA_SSH_KEY`, or `RADICLE_KEY`. Prefer explicit secret mappings in callers. +An absent secret leaves that mirror disabled. + +GitLab, Bitbucket, and Codeberg have published fingerprints pinned +in the workflow. Their official reference pages are: + +* https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints[GitLab] +* https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/[Bitbucket] +* https://docs.codeberg.org/security/ssh-fingerprint/[Codeberg] + +SourceHut requires `SOURCEHUT_SSH_FINGERPRINT`, and Disroot requires +`DISROOT_SSH_FINGERPRINT`; no independently verified default is supplied for +either forge. A custom Gitea mirror requires +both `GITEA_HOST` and `GITEA_SSH_FINGERPRINT`. Obtain these values through an +authenticated administrative channel or the forge's published host-key +documentation. Do not populate them from the network scan being verified. + +For an approved host-key rotation, set the corresponding repository or +organisation variable `_SSH_FINGERPRINT` to the approved `SHA256:...` +value. The six SSH forges support this override. The expected value contains +one Ed25519 fingerprint, with no surrounding whitespace or key material. + +Mirror organisation selection is `_ORG`, then `MIRROR_ORG`, then the +GitHub repository owner. This preserves per-forge destinations while allowing +a shared destination for transferred repositories. + +`scripts/tests/science-ci-security-test.sh` launches the assertions in +`scripts/tests/science-ci-security-test.rb`, which exercise every actual SSH +verification step with the real `ssh-keygen` fingerprint implementation. It +tests approved, tampered, absent, malformed, and unapproved keys, including +that failure cannot export a trusted SSH configuration. The same suite checks +that the Hypatia source checkout matches its resolved cache identity. diff --git a/scripts/apply-baseline.sh b/scripts/apply-baseline.sh index 72c1568d..68ffbbbb 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -233,9 +233,9 @@ rank() { case "$1" in critical) echo 5 ;; high) echo 4 ;; - medium) echo 3 ;; + medium|warn) echo 3 ;; low) echo 2 ;; - info) echo 1 ;; + info|informational) echo 1 ;; advisory) echo 0 ;; *) echo 0 ;; esac diff --git a/scripts/check-descriptile-policy.sh b/scripts/check-descriptile-policy.sh new file mode 100644 index 00000000..d055bf09 --- /dev/null +++ b/scripts/check-descriptile-policy.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# A CI policy must not require files that the structural-drift gate forbids. +set -euo pipefail +status=0 +while IFS= read -r -d '' file; do + [[ -f "$file" ]] || continue + # Restrict this check to executable file-existence tests. Historical prose + # and commented examples are not policy enforcement. + if awk ' + /^[[:space:]]*#/ { next } + { + source=$0 + # Mask quoted prose while retaining literal path arguments. Keep command + # substitutions visible: an echo can still execute a file test in $(). + code=""; quote=""; quoted="" + for (i=1; i<=length(source); i++) { + ch=substr(source,i,1) + if (quote != "") { + if (ch == quote) { + if (quoted ~ /^\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml$/ || + (quote == "\"" && quoted ~ /\$\(|`/)) code=code quoted + else code=code " " + quote=""; quoted="" + } else if (ch == "\\" && quote == "\"") { + quoted=quoted ch substr(source,++i,1) + } else quoted=quoted ch + } else if (ch == "\"" || ch == sprintf("%c",39)) quote=ch + else code=code ch + } + # A multiline shell quote cannot be classified from this physical line. + if (quote != "") code=code quoted + if (code ~ /(-f[[:space:]]|-e[[:space:]]|check_file[[:space:]])/ && + code ~ /\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml/) { + found=1; print FNR ":" source + } + } + END { exit !found } + ' "$file"; then + printf '::error file=%s::Policy requires a retired descriptile path; use .machine_readable/descriptiles/ and reconcile existing files\n' "$file" + status=1 + fi +done < <(git ls-files -z -- '.github/workflows/*.yml' '.github/workflows/*.yaml' 'scripts/*.sh' '.githooks/*.sh' Justfile justfile) +exit "$status" diff --git a/scripts/plan-ruleset-constraint-repair.rb b/scripts/plan-ruleset-constraint-repair.rb new file mode 100644 index 00000000..82d9ff56 --- /dev/null +++ b/scripts/plan-ruleset-constraint-repair.rb @@ -0,0 +1,45 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Produce a reviewable PUT body for the four retired constraints documented +# in config/README.adoc. This script never calls GitHub or changes a ruleset. +require 'json' + +module RulesetConstraintRepair + RETIRED = %w[update required_deployments code_quality code_coverage].freeze + WRITABLE = %w[name target enforcement conditions bypass_actors rules].freeze + + # Supply an app ID only after GitHub rejects it as no longer installed. Removing + # a stale bypass tightens access; never infer app availability from its name. + def self.plan(source, uninstalled_app_id: nil) + unless source.is_a?(Hash) && source['target'] == 'branch' && source['enforcement'] == 'active' && + source.dig('conditions', 'ref_name', 'include') == ['~DEFAULT_BRANCH'] && + source.dig('conditions', 'ref_name', 'exclude') == [] && source['rules'].is_a?(Array) + raise ArgumentError, 'Expected an active default-branch ruleset with no exclusions' + end + rules = source.fetch('rules') + unless rules.all? { |rule| rule.is_a?(Hash) && rule['type'].is_a?(String) } + raise ArgumentError, 'Malformed rule data; refusing a partial plan' + end + raise ArgumentError, 'Missing pull-request protection' unless rules.any? { |r| r['type'] == 'pull_request' } + + result = source.select { |key, _value| WRITABLE.include?(key) } + result['rules'] = rules.reject { |rule| RETIRED.include?(rule['type']) } + if uninstalled_app_id + actors = source.fetch('bypass_actors') + unless uninstalled_app_id.is_a?(Integer) && uninstalled_app_id.positive? && + actors.is_a?(Array) && actors.any? { |actor| actor['actor_type'] == 'Integration' && actor['actor_id'] == uninstalled_app_id } + raise ArgumentError, 'Expected the exact ID of an existing integration bypass rejected by GitHub' + end + result['bypass_actors'] = actors.reject { |actor| actor['actor_type'] == 'Integration' && actor['actor_id'] == uninstalled_app_id } + end + result + end +end + +if $PROGRAM_NAME == __FILE__ + unless ARGV.length == 1 || (ARGV.length == 3 && ARGV[1] == '--remove-uninstalled-app') + abort 'Usage: plan-ruleset-constraint-repair.rb [--remove-uninstalled-app ]' + end + app_id = ARGV[2] && Integer(ARGV[2], 10) + puts JSON.pretty_generate(RulesetConstraintRepair.plan(JSON.parse(File.read(ARGV[0])), uninstalled_app_id: app_id)) +end diff --git a/scripts/reconcile-scorecard-actions-lock.rb b/scripts/reconcile-scorecard-actions-lock.rb new file mode 100644 index 00000000..1c4ad668 --- /dev/null +++ b/scripts/reconcile-scorecard-actions-lock.rb @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Reconcile only Scorecard's inline action-pin findings with native lock coverage. +# No lock semantics are reimplemented: gh actions-lock verifies each workflow. +# Usage: ruby reconcile-scorecard-actions-lock.rb INPUT OUTPUT AUDIT_JSON [ROOT] +require 'json' +require 'open3' +require 'yaml' + +module ScorecardActionsLock + PIN_MESSAGE = /\Ascore is \d+: (?:GitHub-owned |third-party )?GitHubAction not pinned by hash\n/ + + def self.action_lines(path) + lines = [] + visit = lambda do |node| + if node.is_a?(Psych::Nodes::Mapping) + node.children.each_slice(2) do |key, value| + if key.is_a?(Psych::Nodes::Scalar) && key.value == 'uses' && value.is_a?(Psych::Nodes::Scalar) + # Only remote GitHub actions, never containers or local paths. + lines << key.start_line + 1 if value.value.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[^\s]+\z}) + end + end + end + Array(node.children).each { |child| visit.call(child) } if node.respond_to?(:children) + end + visit.call(Psych.parse_stream(File.read(path))) + lines + end + + def self.reconcile(document, root) + raise 'Expected a SARIF 2.1.0 document with runs' unless document.is_a?(Hash) && + document['version'] == '2.1.0' && document['runs'].is_a?(Array) && !document['runs'].empty? + + root = File.realpath(root) + verified = {} + audit = [] + document['runs'].each do |run| + raise 'Expected a results array' unless run['results'].is_a?(Array) + next unless run.dig('tool', 'driver', 'name') == 'Scorecard' + + run['results'] = run['results'].reject do |result| + next false unless result['ruleId'] == 'PinnedDependenciesID' && + result.dig('message', 'text').to_s.match?(PIN_MESSAGE) + locations = result['locations'] + next false unless locations.is_a?(Array) && locations.length == 1 + location = locations[0]['physicalLocation'] || {} + relative = location.dig('artifactLocation', 'uri') + line = location.dig('region', 'startLine') + next false unless relative.is_a?(String) && relative.match?(%r{\A\.github/workflows/[^/]+\.ya?ml\z}) && + line.is_a?(Integer) && line.positive? + + path = File.join(root, relative) + next false unless File.file?(path) && !File.symlink?(path) && + File.realpath(path).start_with?(root + '/') && File.file?(File.join(root, '.github/workflows/actions.lock')) + next false unless action_lines(path).include?(line) + + unless verified.key?(relative) + stdout, stderr, status = Open3.capture3('gh', 'actions-lock', relative, + '--verify', '--no-interactive', '--json=valid,findings', chdir: root) + warn stderr unless stderr.empty? + verification = JSON.parse(stdout) + raise "Native action-lock verification failed for #{relative}" unless status.success? && + verification.is_a?(Hash) && verification['valid'] == true && verification['findings'].is_a?(Array) + verified[relative] = verification + end + audit << { 'file' => relative, 'line' => line, 'rule' => result['ruleId'], + 'reason' => 'False positive: native direct and transitive pins verified by gh actions-lock --verify', + 'verification' => verified.fetch(relative) } + true + end + end + [document, audit] + end +end + +if $PROGRAM_NAME == __FILE__ + begin + input, output, audit_path, root = ARGV + raise 'Usage: INPUT OUTPUT AUDIT_JSON [ROOT]' unless input && output && audit_path && ARGV.length <= 4 + raise 'Keep the original SARIF as a separate artifact' if File.expand_path(input) == File.expand_path(output) + document, audit = ScorecardActionsLock.reconcile(JSON.parse(File.read(input)), root || Dir.pwd) + File.write(output, JSON.pretty_generate(document) + "\n") + File.write(audit_path, JSON.pretty_generate(audit) + "\n") + puts "Reconciled #{audit.length} verified native action-pin false positives; all other findings retained." + rescue StandardError => error + warn "Scorecard reconciliation failed: #{error.message}" + exit 2 + end +end diff --git a/scripts/tests/governance-reusable-contract-test.sh b/scripts/tests/governance-reusable-contract-test.sh index 3d0b7a89..ff0a91da 100644 --- a/scripts/tests/governance-reusable-contract-test.sh +++ b/scripts/tests/governance-reusable-contract-test.sh @@ -18,8 +18,8 @@ fail() { helper_checkout="$(grep -F -A 18 -- '- name: Checkout the pinned Standards policy helpers' "$GOVERNANCE")" # GitHub expression is an asserted literal. # shellcheck disable=SC2016 -printf '%s\n' "$helper_checkout" | grep -Fq 'ref: ${{ job.workflow_sha }}' || - fail "governance helpers are not fetched from job.workflow_sha" +printf '%s\n' "$helper_checkout" | grep -Eq 'ref: [0-9a-f]{40}$' || + fail "governance helpers are not fetched from an immutable commit" if printf '%s\n' "$helper_checkout" | grep -Eq '^[[:space:]]*ref:[[:space:]]*main[[:space:]]*$'; then fail "governance helper execution still follows moving main" fi diff --git a/scripts/tests/policy-gates-test.sh b/scripts/tests/policy-gates-test.sh index cb550b14..e7260b51 100644 --- a/scripts/tests/policy-gates-test.sh +++ b/scripts/tests/policy-gates-test.sh @@ -89,10 +89,26 @@ ln -s "$(command -v git)" "$without_parser/bin/git" valid="$fixture/valid" init_fixture "$valid" mkdir -p "$valid/.github/workflows" -printf '%s\n' 'name: test' 'on: push' 'jobs: {}' > "$valid/.github/workflows/test.yml" +printf '%s\n' 'name: test' 'on: push' 'jobs:' ' test:' ' runs-on: ubuntu-latest' ' steps:' ' - run: echo tested' > "$valid/.github/workflows/test.yml" git -C "$valid" add .github/workflows/test.yml (cd "$valid" && expect_pass "$workflow_gate") +empty_jobs="$fixture/empty-jobs" +init_fixture "$empty_jobs" +mkdir -p "$empty_jobs/.github/workflows" +printf '%s\n' 'name: test' 'on: push' 'jobs: {}' > "$empty_jobs/.github/workflows/test.yml" +git -C "$empty_jobs" add .github/workflows/test.yml +(cd "$empty_jobs" && expect_fail "$workflow_gate") +for malformed_jobs in 'jobs: scalar' 'jobs: [one, two]' 'jobs: null'; do + printf '%s\n' 'name: test' 'on: push' "$malformed_jobs" > "$empty_jobs/.github/workflows/test.yml" + (cd "$empty_jobs" && expect_fail "$workflow_gate") +done + +for invalid_jobs in 'placeholder' '[placeholder]'; do + printf '%s\n' 'name: test' 'on: push' "jobs: $invalid_jobs" > "$empty_jobs/.github/workflows/test.yml" + (cd "$empty_jobs" && expect_fail "$workflow_gate") +done + invalid="$fixture/invalid" init_fixture "$invalid" mkdir -p "$invalid/.github/workflows" diff --git a/scripts/tests/reconcile-scorecard-actions-lock-test.rb b/scripts/tests/reconcile-scorecard-actions-lock-test.rb new file mode 100644 index 00000000..fedb9a09 --- /dev/null +++ b/scripts/tests/reconcile-scorecard-actions-lock-test.rb @@ -0,0 +1,85 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Prove native-lock reconciliation is narrow and verifier failures stay failures. +require 'tmpdir' +require 'fileutils' +require 'minitest/autorun' +require_relative '../reconcile-scorecard-actions-lock' + +class ScorecardActionsLockTest < Minitest::Test + def setup + @root = Dir.mktmpdir('scorecard-lock-') + @old_path = ENV.fetch('PATH') + FileUtils.mkdir_p(File.join(@root, '.github/workflows')) + File.write(File.join(@root, '.github/workflows/ci.yml'), "jobs:\n check:\n steps:\n - uses: actions/checkout@v7\n - run: \"echo 'uses: untrusted/action@main'\"\n") + File.write(File.join(@root, '.github/workflows/actions.lock'), 'fixture; semantics belong to gh actions-lock') + File.write(File.join(@root, 'gh'), <<~SH) + #!/bin/sh + printf '%s\\n' "$*" >> invocation + case "$*" in + 'actions-lock .github/workflows/ci.yml --verify --no-interactive --json=valid,findings') ;; + *) exit 97 ;; + esac + printf '%s' "$TEST_LOCK_JSON" + exit "${TEST_LOCK_EXIT:-0}" + SH + File.chmod(0o755, File.join(@root, 'gh')) + ENV['PATH'] = "#{@root}:#{@old_path}" + ENV['TEST_LOCK_JSON'] = '{"valid":true,"findings":[]}' + end + + def teardown + ENV['PATH'] = @old_path + ENV.delete('TEST_LOCK_JSON') + ENV.delete('TEST_LOCK_EXIT') + FileUtils.remove_entry(@root) + end + + def finding(rule: 'PinnedDependenciesID', message: "score is 3: GitHub-owned GitHubAction not pinned by hash\nRemediation", path: '.github/workflows/ci.yml', line: 4) + { 'ruleId' => rule, 'message' => { 'text' => message }, 'locations' => [ + { 'physicalLocation' => { 'artifactLocation' => { 'uri' => path }, 'region' => { 'startLine' => line } } } + ] } + end + + def document(*results, tool: 'Scorecard') + { 'version' => '2.1.0', 'runs' => [{ 'tool' => { 'driver' => { 'name' => tool } }, 'results' => results }] } + end + + def test_only_verified_action_pin_findings_are_removed + other = [finding(rule: 'TokenPermissionsID'), finding(message: "score is 3: container not pinned by hash\n"), + finding(line: 5), finding(path: '../outside.yml'), finding(path: '.github/workflows/missing.yml')] + result, audit = ScorecardActionsLock.reconcile(document(finding, finding, *other), @root) + assert_equal other, result['runs'][0]['results'] + assert_equal 2, audit.length + assert_equal 1, File.readlines(File.join(@root, 'invocation')).length + end + + def test_missing_lock_and_foreign_tools_are_not_filtered + File.unlink(File.join(@root, '.github/workflows/actions.lock')) + original = document(finding) + result, audit = ScorecardActionsLock.reconcile(original, @root) + assert_equal original, result + assert_empty audit + _, audit = ScorecardActionsLock.reconcile(document(finding, tool: 'CodeQL'), @root) + assert_empty audit + refute File.exist?(File.join(@root, 'invocation')) + end + + def test_invalid_verification_never_becomes_a_clean_result + ['{}', '{"valid":false,"findings":[]}', '{"valid":true}', '[]', 'not JSON'].each do |invalid| + ENV['TEST_LOCK_JSON'] = invalid + assert_raises(StandardError) { ScorecardActionsLock.reconcile(document(finding), @root) } + end + ENV['TEST_LOCK_JSON'] = '{"valid":true,"findings":[]}' + ENV['TEST_LOCK_EXIT'] = '1' + assert_raises(StandardError) { ScorecardActionsLock.reconcile(document(finding), @root) } + end + + def test_workflow_symlinks_are_not_filtered + File.rename(File.join(@root, '.github/workflows/ci.yml'), File.join(@root, 'actual.yml')) + File.symlink('../../actual.yml', File.join(@root, '.github/workflows/ci.yml')) + _, audit = ScorecardActionsLock.reconcile(document(finding), @root) + assert_empty audit + refute File.exist?(File.join(@root, 'invocation')) + end +end diff --git a/scripts/tests/reconcile-scorecard-actions-lock-test.sh b/scripts/tests/reconcile-scorecard-actions-lock-test.sh new file mode 100644 index 00000000..7bab8b69 --- /dev/null +++ b/scripts/tests/reconcile-scorecard-actions-lock-test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ruby "$(dirname "$0")/reconcile-scorecard-actions-lock-test.rb" diff --git a/scripts/tests/ruleset-constraint-repair-test.sh b/scripts/tests/ruleset-constraint-repair-test.sh new file mode 100644 index 00000000..d75f6126 --- /dev/null +++ b/scripts/tests/ruleset-constraint-repair-test.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ruby - "$(dirname "$0")/../plan-ruleset-constraint-repair.rb" <<'RUBY' +require File.expand_path(ARGV.fetch(0)) +retained = [ + { 'type' => 'required_signatures' }, + { 'type' => 'pull_request', 'parameters' => { 'required_review_thread_resolution' => true } }, + { 'type' => 'required_status_checks', 'parameters' => { 'required_status_checks' => [ + { 'context' => 'scan / gitleaks', 'integration_id' => 15368 }] } }, + { 'type' => 'code_scanning', 'parameters' => { 'code_scanning_tools' => ['CodeQL'] } } +] +fixture = { + 'id' => 123, 'name' => 'Existing name', 'target' => 'branch', 'enforcement' => 'active', + 'conditions' => { 'ref_name' => { 'include' => ['~DEFAULT_BRANCH'], 'exclude' => [] } }, + 'bypass_actors' => [], 'rules' => retained + RulesetConstraintRepair::RETIRED.map { |type| { 'type' => type } } +} +result = RulesetConstraintRepair.plan(fixture) +raise 'Changed active protections' unless result.fetch('rules') == retained +raise 'Changed bypass actors' unless result.fetch('bypass_actors') == [] +raise 'Mutated input' unless fixture.fetch('rules').length == 8 +raise 'Not idempotent' unless RulesetConstraintRepair.plan(result) == result +actors = [ + { 'actor_type' => 'Integration', 'actor_id' => 288115, 'bypass_mode' => 'always' }, + { 'actor_type' => 'RepositoryRole', 'actor_id' => 288115, 'bypass_mode' => 'pull_request' }, + { 'actor_type' => 'Integration', 'actor_id' => 15368, 'bypass_mode' => 'always' } +] +with_actors = fixture.merge('bypass_actors' => actors) +raise 'Changed actors without explicit selection' unless RulesetConstraintRepair.plan(with_actors)['bypass_actors'] == actors +without_stale = RulesetConstraintRepair.plan(with_actors, uninstalled_app_id: 288115) +raise 'Removed an unrelated bypass' unless without_stale['bypass_actors'] == actors.drop(1) +begin + RulesetConstraintRepair.plan(with_actors, uninstalled_app_id: 999) + raise 'Accepted an unknown app' +rescue ArgumentError + # An explicit, present integration ID is required. +end +['tag', nil].each do |bad_target| + begin + RulesetConstraintRepair.plan(fixture.merge('target' => bad_target)) + raise 'Accepted unsupported scope' + rescue ArgumentError + # Expected: malformed or unsupported scopes are never rewritten. + end +end +puts 'PASS: only four retired constraints removed; CI, signatures, reviews, scans, scope, and bypass preserved' +RUBY diff --git a/scripts/tests/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb new file mode 100644 index 00000000..63c8ad17 --- /dev/null +++ b/scripts/tests/science-ci-security-test.rb @@ -0,0 +1,143 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Exercise the workflows' actual shell steps with controlled remote data. +require 'yaml' +require 'tmpdir' +require 'fileutils' +require 'open3' + +ROOT = File.expand_path('../..', __dir__) +PUBLIC_KEY = "gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf\n" +FINGERPRINT = 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' + +def assert(condition, message) + raise message unless condition +end + +def run!(*args, **options) + out, err, status = Open3.capture3(*args, **options) + assert(status.success?, "Command failed: #{args.inspect}\n#{out}#{err}") + out.strip +end + +def workflow(name) + YAML.safe_load(File.read(File.join(ROOT, '.github/workflows', name)), aliases: true) +end + +workflow('mirror-reusable.yml')['jobs'].each do |name, job| + next if name == 'mirror-radicle' + + verify = job['steps'].find { |step| step.fetch('name', '').start_with?('Verify ') } + push = job['steps'].find { |step| step.fetch('run', '').include?('git push') } + assert(verify && push, "#{name}: missing verification or push") + assert(job['steps'].index(verify) < job['steps'].index(push), "#{name}: verifies too late") + assert(verify['if'] == push['if'], "#{name}: verification condition differs from push") + assert(!verify['continue-on-error'], "#{name}: verification failure is ignored") + assert(job['continue-on-error'] == true, 'GitLab mirror must remain advisory') if name == 'mirror-gitlab' + if name == 'mirror-disroot' + assert(verify['env']['APPROVED_FINGERPRINT'] == '${{ vars.DISROOT_SSH_FINGERPRINT }}', + 'Disroot must require an independently approved fingerprint') + end + Dir.mktmpdir('mirror-verification-') do |tmp| + stub = File.join(tmp, 'ssh-keyscan') + File.write(stub, "#!/bin/sh\nprintf '%s' \"$TEST_HOST_KEY\"\n") + File.chmod(0o755, stub) + environment_file = File.join(tmp, 'env') + env = { 'PATH' => "#{tmp}:#{ENV.fetch('PATH')}", 'RUNNER_TEMP' => tmp, + 'GITHUB_ENV' => environment_file, 'MIRROR_HOST' => 'gitlab.com' } + [[PUBLIC_KEY, FINGERPRINT, true], + [PUBLIC_KEY.sub('OzRdf', 'OzRdg'), FINGERPRINT, false], + ['', FINGERPRINT, false], ['invalid key', FINGERPRINT, false], + [PUBLIC_KEY, '', false]].each do |key, fingerprint, succeeds| + FileUtils.rm_f(environment_file) + out, err, status = Open3.capture3(env.merge('TEST_HOST_KEY' => key, + 'APPROVED_FINGERPRINT' => fingerprint), 'bash', '-c', verify['run']) + assert(status.success? == succeeds, "#{name}: unexpected verification result\n#{out}#{err}") + assert(File.exist?(environment_file) == succeeds, "#{name}: unsafe environment was exported") + if succeeds + assert(File.read(environment_file).include?('StrictHostKeyChecking=yes'), "#{name}: strict checking missing") + end + end + end +end +puts 'PASS: all six mirrors accept approved keys and reject tampered, empty, malformed, or unapproved keys' + +step = workflow('hypatia-scan-reusable.yml')['jobs']['scan']['steps'].find do |candidate| + candidate['name'] == 'Check out resolved Hypatia commit' +end +Dir.mktmpdir('scanner-source-') do |tmp| + upstream = File.join(tmp, 'upstream') + run!('git', 'init', '-q', upstream) + commit = lambda do |content| + File.write(File.join(upstream, 'source'), content) + run!('git', '-C', upstream, 'add', 'source') + run!('git', '-C', upstream, '-c', 'user.name=CI Test', '-c', 'user.email=ci@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '-qm', content) + run!('git', '-C', upstream, 'rev-parse', 'HEAD') + end + resolved = commit.call('resolved') + newer = commit.call('advanced') + source = File.join(tmp, 'scanner') + script = step.fetch('run').gsub('$HOME/hypatia', source).gsub('https://github.com/hyperpolymath/hypatia.git', upstream) + 2.times { run!({ 'HYPATIA_SHA' => resolved }, 'bash', '-c', script) } + assert(File.read(File.join(source, 'source')) == 'resolved', 'Scanner followed advancing HEAD') + out, _err, status = Open3.capture3({ 'HYPATIA_SHA' => newer }, 'bash', '-c', script) + assert(!status.success? && out.include?('cached source does not match'), 'Mismatched cache was accepted') +end +puts 'PASS: scanner checks out the resolved commit on cache miss/hit and rejects a mismatched cache' + +step = workflow('hypatia-scan-reusable.yml')['jobs']['scan']['steps'].find do |candidate| + candidate['name'] == 'Validate findings and count severities' +end +Dir.mktmpdir('scanner-contract-') do |tmp| + output = File.join(tmp, 'output') + env = { 'GITHUB_OUTPUT' => output, 'GITHUB_STEP_SUMMARY' => File.join(tmp, 'summary') } + findings = File.join(tmp, 'hypatia-findings.json') + File.write(findings, '[{"severity":"warn"},{"severity":"medium"},{"severity":"critical"}]') + run!(env, 'bash', '-c', step.fetch('run'), chdir: tmp) + assert(File.read(output).lines.map(&:chomp).include?('medium=2'), 'warn was not counted at medium rank') + assert(File.read(output).include?('critical=1'), 'critical finding was lost') + ['', '[', '[] []', '{}', '[{"severity":"unknown"}]', '[{}]'].each do |invalid| + FileUtils.rm_f(output) + File.write(findings, invalid) + _out, _err, status = Open3.capture3(env, 'bash', '-c', step.fetch('run'), chdir: tmp) + assert(status.exitstatus == 2, "Invalid scanner output accepted: #{invalid.inspect}") + assert(!File.exist?(output), 'Invalid output produced gate counts') + end + File.write(findings, '[{"severity":"warn","rule_module":"research_extensions","type":"RE001","file":"ci.yml"}]') + _out, _err, status = Open3.capture3({ 'BLOCKING_THRESHOLD' => 'medium' }, 'bash', + File.join(ROOT, 'scripts/apply-baseline.sh'), findings, File.join(tmp, 'absent-baseline.json'), 'blocking') + assert(status.exitstatus == 1, 'warn escaped the medium baseline threshold') +end +puts 'PASS: warn retains medium severity; malformed, unknown, and multiple scanner documents fail closed' + +Dir.mktmpdir('policy-startup-') do |tmp| + path = File.join(tmp, '.github/workflows/ci.yml') + FileUtils.mkdir_p(File.dirname(path)) + run!('git', 'init', '-q', tmp) + File.write(path, "name: CI\non: push\njobs:\n # test:\n # runs-on: ubuntu-latest\n") + run!('git', '-C', tmp, 'add', '.') + parser = File.join(ROOT, 'tools/policy/check-workflows-parse.sh') + checker = File.join(ROOT, 'scripts/check-descriptile-policy.sh') + _out, _err, status = Open3.capture3('bash', parser, chdir: tmp) + assert(!status.success?, 'Comment-only jobs were accepted') + File.write(path, "name: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: test -f .machine_readable/STATE.a2ml\n") + _out, _err, status = Open3.capture3('bash', checker, chdir: tmp) + assert(!status.success?, 'Retired policy path was accepted') + File.write(path, File.read(path).sub('.machine_readable/STATE', '.machine_readable/descriptiles/STATE')) + [parser, checker].each { |check| run!('bash', check, chdir: tmp) } + [%(echo "test -f .machine_readable/STATE.a2ml"), + %(printf '%s\\n' 'check_file .machine_readable/META.a2ml')].each do |example| + File.write(path, "name: CI\non: push\njobs:\n test:\n steps:\n - run: |\n #{example}\n") + run!('bash', checker, chdir: tmp) + end + [%(test -f ".machine_readable/STATE.a2ml"), + %(test -e '.machine_readable/6a2/META.a2ml'), + %(check_file '.machine_readable/AGENTIC.a2ml'), + %(echo "$(test -f .machine_readable/STATE.a2ml)")].each do |example| + File.write(path, "name: CI\non: push\njobs:\n test:\n steps:\n - run: |\n #{example}\n") + _out, _err, status = Open3.capture3('bash', checker, chdir: tmp) + assert(!status.success?, "Executable retired-path check was accepted: #{example}") + end +end +puts 'PASS: empty workflows and retired policy fail; executable jobs with canonical policy pass' diff --git a/scripts/tests/science-ci-security-test.sh b/scripts/tests/science-ci-security-test.sh new file mode 100755 index 00000000..8edb6e08 --- /dev/null +++ b/scripts/tests/science-ci-security-test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ruby "$(dirname "$0")/science-ci-security-test.rb" diff --git a/tests/test_governance_reusable_shape.sh b/tests/test_governance_reusable_shape.sh index d311d9c2..5f3b062a 100755 --- a/tests/test_governance_reusable_shape.sh +++ b/tests/test_governance_reusable_shape.sh @@ -13,7 +13,7 @@ # applier (step 3) derives contexts from emitted check-runs. # 2. Every job honours `inputs.runs-on` (no hardcoded runner). # 3. `actions-lock-verify` is its own job, runs the tested gate script, and -# checks standards out at job.workflow_sha (the reusable's own SHA), never +# checks standards out at an explicit reviewed helper SHA, never # at a floating `main`. # 4. The lock check no longer hides inside workflow-lint. # 5. `continue-on-error: true` appears only in the jobs listed as advisory or @@ -60,7 +60,7 @@ njobs=$(grep -cP '^ [a-z][a-z-]*:$' "$F"); nro=$(grep -c 'runs-on: ${{ inputs.r B=$(job_block actions-lock-verify) [ -n "$B" ] && ok "actions-lock-verify job exists" || bad "actions-lock-verify job missing" printf '%s' "$B" | grep -q 'check-actions-lock-gate.sh' && ok "actions-lock-verify runs the tested gate script" || bad "actions-lock-verify does not run check-actions-lock-gate.sh" -printf '%s' "$B" | grep -q 'ref: ${{ job.workflow_sha }}' && ok "actions-lock-verify pins standards at job.workflow_sha" || bad "actions-lock-verify standards checkout not pinned to job.workflow_sha" +printf '%s' "$B" | grep -Eq 'ref: [0-9a-f]{40}$' && ok "actions-lock-verify pins standards at an immutable commit" || bad "actions-lock-verify standards checkout not pinned to an immutable commit" printf '%s' "$B" | grep -q 'ref: main' && bad "actions-lock-verify floats a standards checkout at main" || ok "actions-lock-verify has no floating ref: main" printf '%s' "$B" | grep -q 'ACTIONS_LOCK_VERIFIER=' && ok "gate is pointed at the fetched verifier" || bad "ACTIONS_LOCK_VERIFIER not set for the gate" diff --git a/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh index 5d99eae5..85a1d99e 100755 --- a/tools/policy/check-workflows-parse.sh +++ b/tools/policy/check-workflows-parse.sh @@ -21,19 +21,16 @@ fi parser='' if command -v yq >/dev/null 2>&1; then parser=yq -elif command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - parser=python elif command -v ruby >/dev/null 2>&1; then parser=ruby else - echo "::error::no YAML parser available (yq, python3+pyyaml, or ruby)" + echo "::error::no YAML parser available (yq or ruby)" >&2 exit 1 fi parse_ok() { case "$parser" in yq) yq '.' "$1" >/dev/null 2>&1 ;; - python) python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1], encoding="utf-8"))' "$1" >/dev/null 2>&1 ;; ruby) ruby -ryaml -e 'YAML.safe_load(File.read(ARGV[0]), aliases: true)' "$1" >/dev/null 2>&1 ;; esac } @@ -46,15 +43,23 @@ has_reusable_timeout() { yq) yq -e '[.jobs[] | select(has("uses") and has("timeout-minutes"))] | length > 0' "$1" >/dev/null 2>&1 ;; - python) - python3 -c 'import sys,yaml; d=yaml.safe_load(open(sys.argv[1], encoding="utf-8")) or {}; sys.exit(not any(isinstance(j,dict) and "uses" in j and "timeout-minutes" in j for j in (d.get("jobs") or {}).values()))' "$1" - ;; ruby) ruby -ryaml -e 'd=YAML.safe_load(File.read(ARGV[0]), aliases: true) || {}; jobs=d["jobs"] || {}; exit(jobs.values.any? { |j| j.is_a?(Hash) && j.key?("uses") && j.key?("timeout-minutes") } ? 0 : 1)' "$1" ;; esac } +# A syntactically valid file with no jobs is still rejected at startup. +has_no_jobs() { + local file="$1" result + case "$parser" in + yq) yq -e '(.jobs | tag) != "!!map" or (.jobs | length == 0)' "$file" >/dev/null 2>&1; result=$? ;; + ruby) ruby -ryaml -e 'd=YAML.safe_load(File.read(ARGV[0]), aliases: true); exit(!d.is_a?(Hash) || !d["jobs"].is_a?(Hash) || d["jobs"].empty? ? 0 : 1)' "$file"; result=$? ;; + *) echo "::error::unsupported workflow parser: $parser" >&2; return 0 ;; + esac + return "$result" +} + has_forbidden_control() { od -An -v -tu1 "$1" | awk ' { for (i=1; i<=NF; i++) if (($i < 9) || ($i > 10 && $i < 13) || ($i > 13 && $i < 32)) found=1 } @@ -71,6 +76,9 @@ for file in "${workflows[@]}"; do if has_forbidden_control "$file"; then echo ' contains a YAML-forbidden control character' fi + elif has_no_jobs "$file"; then + status=1 + printf '::error file=%s::workflow has no executable jobs; commented templates do not create checks\n' "$file" elif has_reusable_timeout "$file"; then status=1 printf '%s\n' "::error file=$file::a reusable-workflow call job cannot declare timeout-minutes; GitHub rejects it before creating any jobs"