diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 8f31a5a4b..1c425eac5 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -35,9 +35,9 @@ jobs: id: check run: | if [ -f ".hypatia-baseline.json" ]; then - echo "has_baseline=true" >> $GITHUB_OUTPUT + echo "has_baseline=true" >> "$GITHUB_OUTPUT" else - echo "has_baseline=false" >> $GITHUB_OUTPUT + echo "has_baseline=false" >> "$GITHUB_OUTPUT" fi - name: Checkout pinned standards history @@ -45,13 +45,13 @@ jobs: with: repository: hyperpolymath/standards # The gate needs the full graph for ancestry and age checks, but its - # script must come from the exact reusable commit rather than moving - # main. This also lets a Standards PR test its own validator change. - ref: ${{ job.workflow_sha }} + # script comes from a reviewed immutable helper revision. GitHub does + # not expose job.workflow_sha; an absent property silently floats. + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-history fetch-depth: 0 filter: tree:0 - persist-credentials: false - name: Run staleness check env: @@ -85,9 +85,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - # Exact commit of this reusable workflow. This preserves consumer - # pin semantics and lets a standards PR test its own scripts. - ref: ${{ job.workflow_sha }} + # Immutable helper revision paired with this workflow. Standards PRs + # test their own scripts through the bootstrap rule below. + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -123,7 +124,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-checkout sparse-checkout: | rhodium-standard-repositories/actions-allowlist @@ -258,16 +260,16 @@ jobs: # A reusable workflow only auto-checks-out its own YAML, not sibling # scripts. Sparse-check-out standards' scripts/ to get apply-baseline.sh, - # mirroring the language-policy job below. `job.workflow_sha` is the exact - # commit of this called workflow, so the filter and schema semantics cannot - # drift from the reusable workflow that invoked them. This also lets a - # standards PR test its own script change instead of silently using main. + # mirroring the language-policy job below. The explicit immutable helper + # commit below keeps the filter and schema semantics reviewable. Update + # that pin when changing the helpers; the job context has no workflow_sha. - name: Check out standards for the baseline filter if: needs.workflow-staleness.outputs.has_baseline == 'true' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-checkout sparse-checkout: | scripts @@ -1107,15 +1109,25 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - # Execute helper code from the same immutable Standards revision as - # this reusable workflow. A moving `main` checkout would let a caller - # pinned to workflow revision X execute implementation revision Y. - ref: ${{ job.workflow_sha }} + # Execute helpers from this reviewed immutable revision, which includes + # the parser and descriptile-policy checks. Bump this helper pin when + # changing the implementation; job.workflow_sha is not a GitHub context. + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 + persist-credentials: false path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh + scripts/check-descriptile-policy.sh tools/policy/check-workflows-parse.sh sparse-checkout-cone-mode: false + - name: Check descriptile policy consistency + run: | + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then + SCRIPT=scripts/check-descriptile-policy.sh + else + SCRIPT=.standards-dupkey/scripts/check-descriptile-policy.sh + fi + bash "$SCRIPT" - name: Parse every tracked workflow run: | if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then @@ -1223,9 +1235,8 @@ jobs: # refs are red now, all-pinned-but-no-lock is a ::warning until # ENFORCE_ACTIONS_LOCK_FROM (2026-10-01), red after. # - # Standards is checked out at `job.workflow_sha` = the SHA of THIS reusable - # as pinned by the caller (verified 2026-09-02 on consumer run logs), so the - # gate script and the YAML move together — one speed, no `ref: main` float. + # Standards helpers use the explicit immutable commit below. Update that + # helper pin with helper changes; job.workflow_sha is not a GitHub context. actions-lock-verify: name: Actions lockfile verify runs-on: ${{ inputs.runs-on }} @@ -1242,9 +1253,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: ${{ job.workflow_sha }} - path: .standards-lock + ref: 874ffe5839095bd9b181eb465ec20fb473cda7c5 persist-credentials: false + path: .standards-lock sparse-checkout: | scripts/check-actions-lock-gate.sh scripts/update-actions-lock.sh @@ -1266,7 +1277,7 @@ jobs: fi for f in check-actions-lock-gate.sh update-actions-lock.sh; do if [ ! -f "$SRC/$f" ]; then - echo "::error::actions-lock gate: $f not found in $SRC (standards checkout at job.workflow_sha failed?)" + echo "::error::actions-lock gate: $f not found in $SRC (standards checkout at the explicit helper pin failed?)" exit 1 fi cp "$SRC/$f" "$RUNNER_TEMP/$f" diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 52a6c52ca..27f2e7e1a 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -42,7 +42,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 @@ -63,15 +63,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 @@ -119,7 +129,7 @@ jobs: if [ ! -s hypatia-findings.json ] || ! jq -e ' type == "array" and length > 0 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 a valid findings array" exit 2 @@ -128,7 +138,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/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 2e61ae0cd..8245e6c70 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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # 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@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: - sarif_file: results.sarif + sarif_file: results.reconciled.sarif - name: Retain scan evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: scorecard-pr-results - path: results.sarif + path: | + results.sarif + results.reconciled.sarif + actions-lock-audit.json if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 8f7172391..77a863c71 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 8005c5d7f..efda04ac2 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -234,9 +234,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 5 ;; # Unknown severity → critical rank esac diff --git a/scripts/check-descriptile-policy.sh b/scripts/check-descriptile-policy.sh new file mode 100644 index 000000000..d055bf098 --- /dev/null +++ b/scripts/check-descriptile-policy.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# A CI policy must not require files that the structural-drift gate forbids. +set -euo pipefail +status=0 +while IFS= read -r -d '' file; do + [[ -f "$file" ]] || continue + # Restrict this check to executable file-existence tests. Historical prose + # and commented examples are not policy enforcement. + if awk ' + /^[[:space:]]*#/ { next } + { + source=$0 + # Mask quoted prose while retaining literal path arguments. Keep command + # substitutions visible: an echo can still execute a file test in $(). + code=""; quote=""; quoted="" + for (i=1; i<=length(source); i++) { + ch=substr(source,i,1) + if (quote != "") { + if (ch == quote) { + if (quoted ~ /^\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml$/ || + (quote == "\"" && quoted ~ /\$\(|`/)) code=code quoted + else code=code " " + quote=""; quoted="" + } else if (ch == "\\" && quote == "\"") { + quoted=quoted ch substr(source,++i,1) + } else quoted=quoted ch + } else if (ch == "\"" || ch == sprintf("%c",39)) quote=ch + else code=code ch + } + # A multiline shell quote cannot be classified from this physical line. + if (quote != "") code=code quoted + if (code ~ /(-f[[:space:]]|-e[[:space:]]|check_file[[:space:]])/ && + code ~ /\.machine_readable\/(6a2\/)?(STATE|META|ECOSYSTEM|AGENTIC|NEUROSYM|PLAYBOOK|ANCHOR)\.a2ml/) { + found=1; print FNR ":" source + } + } + END { exit !found } + ' "$file"; then + printf '::error file=%s::Policy requires a retired descriptile path; use .machine_readable/descriptiles/ and reconcile existing files\n' "$file" + status=1 + fi +done < <(git ls-files -z -- '.github/workflows/*.yml' '.github/workflows/*.yaml' 'scripts/*.sh' '.githooks/*.sh' Justfile justfile) +exit "$status" diff --git a/scripts/plan-ruleset-constraint-repair.rb b/scripts/plan-ruleset-constraint-repair.rb new file mode 100644 index 000000000..82d9ff566 --- /dev/null +++ b/scripts/plan-ruleset-constraint-repair.rb @@ -0,0 +1,45 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Produce a reviewable PUT body for the four retired constraints documented +# in config/README.adoc. This script never calls GitHub or changes a ruleset. +require 'json' + +module RulesetConstraintRepair + RETIRED = %w[update required_deployments code_quality code_coverage].freeze + WRITABLE = %w[name target enforcement conditions bypass_actors rules].freeze + + # Supply an app ID only after GitHub rejects it as no longer installed. Removing + # a stale bypass tightens access; never infer app availability from its name. + def self.plan(source, uninstalled_app_id: nil) + unless source.is_a?(Hash) && source['target'] == 'branch' && source['enforcement'] == 'active' && + source.dig('conditions', 'ref_name', 'include') == ['~DEFAULT_BRANCH'] && + source.dig('conditions', 'ref_name', 'exclude') == [] && source['rules'].is_a?(Array) + raise ArgumentError, 'Expected an active default-branch ruleset with no exclusions' + end + rules = source.fetch('rules') + unless rules.all? { |rule| rule.is_a?(Hash) && rule['type'].is_a?(String) } + raise ArgumentError, 'Malformed rule data; refusing a partial plan' + end + raise ArgumentError, 'Missing pull-request protection' unless rules.any? { |r| r['type'] == 'pull_request' } + + result = source.select { |key, _value| WRITABLE.include?(key) } + result['rules'] = rules.reject { |rule| RETIRED.include?(rule['type']) } + if uninstalled_app_id + actors = source.fetch('bypass_actors') + unless uninstalled_app_id.is_a?(Integer) && uninstalled_app_id.positive? && + actors.is_a?(Array) && actors.any? { |actor| actor['actor_type'] == 'Integration' && actor['actor_id'] == uninstalled_app_id } + raise ArgumentError, 'Expected the exact ID of an existing integration bypass rejected by GitHub' + end + result['bypass_actors'] = actors.reject { |actor| actor['actor_type'] == 'Integration' && actor['actor_id'] == uninstalled_app_id } + end + result + end +end + +if $PROGRAM_NAME == __FILE__ + unless ARGV.length == 1 || (ARGV.length == 3 && ARGV[1] == '--remove-uninstalled-app') + abort 'Usage: plan-ruleset-constraint-repair.rb [--remove-uninstalled-app ]' + end + app_id = ARGV[2] && Integer(ARGV[2], 10) + puts JSON.pretty_generate(RulesetConstraintRepair.plan(JSON.parse(File.read(ARGV[0])), uninstalled_app_id: app_id)) +end diff --git a/scripts/reconcile-scorecard-actions-lock.rb b/scripts/reconcile-scorecard-actions-lock.rb new file mode 100644 index 000000000..1c4ad6685 --- /dev/null +++ b/scripts/reconcile-scorecard-actions-lock.rb @@ -0,0 +1,89 @@ +#!/usr/bin/env ruby +# SPDX-License-Identifier: MPL-2.0 +# Reconcile only Scorecard's inline action-pin findings with native lock coverage. +# No lock semantics are reimplemented: gh actions-lock verifies each workflow. +# Usage: ruby reconcile-scorecard-actions-lock.rb INPUT OUTPUT AUDIT_JSON [ROOT] +require 'json' +require 'open3' +require 'yaml' + +module ScorecardActionsLock + PIN_MESSAGE = /\Ascore is \d+: (?:GitHub-owned |third-party )?GitHubAction not pinned by hash\n/ + + def self.action_lines(path) + lines = [] + visit = lambda do |node| + if node.is_a?(Psych::Nodes::Mapping) + node.children.each_slice(2) do |key, value| + if key.is_a?(Psych::Nodes::Scalar) && key.value == 'uses' && value.is_a?(Psych::Nodes::Scalar) + # Only remote GitHub actions, never containers or local paths. + lines << key.start_line + 1 if value.value.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_./-]+@[^\s]+\z}) + end + end + end + Array(node.children).each { |child| visit.call(child) } if node.respond_to?(:children) + end + visit.call(Psych.parse_stream(File.read(path))) + lines + end + + def self.reconcile(document, root) + raise 'Expected a SARIF 2.1.0 document with runs' unless document.is_a?(Hash) && + document['version'] == '2.1.0' && document['runs'].is_a?(Array) && !document['runs'].empty? + + root = File.realpath(root) + verified = {} + audit = [] + document['runs'].each do |run| + raise 'Expected a results array' unless run['results'].is_a?(Array) + next unless run.dig('tool', 'driver', 'name') == 'Scorecard' + + run['results'] = run['results'].reject do |result| + next false unless result['ruleId'] == 'PinnedDependenciesID' && + result.dig('message', 'text').to_s.match?(PIN_MESSAGE) + locations = result['locations'] + next false unless locations.is_a?(Array) && locations.length == 1 + location = locations[0]['physicalLocation'] || {} + relative = location.dig('artifactLocation', 'uri') + line = location.dig('region', 'startLine') + next false unless relative.is_a?(String) && relative.match?(%r{\A\.github/workflows/[^/]+\.ya?ml\z}) && + line.is_a?(Integer) && line.positive? + + path = File.join(root, relative) + next false unless File.file?(path) && !File.symlink?(path) && + File.realpath(path).start_with?(root + '/') && File.file?(File.join(root, '.github/workflows/actions.lock')) + next false unless action_lines(path).include?(line) + + unless verified.key?(relative) + stdout, stderr, status = Open3.capture3('gh', 'actions-lock', relative, + '--verify', '--no-interactive', '--json=valid,findings', chdir: root) + warn stderr unless stderr.empty? + verification = JSON.parse(stdout) + raise "Native action-lock verification failed for #{relative}" unless status.success? && + verification.is_a?(Hash) && verification['valid'] == true && verification['findings'].is_a?(Array) + verified[relative] = verification + end + audit << { 'file' => relative, 'line' => line, 'rule' => result['ruleId'], + 'reason' => 'False positive: native direct and transitive pins verified by gh actions-lock --verify', + 'verification' => verified.fetch(relative) } + true + end + end + [document, audit] + end +end + +if $PROGRAM_NAME == __FILE__ + begin + input, output, audit_path, root = ARGV + raise 'Usage: INPUT OUTPUT AUDIT_JSON [ROOT]' unless input && output && audit_path && ARGV.length <= 4 + raise 'Keep the original SARIF as a separate artifact' if File.expand_path(input) == File.expand_path(output) + document, audit = ScorecardActionsLock.reconcile(JSON.parse(File.read(input)), root || Dir.pwd) + File.write(output, JSON.pretty_generate(document) + "\n") + File.write(audit_path, JSON.pretty_generate(audit) + "\n") + puts "Reconciled #{audit.length} verified native action-pin false positives; all other findings retained." + rescue StandardError => error + warn "Scorecard reconciliation failed: #{error.message}" + exit 2 + end +end diff --git a/scripts/tests/governance-reusable-contract-test.sh b/scripts/tests/governance-reusable-contract-test.sh index 3d0b7a89d..ff0a91dab 100644 --- a/scripts/tests/governance-reusable-contract-test.sh +++ b/scripts/tests/governance-reusable-contract-test.sh @@ -18,8 +18,8 @@ fail() { helper_checkout="$(grep -F -A 18 -- '- name: Checkout the pinned Standards policy helpers' "$GOVERNANCE")" # GitHub expression is an asserted literal. # shellcheck disable=SC2016 -printf '%s\n' "$helper_checkout" | grep -Fq 'ref: ${{ job.workflow_sha }}' || - fail "governance helpers are not fetched from job.workflow_sha" +printf '%s\n' "$helper_checkout" | grep -Eq 'ref: [0-9a-f]{40}$' || + fail "governance helpers are not fetched from an immutable commit" if printf '%s\n' "$helper_checkout" | grep -Eq '^[[:space:]]*ref:[[:space:]]*main[[:space:]]*$'; then fail "governance helper execution still follows moving main" fi diff --git a/scripts/tests/policy-gates-test.sh b/scripts/tests/policy-gates-test.sh index cb550b146..e7260b514 100644 --- a/scripts/tests/policy-gates-test.sh +++ b/scripts/tests/policy-gates-test.sh @@ -89,10 +89,26 @@ ln -s "$(command -v git)" "$without_parser/bin/git" valid="$fixture/valid" init_fixture "$valid" mkdir -p "$valid/.github/workflows" -printf '%s\n' 'name: test' 'on: push' 'jobs: {}' > "$valid/.github/workflows/test.yml" +printf '%s\n' 'name: test' 'on: push' 'jobs:' ' test:' ' runs-on: ubuntu-latest' ' steps:' ' - run: echo tested' > "$valid/.github/workflows/test.yml" git -C "$valid" add .github/workflows/test.yml (cd "$valid" && expect_pass "$workflow_gate") +empty_jobs="$fixture/empty-jobs" +init_fixture "$empty_jobs" +mkdir -p "$empty_jobs/.github/workflows" +printf '%s\n' 'name: test' 'on: push' 'jobs: {}' > "$empty_jobs/.github/workflows/test.yml" +git -C "$empty_jobs" add .github/workflows/test.yml +(cd "$empty_jobs" && expect_fail "$workflow_gate") +for malformed_jobs in 'jobs: scalar' 'jobs: [one, two]' 'jobs: null'; do + printf '%s\n' 'name: test' 'on: push' "$malformed_jobs" > "$empty_jobs/.github/workflows/test.yml" + (cd "$empty_jobs" && expect_fail "$workflow_gate") +done + +for invalid_jobs in 'placeholder' '[placeholder]'; do + printf '%s\n' 'name: test' 'on: push' "jobs: $invalid_jobs" > "$empty_jobs/.github/workflows/test.yml" + (cd "$empty_jobs" && expect_fail "$workflow_gate") +done + invalid="$fixture/invalid" init_fixture "$invalid" mkdir -p "$invalid/.github/workflows" diff --git a/scripts/tests/reconcile-scorecard-actions-lock-test.rb b/scripts/tests/reconcile-scorecard-actions-lock-test.rb new file mode 100644 index 000000000..fedb9a09f --- /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 000000000..7bab8b699 --- /dev/null +++ b/scripts/tests/reconcile-scorecard-actions-lock-test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +set -euo pipefail +ruby "$(dirname "$0")/reconcile-scorecard-actions-lock-test.rb" diff --git a/scripts/tests/ruleset-constraint-repair-test.sh b/scripts/tests/ruleset-constraint-repair-test.sh new file mode 100644 index 000000000..d75f61264 --- /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 old mode 100755 new mode 100644 diff --git a/tests/test_governance_reusable_shape.sh b/tests/test_governance_reusable_shape.sh index d311d9c29..5f3b062a9 100755 --- a/tests/test_governance_reusable_shape.sh +++ b/tests/test_governance_reusable_shape.sh @@ -13,7 +13,7 @@ # applier (step 3) derives contexts from emitted check-runs. # 2. Every job honours `inputs.runs-on` (no hardcoded runner). # 3. `actions-lock-verify` is its own job, runs the tested gate script, and -# checks standards out at job.workflow_sha (the reusable's own SHA), never +# checks standards out at an explicit reviewed helper SHA, never # at a floating `main`. # 4. The lock check no longer hides inside workflow-lint. # 5. `continue-on-error: true` appears only in the jobs listed as advisory or @@ -60,7 +60,7 @@ njobs=$(grep -cP '^ [a-z][a-z-]*:$' "$F"); nro=$(grep -c 'runs-on: ${{ inputs.r B=$(job_block actions-lock-verify) [ -n "$B" ] && ok "actions-lock-verify job exists" || bad "actions-lock-verify job missing" printf '%s' "$B" | grep -q 'check-actions-lock-gate.sh' && ok "actions-lock-verify runs the tested gate script" || bad "actions-lock-verify does not run check-actions-lock-gate.sh" -printf '%s' "$B" | grep -q 'ref: ${{ job.workflow_sha }}' && ok "actions-lock-verify pins standards at job.workflow_sha" || bad "actions-lock-verify standards checkout not pinned to job.workflow_sha" +printf '%s' "$B" | grep -Eq 'ref: [0-9a-f]{40}$' && ok "actions-lock-verify pins standards at an immutable commit" || bad "actions-lock-verify standards checkout not pinned to an immutable commit" printf '%s' "$B" | grep -q 'ref: main' && bad "actions-lock-verify floats a standards checkout at main" || ok "actions-lock-verify has no floating ref: main" printf '%s' "$B" | grep -q 'ACTIONS_LOCK_VERIFIER=' && ok "gate is pointed at the fetched verifier" || bad "ACTIONS_LOCK_VERIFIER not set for the gate" diff --git a/tools/policy/check-workflows-parse.sh b/tools/policy/check-workflows-parse.sh index 5d99eae50..85a1d99e1 100755 --- a/tools/policy/check-workflows-parse.sh +++ b/tools/policy/check-workflows-parse.sh @@ -21,19 +21,16 @@ fi parser='' if command -v yq >/dev/null 2>&1; then parser=yq -elif command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then - parser=python elif command -v ruby >/dev/null 2>&1; then parser=ruby else - echo "::error::no YAML parser available (yq, python3+pyyaml, or ruby)" + echo "::error::no YAML parser available (yq or ruby)" >&2 exit 1 fi parse_ok() { case "$parser" in yq) yq '.' "$1" >/dev/null 2>&1 ;; - python) python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1], encoding="utf-8"))' "$1" >/dev/null 2>&1 ;; ruby) ruby -ryaml -e 'YAML.safe_load(File.read(ARGV[0]), aliases: true)' "$1" >/dev/null 2>&1 ;; esac } @@ -46,15 +43,23 @@ has_reusable_timeout() { yq) yq -e '[.jobs[] | select(has("uses") and has("timeout-minutes"))] | length > 0' "$1" >/dev/null 2>&1 ;; - python) - python3 -c 'import sys,yaml; d=yaml.safe_load(open(sys.argv[1], encoding="utf-8")) or {}; sys.exit(not any(isinstance(j,dict) and "uses" in j and "timeout-minutes" in j for j in (d.get("jobs") or {}).values()))' "$1" - ;; ruby) ruby -ryaml -e 'd=YAML.safe_load(File.read(ARGV[0]), aliases: true) || {}; jobs=d["jobs"] || {}; exit(jobs.values.any? { |j| j.is_a?(Hash) && j.key?("uses") && j.key?("timeout-minutes") } ? 0 : 1)' "$1" ;; esac } +# A syntactically valid file with no jobs is still rejected at startup. +has_no_jobs() { + local file="$1" result + case "$parser" in + yq) yq -e '(.jobs | tag) != "!!map" or (.jobs | length == 0)' "$file" >/dev/null 2>&1; result=$? ;; + ruby) ruby -ryaml -e 'd=YAML.safe_load(File.read(ARGV[0]), aliases: true); exit(!d.is_a?(Hash) || !d["jobs"].is_a?(Hash) || d["jobs"].empty? ? 0 : 1)' "$file"; result=$? ;; + *) echo "::error::unsupported workflow parser: $parser" >&2; return 0 ;; + esac + return "$result" +} + has_forbidden_control() { od -An -v -tu1 "$1" | awk ' { for (i=1; i<=NF; i++) if (($i < 9) || ($i > 10 && $i < 13) || ($i > 13 && $i < 32)) found=1 } @@ -71,6 +76,9 @@ for file in "${workflows[@]}"; do if has_forbidden_control "$file"; then echo ' contains a YAML-forbidden control character' fi + elif has_no_jobs "$file"; then + status=1 + printf '::error file=%s::workflow has no executable jobs; commented templates do not create checks\n' "$file" elif has_reusable_timeout "$file"; then status=1 printf '%s\n' "::error file=$file::a reusable-workflow call job cannot declare timeout-minutes; GitHub rejects it before creating any jobs"