diff --git a/.github/workflows/mirror-reusable.yml b/.github/workflows/mirror-reusable.yml index 1ccafb230..34176e2af 100644 --- a/.github/workflows/mirror-reusable.yml +++ b/.github/workflows/mirror-reusable.yml @@ -13,10 +13,10 @@ # `vars._MIRROR_ENABLED == 'true'`, so forge selection is # configured per-repo via Actions vars — no per-call inputs required. # -# Caller (one-line wrapper) MUST use `secrets: inherit` so the reusable +# Callers pass explicit secrets (preferred) or `secrets: inherit` so the reusable # can read the per-forge SSH keys (GITLAB_SSH_KEY, BITBUCKET_SSH_KEY, # CODEBERG_SSH_KEY, SOURCEHUT_SSH_KEY, DISROOT_SSH_KEY, GITEA_SSH_KEY) -# and RADICLE_KEY from the caller repo. Without `secrets: inherit`, +# and RADICLE_KEY from the caller repo. Without a passed secret, # `${{ secrets.X }}` inside the reusable evaluates to empty. # # Caller example (wrapper): @@ -44,12 +44,30 @@ on: required: false default: ubuntu-latest + secrets: + GITLAB_SSH_KEY: + required: false + BITBUCKET_SSH_KEY: + required: false + CODEBERG_SSH_KEY: + required: false + SOURCEHUT_SSH_KEY: + required: false + DISROOT_SSH_KEY: + required: false + GITEA_SSH_KEY: + required: false + RADICLE_KEY: + required: false + permissions: actions: read contents: read jobs: mirror-gitlab: + # Advisory mirror: verification still fails closed and skips the push. + continue-on-error: true timeout-minutes: 20 runs-on: ${{ inputs.runs-on }} if: vars.GITLAB_MIRROR_ENABLED == 'true' @@ -67,6 +85,27 @@ jobs: if: ${{ env.GITLAB_KEY != '' }} with: ssh-private-key: ${{ secrets.GITLAB_SSH_KEY }} + - name: Verify gitlab SSH host + if: ${{ env.GITLAB_KEY != '' }} + env: + MIRROR_HOST: gitlab.com + APPROVED_FINGERPRINT: ${{ vars.GITLAB_SSH_FINGERPRINT || 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid GITLAB_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-gitlab-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to GitLab # continue-on-error: GitLab branch protection on the mirror repo may block # force-push even for a deploy key. Owner action required: in GitLab go to @@ -75,9 +114,12 @@ jobs: # Until then this step is advisory-only; failures do not red main. continue-on-error: true if: ${{ env.GITLAB_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.GITLAB_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: gitlab.com run: | - ssh-keyscan -t ed25519 gitlab.com >> ~/.ssh/known_hosts - git remote add gitlab git@gitlab.com:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add gitlab "git@gitlab.com:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force gitlab main - name: Skipped (GITLAB_SSH_KEY not configured) if: ${{ env.GITLAB_KEY == '' }} @@ -100,11 +142,35 @@ jobs: if: ${{ env.BITBUCKET_KEY != '' }} with: ssh-private-key: ${{ secrets.BITBUCKET_SSH_KEY }} + - name: Verify bitbucket SSH host + if: ${{ env.BITBUCKET_KEY != '' }} + env: + MIRROR_HOST: bitbucket.org + APPROVED_FINGERPRINT: ${{ vars.BITBUCKET_SSH_FINGERPRINT || 'SHA256:ybgmFkzwOSotHTHLJgHO0QN8L0xErw6vd0VhFA9m3SM' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/ + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid BITBUCKET_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-bitbucket-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Bitbucket if: ${{ env.BITBUCKET_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.BITBUCKET_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: bitbucket.org run: | - ssh-keyscan -t ed25519 bitbucket.org >> ~/.ssh/known_hosts - git remote add bitbucket git@bitbucket.org:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add bitbucket "git@bitbucket.org:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force bitbucket main - name: Skipped (BITBUCKET_SSH_KEY not configured) if: ${{ env.BITBUCKET_KEY == '' }} @@ -127,11 +193,35 @@ jobs: if: ${{ env.CODEBERG_KEY != '' }} with: ssh-private-key: ${{ secrets.CODEBERG_SSH_KEY }} + - name: Verify codeberg SSH host + if: ${{ env.CODEBERG_KEY != '' }} + env: + MIRROR_HOST: codeberg.org + APPROVED_FINGERPRINT: ${{ vars.CODEBERG_SSH_FINGERPRINT || 'SHA256:mIlxA9k46MmM6qdJOdMnAQpzGxF4WIVVL+fj+wZbw0g' }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://docs.codeberg.org/security/ssh-fingerprint/ + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid CODEBERG_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-codeberg-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Codeberg if: ${{ env.CODEBERG_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.CODEBERG_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: codeberg.org run: | - ssh-keyscan -t ed25519 codeberg.org >> ~/.ssh/known_hosts - git remote add codeberg git@codeberg.org:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add codeberg "git@codeberg.org:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force codeberg main - name: Skipped (CODEBERG_SSH_KEY not configured) if: ${{ env.CODEBERG_KEY == '' }} @@ -154,11 +244,35 @@ jobs: if: ${{ env.SOURCEHUT_KEY != '' }} with: ssh-private-key: ${{ secrets.SOURCEHUT_SSH_KEY }} + - name: Verify sourcehut SSH host + if: ${{ env.SOURCEHUT_KEY != '' }} + env: + MIRROR_HOST: git.sr.ht + APPROVED_FINGERPRINT: ${{ vars.SOURCEHUT_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: https://man.sr.ht/ + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid SOURCEHUT_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-sourcehut-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to SourceHut if: ${{ env.SOURCEHUT_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.SOURCEHUT_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: git.sr.ht run: | - ssh-keyscan -t ed25519 git.sr.ht >> ~/.ssh/known_hosts - git remote add sourcehut git@git.sr.ht:~hyperpolymath/${{ github.event.repository.name }} || true + git remote add sourcehut "git@git.sr.ht:~${MIRROR_ORG}/${REPO_NAME}" || true git push --force sourcehut main - name: Skipped (SOURCEHUT_SSH_KEY not configured) if: ${{ env.SOURCEHUT_KEY == '' }} @@ -181,11 +295,35 @@ jobs: if: ${{ env.DISROOT_KEY != '' }} with: ssh-private-key: ${{ secrets.DISROOT_SSH_KEY }} + - name: Verify disroot SSH host + if: ${{ env.DISROOT_KEY != '' }} + env: + MIRROR_HOST: git.disroot.org + APPROVED_FINGERPRINT: ${{ vars.DISROOT_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Caller must approve this independently of the ssh-keyscan result. + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid DISROOT_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-disroot-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Disroot if: ${{ env.DISROOT_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.DISROOT_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: git.disroot.org run: | - ssh-keyscan -t ed25519 git.disroot.org >> ~/.ssh/known_hosts - git remote add disroot git@git.disroot.org:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add disroot "git@git.disroot.org:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force disroot main - name: Skipped (DISROOT_SSH_KEY not configured) if: ${{ env.DISROOT_KEY == '' }} @@ -208,11 +346,35 @@ jobs: if: ${{ env.GITEA_KEY != '' }} with: ssh-private-key: ${{ secrets.GITEA_SSH_KEY }} + - name: Verify gitea SSH host + if: ${{ env.GITEA_KEY != '' }} + env: + MIRROR_HOST: ${{ vars.GITEA_HOST }} + APPROVED_FINGERPRINT: ${{ vars.GITEA_SSH_FINGERPRINT }} + run: | + set -euo pipefail + # Approved independently of ssh-keyscan; source: caller-controlled instance + if [[ ! "$MIRROR_HOST" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]] || + [[ ! "$APPROVED_FINGERPRINT" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]]; then + echo "::error::Configure a valid GITEA_SSH_FINGERPRINT and mirror hostname" + exit 1 + fi + CANDIDATE="$RUNNER_TEMP/mirror-gitea-known-hosts" + ssh-keyscan -T 10 -t ed25519 "$MIRROR_HOST" > "$CANDIDATE" + ACTUAL=$(ssh-keygen -lf "$CANDIDATE" -E sha256 | awk '{print $2}' | sort -u) + if [ "$ACTUAL" != "$APPROVED_FINGERPRINT" ]; then + echo "::error::Mirror SSH host fingerprint mismatch; refusing push" + exit 1 + fi + printf 'GIT_SSH_COMMAND=ssh -o StrictHostKeyChecking=yes -o GlobalKnownHostsFile=/dev/null -o UserKnownHostsFile=%s\n' "$CANDIDATE" >> "$GITHUB_ENV" - name: Mirror to Gitea if: ${{ env.GITEA_KEY != '' }} + env: + MIRROR_ORG: ${{ vars.GITEA_ORG || vars.MIRROR_ORG || github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + MIRROR_HOST: ${{ vars.GITEA_HOST }} run: | - ssh-keyscan -t ed25519 ${{ vars.GITEA_HOST }} >> ~/.ssh/known_hosts - git remote add gitea git@${{ vars.GITEA_HOST }}:hyperpolymath/${{ github.event.repository.name }}.git || true + git remote add gitea "git@${MIRROR_HOST}:${MIRROR_ORG}/${REPO_NAME}.git" || true git push --force gitea main - name: Skipped (GITEA_SSH_KEY not configured) if: ${{ env.GITEA_KEY == '' }} @@ -252,12 +414,12 @@ jobs: if: ${{ env.RADICLE_KEY != '' }} run: | cargo install radicle-cli --locked - echo "$HOME/.cargo/bin" >> $GITHUB_PATH + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" - name: Mirror to Radicle if: ${{ env.RADICLE_KEY != '' }} run: | mkdir -p ~/.radicle/keys - echo "${{ secrets.RADICLE_KEY }}" > ~/.radicle/keys/radicle + printf "%s\n" "$RADICLE_KEY" > ~/.radicle/keys/radicle chmod 600 ~/.radicle/keys/radicle rad sync --announce || echo "Radicle sync attempted" - name: Skipped (RADICLE_KEY not configured) diff --git a/docs/MIRROR-HOST-VERIFICATION.adoc b/docs/MIRROR-HOST-VERIFICATION.adoc new file mode 100644 index 000000000..67a054c9e --- /dev/null +++ b/docs/MIRROR-HOST-VERIFICATION.adoc @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 += Mirror SSH host verification + +The reusable mirror workflow verifies the remote Ed25519 host fingerprint +before exporting a strict SSH configuration or pushing any refs. An empty, +malformed, changed, or unapproved host key fails that mirror job. The fetched +key is never trusted merely because `ssh-keyscan` returned it. The GitLab +mirror remains advisory at job level: a verification failure skips the push +and records the job failure without failing the overall workflow. + +Callers enable each mirror with its optional secret: `GITLAB_SSH_KEY`, +`BITBUCKET_SSH_KEY`, `CODEBERG_SSH_KEY`, `SOURCEHUT_SSH_KEY`, `DISROOT_SSH_KEY`, +`GITEA_SSH_KEY`, or `RADICLE_KEY`. Prefer explicit secret mappings in callers. +An absent secret leaves that mirror disabled. + +GitLab, Bitbucket, and Codeberg have published fingerprints pinned +in the workflow. Their official reference pages are: + +* https://docs.gitlab.com/user/gitlab_com/#ssh-host-keys-fingerprints[GitLab] +* https://support.atlassian.com/bitbucket-cloud/docs/configure-ssh-and-two-step-verification/[Bitbucket] +* https://docs.codeberg.org/security/ssh-fingerprint/[Codeberg] + +SourceHut requires `SOURCEHUT_SSH_FINGERPRINT`, and Disroot requires +`DISROOT_SSH_FINGERPRINT`; no independently verified default is supplied for +either forge. A custom Gitea mirror requires +both `GITEA_HOST` and `GITEA_SSH_FINGERPRINT`. Obtain these values through an +authenticated administrative channel or the forge's published host-key +documentation. Do not populate them from the network scan being verified. + +For an approved host-key rotation, set the corresponding repository or +organisation variable `_SSH_FINGERPRINT` to the approved `SHA256:...` +value. The six SSH forges support this override. The expected value contains +one Ed25519 fingerprint, with no surrounding whitespace or key material. + +Mirror organisation selection is `_ORG`, then `MIRROR_ORG`, then the +GitHub repository owner. This preserves per-forge destinations while allowing +a shared destination for transferred repositories. + +`scripts/tests/science-ci-security-test.sh` launches the assertions in +`scripts/tests/science-ci-security-test.rb`, which exercise every actual SSH +verification step with the real `ssh-keygen` fingerprint implementation. It +tests approved, tampered, absent, malformed, and unapproved keys, including +that failure cannot export a trusted SSH configuration. The same suite checks +that the Hypatia source checkout matches its resolved cache identity. diff --git a/scripts/tests/science-ci-security-test.rb b/scripts/tests/science-ci-security-test.rb new file mode 100755 index 000000000..63c8ad174 --- /dev/null +++ b/scripts/tests/science-ci-security-test.rb @@ -0,0 +1,143 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Exercise the workflows' actual shell steps with controlled remote data. +require 'yaml' +require 'tmpdir' +require 'fileutils' +require 'open3' + +ROOT = File.expand_path('../..', __dir__) +PUBLIC_KEY = "gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf\n" +FINGERPRINT = 'SHA256:eUXGGm1YGsMAS7vkcx6JOJdOGHPem5gQp4taiCfCLB8' + +def assert(condition, message) + raise message unless condition +end + +def run!(*args, **options) + out, err, status = Open3.capture3(*args, **options) + assert(status.success?, "Command failed: #{args.inspect}\n#{out}#{err}") + out.strip +end + +def workflow(name) + YAML.safe_load(File.read(File.join(ROOT, '.github/workflows', name)), aliases: true) +end + +workflow('mirror-reusable.yml')['jobs'].each do |name, job| + next if name == 'mirror-radicle' + + verify = job['steps'].find { |step| step.fetch('name', '').start_with?('Verify ') } + push = job['steps'].find { |step| step.fetch('run', '').include?('git push') } + assert(verify && push, "#{name}: missing verification or push") + assert(job['steps'].index(verify) < job['steps'].index(push), "#{name}: verifies too late") + assert(verify['if'] == push['if'], "#{name}: verification condition differs from push") + assert(!verify['continue-on-error'], "#{name}: verification failure is ignored") + assert(job['continue-on-error'] == true, 'GitLab mirror must remain advisory') if name == 'mirror-gitlab' + if name == 'mirror-disroot' + assert(verify['env']['APPROVED_FINGERPRINT'] == '${{ vars.DISROOT_SSH_FINGERPRINT }}', + 'Disroot must require an independently approved fingerprint') + end + Dir.mktmpdir('mirror-verification-') do |tmp| + stub = File.join(tmp, 'ssh-keyscan') + File.write(stub, "#!/bin/sh\nprintf '%s' \"$TEST_HOST_KEY\"\n") + File.chmod(0o755, stub) + environment_file = File.join(tmp, 'env') + env = { 'PATH' => "#{tmp}:#{ENV.fetch('PATH')}", 'RUNNER_TEMP' => tmp, + 'GITHUB_ENV' => environment_file, 'MIRROR_HOST' => 'gitlab.com' } + [[PUBLIC_KEY, FINGERPRINT, true], + [PUBLIC_KEY.sub('OzRdf', 'OzRdg'), FINGERPRINT, false], + ['', FINGERPRINT, false], ['invalid key', FINGERPRINT, false], + [PUBLIC_KEY, '', false]].each do |key, fingerprint, succeeds| + FileUtils.rm_f(environment_file) + out, err, status = Open3.capture3(env.merge('TEST_HOST_KEY' => key, + 'APPROVED_FINGERPRINT' => fingerprint), 'bash', '-c', verify['run']) + assert(status.success? == succeeds, "#{name}: unexpected verification result\n#{out}#{err}") + assert(File.exist?(environment_file) == succeeds, "#{name}: unsafe environment was exported") + if succeeds + assert(File.read(environment_file).include?('StrictHostKeyChecking=yes'), "#{name}: strict checking missing") + end + end + end +end +puts 'PASS: all six mirrors accept approved keys and reject tampered, empty, malformed, or unapproved keys' + +step = workflow('hypatia-scan-reusable.yml')['jobs']['scan']['steps'].find do |candidate| + candidate['name'] == 'Check out resolved Hypatia commit' +end +Dir.mktmpdir('scanner-source-') do |tmp| + upstream = File.join(tmp, 'upstream') + run!('git', 'init', '-q', upstream) + commit = lambda do |content| + File.write(File.join(upstream, 'source'), content) + run!('git', '-C', upstream, 'add', 'source') + run!('git', '-C', upstream, '-c', 'user.name=CI Test', '-c', 'user.email=ci@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '-qm', content) + run!('git', '-C', upstream, 'rev-parse', 'HEAD') + end + resolved = commit.call('resolved') + newer = commit.call('advanced') + source = File.join(tmp, 'scanner') + script = step.fetch('run').gsub('$HOME/hypatia', source).gsub('https://github.com/hyperpolymath/hypatia.git', upstream) + 2.times { run!({ 'HYPATIA_SHA' => resolved }, 'bash', '-c', script) } + assert(File.read(File.join(source, 'source')) == 'resolved', 'Scanner followed advancing HEAD') + out, _err, status = Open3.capture3({ 'HYPATIA_SHA' => newer }, 'bash', '-c', script) + assert(!status.success? && out.include?('cached source does not match'), 'Mismatched cache was accepted') +end +puts 'PASS: scanner checks out the resolved commit on cache miss/hit and rejects a mismatched cache' + +step = workflow('hypatia-scan-reusable.yml')['jobs']['scan']['steps'].find do |candidate| + candidate['name'] == 'Validate findings and count severities' +end +Dir.mktmpdir('scanner-contract-') do |tmp| + output = File.join(tmp, 'output') + env = { 'GITHUB_OUTPUT' => output, 'GITHUB_STEP_SUMMARY' => File.join(tmp, 'summary') } + findings = File.join(tmp, 'hypatia-findings.json') + File.write(findings, '[{"severity":"warn"},{"severity":"medium"},{"severity":"critical"}]') + run!(env, 'bash', '-c', step.fetch('run'), chdir: tmp) + assert(File.read(output).lines.map(&:chomp).include?('medium=2'), 'warn was not counted at medium rank') + assert(File.read(output).include?('critical=1'), 'critical finding was lost') + ['', '[', '[] []', '{}', '[{"severity":"unknown"}]', '[{}]'].each do |invalid| + FileUtils.rm_f(output) + File.write(findings, invalid) + _out, _err, status = Open3.capture3(env, 'bash', '-c', step.fetch('run'), chdir: tmp) + assert(status.exitstatus == 2, "Invalid scanner output accepted: #{invalid.inspect}") + assert(!File.exist?(output), 'Invalid output produced gate counts') + end + File.write(findings, '[{"severity":"warn","rule_module":"research_extensions","type":"RE001","file":"ci.yml"}]') + _out, _err, status = Open3.capture3({ 'BLOCKING_THRESHOLD' => 'medium' }, 'bash', + File.join(ROOT, 'scripts/apply-baseline.sh'), findings, File.join(tmp, 'absent-baseline.json'), 'blocking') + assert(status.exitstatus == 1, 'warn escaped the medium baseline threshold') +end +puts 'PASS: warn retains medium severity; malformed, unknown, and multiple scanner documents fail closed' + +Dir.mktmpdir('policy-startup-') do |tmp| + path = File.join(tmp, '.github/workflows/ci.yml') + FileUtils.mkdir_p(File.dirname(path)) + run!('git', 'init', '-q', tmp) + File.write(path, "name: CI\non: push\njobs:\n # test:\n # runs-on: ubuntu-latest\n") + run!('git', '-C', tmp, 'add', '.') + parser = File.join(ROOT, 'tools/policy/check-workflows-parse.sh') + checker = File.join(ROOT, 'scripts/check-descriptile-policy.sh') + _out, _err, status = Open3.capture3('bash', parser, chdir: tmp) + assert(!status.success?, 'Comment-only jobs were accepted') + File.write(path, "name: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: test -f .machine_readable/STATE.a2ml\n") + _out, _err, status = Open3.capture3('bash', checker, chdir: tmp) + assert(!status.success?, 'Retired policy path was accepted') + File.write(path, File.read(path).sub('.machine_readable/STATE', '.machine_readable/descriptiles/STATE')) + [parser, checker].each { |check| run!('bash', check, chdir: tmp) } + [%(echo "test -f .machine_readable/STATE.a2ml"), + %(printf '%s\\n' 'check_file .machine_readable/META.a2ml')].each do |example| + File.write(path, "name: CI\non: push\njobs:\n test:\n steps:\n - run: |\n #{example}\n") + run!('bash', checker, chdir: tmp) + end + [%(test -f ".machine_readable/STATE.a2ml"), + %(test -e '.machine_readable/6a2/META.a2ml'), + %(check_file '.machine_readable/AGENTIC.a2ml'), + %(echo "$(test -f .machine_readable/STATE.a2ml)")].each do |example| + File.write(path, "name: CI\non: push\njobs:\n test:\n steps:\n - run: |\n #{example}\n") + _out, _err, status = Open3.capture3('bash', checker, chdir: tmp) + assert(!status.success?, "Executable retired-path check was accepted: #{example}") + end +end +puts 'PASS: empty workflows and retired policy fail; executable jobs with canonical policy pass' diff --git a/scripts/tests/science-ci-security-test.sh b/scripts/tests/science-ci-security-test.sh new file mode 100755 index 000000000..8edb6e085 --- /dev/null +++ b/scripts/tests/science-ci-security-test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ruby "$(dirname "$0")/science-ci-security-test.rb"