From 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:43:18 +0100 Subject: [PATCH 01/13] fix(ci): verify scanner sources and mirror hosts; test startup gates --- .github/workflows/hypatia-scan-reusable.yml | 24 ++- .github/workflows/mirror-reusable.yml | 168 ++++++++++++++++++-- scripts/check-descriptile-policy.sh | 19 +++ scripts/tests/science-ci-security-test.py | 108 +++++++++++++ scripts/tests/science-ci-security-test.sh | 4 + tools/policy/check-workflows-parse.sh | 12 ++ 6 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 scripts/check-descriptile-policy.sh create mode 100644 scripts/tests/science-ci-security-test.py create mode 100755 scripts/tests/science-ci-security-test.sh diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 913d0fc2..2e75633e 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 diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 1ccafb23..223299b9 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -67,6 +67,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 +96,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 +124,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 +175,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 +226,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 +277,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 || 'SHA256:B8RHZmR8N7oyt0DG04jn+SWDDRpFrQh4F2Vo3PfUNqY' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://disroot.org/services/git + 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 +328,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 == '' }} diff --git a/scripts/check-descriptile-policy.sh b/scripts/check-descriptile-policy.sh new file mode 100644 index 00000000..95a28bcf --- /dev/null +++ b/scripts/check-descriptile-policy.sh @@ -0,0 +1,19 @@ +#!/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 } + /(-f[[:space:]]|-e[[:space:]]|check_file[[:space:]])/ && /\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml/ { found=1; print FNR ":" $0 } + 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/tests/science-ci-security-test.py b/scripts/tests/science-ci-security-test.py new file mode 100644 index 00000000..160efa98 --- /dev/null +++ b/scripts/tests/science-ci-security-test.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MPL-2.0 +"""Exercise the workflow's actual shell steps with controlled remote data.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +PUBLIC_KEY = "gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf\n" +FINGERPRINT = "SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8" + + +class MirrorHostVerification(unittest.TestCase): + def test_every_ssh_mirror_verifies_before_push(self): + workflow = yaml.safe_load((ROOT / ".github/workflows/mirror-reusable.yml").read_text()) + for name, job in workflow["jobs"].items(): + if name == "mirror-radicle": + continue + verify = next(s for s in job["steps"] if s.get("name", "").startswith("Verify ")) + push = next(s for s in job["steps"] if "git push" in s.get("run", "")) + self.assertLess(job["steps"].index(verify), job["steps"].index(push)) + self.assertEqual(verify["if"], push["if"]) + self.assertFalse(verify.get("continue-on-error", False)) + with tempfile.TemporaryDirectory() as directory: + tmp = Path(directory) + stub = tmp / "ssh-keyscan" + stub.write_text('#!/bin/sh\nprintf "%s" "$TEST_HOST_KEY"\n') + stub.chmod(0o755) + env = dict(os.environ, PATH=f"{tmp}:{os.environ['PATH']}", RUNNER_TEMP=directory, + GITHUB_ENV=str(tmp / "env"), MIRROR_HOST="gitlab.com", + APPROVED_FINGERPRINT=FINGERPRINT) + for key, fingerprint, succeeds in [ + (PUBLIC_KEY, FINGERPRINT, True), + (PUBLIC_KEY.replace("OzRdf", "OzRdg"), FINGERPRINT, False), + ("", FINGERPRINT, False), + ("invalid key", FINGERPRINT, False), + (PUBLIC_KEY, "", False), + ]: + with self.subTest(job=name, key=key[-8:], fingerprint=fingerprint): + (tmp / "env").unlink(missing_ok=True) + result = subprocess.run(["bash", "-c", verify["run"]], + env=dict(env, TEST_HOST_KEY=key, APPROVED_FINGERPRINT=fingerprint), + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, succeeds, result.stderr) + self.assertEqual((tmp / "env").exists(), succeeds) + if succeeds: + self.assertIn("StrictHostKeyChecking=yes", (tmp / "env").read_text()) + + +class ScannerSourceVerification(unittest.TestCase): + def test_resolved_commit_survives_remote_advancing_and_cache_mismatch_fails(self): + workflow = yaml.safe_load((ROOT / ".github/workflows/hypatia-scan-reusable.yml").read_text()) + step = next(s for s in workflow["jobs"]["scan"]["steps"] + if s.get("name") == "Check out resolved Hypatia commit") + with tempfile.TemporaryDirectory() as directory: + tmp = Path(directory) + upstream = tmp / "upstream" + subprocess.run(["git", "init", "-q", str(upstream)], check=True) + def commit(text): + (upstream / "source").write_text(text) + subprocess.run(["git", "-C", str(upstream), "add", "source"], check=True) + subprocess.run(["git", "-C", str(upstream), "-c", "user.name=CI Test", + "-c", "user.email=ci@example.invalid", "-c", "commit.gpgsign=false", + "commit", "-qm", text], check=True) + return subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() + resolved = commit("resolved") + newer = commit("advanced") + source = tmp / "scanner" + script = step["run"].replace('$HOME/hypatia', str(source)).replace( + "https://github.com/hyperpolymath/hypatia.git", str(upstream)) + env = dict(os.environ, HYPATIA_SHA=resolved) + for expected in (0, 0): + result = subprocess.run(["bash", "-c", script], env=env, capture_output=True) + self.assertEqual(result.returncode, expected, result.stderr) + self.assertEqual((source / "source").read_text(), "resolved") + result = subprocess.run(["bash", "-c", script], env=dict(env, HYPATIA_SHA=newer), capture_output=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn(b"cached source does not match", result.stdout) + + +class PolicyStartupVerification(unittest.TestCase): + def test_commented_jobs_and_retired_policy_fail_with_positive_controls(self): + with tempfile.TemporaryDirectory() as directory: + tmp = Path(directory) + workflow = tmp / ".github/workflows/ci.yml" + workflow.parent.mkdir(parents=True) + subprocess.run(["git", "init", "-q", directory], check=True) + workflow.write_text("name: CI\non: push\njobs:\n # test:\n # runs-on: ubuntu-latest\n") + subprocess.run(["git", "-C", directory, "add", "."], check=True) + parser = ROOT / "tools/policy/check-workflows-parse.sh" + result = subprocess.run(["bash", str(parser)], cwd=directory, capture_output=True) + self.assertNotEqual(result.returncode, 0) + workflow.write_text("name: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: test -f .machine_readable/STATE.a2ml\n") + checker = ROOT / "scripts/check-descriptile-policy.sh" + result = subprocess.run(["bash", str(checker)], cwd=directory, capture_output=True) + self.assertNotEqual(result.returncode, 0) + workflow.write_text(workflow.read_text().replace(".machine_readable/STATE", ".machine_readable/descriptiles/STATE")) + for check in (parser, checker): + result = subprocess.run(["bash", str(check)], cwd=directory, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/science-ci-security-test.sh b/scripts/tests/science-ci-security-test.sh new file mode 100755 index 00000000..cf2fa490 --- /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 +python3 "$(dirname "$0")/science-ci-security-test.py" diff --git a/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh index 5d99eae5..d1952a4e 100755 --- a/tools/policy/check-workflows-parse.sh +++ b/tools/policy/check-workflows-parse.sh @@ -55,6 +55,15 @@ has_reusable_timeout() { esac } +# A syntactically valid file with no jobs is still rejected at startup. +has_no_jobs() { + case "$parser" in + yq) yq -e '.jobs == null or (.jobs | length == 0)' "$1" >/dev/null 2>&1 ;; + python) python3 -c 'import sys,yaml; d=yaml.safe_load(open(sys.argv[1], encoding="utf-8")); sys.exit(not (not isinstance(d,dict) or not isinstance(d.get("jobs"),dict) or not d["jobs"]))' "$1" ;; + 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)' "$1" ;; + esac +} + 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 +80,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" From d746d4625b2cb306f2c7427c5c97efc6d25fcb34 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:48:55 +0100 Subject: [PATCH 02/13] fix(governance): pin helper code to a real commit and check path policy --- .github/workflows/governance-reusable.yml | 31 ++++++++++++------- .../governance-reusable-contract-test.sh | 4 +-- tests/test_governance_reusable_shape.sh | 4 +-- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 8f31a5a4..a2558c39 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -45,9 +45,9 @@ 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: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 path: .standards-history fetch-depth: 0 filter: tree:0 @@ -87,7 +87,7 @@ jobs: 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 }} + ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -267,7 +267,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 path: .standards-checkout sparse-checkout: | scripts @@ -1107,15 +1107,24 @@ 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: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 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 @@ -1242,7 +1251,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 path: .standards-lock persist-credentials: false sparse-checkout: | 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/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" From 0ec476f32193c33d024ec99d26e2cea0d74168db Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:52:40 +0100 Subject: [PATCH 03/13] fix(mirror): declare optional secrets for least-privilege callers --- .github/workflows/mirror-reusable.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 223299b9..1e40eab6 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,6 +44,22 @@ 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 From 953756fcbc88d5c17943faee243bd3efe9a5815c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:08:18 +0100 Subject: [PATCH 04/13] test(ci): use Ruby and reject empty workflow fixtures --- scripts/tests/policy-gates-test.sh | 9 +- scripts/tests/science-ci-security-test.py | 108 ---------------------- scripts/tests/science-ci-security-test.rb | 100 ++++++++++++++++++++ scripts/tests/science-ci-security-test.sh | 2 +- tools/policy/check-workflows-parse.sh | 9 +- 5 files changed, 110 insertions(+), 118 deletions(-) delete mode 100644 scripts/tests/science-ci-security-test.py create mode 100644 scripts/tests/science-ci-security-test.rb diff --git a/scripts/tests/policy-gates-test.sh b/scripts/tests/policy-gates-test.sh index cb550b14..3f77167f 100644 --- a/scripts/tests/policy-gates-test.sh +++ b/scripts/tests/policy-gates-test.sh @@ -89,10 +89,17 @@ 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") + invalid="$fixture/invalid" init_fixture "$invalid" mkdir -p "$invalid/.github/workflows" diff --git a/scripts/tests/science-ci-security-test.py b/scripts/tests/science-ci-security-test.py deleted file mode 100644 index 160efa98..00000000 --- a/scripts/tests/science-ci-security-test.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: MPL-2.0 -"""Exercise the workflow's actual shell steps with controlled remote data.""" -import os -from pathlib import Path -import subprocess -import tempfile -import unittest - -import yaml - -ROOT = Path(__file__).resolve().parents[2] -PUBLIC_KEY = "gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf\n" -FINGERPRINT = "SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8" - - -class MirrorHostVerification(unittest.TestCase): - def test_every_ssh_mirror_verifies_before_push(self): - workflow = yaml.safe_load((ROOT / ".github/workflows/mirror-reusable.yml").read_text()) - for name, job in workflow["jobs"].items(): - if name == "mirror-radicle": - continue - verify = next(s for s in job["steps"] if s.get("name", "").startswith("Verify ")) - push = next(s for s in job["steps"] if "git push" in s.get("run", "")) - self.assertLess(job["steps"].index(verify), job["steps"].index(push)) - self.assertEqual(verify["if"], push["if"]) - self.assertFalse(verify.get("continue-on-error", False)) - with tempfile.TemporaryDirectory() as directory: - tmp = Path(directory) - stub = tmp / "ssh-keyscan" - stub.write_text('#!/bin/sh\nprintf "%s" "$TEST_HOST_KEY"\n') - stub.chmod(0o755) - env = dict(os.environ, PATH=f"{tmp}:{os.environ['PATH']}", RUNNER_TEMP=directory, - GITHUB_ENV=str(tmp / "env"), MIRROR_HOST="gitlab.com", - APPROVED_FINGERPRINT=FINGERPRINT) - for key, fingerprint, succeeds in [ - (PUBLIC_KEY, FINGERPRINT, True), - (PUBLIC_KEY.replace("OzRdf", "OzRdg"), FINGERPRINT, False), - ("", FINGERPRINT, False), - ("invalid key", FINGERPRINT, False), - (PUBLIC_KEY, "", False), - ]: - with self.subTest(job=name, key=key[-8:], fingerprint=fingerprint): - (tmp / "env").unlink(missing_ok=True) - result = subprocess.run(["bash", "-c", verify["run"]], - env=dict(env, TEST_HOST_KEY=key, APPROVED_FINGERPRINT=fingerprint), - capture_output=True, text=True) - self.assertEqual(result.returncode == 0, succeeds, result.stderr) - self.assertEqual((tmp / "env").exists(), succeeds) - if succeeds: - self.assertIn("StrictHostKeyChecking=yes", (tmp / "env").read_text()) - - -class ScannerSourceVerification(unittest.TestCase): - def test_resolved_commit_survives_remote_advancing_and_cache_mismatch_fails(self): - workflow = yaml.safe_load((ROOT / ".github/workflows/hypatia-scan-reusable.yml").read_text()) - step = next(s for s in workflow["jobs"]["scan"]["steps"] - if s.get("name") == "Check out resolved Hypatia commit") - with tempfile.TemporaryDirectory() as directory: - tmp = Path(directory) - upstream = tmp / "upstream" - subprocess.run(["git", "init", "-q", str(upstream)], check=True) - def commit(text): - (upstream / "source").write_text(text) - subprocess.run(["git", "-C", str(upstream), "add", "source"], check=True) - subprocess.run(["git", "-C", str(upstream), "-c", "user.name=CI Test", - "-c", "user.email=ci@example.invalid", "-c", "commit.gpgsign=false", - "commit", "-qm", text], check=True) - return subprocess.check_output(["git", "-C", str(upstream), "rev-parse", "HEAD"], text=True).strip() - resolved = commit("resolved") - newer = commit("advanced") - source = tmp / "scanner" - script = step["run"].replace('$HOME/hypatia', str(source)).replace( - "https://github.com/hyperpolymath/hypatia.git", str(upstream)) - env = dict(os.environ, HYPATIA_SHA=resolved) - for expected in (0, 0): - result = subprocess.run(["bash", "-c", script], env=env, capture_output=True) - self.assertEqual(result.returncode, expected, result.stderr) - self.assertEqual((source / "source").read_text(), "resolved") - result = subprocess.run(["bash", "-c", script], env=dict(env, HYPATIA_SHA=newer), capture_output=True) - self.assertNotEqual(result.returncode, 0) - self.assertIn(b"cached source does not match", result.stdout) - - -class PolicyStartupVerification(unittest.TestCase): - def test_commented_jobs_and_retired_policy_fail_with_positive_controls(self): - with tempfile.TemporaryDirectory() as directory: - tmp = Path(directory) - workflow = tmp / ".github/workflows/ci.yml" - workflow.parent.mkdir(parents=True) - subprocess.run(["git", "init", "-q", directory], check=True) - workflow.write_text("name: CI\non: push\njobs:\n # test:\n # runs-on: ubuntu-latest\n") - subprocess.run(["git", "-C", directory, "add", "."], check=True) - parser = ROOT / "tools/policy/check-workflows-parse.sh" - result = subprocess.run(["bash", str(parser)], cwd=directory, capture_output=True) - self.assertNotEqual(result.returncode, 0) - workflow.write_text("name: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: test -f .machine_readable/STATE.a2ml\n") - checker = ROOT / "scripts/check-descriptile-policy.sh" - result = subprocess.run(["bash", str(checker)], cwd=directory, capture_output=True) - self.assertNotEqual(result.returncode, 0) - workflow.write_text(workflow.read_text().replace(".machine_readable/STATE", ".machine_readable/descriptiles/STATE")) - for check in (parser, checker): - result = subprocess.run(["bash", str(check)], cwd=directory, capture_output=True) - self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb new file mode 100644 index 00000000..b91e4e2d --- /dev/null +++ b/scripts/tests/science-ci-security-test.rb @@ -0,0 +1,100 @@ +#!/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") + 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' + +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) } +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 index cf2fa490..8edb6e08 100755 --- a/scripts/tests/science-ci-security-test.sh +++ b/scripts/tests/science-ci-security-test.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash # SPDX-License-Identifier: MPL-2.0 set -euo pipefail -python3 "$(dirname "$0")/science-ci-security-test.py" +ruby "$(dirname "$0")/science-ci-security-test.rb" diff --git a/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh index d1952a4e..9f7ffd0f 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)" 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,9 +43,6 @@ 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" ;; @@ -59,7 +53,6 @@ has_reusable_timeout() { has_no_jobs() { case "$parser" in yq) yq -e '.jobs == null or (.jobs | length == 0)' "$1" >/dev/null 2>&1 ;; - python) python3 -c 'import sys,yaml; d=yaml.safe_load(open(sys.argv[1], encoding="utf-8")); sys.exit(not (not isinstance(d,dict) or not isinstance(d.get("jobs"),dict) or not d["jobs"]))' "$1" ;; 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)' "$1" ;; esac } From 8e50188e183222ffeb44f9729dde056a33dded38 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:08:50 +0100 Subject: [PATCH 05/13] docs(ci): document mirror trust configuration and pin tested helpers --- .github/workflows/governance-reusable.yml | 12 +++---- docs/MIRROR-HOST-VERIFICATION.adoc | 40 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 docs/MIRROR-HOST-VERIFICATION.adoc diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index a2558c39..acab642e 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -47,7 +47,7 @@ jobs: # The gate needs the full graph for ancestry and age checks, but its # script comes from a reviewed immutable helper revision. GitHub does # not expose job.workflow_sha; an absent property silently floats. - ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 + ref: 953756fcbc88d5c17943faee243bd3efe9a5815c path: .standards-history fetch-depth: 0 filter: tree:0 @@ -87,7 +87,7 @@ jobs: repository: hyperpolymath/standards # Exact commit of this reusable workflow. This preserves consumer # pin semantics and lets a standards PR test its own scripts. - ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 + ref: 953756fcbc88d5c17943faee243bd3efe9a5815c path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 + ref: 953756fcbc88d5c17943faee243bd3efe9a5815c path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -267,7 +267,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 + ref: 953756fcbc88d5c17943faee243bd3efe9a5815c path: .standards-checkout sparse-checkout: | scripts @@ -1110,7 +1110,7 @@ jobs: # 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: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 + ref: 953756fcbc88d5c17943faee243bd3efe9a5815c path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh @@ -1251,7 +1251,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 73856a817dcaa91a87cca97f32f47fb0edb8b9a1 + ref: 953756fcbc88d5c17943faee243bd3efe9a5815c path: .standards-lock persist-credentials: false sparse-checkout: | diff --git a/docs/MIRROR-HOST-VERIFICATION.adoc b/docs/MIRROR-HOST-VERIFICATION.adoc new file mode 100644 index 00000000..806cb444 --- /dev/null +++ b/docs/MIRROR-HOST-VERIFICATION.adoc @@ -0,0 +1,40 @@ +// 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. + +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, Codeberg, and Disroot 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] +* https://disroot.org/services/git[Disroot] + +SourceHut requires `SOURCEHUT_SSH_FINGERPRINT`. 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` exercises 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. From 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:36:35 +0100 Subject: [PATCH 06/13] fix(ci): address review findings in shared policy and mirror gates --- .github/workflows/governance-reusable.yml | 15 ++++++------- .github/workflows/mirror-reusable.yml | 6 +++-- docs/MIRROR-HOST-VERIFICATION.adoc | 14 +++++++----- scripts/check-descriptile-policy.sh | 27 ++++++++++++++++++++++- scripts/tests/policy-gates-test.sh | 5 +++++ scripts/tests/science-ci-security-test.rb | 18 +++++++++++++++ tools/policy/check-workflows-parse.sh | 2 +- 7 files changed, 70 insertions(+), 17 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index acab642e..ca00c7c9 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -258,10 +258,9 @@ 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 + # pin selects the reviewed filter and schema implementation. Update that + # pin when changing helpers; GitHub does not expose job.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 @@ -1232,9 +1231,9 @@ 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 are checked out at an explicit reviewed commit. Update + # this immutable helper pin when the gate implementation changes; it does + # not automatically follow the caller's reusable-workflow revision. actions-lock-verify: name: Actions lockfile verify runs-on: ${{ inputs.runs-on }} @@ -1275,7 +1274,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/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 1e40eab6..7308e442 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -66,6 +66,8 @@ permissions: 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' @@ -297,10 +299,10 @@ jobs: if: ${{ env.DISROOT_KEY != '' }} env: MIRROR_HOST: git.disroot.org - APPROVED_FINGERPRINT: ${{ vars.DISROOT_SSH_FINGERPRINT || 'SHA256:B8RHZmR8N7oyt0DG04jn+SWDDRpFrQh4F2Vo3PfUNqY' }} + APPROVED_FINGERPRINT: ${{ vars.DISROOT_SSH_FINGERPRINT }} run: | set -euo pipefail - # Approved independently of ssh-keyscan; source: https://disroot.org/services/git + # 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" diff --git a/docs/MIRROR-HOST-VERIFICATION.adoc b/docs/MIRROR-HOST-VERIFICATION.adoc index 806cb444..67a054c9 100644 --- a/docs/MIRROR-HOST-VERIFICATION.adoc +++ b/docs/MIRROR-HOST-VERIFICATION.adoc @@ -4,22 +4,25 @@ 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. +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, Codeberg, and Disroot have published fingerprints pinned +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] -* https://disroot.org/services/git[Disroot] -SourceHut requires `SOURCEHUT_SSH_FINGERPRINT`. A custom Gitea mirror requires +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. @@ -33,7 +36,8 @@ 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` exercises every actual SSH +`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 diff --git a/scripts/check-descriptile-policy.sh b/scripts/check-descriptile-policy.sh index 95a28bcf..d055bf09 100644 --- a/scripts/check-descriptile-policy.sh +++ b/scripts/check-descriptile-policy.sh @@ -9,7 +9,32 @@ while IFS= read -r -d '' file; do # and commented examples are not policy enforcement. if awk ' /^[[:space:]]*#/ { next } - /(-f[[:space:]]|-e[[:space:]]|check_file[[:space:]])/ && /\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml/ { found=1; print FNR ":" $0 } + { + 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" diff --git a/scripts/tests/policy-gates-test.sh b/scripts/tests/policy-gates-test.sh index 3f77167f..9475d816 100644 --- a/scripts/tests/policy-gates-test.sh +++ b/scripts/tests/policy-gates-test.sh @@ -100,6 +100,11 @@ printf '%s\n' 'name: test' 'on: push' 'jobs: {}' > "$empty_jobs/.github/workflow git -C "$empty_jobs" add .github/workflows/test.yml (cd "$empty_jobs" && expect_fail "$workflow_gate") +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/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb index b91e4e2d..c52987c2 100644 --- a/scripts/tests/science-ci-security-test.rb +++ b/scripts/tests/science-ci-security-test.rb @@ -33,6 +33,11 @@ def workflow(name) 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") @@ -96,5 +101,18 @@ def workflow(name) 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/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh index 9f7ffd0f..59a8b2d3 100755 --- a/tools/policy/check-workflows-parse.sh +++ b/tools/policy/check-workflows-parse.sh @@ -52,7 +52,7 @@ has_reusable_timeout() { # A syntactically valid file with no jobs is still rejected at startup. has_no_jobs() { case "$parser" in - yq) yq -e '.jobs == null or (.jobs | length == 0)' "$1" >/dev/null 2>&1 ;; + yq) yq -e '(.jobs | type) != "!!map" or (.jobs | length == 0)' "$1" >/dev/null 2>&1 ;; 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)' "$1" ;; esac } From af24e419f48e543ced58dbca99ab39dc48ab6b11 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 00:37:36 +0100 Subject: [PATCH 07/13] fix(ci): consume the reviewed and tested policy helpers --- .github/workflows/governance-reusable.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index ca00c7c9..15d57e88 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -47,7 +47,7 @@ jobs: # The gate needs the full graph for ancestry and age checks, but its # script comes from a reviewed immutable helper revision. GitHub does # not expose job.workflow_sha; an absent property silently floats. - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f path: .standards-history fetch-depth: 0 filter: tree:0 @@ -87,7 +87,7 @@ jobs: repository: hyperpolymath/standards # Exact commit of this reusable workflow. This preserves consumer # pin semantics and lets a standards PR test its own scripts. - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -123,7 +123,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -266,7 +266,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f path: .standards-checkout sparse-checkout: | scripts @@ -1109,7 +1109,7 @@ jobs: # 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: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh @@ -1250,7 +1250,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 5ab3a2849d68a074ebc70d0ac3aa4525804cea0f path: .standards-lock persist-credentials: false sparse-checkout: | From 460c375ff6889a2ad7eeffbdc246eedf767b6136 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:02:22 +0100 Subject: [PATCH 08/13] fix(policy): reject malformed jobs and plan narrow ruleset repairs --- scripts/check-descriptile-policy.sh | 8 ++-- scripts/plan-ruleset-constraint-repair.rb | 45 ++++++++++++++++++ scripts/tests/policy-gates-test.sh | 4 ++ .../tests/ruleset-constraint-repair-test.sh | 47 +++++++++++++++++++ scripts/tests/science-ci-security-test.rb | 2 + tools/policy/check-workflows-parse.sh | 9 ++-- 6 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 scripts/plan-ruleset-constraint-repair.rb create mode 100644 scripts/tests/ruleset-constraint-repair-test.sh diff --git a/scripts/check-descriptile-policy.sh b/scripts/check-descriptile-policy.sh index 95a28bcf..eb198991 100644 --- a/scripts/check-descriptile-policy.sh +++ b/scripts/check-descriptile-policy.sh @@ -5,11 +5,13 @@ 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. + # Recognise direct shell tests and check_file calls at a command start, + # including inline YAML run steps. Quoted echo examples are not execution. + # Compound expressions and dynamically constructed paths need shell analysis. if awk ' /^[[:space:]]*#/ { next } - /(-f[[:space:]]|-e[[:space:]]|check_file[[:space:]])/ && /\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml/ { found=1; print FNR ":" $0 } + /^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*/ { sub(/^[[:space:]]*(-[[:space:]]+)?run:[[:space:]]*/, "") } + /^[[:space:]]*((if|elif|while|until)[[:space:]]+)?(![[:space:]]+)?((test|\[\[?)[[:space:]]+(![[:space:]]+)?-[fe][[:space:]]+|check_file[[:space:]]+)["\047]?\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml(["\047]|[[:space:];]|$)/ { found=1; print FNR ":" $0 } 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" 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/tests/policy-gates-test.sh b/scripts/tests/policy-gates-test.sh index 3f77167f..a4f82245 100644 --- a/scripts/tests/policy-gates-test.sh +++ b/scripts/tests/policy-gates-test.sh @@ -99,6 +99,10 @@ 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 invalid="$fixture/invalid" init_fixture "$invalid" 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 index b91e4e2d..ed4d5be1 100644 --- a/scripts/tests/science-ci-security-test.rb +++ b/scripts/tests/science-ci-security-test.rb @@ -96,5 +96,7 @@ def workflow(name) 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) } + File.write(path, "name: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo 'test -f .machine_readable/STATE.a2ml'\n") + run!('bash', checker, chdir: tmp) end puts 'PASS: empty workflows and retired policy fail; executable jobs with canonical policy pass' diff --git a/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh index 9f7ffd0f..85a1d99e 100755 --- a/tools/policy/check-workflows-parse.sh +++ b/tools/policy/check-workflows-parse.sh @@ -24,7 +24,7 @@ if command -v yq >/dev/null 2>&1; then elif command -v ruby >/dev/null 2>&1; then parser=ruby else - echo "::error::no YAML parser available (yq or ruby)" + echo "::error::no YAML parser available (yq or ruby)" >&2 exit 1 fi @@ -51,10 +51,13 @@ has_reusable_timeout() { # 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 == null or (.jobs | length == 0)' "$1" >/dev/null 2>&1 ;; - 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)' "$1" ;; + 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() { From 62e38a72a41d072f70ad78baa402d5ca4bb13022 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:07:37 +0100 Subject: [PATCH 09/13] fix(governance): pin reviewed helpers and drop checkout credentials --- .github/workflows/governance-reusable.yml | 36 +++++++++++++---------- docs/MIRROR-HOST-VERIFICATION.adoc | 3 +- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index acab642e..7337b00e 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -47,7 +47,8 @@ jobs: # The gate needs the full graph for ancestry and age checks, but its # script comes from a reviewed immutable helper revision. GitHub does # not expose job.workflow_sha; an absent property silently floats. - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + persist-credentials: false path: .standards-history fetch-depth: 0 filter: tree:0 @@ -85,9 +86,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: 953756fcbc88d5c17943faee243bd3efe9a5815c + # Immutable helper revision paired with this workflow. Standards PRs + # test their own scripts through the bootstrap rule below. + ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + persist-credentials: false path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -123,7 +125,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + persist-credentials: false path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -258,16 +261,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: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + persist-credentials: false path: .standards-checkout sparse-checkout: | scripts @@ -1110,7 +1113,8 @@ jobs: # 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: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + persist-credentials: false path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh @@ -1232,9 +1236,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 }} @@ -1251,7 +1254,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 953756fcbc88d5c17943faee243bd3efe9a5815c + ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + persist-credentials: false path: .standards-lock persist-credentials: false sparse-checkout: | @@ -1275,7 +1279,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/docs/MIRROR-HOST-VERIFICATION.adoc b/docs/MIRROR-HOST-VERIFICATION.adoc index 806cb444..4716fe5f 100644 --- a/docs/MIRROR-HOST-VERIFICATION.adoc +++ b/docs/MIRROR-HOST-VERIFICATION.adoc @@ -33,7 +33,8 @@ 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` exercises every actual SSH +`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 From febccbdc19e0fc80761797dc52811dc056c0a39e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:08:15 +0100 Subject: [PATCH 10/13] fix(governance): keep checkout inputs unique and quote outputs --- .github/workflows/governance-reusable.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 7337b00e..cb2f3d43 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 @@ -52,7 +52,6 @@ jobs: path: .standards-history fetch-depth: 0 filter: tree:0 - persist-credentials: false - name: Run staleness check env: @@ -1257,7 +1256,6 @@ jobs: ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 persist-credentials: false path: .standards-lock - persist-credentials: false sparse-checkout: | scripts/check-actions-lock-gate.sh scripts/update-actions-lock.sh From e9fa12b965897f485f8ec143e048a424b52f689a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:43:37 +0100 Subject: [PATCH 11/13] fix(governance): consume the reconciled and tested helper revision --- .github/workflows/governance-reusable.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index cb2f3d43..1c425eac 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -47,7 +47,7 @@ jobs: # The gate needs the full graph for ancestry and age checks, but its # script comes from a reviewed immutable helper revision. GitHub does # not expose job.workflow_sha; an absent property silently floats. - ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false path: .standards-history fetch-depth: 0 @@ -87,7 +87,7 @@ jobs: repository: hyperpolymath/standards # Immutable helper revision paired with this workflow. Standards PRs # test their own scripts through the bootstrap rule below. - ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false path: .standards-checkout sparse-checkout: | @@ -124,7 +124,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false path: .standards-checkout sparse-checkout: | @@ -268,7 +268,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false path: .standards-checkout sparse-checkout: | @@ -1112,7 +1112,7 @@ jobs: # 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: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false path: .standards-dupkey sparse-checkout: | @@ -1253,7 +1253,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: 460c375ff6889a2ad7eeffbdc246eedf767b6136 + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false path: .standards-lock sparse-checkout: | From 591cea1b074b56367f25ec0084a0c4ab66b42a7e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:46:37 +0100 Subject: [PATCH 12/13] fix(ci): reconcile native action pins and retain Hypatia warning severity --- .github/workflows/hypatia-scan-reusable.yml | 5 +- .github/workflows/self-test.yml | 2 +- scripts/apply-baseline.sh | 4 +- scripts/reconcile-scorecard-actions-lock.rb | 89 +++++++++++++++++++ .../reconcile-scorecard-actions-lock-test.rb | 85 ++++++++++++++++++ .../reconcile-scorecard-actions-lock-test.sh | 4 + scripts/tests/science-ci-security-test.rb | 25 ++++++ 7 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 scripts/reconcile-scorecard-actions-lock.rb create mode 100644 scripts/tests/reconcile-scorecard-actions-lock-test.rb create mode 100644 scripts/tests/reconcile-scorecard-actions-lock-test.sh diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 2e75633e..95a6dfd9 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -128,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 @@ -137,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/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/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/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/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/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb index c52987c2..63c8ad17 100644 --- a/scripts/tests/science-ci-security-test.rb +++ b/scripts/tests/science-ci-security-test.rb @@ -86,6 +86,31 @@ def workflow(name) 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)) From da2c748aad55c1a1dcba00b60fe4a35017bc6540 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:48:56 +0100 Subject: [PATCH 13/13] fix(scorecard): publish findings reconciled with verified native action locks --- .github/workflows/scorecard-reusable.yml | 48 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) 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