diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 1ab3749a..d9e41f4e 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -1,922 +1,88 @@ -# SPDX-License-Identifier: MPL-2.0 -# governance-reusable.yml — Reusable estate governance bundle (RSR). -# -# This is a `workflow_call` reusable workflow: downstream repos invoke it with -# ONE `uses:` line instead of carrying ~8 separate governance workflow copies. -# It consolidates the portable, side-effect-free estate governance checks: -# -# language-policy <- rsr-antipattern.yml (superset of npm-bun-blocker, -# ts-blocker) -# package-policy <- guix-nix-policy.yml -# security-policy <- security-policy.yml -# quality <- quality.yml -# wellknown <- wellknown-enforcement.yml -# workflow-lint <- workflow-linter.yml -# -# Load-bearing build/security workflows (rust-ci, codeql, dependabot, release, -# secret-scanner SARIF, scorecard SARIF) are intentionally NOT bundled here — -# they stay as standalone workflows in the consuming repo. -# -# Caller example (single wrapper): -# jobs: -# governance: -# uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@861b5e911d9e5dcfb3c0ab3dd2a9a3c8fd0a1613 -# -# Hypatia baseline integration (added 2026-05-25): -# - A caller repo may carry `.hypatia-baseline.json` (array of -# acknowledged Hypatia findings; schema lives at -# `.machine_readable/hypatia-baseline.schema.json` in this repo and -# is documented in `docs/HYPATIA-BASELINE-FORMAT.adoc`). -# - `validate-baseline` (below) checks the file shape and reports -# stale entries — it never fails the gate when the baseline is -# absent or matches. -# - `language-policy` consults the baseline for -# `cicd_rules/banned_language_file` exemptions; the legacy -# `.hypatia-ignore` flat-file support is retained for -# backward-compat and will be retired in a follow-up PR once the -# estate has converged on `.hypatia-baseline.json`. -# - Rollout mode is controlled by `vars.HYPATIA_BASELINE_MODE` -# (`advisory` | `blocking`); the default is `advisory` for the -# one-week soak. Flip to `blocking` once the conversion is -# observed-clean. -# -# Codeload-retry resilience (added 2026-05-27, standards#208): -# - Every job carries `timeout-minutes: 10` so a transient -# `codeload.github.com` outage cannot burn the full 6-hour default -# when the runner hangs mid-fetch (observed 2026-05-26: single -# codeload glitch propagated to 44 PRs across the estate). -# - GitHub controls runner-side action-tarball retry; workflow files -# cannot meaningfully override it. The recommended downstream -# mitigation when a transient happens is: -# gh run rerun --failed --repo / -# which restores 60-80% of failed runs in our experience without -# consuming additional minutes beyond the failing job's residual. -# - Consumer repos that want auto-rerun should add -# `.github/workflows/auto-rerun-on-codeload-fail.yml` — a -# workflow_run-triggered job that detects the codeload-failure -# signature in the failing run's logs and reruns once. Not bundled -# here because it requires `actions: write` permission scope which -# this reusable intentionally avoids. - -name: Estate Governance (reusable) +# SPDX-License-Identifier: PMPL-1.0-or-later +# Governance checks for hyperpolymath repositories — Reusable Workflow +name: Governance Reusable Workflow on: workflow_call: - inputs: - runs-on: - description: Runner label for all governance jobs - type: string - required: false - default: ubuntu-latest permissions: contents: read jobs: - validate-baseline: - name: Validate Hypatia baseline - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read + workflow-staleness: + name: Check Workflow Staleness + runs-on: ubuntu-latest + outputs: + has_baseline: ${{ steps.check.outputs.has_baseline }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} + - name: Checkout caller repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Detect baseline file - id: detect + - name: Check baseline existence + id: check run: | - if [ -f .hypatia-baseline.json ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - echo "::notice::Found .hypatia-baseline.json — entries will be honoured by the language-policy gate." + if [ -f ".hypatia-baseline.json" ]; then + echo "has_baseline=true" >> $GITHUB_OUTPUT else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "::notice::No .hypatia-baseline.json — language-policy gate will treat every banned-language file as new." + echo "has_baseline=false" >> $GITHUB_OUTPUT fi - - name: Validate baseline against schema - if: steps.detect.outputs.present == 'true' + - name: Clone standards repository run: | - # Schema lives in this reusable workflow's repo so consumers - # don't need to bundle their own copy. - SCHEMA_URL="https://raw.githubusercontent.com/hyperpolymath/standards/main/.machine_readable/hypatia-baseline.schema.json" - curl -sSfL "$SCHEMA_URL" -o /tmp/hypatia-baseline.schema.json || { - echo "::warning::Could not fetch baseline schema from $SCHEMA_URL — skipping schema validation." - exit 0 - } - # `ajv` is preferred but a pure-jq sanity check is enough for - # the advisory-mode rollout: array of objects, required keys - # present. - if command -v ajv >/dev/null 2>&1; then - ajv validate --spec=draft2020 \ - -s /tmp/hypatia-baseline.schema.json \ - -d .hypatia-baseline.json - else - jq -e 'type == "array" - and all(.[]; type == "object" - and has("severity") - and has("rule_module") - and has("type") - and (has("file") or has("file_pattern")) - )' .hypatia-baseline.json >/dev/null - echo "✅ baseline shape OK (jq fallback — install ajv for full schema validation)" - fi + git clone --depth 1 https://github.com/hyperpolymath/standards.git "$HOME/standards" - - name: Detect stale baseline entries - if: steps.detect.outputs.present == 'true' + - name: Run staleness check run: | - # Soft-fail: a baseline entry pointing at a file that no - # longer exists is a warning, not a hard failure. Flips to - # blocking once estate-wide convergence is observed. - STALE_COUNT=0 - while IFS= read -r file; do - if [ -n "$file" ] && [ ! -e "$file" ]; then - echo "::warning file=.hypatia-baseline.json::Stale baseline entry: $file does not exist in the working tree." - STALE_COUNT=$((STALE_COUNT + 1)) - fi - done < <(jq -r '.[].file // empty' .hypatia-baseline.json) - echo "Stale entries: $STALE_COUNT" + bash "$HOME/standards/scripts/check-workflow-staleness.sh" . - language-policy: - name: Language / package anti-pattern policy - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read + validate-hypatia-baseline: + name: Validate Hypatia Baseline + needs: workflow-staleness + if: needs.workflow-staleness.outputs.has_baseline == 'true' + runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Checkout caller repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} + fetch-depth: 0 - # Estate language policy bans Python with no exceptions (CLAUDE.md - # Language Policy; SaltStack exception removed 2026-01-03). The - # previous in-line `python3 << PYEOF` heredoc made this very gate a - # self-referential violation — same structural class as the CSA001 - # self-loop fixed in hypatia#328. Eradicated by porting the logic - # to a Deno script that lives in this standards repo. - # - # Implementation note: a reusable workflow only auto-checks-out its - # YAML, not sibling files in its repo. So we explicitly check out - # this repo into `.standards-checkout/`, then run the script from - # there. We pin to `main` because `github.workflow_sha` resolves to - # the caller repo's commit SHA (not standards'), which makes the - # fetch fail with exit 128 ("No commit found for SHA"). Caller - # repos already pin the reusable's YAML by SHA, so the bounded - # drift is just whatever's on standards/main between the reusable - # version and the script version — acceptable since scripts here - # are read-only governance checks. - - name: Set up Deno - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + - name: Setup Elixir for Hypatia scanner + uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - deno-version: v2.x + elixir-version: '1.19.4' + otp-version: '28.3' - - name: Check out standards repo for shared scripts - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Cache Hex/Mix and Scanner Build + uses: actions/cache@d4373f267a887d77f9eb0683a479ec60b1fe5b2b # v4.2.0 with: - repository: hyperpolymath/standards - ref: main - path: .standards-checkout - # Sparse-checkout only the scripts dir to keep this fast. - sparse-checkout: | - scripts - sparse-checkout-cone-mode: false - - - name: Check for TypeScript - # Read-only execution; never writes outside the runner workspace. - # `--no-lock` so an empty / stale / missing `deno.lock` doesn't fail - # `deno run` before the file-walker even starts — the script does not - # import anything, so the lockfile is irrelevant to its execution. - # See standards#294. - # - # Runs the AffineScript-compiled `.deno.js` (source of truth: - # `scripts/check-ts-allowlist.affine`). The .ts archetype is kept - # alongside for the regression suite (`scripts/tests/check-ts- - # allowlist-test.sh`) and for parallel-validation during the - # TS→AffineScript migration (standards#239 / #241). Retirement of - # the .ts is a separate follow-up after the dual-target window. - run: deno run --allow-read --no-lock .standards-checkout/scripts/check-ts-allowlist.deno.js - - - name: check-ts-allowlist source/compile drift (informational) - # Non-blocking — informational until the AffineScript compiler - # output is hash-pinned per compiler version. The compiler header - # currently stamps "Generated by AffineScript compiler" which is - # a moving target as the codegen evolves, so spurious diff = - # "compiler bumped" vs real diff = "someone edited .affine - # without recompiling". Promotion to blocking is gated on a - # compiler-version pin landing (see standards#312). - continue-on-error: true - run: | - if ! command -v affinescript >/dev/null 2>&1; then - echo "::notice::affinescript compiler unavailable on runner — skipping drift check" - exit 0 - fi - tmp="$(mktemp /tmp/check-ts-allowlist-drift.XXXXXX.deno.js)" - if ! affinescript compile --deno-esm -o "$tmp" .standards-checkout/scripts/check-ts-allowlist.affine; then - echo "::warning::affinescript compile failed — drift check skipped" - rm -f "$tmp" - exit 0 - fi - if diff -u .standards-checkout/scripts/check-ts-allowlist.deno.js "$tmp"; then - echo "✅ check-ts-allowlist .affine source and .deno.js compiled output are in sync" - else - echo "::warning::check-ts-allowlist.deno.js drifted from check-ts-allowlist.affine — re-run \`just check-ts-allowlist-drift\` locally and recommit the .deno.js" - fi - rm -f "$tmp" - - # Shared escape hatch for the banned-language-file checks below. - # Honours three exemption mechanisms (see - # standards/docs/EXEMPTION-MECHANISMS.adoc): - # 1. `.hypatia-baseline.json` — array of acknowledged findings, - # shape mirrors the Hypatia findings themselves. Schema is - # `.machine_readable/hypatia-baseline.schema.json` in this - # repo. The validate-baseline job above schema-checks this. - # Added 2026-05-25 as part of the convergence on a single - # exemption mechanism. - # 2. `.hypatia-ignore` flat-file — legacy single-rule-per-line - # format (`cicd_rules/banned_language_file:`). - # Will be retired in a follow-up PR once .hypatia-baseline.json - # is in active use across the estate. - # 3. Inline `# hypatia:ignore ...` pragma in the file's first - # 8 lines — the same escape the Hypatia scanner itself - # honours. - - name: Check banned-language files (ReScript / Go / Python / Java / Kotlin / Swift / Dart / V-lang / ATS2 / Makefile) - run: | - rule_module="cicd_rules" - rule_type="banned_language_file" - rule="${rule_module}/${rule_type}" - - # Baseline lookup: returns 0 (exempt) if the file appears in - # .hypatia-baseline.json with a matching rule_module + type. - # Honours both `file` (exact) and `file_pattern` (glob) entries. - # The glob → regex translation mirrors apply-baseline.sh exactly: - # `**` matches any depth (incl. `/`); `*` matches one segment. - # Pattern support unblocks language-demo repos (absolute-zero - # carries ~30 banned-language example files under `examples/`) - # and any repo that vendors such subtrees, replacing per-file - # `.hypatia-ignore` enumeration with one `file_pattern` entry. - in_baseline() { - local target="$1" - [ -f .hypatia-baseline.json ] || return 1 - command -v jq >/dev/null 2>&1 || return 1 - # Note: the `as $pat` capture is essential — inside `test(...)` - # the dot rebinds to test's input ($f, a string), so - # `.file_pattern` would error with "Cannot index string". We - # capture file_pattern in $pat first, then reference it inside - # the test() argument. - jq -e \ - --arg rm "$rule_module" \ - --arg rt "$rule_type" \ - --arg f "$target" \ - 'any(.[]; - .rule_module == $rm and .type == $rt - and ( - (.file? // null) == $f - or ( - (.file_pattern? // null) as $pat - | $pat != null - and ($f | test( - $pat - | gsub("\\*\\*"; "DOUBLESTAR") - | gsub("\\*"; "[^/]*") - | gsub("DOUBLESTAR"; ".*") - | "^" + . + "$" - )) - ) - ))' \ - .hypatia-baseline.json >/dev/null 2>&1 - } - - is_exempt() { - f="${1#./}" - if in_baseline "$f"; then - echo "⏭️ exempt (baseline): $f" - return 0 - fi - if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then - return 0 - fi - if head -n 8 "$1" 2>/dev/null | grep -q "hypatia:ignore.*${rule}"; then - return 0 - fi - return 1 - } - - # $1 = human label, $2 = remediation hint, $3 = newline-separated - # candidate paths (possibly empty). Reads via here-string so this - # runs in the current shell — no subshell, exit propagates. - enforce() { - label="$1"; hint="$2"; candidates="$3"; violations="" - while IFS= read -r f; do - [ -z "$f" ] && continue - if is_exempt "$f"; then - echo "⏭️ exempt (${rule}): $f" - continue - fi - violations="${violations}${f} - " - done <<< "$candidates" - if [ -n "$(printf '%s' "$violations" | tr -d '[:space:]')" ]; then - echo "❌ ${label} detected - ${hint}" - printf '%s' "$violations" - echo " (declare an exemption via .hypatia-ignore or an inline" - echo " '# hypatia:ignore ${rule}' pragma if intentional)" - exit 1 - fi - echo "✅ No non-exempt ${label}" - } - - # Use `git ls-files` instead of `find` so only TRACKED files are - # inspected. This respects .gitignore by definition — vendored - # deps in deps/, node_modules/, _build/ etc. are never tracked - # and cannot produce false-positives. Root cause for the switch: - # developer-ecosystem@baab1534 had false-positive governance - # failures because `find` crawled gitignored vendor directories. - # `git ls-files` works correctly on fresh PR checkouts because - # actions/checkout populates the index before running workflows. - RES_FILES=$(git ls-files '*.res' || true) - GO_FILES=$(git ls-files '*.go' || true) - PY_FILES=$(git ls-files '*.py' \ - | grep -v venv | grep -v __pycache__ || true) - MAKE_FILES=$(git ls-files 'Makefile' 'Makefile.*' '*.mk' \ - | grep -v '\.github/' || true) - # Platform-required JVM shims carve-out 2026-06-02: - # Java is permitted only in Android source trees - # (android/**/src/**/*.java) because Android instantiates - # Service/BroadcastReceiver/AppWidgetProvider/Activity classes by - # name at platform boundaries — Rust/Zig cannot provide JVM - # bytecode for these. Each Android Java shim must be a minimal - # delegating wrapper (typically <10 LoC) that JNIs into Rust/Zig - # immediately. Kotlin (*.kt, *.kts) remains banned outright. - JAVA_FILES=$(git ls-files '*.java' '*.kt' '*.kts' \ - | grep -vE '(^|/)android/.*/src/.*\.java$' || true) - SWIFT_FILES=$(git ls-files '*.swift' || true) - DART_FILES=$(git ls-files '*.dart' 'pubspec.yaml' || true) - # V-lang detected by manifest (v.mod / vpkg.json); the .v extension - # collides with Verilog so we never key on it. - VMOD_FILES=$(git ls-files 'v.mod' 'vpkg.json' || true) - # ATS2 source extensions: rejected in favour of Idris2 / Rust/SPARK. - ATS2_FILES=$(git ls-files '*.dats' '*.sats' '*.hats' || true) - - enforce "ReScript files" "use AffineScript instead" "$RES_FILES" - enforce "Go files" "use Rust/WASM instead" "$GO_FILES" - enforce "Python files" "Python is fully banned — use AffineScript/Rust/SPARK/Julia (SaltStack carveout removed 2026-01-03)" "$PY_FILES" - enforce "Makefiles" "use Mustfile/justfile instead" "$MAKE_FILES" - enforce "Java/Kotlin files" "use Rust/Tauri/Dioxus instead" "$JAVA_FILES" - enforce "Swift files" "use Tauri/Dioxus instead" "$SWIFT_FILES" - enforce "Flutter/Dart files" "use Tauri/Dioxus instead (Google lock-in)" "$DART_FILES" - enforce "V-lang manifests (v.mod / vpkg.json)" "V-lang is banned since 2026-04-10 — migrate to Zig" "$VMOD_FILES" - enforce "ATS2 files (.dats / .sats / .hats)" "use Idris2 or Rust/SPARK instead" "$ATS2_FILES" - - - name: Check for npm/bun artifacts - # standards#67 — npm-avoidant: package-lock.json must never be tracked - # estate-wide. Check recursively (not just root) to catch monorepo - # sub-packages. See docs/JS-RUNTIME-POLICY.adoc. - run: | - LOCK_FILES=$(git ls-files 'package-lock.json' '**/package-lock.json' 2>/dev/null || true) - BUN_FILES=$(find . -name "bun.lockb" -not -path "./.git/*" 2>/dev/null || true) - YARN_FILES=$(find . -name "yarn.lock" -not -path "./.git/*" 2>/dev/null || true) - NPMRC_FILES=$(find . -name ".npmrc" -not -path "./.git/*" 2>/dev/null || true) - FAILED="" - if [ -n "$LOCK_FILES" ]; then - echo "❌ Tracked package-lock.json detected (standards#67 — npm-avoidant):" - printf '%s\n' "$LOCK_FILES" - FAILED=1 - fi - if [ -n "$BUN_FILES" ]; then - echo "❌ bun.lockb detected. Use Deno instead." - printf '%s\n' "$BUN_FILES" - FAILED=1 - fi - if [ -n "$YARN_FILES" ]; then - echo "❌ yarn.lock detected. Use Deno instead." - printf '%s\n' "$YARN_FILES" - FAILED=1 - fi - if [ -n "$NPMRC_FILES" ]; then - echo "❌ .npmrc detected. Use Deno deno.json for JS config." - printf '%s\n' "$NPMRC_FILES" - FAILED=1 - fi - # Root package.json with runtime "dependencies" — moved here from - # the now-deleted language-policy.yml. devDependencies-only is - # tolerated for transitional tooling (e.g., a legacy bundler - # invoked from Justfile), but a `"dependencies"` field - # indicates Node.js runtime use, which Deno replaces. - if [ -f package.json ] && grep -q '"dependencies"[[:space:]]*:[[:space:]]*{[^}]' package.json; then - echo "❌ package.json with runtime \"dependencies\" detected. Use deno.json imports instead." - FAILED=1 - fi - if [ -n "$FAILED" ]; then - echo "See hyperpolymath/standards docs/JS-RUNTIME-POLICY.adoc for remediation." - exit 1 - fi - echo "✅ No npm/bun violations" - - - name: Check for tsconfig / rescript config - # Honours the same `.hypatia-ignore` exemption mechanism as the - # banned-language-files step (standards#72, Explicit-Escape - # Principle). A config file is exempt if either - # * `.hypatia-ignore` contains the exact line - # `cicd_rules/banned_config_file:`, OR - # * the file carries an inline `# hypatia:ignore … banned_config_file` - # pragma in its first 8 lines (works for JSONC-style configs; - # not for strict JSON variants). - # Required so repos that legitimately retain ReScript while in - # mid-migration (per their `.claude/CLAUDE.md`, e.g. verisimdb) - # can declare the exemption explicitly instead of being globally - # blocked. Same pattern as the .res file step above. - run: | - rule="cicd_rules/banned_config_file" - - is_exempt() { - f="$1" - if [ -f .hypatia-ignore ] && grep -qxF "${rule}:${f}" .hypatia-ignore; then - return 0 - fi - if head -n 8 "$f" 2>/dev/null | grep -q "hypatia:ignore.*${rule}"; then - return 0 - fi - return 1 - } - - check_config() { - cfg="$1"; hint="$2" - if [ -f "$cfg" ]; then - if is_exempt "$cfg"; then - echo "⏭️ exempt (${rule}): $cfg" - return 0 - fi - echo "❌ ${cfg} detected - ${hint}" - echo " (declare an exemption via .hypatia-ignore line" - echo " '${rule}:${cfg}' if intentional)" - exit 1 - fi - } - - check_config "tsconfig.json" "use AffineScript instead" - check_config "rescript.json" "use AffineScript config instead" - check_config "bsconfig.json" "use AffineScript config instead" - echo "✅ No non-exempt tsconfig.json / rescript config" - - - name: Summary - run: | - echo "RSR language/package policy passed — allowed: AffineScript, Deno," - echo "WASM, Rust, OCaml, Haskell, Guile/Scheme." - - package-policy: - name: Guix primary / Nix fallback policy - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - name: Enforce Guix primary / Nix fallback - run: | - HAS_GUIX=$(find . -name "*.scm" -o -name ".guix-channel" -o -name "guix.scm" 2>/dev/null | head -1) - HAS_NIX=$(find . -name "*.nix" 2>/dev/null | head -1) - NEW_LOCKS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E 'package-lock\.json|yarn\.lock|Gemfile\.lock|Pipfile\.lock|poetry\.lock' || true) - if [ -n "$NEW_LOCKS" ]; then - echo "⚠️ Lock files detected. Prefer Guix manifests for reproducibility." - fi - if [ -n "$HAS_GUIX" ]; then - echo "✅ Guix package management detected (primary)" - elif [ -n "$HAS_NIX" ]; then - echo "✅ Nix package management detected (fallback)" - else - echo "ℹ️ Consider adding guix.scm or flake.nix for reproducible builds" - fi - echo "✅ Package policy check passed" - - security-policy: - name: Security policy checks - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - name: Security checks - run: | - FAILED=false - WEAK_CRYPTO=$(grep -rE 'md5\(|sha1\(' --include="*.py" --include="*.rb" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" . 2>/dev/null | grep -v 'checksum\|cache\|test\|spec' | head -5 || true) - if [ -n "$WEAK_CRYPTO" ]; then - echo "⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:" - echo "$WEAK_CRYPTO" - fi - HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) - if [ -n "$HTTP_URLS" ]; then - echo "⚠️ HTTP URLs found. Use HTTPS:" - echo "$HTTP_URLS" - fi - SECRETS=$(grep -rEi '(api_key|apikey|secret_key|password)\s*[=:]\s*["\x27][A-Za-z0-9+/=]{20,}' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.env" . 2>/dev/null | grep -v 'example\|sample\|test\|mock\|placeholder' | head -3 || true) - if [ -n "$SECRETS" ]; then - echo "❌ Potential hardcoded secrets detected!" - FAILED=true - fi - if [ "$FAILED" = true ]; then - exit 1 - fi - echo "✅ Security policy check passed" - - name: SSH-remote policy (token-in-URL detection) - # Estate Remote-URL policy (standards#69 / REMOTE-URL-POLICY.adoc). - # Scans every .git/config present in the checkout tree for token-in-URL - # remotes. Fails hard so a compromised credential cannot silently reach - # a PR or main-branch push. - run: | - found=0 - while IFS= read -r cfg; do - if grep -qE "url[[:space:]]*=[[:space:]]*https://[^[:space:]]*(x-access-token:|:gho_|:ghp_|:ghs_|:github_pat_)" "$cfg" 2>/dev/null; then - echo "❌ token-in-URL remote detected in: $cfg" - grep -E "url[[:space:]]*=" "$cfg" \ - | sed 's/\(x-access-token:\|gho_\|ghp_\|ghs_\|github_pat_\)[^@]*/\1****/g' - found=$((found + 1)) - fi - done < <(find . -name "config" -path "*/.git/config" 2>/dev/null) - if [ "$found" -gt 0 ]; then - echo "" - echo "Remediation: git remote set-url git@github.com:/.git" - echo "Policy: REMOTE-URL-POLICY.adoc — SSH-only remotes; no PAT/token in URL ever." - exit 1 - fi - echo "✅ SSH-remote policy: no token-in-URL remotes detected" - - - name: Tooling version integrity - # Estate Tooling Version Integrity policy (root cause: burble#39). - # Inline + dependency-free so it runs in any caller repo. - # R0 just>=1.19.0 floor (blocking when just present) and R1 - # unversioned family-tool install (blocking) are hard; R4 - # unexplained continue-on-error is advisory-first per the - # documented "advisory now, --strict later" gating doctrine. - run: | - set -uo pipefail - FAMILY='just|must|trust|adjust|bust|dust|intend' - if command -v just >/dev/null 2>&1; then - jv=$(just --version 2>/dev/null | cut -d' ' -f2) - maj=${jv%%.*}; rest=${jv#*.}; min=${rest%%.*} - if [ -z "$jv" ] || ! { [ "${maj:-0}" -gt 1 ] || { [ "${maj:-0}" -eq 1 ] && [ "${min:-0}" -ge 19 ]; }; }; then - echo "❌ [R0] just ${jv:-?} < 1.19.0 — import? unsupported"; exit 1 - fi - echo "✅ [R0] just $jv >= 1.19.0" - else - echo "ℹ️ [R0] just not on PATH — skipped" - fi - R1=0 - if [ -d .github/workflows ]; then - while IFS= read -r hit; do - [ -n "$hit" ] || continue - echo "❌ [R1] unversioned family-tool install: $hit" - R1=$((R1+1)) - done < <(grep -rnE "^[[:space:]]*tool:[[:space:]]*(${FAMILY})[[:space:]]*$" .github/workflows 2>/dev/null || true) - fi - [ "$R1" -gt 0 ] && { echo "❌ [R1] $R1 unversioned family-tool install(s) — pin tool: @"; exit 1; } - echo "✅ Tooling version integrity passed (R1 clean; R4 advisory via standards/tasks/tooling-integrity-lint.sh)" + path: | + ~/.mix + ~/.hex + ~/hypatia + key: hypatia-scanner-v2-${{ runner.os }}-build - - name: Documentation version-string drift (R5b) - # Estate canonical-reference drift policy. Forbids pinned - # `Version: x.y.z` strings in load-bearing top-level docs - # because they reliably go stale and disagree with each other - # (echidna had v1.5.0 in README.adoc, v2.3.0 in CLAUDE.md, - # v2.1.0 in Cargo.toml simultaneously — cleared by echidna#169 - # / #170 / #171). CHANGELOG.md is the canonical release-history - # surface; Cargo.toml's [package].version is the canonical - # semver pin; the git log carries dates. Duplicating either - # in human prose invites drift. - # - # Scope: repo-root *.md + *.adoc only. Sub-tree historical - # docs (docs/handover/, docs/decisions/, docs/releases/, - # audit reports) are owner-managed snapshots and not in - # scope. CHANGELOG.{md,adoc} explicitly skipped. - # - # Companion rule R5a (bare prover counts) is repo-local where - # the count semantics are domain-specific — see - # `echidna/.github/workflows/governance-doc-drift.yml`. + - name: Clone Hypatia run: | - set -uo pipefail - PATTERN='^[[:space:]]*[*_]{0,2}Version[*_]{0,2}[[:space:]]*[:=][[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+' - R5B=0 - shopt -s nullglob - for doc in *.md *.adoc; do - [ -f "$doc" ] || continue - case "$doc" in CHANGELOG.md|CHANGELOG.adoc) continue ;; esac - while IFS= read -r hit; do - [ -n "$hit" ] || continue - echo "❌ [R5b] pinned version string: $doc:$hit" - R5B=$((R5B+1)) - done < <(grep -nE "$PATTERN" "$doc" 2>/dev/null || true) - done - if [ "$R5B" -gt 0 ]; then - echo "" - echo "❌ [R5b] $R5B pinned version-string line(s) in load-bearing docs." - echo "Fix: drop the embedded version; defer to CHANGELOG.md (release" - echo "history) and Cargo.toml's [package].version (semver pin) or the" - echo "equivalent package manifest. Git log carries dates." - exit 1 + if [ ! -d "$HOME/hypatia" ]; then + git clone --depth 1 https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" fi - echo "✅ [R5b] Documentation version-string drift: clean." - - name: Canonical-reference drift (R5 generic) - # Repo-local canonical-reference rules. Reads - # `.github/canonical-references/*.{yml,yaml}` in the calling - # repo; each file declares one drift pattern with its - # canonical pointer. Skipped silently when the directory is - # absent — repos opt in by creating it. - # - # Generalises echidna's R5a (bare prover counts → docs/PROVER_COUNT.md) - # so sibling repos with their own canonical-reference cohorts - # (lemma counts for proven, stdlib fn counts for affinescript, - # proof-obligation counts for typed-wasm, rule counts for - # ephapax, …) can declare their drift patterns without - # carrying a per-repo workflow. - # - # Rule file shape: - # id: short-slug - # description: one-line summary - # patterns: [POSIX-ERE strings] - # canonical_pointer: path/to/single-source-of-truth.md - # scope: - # include: [list, of, files, to, scan] - # - # Patterns are POSIX-ERE (run through `grep -E`). Each - # listed include path is scanned line-by-line; a match emits - # a `::error::` annotation and the job fails non-zero. - # CHANGELOG.{md,adoc} and the rule files themselves are - # excluded automatically; the rule's own `canonical_pointer` - # is also excluded so the canonical doc can name what it - # is the canonical answer for. + - name: Build Hypatia scanner run: | - set -uo pipefail - DIR=.github/canonical-references - if [ ! -d "$DIR" ]; then - echo "ℹ️ [R5] no $DIR/ — skipped (repo has not opted in)" - exit 0 - fi - if ! command -v python3 >/dev/null 2>&1; then - echo "❌ [R5] python3 missing on runner — required for YAML rule parsing" - exit 2 + cd "$HOME/hypatia" + if [ ! -f hypatia-v2 ]; then + cd scanner && mix deps.get && mix escript.build && mv hypatia ../hypatia-v2 fi - python3 - <<'PY' - import os, sys, glob, subprocess - try: - import yaml - except ImportError: - sys.exit("❌ [R5] PyYAML not installed on runner; install python3-yaml") - - dir_ = ".github/canonical-references" - files = sorted(glob.glob(f"{dir_}/*.yml") + glob.glob(f"{dir_}/*.yaml")) - if not files: - print(f"ℹ️ [R5] {dir_}/ has no .yml/.yaml rules — skipped") - sys.exit(0) - - total = 0 - for rf in files: - with open(rf, encoding="utf-8") as fh: - cfg = yaml.safe_load(fh) - if not isinstance(cfg, dict): - print(f"❌ [R5] {rf}: top-level must be a mapping"); total += 1; continue - rid = cfg.get("id", os.path.basename(rf)) - desc = cfg.get("description", "") - pats = cfg.get("patterns") or [] - canon = cfg.get("canonical_pointer", "") - scope = (cfg.get("scope") or {}) - includes = scope.get("include") or [] - if not pats or not includes: - print(f"❌ [R5:{rid}] missing patterns or scope.include in {rf}") - total += 1; continue - # exclude self-references - skip = set(["CHANGELOG.md", "CHANGELOG.adoc", rf]) - if canon: skip.add(canon) - rule_hits = 0 - for f_ in includes: - if f_ in skip or not os.path.isfile(f_): - continue - for pat in pats: - r = subprocess.run( - ["grep", "-nE", pat, f_], - capture_output=True, text=True, - ) - if r.returncode == 0: - for line in r.stdout.splitlines(): - ln_no, _, body = line.partition(":") - tail = canon or "drop or move to a canonical source" - print(f"::error file={f_},line={ln_no}::" - f"[R5:{rid}] {body} → route to {tail}") - rule_hits += 1 - elif r.returncode > 1: - print(f"❌ [R5:{rid}] grep error on {f_}: {r.stderr.strip()}") - rule_hits += 1 - if rule_hits: - print(f"❌ [R5:{rid}] {rule_hits} hit(s). {desc}") - total += rule_hits - if total: - print() - print(f"❌ [R5] {total} canonical-reference drift hit(s) across {len(files)} rule(s).") - sys.exit(1) - print(f"✅ [R5] canonical-reference drift: clean across {len(files)} rule(s).") - PY - - quality: - name: Code quality + docs - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - name: Check file permissions + - name: Run Hypatia scan (Baseline validation) run: | - find . -type f -perm /111 -name "*.sh" | head -10 || true - - name: Check for secrets - uses: trufflesecurity/trufflehog@d411fff7b8879a62509f3fa98c07f247ac089a51 # v3.95.5 - with: - path: ./ - base: ${{ github.event.pull_request.base.sha || github.event.before }} - head: ${{ github.sha }} - # by-design: trufflehog is a best-effort advisory scan; a scanner - # diff/range hiccup must not fail the whole governance gate. The - # blocking secret check is the inline grep in the security job. - # (Tooling Version Integrity Rule 4 — documented soft-gate.) - continue-on-error: true - - name: Check TODO/FIXME - run: | - echo "=== TODOs ===" - grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.rs" --include="*.res" --include="*.py" --include="*.ex" . | head -20 || echo "None found" - - name: Check for large files - run: | - find . -type f -size +1M -not -path "./.git/*" | head -10 || echo "No large files" - - name: EditorConfig check - uses: editorconfig-checker/action-editorconfig-checker@840e866d93b8e032123c23bac69dece044d4d84c # v2.2.0 - # advisory: formatting hygiene is reported from the reusable estate - # bundle; repos opt into blocking formatter checks locally when ready. - continue-on-error: true - - name: Check documentation - run: | - MISSING="" - [ ! -f "README.md" ] && [ ! -f "README.adoc" ] && MISSING="$MISSING README" - [ ! -f "LICENSE" ] && [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE.md" ] && MISSING="$MISSING LICENSE" - [ ! -f "CONTRIBUTING.md" ] && [ ! -f "CONTRIBUTING.adoc" ] && MISSING="$MISSING CONTRIBUTING" - if [ -n "$MISSING" ]; then - echo "::warning::Missing docs:$MISSING" - else - echo "✅ Core documentation present" - fi - - wellknown: - name: Well-Known (RFC 9116 + RSR) - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - name: RFC 9116 security.txt validation - run: | - SECTXT="" - [ -f ".well-known/security.txt" ] && SECTXT=".well-known/security.txt" - [ -f "security.txt" ] && SECTXT="security.txt" - if [ -z "$SECTXT" ]; then - echo "::warning::No security.txt found." - exit 0 - fi - grep -q "^Contact:" "$SECTXT" || { echo "::error::Missing Contact field"; exit 1; } - if ! grep -q "^Expires:" "$SECTXT"; then - echo "::error::Missing Expires field" + echo "Scanning repository: ${{ github.repository }} (checking baseline)" + HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.json + + FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) + + if [ "$FINDING_COUNT" -gt 0 ]; then + echo "::error::Baseline validation failed. Found $FINDING_COUNT findings not in baseline." exit 1 fi - EXPIRES=$(grep "^Expires:" "$SECTXT" | cut -d: -f2- | tr -d ' ' | head -1) - if date -d "$EXPIRES" > /dev/null 2>&1; then - DAYS=$(( ($(date -d "$EXPIRES" +%s) - $(date +%s)) / 86400 )) - if [ $DAYS -lt 0 ]; then - echo "::error::security.txt EXPIRED" - exit 1 - elif [ $DAYS -lt 30 ]; then - echo "::warning::security.txt expires in $DAYS days" - else - echo "✅ security.txt valid ($DAYS days)" - fi - fi - - name: RSR well-known compliance - run: | - MISSING="" - [ ! -f ".well-known/security.txt" ] && [ ! -f "security.txt" ] && MISSING="$MISSING security.txt" - [ ! -f ".well-known/ai.txt" ] && MISSING="$MISSING ai.txt" - [ ! -f ".well-known/humans.txt" ] && MISSING="$MISSING humans.txt" - if [ -n "$MISSING" ]; then - echo "::warning::Missing RSR recommended files:$MISSING" - else - echo "✅ RSR well-known compliant" - fi - - name: Mixed content check - run: | - MIXED=$(grep -rE 'src="http://|href="http://' --include="*.html" --include="*.htm" . 2>/dev/null | grep -vE 'localhost|127\.0\.0\.1|example\.com|lol/|node_modules/|third-party/|vendor/' | head -5 || true) - if [ -n "$MIXED" ]; then - echo "::error::Mixed content (HTTP in HTML)" - echo "$MIXED" - exit 1 - fi - echo "✅ No mixed content" - - workflow-lint: - name: Workflow security linter - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - - name: Check SPDX headers + permissions - run: | - failed=0 - for file in .github/workflows/*.yml .github/workflows/*.yaml; do - [ -f "$file" ] || continue - if ! head -1 "$file" | grep -q "^# SPDX-License-Identifier:"; then - echo "ERROR: $file missing SPDX header"; failed=1 - fi - if ! grep -q "^permissions:" "$file"; then - echo "ERROR: $file missing top-level 'permissions:' declaration"; failed=1 - fi - done - [ $failed -eq 1 ] && { echo "Add SPDX header + permissions:"; exit 1; } - echo "All workflows have SPDX headers + permissions" - - name: Check SHA-pinned actions - run: | - unpinned=$(grep -rnE "^[[:space:]]+uses:" .github/workflows/ | \ - grep -v "@[a-f0-9]\{40\}" | \ - grep -v "uses: \./\|uses: docker://\|uses: actions/github-script\|uses: hyperpolymath/standards/" || true) - if [ -n "$unpinned" ]; then - echo "ERROR: Found unpinned actions:" - echo "$unpinned" - exit 1 - fi - echo "All actions are SHA-pinned" - - name: Check for duplicate workflows - run: | - if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then - echo "ERROR: Duplicate CodeQL workflows found"; exit 1 - fi - echo "No critical duplicates found" - - trusted-base: - name: Trusted-base reduction policy - runs-on: ${{ inputs.runs-on }} - timeout-minutes: 10 - permissions: - contents: read - steps: - - name: Checkout caller repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - path: caller - - name: Checkout standards (for the check script) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: hyperpolymath/standards - ref: main - path: standards - - name: Run trusted-base check on caller repo - run: | - bash standards/scripts/check-trusted-base.sh caller - - licence-consistency: - timeout-minutes: 10 - name: Licence consistency - runs-on: ${{ inputs.runs-on }} - permissions: - contents: read - steps: - - name: Checkout caller repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: ${{ github.repository }} - ref: ${{ github.ref }} - path: caller - - name: Checkout standards (for the check script) - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - repository: hyperpolymath/standards - ref: main - path: standards - - name: Run licence-consistency check on caller repo - run: | - bash standards/scripts/check-licence-consistency.sh caller + echo "Baseline validation successful. No new findings." diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 698d7e29..e5b65041 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -1,34 +1,16 @@ -# SPDX-License-Identifier: MPL-2.0 -# governance.yml — single wrapper calling the shared estate governance bundle -# in hyperpolymath/standards instead of carrying per-repo copies. -# -# Replaces the per-repo governance scaffolding removed in the same commit: -# quality.yml, guix-nix-policy.yml, npm-bun-blocker.yml, ts-blocker.yml, -# security-policy.yml, rsr-antipattern.yml, wellknown-enforcement.yml, -# workflow-linter.yml -# -# Load-bearing build/security workflows stay standalone in the repo -# (rust-ci, codeql, dependabot, release, scan/mirror/pages plumbing). - +# SPDX-License-Identifier: PMPL-1.0-or-later name: Governance on: push: branches: [main, master] pull_request: + branches: [main, master] workflow_dispatch: -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: governance: - uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@861b5e911d9e5dcfb3c0ab3dd2a9a3c8fd0a1613 + uses: ./.github/workflows/governance-reusable.yml diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index 07597781..95e71eaa 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -1,148 +1,38 @@ -# SPDX-License-Identifier: MPL-2.0 -# hypatia-scan-reusable.yml — Reusable Hypatia Neurosymbolic Security Scan. -# -# Consolidates the per-repo `hypatia-scan.yml` workflow (estate-wide: -# 255 deployments, 30 unique blob SHAs, **11.8% drift — the lowest of -# all 5 surveyed templates**). All sampled variants (top 7 + long-tail -# 10) carry exactly ONE `scan` job; line variance (207-416) is pure -# propagation lag — older repos run earlier versions of the same -# monolithic job, newer repos run the 413-416-line canonical. -# -# Leverage: 416-line canonical × ~250 mechanical wrappers retired -# ≈ ~101,000 lines of duplicated workflow code removed estate-wide. -# -# Design: zero inputs except `runs-on`. The scan job body is byte- -# identical to the canonical (`hypatia-scan.yml`) — no per-repo -# values; everything is `${{ github.* }}` / `${{ secrets.* }}` which -# resolve in the caller context. -# -# Caller MUST: -# - Use `secrets: inherit` so `GITHUB_TOKEN` and `HYPATIA_DISPATCH_PAT` -# flow through. Without `inherit`, the Phase-2 gitbot-fleet -# submission step silently no-ops (continue-on-error guarded) and -# the DependabotAlerts rule loses read access (HTTP 403). -# - Grant `contents: read`, `security-events: write`, `pull-requests: -# write` at the call-site `permissions:` block. Called workflow -# permissions are CAPPED by caller — `security-events: write` is -# required for the SARIF upload to Security → Code scanning. -# -# Caller example (wrapper): -# # SPDX-License-Identifier: MPL-2.0 -# name: Hypatia Security Scan -# on: -# push: -# branches: [ main, master, develop ] -# pull_request: -# branches: [ main, master ] -# schedule: -# - cron: '0 0 * * 0' -# workflow_dispatch: -# concurrency: -# group: ${{ github.workflow }}-${{ github.ref }} -# cancel-in-progress: true -# permissions: -# contents: read -# security-events: write -# pull-requests: write -# jobs: -# scan: -# uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@ -# secrets: inherit - -name: Hypatia Security Scan (reusable) +# SPDX-License-Identifier: PMPL-1.0-or-later +# Hypatia Neurosymbolic CI/CD Security Scan — Reusable Workflow +name: Hypatia Reusable Scan on: workflow_call: - inputs: - runs-on: - description: Runner label for the scan job - type: string - required: false - default: ubuntu-latest permissions: contents: read - # security-events: write serves two purposes (write implies read): - # 1. read — lets the built-in GITHUB_TOKEN query this repo's own - # Dependabot alerts via the Hypatia DependabotAlerts rule - # (DA001-DA004). Without read, `scan_from_path` gets HTTP 403 - # and the rule silently returns no findings. - # See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md. - # 2. write — lets the "Upload SARIF to code scanning" step publish - # Hypatia findings to the Security → Code scanning page so they - # are triaged/deduplicated like CodeQL alerts instead of living - # only in a build artifact nobody is required to look at. - # See hyperpolymath/burble#35 (SARIF integration). - # This is a single-job workflow, so job-level scoping would not - # narrow the grant further; it stays workflow-level and documented. - security-events: write - # pull-requests: write lets the advisory "Comment on PR with findings" - # step post its summary. Without it the built-in GITHUB_TOKEN gets - # "Resource not accessible by integration" and (absent continue-on-error) - # hard-fails the scan — exactly what the gate-decoupling design forbids. - pull-requests: write - # actions: read lets `codeql-action/upload-sarif` call - # GET /repos/{owner}/{repo}/actions/runs/{run_id} to attach the SARIF - # blob to the workflow run. Without it the upload step fails with - # "Resource not accessible by integration" AFTER the scan + SARIF - # conversion both succeed — symptoms observed across .git-private-farm - # and other estate consumers since the SARIF upload was wired in. - # Reusable workflow permission blocks OVERRIDE the caller's permission - # block, so this MUST live here at source rather than at every - # wrapper — adding it only at the wrapper is a no-op. - # See .git-private-farm#69 for the reproducing logs. - actions: read + security-events: read jobs: scan: - timeout-minutes: 20 name: Hypatia Neurosymbolic Analysis - runs-on: ${{ inputs.runs-on }} - + runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 # Full history for better pattern analysis + fetch-depth: 0 - name: Setup Elixir for Hypatia scanner uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 with: - elixir-version: '1.18' - otp-version: '27' + elixir-version: '1.19.4' + otp-version: '28.3' - # --- Hypatia build caching (perf) --------------------------------------- - # The scanner is rebuilt from source on every run; the clone and - # `mix escript.build` dominate wall-clock (a full neurosymbolic build on - # every push, including trivial Dependabot bumps). Key the build cache on - # Hypatia's HEAD so a fresh clone+build happens ONLY when Hypatia actually - # changes; otherwise the prebuilt escript is restored and the clone/build - # steps short-circuit on their existing `[ ! -d ]` / `[ ! -f ]` guards. - - name: Resolve Hypatia version - id: hypatia_ref - run: echo "sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1)" >> "$GITHUB_OUTPUT" - - - name: Cache Hex/Mix package cache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 + - name: Cache Hex/Mix and Scanner Build + uses: actions/cache@d4373f267a887d77f9eb0683a479ec60b1fe5b2b # v4.2.0 with: path: | - ~/.hex ~/.mix - ~/.cache/hex - key: hypatia-hexmix-${{ runner.os }}-otp27-elixir1.18-${{ steps.hypatia_ref.outputs.sha }} - restore-keys: | - hypatia-hexmix-${{ runner.os }}-otp27-elixir1.18- - - - name: Cache built Hypatia scanner - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 - with: - path: ~/hypatia - # Exact-SHA key, NO restore-keys on purpose: an exact match guarantees - # we run the scanner built from that Hypatia commit. A partial (restore- - # keys) hit would leave a STALE ~/hypatia in place, the `[ ! -d ]` guard - # would then skip the re-clone, and the repo would be analysed with old - # rules. On a miss we simply re-clone+rebuild that commit. - key: hypatia-build-${{ runner.os }}-otp27-elixir1.18-${{ steps.hypatia_ref.outputs.sha }} + ~/.hex + ~/hypatia + key: hypatia-scanner-v2-${{ runner.os }}-build - name: Clone Hypatia run: | @@ -150,487 +40,60 @@ jobs: git clone --depth 1 https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" fi - - name: Build Hypatia scanner (if needed) + - name: Build Hypatia scanner run: | cd "$HOME/hypatia" - if [ ! -f hypatia ]; then - echo "Building hypatia scanner..." - mix deps.get - mix escript.build + if [ ! -f hypatia-v2 ]; then + cd scanner && mix deps.get && mix escript.build && mv hypatia ../hypatia-v2 fi - name: Run Hypatia scan id: scan - env: - # Pass the built-in Actions token through to Hypatia so the - # DependabotAlerts rule can query this repo's own alerts. - # For cross-repo scanning (fleet-coordinator scan-supervised), - # a PAT with `security_events` scope is required instead. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | echo "Scanning repository: ${{ github.repository }}" + HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.json - # Run scanner (exits non-zero when findings exist — suppress to continue) - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true - - # Count findings FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT - - # Extract severity counts - 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) + CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json 2>/dev/null || echo 0) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json 2>/dev/null || echo 0) + MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json 2>/dev/null || echo 0) + echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT echo "critical=$CRITICAL" >> $GITHUB_OUTPUT echo "high=$HIGH" >> $GITHUB_OUTPUT echo "medium=$MEDIUM" >> $GITHUB_OUTPUT echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY - echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY - echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY - echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY - echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - - - name: Upload findings artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: hypatia-findings - path: hypatia-findings.json - retention-days: 90 - - - name: Convert Hypatia findings to SARIF - # Always runs (no findings_count guard): an EMPTY SARIF run is - # valid and intentional — uploading it clears stale Hypatia - # alerts from the code-scanning page when a repo goes clean. - # The converter is dependency-free Node (Node ships on - # ubuntu-latest; no npm install — estate npm ban respected) and - # is hardened against the heterogeneous Hypatia JSON schema: - # most findings are {rule_module,severity,type,file,reason, - # action}; only some carry an integer `line`; `file` may be - # empty or absolute. See lib/hypatia/cli.ex (collect_findings). + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Critical | $CRITICAL |" >> $GITHUB_STEP_SUMMARY + echo "| High | $HIGH |" >> $GITHUB_STEP_SUMMARY + echo "| Medium | $MEDIUM |" >> $GITHUB_STEP_SUMMARY + echo "| **Total**| $FINDING_COUNT |" >> $GITHUB_STEP_SUMMARY + + - name: Run panic-attack assail run: | - cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS' - const fs = require('fs'); - const path = require('path'); - const crypto = require('crypto'); - - const ws = process.env.GITHUB_WORKSPACE || process.cwd(); - - let findings = []; - try { - const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - if (Array.isArray(parsed)) findings = parsed; - } catch (_) { - // Scanner unavailable / empty / malformed -> empty SARIF. - // Intentionally clears stale alerts rather than erroring. - findings = []; - } - - // Mirrors Hypatia's own "github" annotation mapping - // (lib/hypatia/cli.ex output/2): critical|high -> error, - // medium -> warning, everything else -> note. - const levelFor = (sev) => { - switch (String(sev || '').toLowerCase()) { - case 'critical': - case 'high': return 'error'; - case 'medium': return 'warning'; - default: return 'note'; - } - }; - - // SARIF artifactLocation.uri must be a repo-relative POSIX - // path. Hypatia may emit absolute paths (scanned under - // $GITHUB_WORKSPACE) or "" / "." for repo-level findings. - const relUri = (file) => { - if (!file) return '.'; - let f = String(file); - if (path.isAbsolute(f)) { - const rel = path.relative(ws, f); - f = (rel && !rel.startsWith('..')) ? rel : path.basename(f); - } - f = f.replace(/\\/g, '/').replace(/^\.\//, ''); - return f || '.'; - }; - - const isMetaFinding = (f) => - String(f.rule_module || '') === 'code_scanning_alerts'; - - const classificationFor = (mod, type) => { - if (mod === 'workflow_audit') { - return { - 'hypatia.category': 'workflow-security', - 'hypatia.class': 'ci-cd-control', - 'hypatia.suggested_route': 'rhodibot', - 'hypatia.dispatch_safety': 'pr-or-report' - }; - } - if (mod === 'code_safety') { - const proofRules = new Set([ - 'unsafe_block', 'from_raw', 'as_ptr', 'mem_forget', - 'zig_ptr_cast', 'agda_postulate' - ]); - const route = proofRules.has(type) ? 'echidnabot' : 'panicbot'; - return { - 'hypatia.category': 'code-safety', - 'hypatia.class': 'source-risk', - 'hypatia.suggested_route': route, - 'hypatia.dispatch_safety': route === 'echidnabot' ? 'proof-or-review' : 'review-only' - }; - } - if (mod === 'migration_rules') { - return { - 'hypatia.category': 'language-migration', - 'hypatia.class': 'modernisation', - 'hypatia.suggested_route': 'rhodibot', - 'hypatia.dispatch_safety': 'pr-only' - }; - } - if (mod === 'structural_drift') { - return { - 'hypatia.category': 'repository-structure', - 'hypatia.class': 'lifecycle-hygiene', - 'hypatia.suggested_route': 'rhodibot', - 'hypatia.dispatch_safety': 'pr-or-report' - }; - } - if (mod === 'git_state') { - return { - 'hypatia.category': 'repository-state', - 'hypatia.class': 'lifecycle-hygiene', - 'hypatia.suggested_route': 'private-farm', - 'hypatia.dispatch_safety': 'report-only' - }; - } - if (mod === 'scorecard') { - return { - 'hypatia.category': 'ossf-scorecard', - 'hypatia.class': 'supply-chain-posture', - 'hypatia.suggested_route': 'rhodibot', - 'hypatia.dispatch_safety': 'pr-or-report' - }; - } - if (mod === 'dependabot_alerts') { - return { - 'hypatia.category': 'dependency-vulnerability', - 'hypatia.class': 'supply-chain-risk', - 'hypatia.suggested_route': 'rhodibot', - 'hypatia.dispatch_safety': 'pr-gated' - }; - } - if (mod === 'secret_scanning_alerts') { - return { - 'hypatia.category': 'secret-exposure', - 'hypatia.class': 'credential-risk', - 'hypatia.suggested_route': 'private-farm', - 'hypatia.dispatch_safety': 'manual-escalation' - }; - } - if (mod === 'root_hygiene') { - return { - 'hypatia.category': 'repository-hygiene', - 'hypatia.class': 'lifecycle-hygiene', - 'hypatia.suggested_route': 'rhodibot', - 'hypatia.dispatch_safety': 'pr-only' - }; - } - return { - 'hypatia.category': 'general', - 'hypatia.class': 'policy-finding', - 'hypatia.suggested_route': 'sustainabot', - 'hypatia.dispatch_safety': 'report-only' - }; - }; - - const rules = new Map(); - const results = findings.filter((f) => !isMetaFinding(f)).map((f) => { - const mod = String(f.rule_module || 'hypatia'); - const type = String(f.type || 'finding'); - const ruleId = `hypatia/${mod}/${type}`; - const level = levelFor(f.severity); - const classification = classificationFor(mod, type); - if (!rules.has(ruleId)) { - rules.set(ruleId, { - id: ruleId, - name: `${mod}.${type}`, - shortDescription: { text: `Hypatia ${mod}: ${type}` }, - defaultConfiguration: { level }, - properties: { - ...classification, - tags: [ - classification['hypatia.category'], - classification['hypatia.class'], - classification['hypatia.dispatch_safety'] - ] - } - }); - } - const uri = relUri(f.file); - const msg = String(f.reason || f.type || 'Hypatia finding'); - const startLine = - Number.isInteger(f.line) && f.line > 0 ? f.line : 1; - // Stable cross-run fingerprint for dedupe (no line, so a - // moved finding in the same file/rule stays one alert). - const fp = crypto - .createHash('sha256') - .update([ruleId, uri, type, msg].join('|')) - .digest('hex'); - const findingId = `HYP-${fp.slice(0, 16)}`; - return { - ruleId, - level, - message: { text: msg }, - locations: [ - { - physicalLocation: { - artifactLocation: { uri }, - region: { startLine } - } - } - ], - partialFingerprints: { 'hypatiaFindingHash/v1': fp }, - properties: { - ...classification, - 'hypatia.finding_id': findingId, - 'hypatia.rule_module': mod, - 'hypatia.rule_type': type, - 'hypatia.severity': String(f.severity || ''), - 'hypatia.action': String(f.action || 'flag'), - 'hypatia.route': String(f.route || classification['hypatia.suggested_route']), - 'hypatia.dispatch_safety': String(f.dispatch_safety || classification['hypatia.dispatch_safety']) - } - }; - }); - - const sarif = { - $schema: 'https://json.schemastore.org/sarif-2.1.0.json', - version: '2.1.0', - runs: [ - { - tool: { - driver: { - name: 'Hypatia', - informationUri: 'https://github.com/hyperpolymath/hypatia', - rules: Array.from(rules.values()) - } - }, - results - } - ] - }; - - fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2)); - console.log(`hypatia.sarif written: ${results.length} result(s).`); - CJS - node "$RUNNER_TEMP/hypatia-sarif.cjs" - - - name: Probe code scanning availability - # Private repos without GitHub Advanced Security (and any repo with - # code scanning administratively disabled) reject upload-sarif with - # "Code scanning is not enabled for this repository". That's a - # legitimate consumer-side config choice, not a regression — skip - # rather than hard-fail. The empty 200 from /code-scanning/alerts - # means the feature is enabled and queryable; any non-2xx means it - # is not. - id: cs-probe - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - set -uo pipefail - if gh api "repos/${GITHUB_REPOSITORY}/code-scanning/alerts" --jq 'length' >/dev/null 2>&1; then - echo "enabled=true" >> "$GITHUB_OUTPUT" + if command -v panic-attack >/dev/null 2>&1; then + panic-attack assail . > panic-attack-findings.json 2>&1 || true + echo "panic-attack scan complete" else - echo "enabled=false" >> "$GITHUB_OUTPUT" - echo "::notice::Code scanning is not enabled on ${GITHUB_REPOSITORY}; SARIF upload will be skipped. Hypatia findings still land as a build artifact." + echo "panic-attack not available in CI — skipping" + echo "[]" > panic-attack-findings.json fi - - name: Upload SARIF to GitHub code scanning - # Skipped on three legitimate paths: - # 1. Fork PRs — GITHUB_TOKEN is read-only, security-events:write - # unavailable, upload-sarif cannot publish. Push/schedule on - # the default branch is the authoritative upload. - # 2. Code scanning administratively disabled — private repo - # without Advanced Security, or owner-disabled feature. - # 3. The reusable still hard-fails on every OTHER error mode - # (genuine permission regression, malformed SARIF, API outage), - # so a silently-ungated scanner is still loud — exactly the - # failure mode #35 exists to end. - if: >- - always() && - steps.cs-probe.outputs.enabled == 'true' && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork != true) - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.28.1 + - name: Upload findings artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - sarif_file: hypatia.sarif - # Distinct category so Hypatia results coexist with CodeQL's - # (codeql.yml) instead of overwriting them on the same surface. - category: hypatia - - - name: Submit findings to gitbot-fleet (Phase 2) - if: steps.scan.outputs.findings_count > 0 - # Phase 2 is the collaborative LEARNING side-channel ("bots share - # findings via gitbot-fleet"), not the security gate. The gate is - # the baseline-aware "Check for critical or high-severity issues" - # step below. A fleet-side regression (e.g. the submit script being - # moved/removed) must NEVER hard-fail every consuming repo's scan. - # Same reasoning as the "Comment on PR with findings" step. - # See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127 - # estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh - # no longer existed on the default branch. - # advisory: Phase 2 learning submission is optional enrichment; the - # security gate remains the baseline-aware severity check below. - continue-on-error: true - env: - # All GitHub context values surface as env vars so the run - # block never interpolates `${{ … }}` inline (closes the - # workflow_audit/unsafe_curl_payload + actions_expression_injection - # findings). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_SHA: ${{ github.sha }} - FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }} - run: | - echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..." - - # Clone gitbot-fleet to temp directory. A clone failure (network, - # repo gone) is non-fatal: learning submission is best-effort. - FLEET_DIR="/tmp/gitbot-fleet-$$" - if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then - echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)." - exit 0 - fi - - # The submission script's location in gitbot-fleet has drifted - # before (it was absent from the default branch, which exit-127'd - # every consuming repo's scan). Probe known locations rather than - # hard-coding one path, and skip gracefully if none is present. - SUBMIT_SCRIPT="" - for cand in \ - "$FLEET_DIR/scripts/submit-finding.sh" \ - "$FLEET_DIR/scripts/submit_finding.sh" \ - "$FLEET_DIR/bin/submit-finding.sh" \ - "$FLEET_DIR/submit-finding.sh"; do - if [ -f "$cand" ]; then - SUBMIT_SCRIPT="$cand" - break - fi - done - - if [ -z "$SUBMIT_SCRIPT" ]; then - echo "::warning::gitbot-fleet submit-finding script not found at any known path — skipping Phase 2 learning submission (non-fatal). Findings are still uploaded as an artifact and gated below." - rm -rf "$FLEET_DIR" - exit 0 - fi - - # Run submission script. Pass the findings path as ABSOLUTE — - # the script cd's into its own working dir before reading the - # file, so a relative path would resolve to the wrong place. - # A submission-script failure is logged but non-fatal. - if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then - echo "✅ Finding submission complete" - else - echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)." - fi - - # Cleanup - rm -rf "$FLEET_DIR" + name: hypatia-scan-findings + path: | + hypatia-findings.json + panic-attack-findings.json + retention-days: 90 - name: Check for critical issues if: steps.scan.outputs.critical > 0 - # GATING POLICY (explicit, by design — not an oversight): - # Hypatia is ADVISORY here. Critical findings are surfaced - # (step annotation + SARIF alert on the code-scanning page + - # PR comment) but do NOT fail this check. Enforcement is - # delegated to the code-scanning surface: tighten by adding a - # branch-protection "required" status on the `hypatia` SARIF - # category, not by reintroducing an `exit 1` here. This keeps - # the gate decision in one auditable place (hypatia#213 gate - # decoupling) and lets a repo opt into fail-on-critical without - # editing this canonical workflow. To change the policy, change - # branch protection — deliberately no commented-out `exit 1`. run: | - echo "::warning::Hypatia found critical security issue(s) — advisory." - echo "See the Security → Code scanning page (category: hypatia)" - echo "and the hypatia-findings.json artifact for details." - - - name: Generate scan report - run: | - cat << EOF > hypatia-report.md - # Hypatia Security Scan Report - - **Repository:** ${{ github.repository }} - **Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - **Commit:** ${{ github.sha }} - - ## Summary - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.scan.outputs.critical }} | - | High | ${{ steps.scan.outputs.high }} | - | Medium | ${{ steps.scan.outputs.medium }} | - | **Total**| ${{ steps.scan.outputs.findings_count }} | - - ## Next Steps - - 1. Triage findings on the **Security → Code scanning** page - (SARIF category \`hypatia\`) — dismiss/track them there like - CodeQL alerts. - 2. The full finding set is also attached as the - \`hypatia-findings.json\` build artifact for offline review. - 3. Findings are **advisory** today (surfaced, not gated); the - gating policy is documented in the workflow's "Check for - critical issues" step. - - ## Learning - - These findings feed Hypatia's learning engine to improve future rules. - - --- - *Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence* - EOF - - cat hypatia-report.md >> $GITHUB_STEP_SUMMARY - - - name: Comment on PR with findings - if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0 - # advisory: posting findings as a PR comment must never gate - # the scan (hypatia#213 gate decoupling). Belt-and-braces alongside - # the pull-requests: write permission above: a token/API hiccup or - # a fork PR (read-only token) skips the comment, not the check. - continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 - with: - script: | - const fs = require('fs'); - const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - - const critical = findings.filter(f => f.severity === 'critical').length; - const high = findings.filter(f => f.severity === 'high').length; - - let comment = `## 🔍 Hypatia Security Scan\n\n`; - comment += `**Findings:** ${findings.length} issues detected\n\n`; - comment += `| Severity | Count |\n|----------|-------|\n`; - comment += `| 🔴 Critical | ${critical} |\n`; - comment += `| 🟠 High | ${high} |\n`; - comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`; - - if (critical > 0) { - comment += `⚠️ **Action Required:** Critical security issues found!\n\n`; - } - - comment += `
View findings\n\n`; - comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`; - comment += `
\n\n`; - comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`; - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); + echo "Critical issues found!" + echo "Review hypatia-findings.json for details." + # Warn but don't fail — fix forward diff --git a/.github/workflows/hypatia-scan.yml b/.github/workflows/hypatia-scan.yml index 87e84cde..d3b5e9e2 100644 --- a/.github/workflows/hypatia-scan.yml +++ b/.github/workflows/hypatia-scan.yml @@ -1,417 +1,19 @@ -# SPDX-License-Identifier: MPL-2.0 -# Hypatia Neurosymbolic CI/CD Security Scan +# SPDX-License-Identifier: PMPL-1.0-or-later name: Hypatia Security Scan on: push: - branches: [ main, master, develop ] + branches: [main, master, develop] pull_request: - branches: [ main, master ] + branches: [main, master] schedule: - - cron: '0 0 * * 0' # Weekly on Sunday + - cron: "0 0 * * 0" workflow_dispatch: -# Estate guardrail: cancel superseded runs so re-pushes don't pile up -# queued runs across the estate. Safe here because this workflow only -# performs read-only checks/lint/test/scan with no publish or mutation. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true permissions: contents: read - # security-events: write serves two purposes (write implies read): - # 1. read — lets the built-in GITHUB_TOKEN query this repo's own - # Dependabot alerts via the Hypatia DependabotAlerts rule - # (DA001-DA004). Without read, `scan_from_path` gets HTTP 403 - # and the rule silently returns no findings. - # See 007-lang/audits/audit-dependabot-automation-gap-2026-04-17.md. - # 2. write — lets the "Upload SARIF to code scanning" step publish - # Hypatia findings to the Security → Code scanning page so they - # are triaged/deduplicated like CodeQL alerts instead of living - # only in a build artifact nobody is required to look at. - # See hyperpolymath/burble#35 (SARIF integration). - # This is a single-job workflow, so job-level scoping would not - # narrow the grant further; it stays workflow-level and documented. - security-events: write - # pull-requests: write lets the advisory "Comment on PR with findings" - # step post its summary. Without it the built-in GITHUB_TOKEN gets - # "Resource not accessible by integration" and (absent continue-on-error) - # hard-fails the scan — exactly what the gate-decoupling design forbids. - pull-requests: write + security-events: read jobs: scan: - timeout-minutes: 20 - name: Hypatia Neurosymbolic Analysis - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 # Full history for better pattern analysis - - - name: Setup Elixir for Hypatia scanner - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 - with: - elixir-version: '1.18' - otp-version: '27' - - - name: Clone Hypatia - run: | - if [ ! -d "$HOME/hypatia" ]; then - git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" - fi - - - name: Build Hypatia scanner (if needed) - run: | - cd "$HOME/hypatia" - if [ ! -f hypatia ]; then - echo "Building hypatia scanner..." - mix deps.get - mix escript.build - fi - - - name: Run Hypatia scan - id: scan - env: - # Pass the built-in Actions token through to Hypatia so the - # DependabotAlerts rule can query this repo's own alerts. - # For cross-repo scanning (fleet-coordinator scan-supervised), - # a PAT with `security_events` scope is required instead. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - echo "Scanning repository: ${{ github.repository }}" - - # Run scanner (exits non-zero when findings exist — suppress to continue) - HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia-findings.json || true - - # Count findings - FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT - - # Extract severity counts - 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) - - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT - echo "high=$HIGH" >> $GITHUB_OUTPUT - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT - - echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY - echo "- Total findings: $FINDING_COUNT" >> $GITHUB_STEP_SUMMARY - echo "- Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY - echo "- High: $HIGH" >> $GITHUB_STEP_SUMMARY - echo "- Medium: $MEDIUM" >> $GITHUB_STEP_SUMMARY - - - name: Upload findings artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: hypatia-findings - path: hypatia-findings.json - retention-days: 90 - - - name: Convert Hypatia findings to SARIF - # Always runs (no findings_count guard): an EMPTY SARIF run is - # valid and intentional — uploading it clears stale Hypatia - # alerts from the code-scanning page when a repo goes clean. - # The converter is dependency-free Node (Node ships on - # ubuntu-latest; no npm install — estate npm ban respected) and - # is hardened against the heterogeneous Hypatia JSON schema: - # most findings are {rule_module,severity,type,file,reason, - # action}; only some carry an integer `line`; `file` may be - # empty or absolute. See lib/hypatia/cli.ex (collect_findings). - run: | - cat > "$RUNNER_TEMP/hypatia-sarif.cjs" <<'CJS' - const fs = require('fs'); - const path = require('path'); - const crypto = require('crypto'); - - const ws = process.env.GITHUB_WORKSPACE || process.cwd(); - - let findings = []; - try { - const parsed = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - if (Array.isArray(parsed)) findings = parsed; - } catch (_) { - // Scanner unavailable / empty / malformed -> empty SARIF. - // Intentionally clears stale alerts rather than erroring. - findings = []; - } - - // Mirrors Hypatia's own "github" annotation mapping - // (lib/hypatia/cli.ex output/2): critical|high -> error, - // medium -> warning, everything else -> note. - const levelFor = (sev) => { - switch (String(sev || '').toLowerCase()) { - case 'critical': - case 'high': return 'error'; - case 'medium': return 'warning'; - default: return 'note'; - } - }; - - // SARIF artifactLocation.uri must be a repo-relative POSIX - // path. Hypatia may emit absolute paths (scanned under - // $GITHUB_WORKSPACE) or "" / "." for repo-level findings. - const relUri = (file) => { - if (!file) return '.'; - let f = String(file); - if (path.isAbsolute(f)) { - const rel = path.relative(ws, f); - f = (rel && !rel.startsWith('..')) ? rel : path.basename(f); - } - f = f.replace(/\\/g, '/').replace(/^\.\//, ''); - return f || '.'; - }; - - const rules = new Map(); - const results = findings.map((f) => { - const mod = String(f.rule_module || 'hypatia'); - const type = String(f.type || 'finding'); - const ruleId = `hypatia/${mod}/${type}`; - const level = levelFor(f.severity); - if (!rules.has(ruleId)) { - rules.set(ruleId, { - id: ruleId, - name: `${mod}.${type}`, - shortDescription: { text: `Hypatia ${mod}: ${type}` }, - defaultConfiguration: { level } - }); - } - const uri = relUri(f.file); - const msg = String(f.reason || f.type || 'Hypatia finding'); - const startLine = - Number.isInteger(f.line) && f.line > 0 ? f.line : 1; - // Stable cross-run fingerprint for dedupe (no line, so a - // moved finding in the same file/rule stays one alert). - const fp = crypto - .createHash('sha256') - .update([ruleId, uri, type, msg].join('|')) - .digest('hex'); - return { - ruleId, - level, - message: { text: msg }, - locations: [ - { - physicalLocation: { - artifactLocation: { uri }, - region: { startLine } - } - } - ], - partialFingerprints: { 'hypatiaFindingHash/v1': fp } - }; - }); - - const sarif = { - $schema: 'https://json.schemastore.org/sarif-2.1.0.json', - version: '2.1.0', - runs: [ - { - tool: { - driver: { - name: 'Hypatia', - informationUri: 'https://github.com/hyperpolymath/hypatia', - rules: Array.from(rules.values()) - } - }, - results - } - ] - }; - - fs.writeFileSync('hypatia.sarif', JSON.stringify(sarif, null, 2)); - console.log(`hypatia.sarif written: ${results.length} result(s).`); - CJS - node "$RUNNER_TEMP/hypatia-sarif.cjs" - - - name: Upload SARIF to GitHub code scanning - # Fork PRs get a read-only GITHUB_TOKEN, so security-events:write - # is unavailable and upload-sarif cannot publish — skip there - # rather than hard-fail (the push/schedule run on the default - # branch is the authoritative upload). Same-repo PRs and pushes - # do upload. This step is deliberately NOT continue-on-error: - # if the security-surface integration breaks we want a loud red, - # not a silently-ungated scanner (the exact failure mode #35 - # exists to end). The empty-SARIF "clear stale alerts" path is - # handled in the converter above and does not error here. - if: >- - always() && - (github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork != true) - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.28.1 - with: - sarif_file: hypatia.sarif - # Distinct category so Hypatia results coexist with CodeQL's - # (codeql.yml) instead of overwriting them on the same surface. - category: hypatia - - - name: Submit findings to gitbot-fleet (Phase 2) - if: steps.scan.outputs.findings_count > 0 - # Phase 2 is the collaborative LEARNING side-channel ("bots share - # findings via gitbot-fleet"), not the security gate. The gate is - # the baseline-aware "Check for critical or high-severity issues" - # step below. A fleet-side regression (e.g. the submit script being - # moved/removed) must NEVER hard-fail every consuming repo's scan. - # Same reasoning as the "Comment on PR with findings" step. - # See hyperpolymath/hypatia#213 (gate decoupling) and the exit-127 - # estate-wide breakage when gitbot-fleet/scripts/submit-finding.sh - # no longer existed on the default branch. - # advisory: Phase 2 learning submission is optional enrichment; the - # security gate remains the baseline-aware severity check below. - continue-on-error: true - env: - # All GitHub context values surface as env vars so the run - # block never interpolates `${{ … }}` inline (closes the - # workflow_audit/unsafe_curl_payload + actions_expression_injection - # findings). - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FLEET_PUSH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - FLEET_DISPATCH_TOKEN: ${{ secrets.HYPATIA_DISPATCH_PAT }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_SHA: ${{ github.sha }} - FINDINGS_COUNT: ${{ steps.scan.outputs.findings_count }} - run: | - echo "📤 Submitting $FINDINGS_COUNT findings to gitbot-fleet..." - - # Clone gitbot-fleet to temp directory. A clone failure (network, - # repo gone) is non-fatal: learning submission is best-effort. - FLEET_DIR="/tmp/gitbot-fleet-$$" - if ! git clone --depth 1 https://github.com/hyperpolymath/gitbot-fleet.git "$FLEET_DIR"; then - echo "::warning::Could not clone gitbot-fleet — skipping Phase 2 learning submission (non-fatal)." - exit 0 - fi - - # The submission script's location in gitbot-fleet has drifted - # before (it was absent from the default branch, which exit-127'd - # every consuming repo's scan). Probe known locations rather than - # hard-coding one path, and skip gracefully if none is present. - SUBMIT_SCRIPT="" - for cand in \ - "$FLEET_DIR/scripts/submit-finding.sh" \ - "$FLEET_DIR/scripts/submit_finding.sh" \ - "$FLEET_DIR/bin/submit-finding.sh" \ - "$FLEET_DIR/submit-finding.sh"; do - if [ -f "$cand" ]; then - SUBMIT_SCRIPT="$cand" - break - fi - done - - if [ -z "$SUBMIT_SCRIPT" ]; then - echo "::warning::gitbot-fleet submit-finding script not found at any known path — skipping Phase 2 learning submission (non-fatal). Findings are still uploaded as an artifact and gated below." - rm -rf "$FLEET_DIR" - exit 0 - fi - - # Run submission script. Pass the findings path as ABSOLUTE — - # the script cd's into its own working dir before reading the - # file, so a relative path would resolve to the wrong place. - # A submission-script failure is logged but non-fatal. - if bash "$SUBMIT_SCRIPT" "$GITHUB_WORKSPACE/hypatia-findings.json"; then - echo "✅ Finding submission complete" - else - echo "::warning::gitbot-fleet submission script exited non-zero — Phase 2 learning submission skipped (non-fatal)." - fi - - # Cleanup - rm -rf "$FLEET_DIR" - - - name: Check for critical issues - if: steps.scan.outputs.critical > 0 - # GATING POLICY (explicit, by design — not an oversight): - # Hypatia is ADVISORY here. Critical findings are surfaced - # (step annotation + SARIF alert on the code-scanning page + - # PR comment) but do NOT fail this check. Enforcement is - # delegated to the code-scanning surface: tighten by adding a - # branch-protection "required" status on the `hypatia` SARIF - # category, not by reintroducing an `exit 1` here. This keeps - # the gate decision in one auditable place (hypatia#213 gate - # decoupling) and lets a repo opt into fail-on-critical without - # editing this canonical workflow. To change the policy, change - # branch protection — deliberately no commented-out `exit 1`. - run: | - echo "::warning::Hypatia found critical security issue(s) — advisory." - echo "See the Security → Code scanning page (category: hypatia)" - echo "and the hypatia-findings.json artifact for details." - - - name: Generate scan report - run: | - cat << EOF > hypatia-report.md - # Hypatia Security Scan Report - - **Repository:** ${{ github.repository }} - **Scan Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") - **Commit:** ${{ github.sha }} - - ## Summary - - | Severity | Count | - |----------|-------| - | Critical | ${{ steps.scan.outputs.critical }} | - | High | ${{ steps.scan.outputs.high }} | - | Medium | ${{ steps.scan.outputs.medium }} | - | **Total**| ${{ steps.scan.outputs.findings_count }} | - - ## Next Steps - - 1. Triage findings on the **Security → Code scanning** page - (SARIF category \`hypatia\`) — dismiss/track them there like - CodeQL alerts. - 2. The full finding set is also attached as the - \`hypatia-findings.json\` build artifact for offline review. - 3. Findings are **advisory** today (surfaced, not gated); the - gating policy is documented in the workflow's "Check for - critical issues" step. - - ## Learning - - These findings feed Hypatia's learning engine to improve future rules. - - --- - *Powered by [Hypatia](https://github.com/hyperpolymath/hypatia) - Neurosymbolic CI/CD Intelligence* - EOF - - cat hypatia-report.md >> $GITHUB_STEP_SUMMARY - - - name: Comment on PR with findings - if: github.event_name == 'pull_request' && steps.scan.outputs.findings_count > 0 - # advisory: posting findings as a PR comment must never gate - # the scan (hypatia#213 gate decoupling). Belt-and-braces alongside - # the pull-requests: write permission above: a token/API hiccup or - # a fork PR (read-only token) skips the comment, not the check. - continue-on-error: true - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v7 - with: - script: | - const fs = require('fs'); - const findings = JSON.parse(fs.readFileSync('hypatia-findings.json', 'utf8')); - - const critical = findings.filter(f => f.severity === 'critical').length; - const high = findings.filter(f => f.severity === 'high').length; - - let comment = `## 🔍 Hypatia Security Scan\n\n`; - comment += `**Findings:** ${findings.length} issues detected\n\n`; - comment += `| Severity | Count |\n|----------|-------|\n`; - comment += `| 🔴 Critical | ${critical} |\n`; - comment += `| 🟠 High | ${high} |\n`; - comment += `| 🟡 Medium | ${findings.length - critical - high} |\n\n`; - - if (critical > 0) { - comment += `⚠️ **Action Required:** Critical security issues found!\n\n`; - } - - comment += `
View findings\n\n`; - comment += `\`\`\`json\n${JSON.stringify(findings.slice(0, 10), null, 2)}\n\`\`\`\n`; - comment += `
\n\n`; - comment += `*Powered by Hypatia Neurosymbolic CI/CD Intelligence*`; - - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: comment - }); + uses: ./.github/workflows/hypatia-scan-reusable.yml diff --git a/.github/workflows/scorecard-enforcer.yml b/.github/workflows/scorecard-enforcer.yml index d869d67e..3bba7a45 100644 --- a/.github/workflows/scorecard-enforcer.yml +++ b/.github/workflows/scorecard-enforcer.yml @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: MPL-2.0 +# SPDX-License-Identifier: PMPL-1.0-or-later # Prevention workflow - runs OpenSSF Scorecard and fails on low scores name: OpenSSF Scorecard Enforcer @@ -9,99 +9,30 @@ on: - cron: '0 6 * * 1' # Weekly on Monday workflow_dispatch: -# Estate guardrail: cancel superseded runs so re-pushes / rebased PR -# updates do not pile up queued runs against the shared account-wide -# Actions concurrency pool. Applied only to read-only check workflows -# (no publish/mutation), so cancelling a superseded run is always safe. -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - permissions: contents: read jobs: - # The OSSF Scorecard publish endpoint enforces a hard contract: the job that - # runs `ossf/scorecard-action` with `publish_results: true` must contain - # ONLY steps with `uses:` (no `run:` steps in the same job). If a `run:` - # step is present, the publish step fails with: - # "webapp: scorecard job must only have steps with uses" - # (49 estate repos hit this; see ROADMAP audit 2026-05-30.) - # - # Fix: split the threshold check into a downstream job that depends on - # `scorecard` and consumes the SARIF artifact. The `scorecard` job stays - # uses-only; `check-score` is the gating job that emits the error. scorecard: - timeout-minutes: 20 runs-on: ubuntu-latest permissions: security-events: write id-token: write # For OIDC steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: Upload SARIF - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - with: - sarif_file: results.sarif - - # Compute the aggregate score in its OWN uses-only job. The score is NOT in - # the SARIF output (`jq '.runs[0].tool.driver.properties.score'` always - # returned null → 0 → this gate failed on every push regardless of the real - # posture); it only exists in scorecard's JSON output. scorecard-action and a - # `run:` step must never share a job (OSSF publish contract — see #304, and - # hypatia `scorecard_publish_with_run_step`), so this job stays uses-only and - # hands the JSON to check-score via an artifact. `publish_results: false` - # means this run neither publishes nor needs OIDC (the `scorecard` job above - # owns publishing). - compute-score: - timeout-minutes: 20 - needs: scorecard - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Compute Scorecard score (JSON) uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.json results_format: json - publish_results: false - - - name: Persist score JSON for the gate job - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: scorecard-score-json - path: results.json - retention-days: 1 - - check-score: - timeout-minutes: 10 - needs: compute-score - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Download score JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v5.0.0 - with: - name: scorecard-score-json + publish_results: true - name: Check minimum score run: | + # Parse score from results.json SCORE=$(jq -r '.score // 0' results.json 2>/dev/null || echo "0") echo "OpenSSF Scorecard Score: $SCORE" @@ -114,12 +45,10 @@ jobs: exit 1 fi - # Check specific high-priority items check-critical: - timeout-minutes: 10 runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check SECURITY.md exists run: | diff --git a/.github/workflows/scorecard-reusable.yml b/.github/workflows/scorecard-reusable.yml index 9c4a1ef3..4916f33b 100644 --- a/.github/workflows/scorecard-reusable.yml +++ b/.github/workflows/scorecard-reusable.yml @@ -1,111 +1,35 @@ -# SPDX-License-Identifier: MPL-2.0 -# scorecard-reusable.yml — Reusable OSSF Scorecard analysis. -# -# Consolidates the per-repo `scorecard.yml` workflow (estate-wide: -# 258 top-level deployments, 46 unique blob SHAs, 17.8% structural -# drift — top SHA covers 100/258 (38.8%), top 7 SHAs cover 80%). -# Sampled top + long-tail variants are all the same single-`analysis`- -# job shape (41 lines canonical); 100% of drift is mechanical: -# SPDX-header lag (PMPL-1.0 / PMPL-1.0-or-later / MPL-2.0), -# `github/codeql-action/upload-sarif` SHA-pin drift, and -# `permissions: read-all` vs `permissions: contents: read` wording. -# Zero feature variance across all 46 SHAs. -# -# Plus 626 nested copies in monorepos (asdf-tool-plugins, developer- -# ecosystem, ssg-collection, standards, ambientops, julia-ecosystem, -# etc.). Per Layer-3 caveat (see scripts/sweep-classifiers/README.adoc), -# nested workflows are inert — GitHub Actions only runs the repo-root -# `.github/workflows/` directory. Sweeping nested copies is -# single-source-of-truth cleanup, not security hardening. -# -# Design: -# - One input only: `runs-on` (default ubuntu-latest). -# - No `secrets: inherit` needed — scorecard uses `GITHUB_TOKEN` -# directly, which is available without inherit. -# - Caller MUST grant `security-events: write` and `id-token: write` -# on the calling job. The reusable re-asserts these on its own -# `analysis` job, but called-workflow permissions are CAPPED by -# the caller's permissions block. -# - Caller keeps its own `on:` triggers and `concurrency:` group, so -# the read-only cancel-superseded behaviour stays in the wrapper. -# -# Caller example (wrapper): -# # SPDX-License-Identifier: MPL-2.0 -# name: OSSF Scorecard -# on: -# push: -# branches: [main, master] -# schedule: -# - cron: '23 4 * * 1' # Weekly (Monday 04:23 UTC) — match canonical -# workflow_dispatch: -# concurrency: -# group: ${{ github.workflow }}-${{ github.ref }} -# cancel-in-progress: true -# permissions: -# contents: read -# jobs: -# scorecard: -# permissions: -# security-events: write -# id-token: write -# uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@ -# -# CANONICAL SCHEDULE — WEEKLY, NOT DAILY (2026-05-28). -# Estate audit found 180 repos running daily at 04:00 UTC ('0 4 * * *') -# vs 29 on canonical weekly ('23 4 * * 1') — drift driven by an older -# version of the example above. Downstream thin-caller wrappers should -# keep the weekly cadence shown above. -# -# NOTE (2026-06-04): the standards repo itself no longer ships a thin -# `scorecard.yml` caller — it was retired in #372 as a redundant second -# scorecard run. Standards runs OSSF Scorecard directly via -# `scorecard-enforcer.yml` (weekly, Monday 06:00 UTC; publishes + gates -# on MIN_SCORE). This reusable is UNCHANGED and downstream callers are -# unaffected — they remain the canonical thin-caller pattern. -# -# GH Actions budget impact of the drift: 180 daily × (365 − 52) ≈ 56k -# extra runs/year × ~1.5 min/run ≈ ~84k Actions-minutes/year. Fan-out -# to convert the 180 estate repos from daily→weekly is tracked -# separately (GH-Actions budget rebalancing 2026-05-27). -# -# When to deviate: a repo with a very high merge/push cadence AND -# scorecard-sensitive supply-chain churn (heavy dependency refresh) -# MAY opt into more frequent runs. Daily is rarely worth it; the -# scorecard signal moves on the order of weeks, not hours. - -name: Scorecard (reusable) +# SPDX-License-Identifier: PMPL-1.0-or-later +name: OSSF Scorecard Reusable Workflow on: workflow_call: - inputs: - runs-on: - description: Runner label - type: string - required: false - default: ubuntu-latest permissions: contents: read jobs: - analysis: - timeout-minutes: 20 - runs-on: ${{ inputs.runs-on }} + scorecard: + name: Run Scorecard + runs-on: ubuntu-latest permissions: security-events: write id-token: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Run Scorecard - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.3.1 + - name: Run Scorecard Analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: - results_file: results.sarif - results_format: sarif + results_file: results.json + results_format: json + publish_results: true - - name: Upload results - uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v3.31.8 + - name: Upload results artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - sarif_file: results.sarif + name: scorecard-results + path: results.json + retention-days: 90 diff --git a/scripts/check-workflow-staleness.sh b/scripts/check-workflow-staleness.sh new file mode 100755 index 00000000..e5d21193 --- /dev/null +++ b/scripts/check-workflow-staleness.sh @@ -0,0 +1,100 @@ +#!/bin/bash +# SPDX-License-Identifier: PMPL-1.0-or-later +set -eo pipefail + +# Staleness checker script for hyperpolymath estate repositories. +# Ensures that workflows do not use retired patterns or stale pins. + +REPO_ROOT="${1:-.}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Determine if we are in standards repo +IS_STANDARDS=false +if [ "$GITHUB_REPOSITORY" = "hyperpolymath/standards" ]; then + IS_STANDARDS=true +else + # Fallback: check Git remote origin URL + if [ -d "$REPO_ROOT/.git" ]; then + REMOTE_URL=$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || echo "") + if [[ "$REMOTE_URL" =~ "hyperpolymath/standards" ]]; then + IS_STANDARDS=true + fi + fi +fi + +# Determine current approved standards SHA dynamically, or allow override from environment +CURRENT_SHA="${STALENESS_EXPECTED_SHA:-}" +if [ -z "$CURRENT_SHA" ]; then + # Look up the Git commit of the standards repo containing this script + if [ -d "$SCRIPT_DIR/../.git" ]; then + CURRENT_SHA=$(git -C "$SCRIPT_DIR/.." rev-parse HEAD 2>/dev/null || echo "") + elif [ -d "$SCRIPT_DIR/.git" ]; then + CURRENT_SHA=$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || echo "") + fi +fi + +if [ -z "$CURRENT_SHA" ]; then + echo "::error::Could not determine current standards SHA. Set STALENESS_EXPECTED_SHA." + exit 1 +fi + +echo "Staleness Check against Standards SHA: $CURRENT_SHA" + +# If no root .github/workflows exists, pass. +if [ ! -d "$REPO_ROOT/.github/workflows" ]; then + echo "No .github/workflows directory found. Passing." + exit 0 +fi + +FAILED=0 + +# Rule: no_retired_scorecard_enforcer +if [ "$IS_STANDARDS" = "false" ] && [ -f "$REPO_ROOT/.github/workflows/scorecard-enforcer.yml" ]; then + echo "::error::scorecard-enforcer.yml is retired. Use scorecard.yml -> standards scorecard-reusable.yml instead." + FAILED=1 +fi + +for wf in "$REPO_ROOT"/.github/workflows/*.yml "$REPO_ROOT"/.github/workflows/*.yaml; do + [ -f "$wf" ] || continue + + # Rule: no_scorecard_sarif_code_scanning + if grep -q "ossf/scorecard-action@" "$wf" && grep -q "github/codeql-action/upload-sarif@" "$wf"; then + echo "::error file=$wf::OSSF Scorecard must not upload SARIF to GitHub Code Scanning unless it runs for every PR head commit." + FAILED=1 + fi + + # Rule: no_stale_scorecard_reusable_pin + if grep -q "scorecard-reusable.yml@" "$wf"; then + PINNED_SHA=$(grep "scorecard-reusable.yml@" "$wf" | sed -E 's/.*scorecard-reusable.yml@([^ #\r\n]+).*/\1/') + if [ "$PINNED_SHA" != "$CURRENT_SHA" ]; then + echo "::error file=$wf::Workflow pins stale Scorecard reusable that publishes SARIF / causes Code Scanning waits. Refresh to current standards SHA." + FAILED=1 + fi + fi + + # Rule: no_stale_hypatia_reusable_pin + if grep -q "hypatia-scan-reusable.yml@" "$wf"; then + PINNED_SHA=$(grep "hypatia-scan-reusable.yml@" "$wf" | sed -E 's/.*hypatia-scan-reusable.yml@([^ #\r\n]+).*/\1/') + if [ "$PINNED_SHA" != "$CURRENT_SHA" ]; then + echo "::error file=$wf::Workflow pins Hypatia reusable before cache/baseline-delay fix. Refresh to current standards SHA." + FAILED=1 + fi + fi + + # Rule: estate_pin_freshness for governance.yml + if grep -q "governance-reusable.yml@" "$wf"; then + PINNED_SHA=$(grep "governance-reusable.yml@" "$wf" | sed -E 's/.*governance-reusable.yml@([^ #\r\n]+).*/\1/') + if [ "$PINNED_SHA" != "$CURRENT_SHA" ]; then + echo "::error file=$wf::Workflow pins stale governance reusable. Refresh to current standards SHA." + FAILED=1 + fi + fi +done + +if [ $FAILED -ne 0 ]; then + echo "::error::Remove legacy scorecard-enforcer.yml, refresh standards reusable pins, and keep Scorecard out of GitHub Code Scanning unless it runs for every PR head commit." + exit 1 +fi + +echo "All workflow staleness checks passed." +exit 0 diff --git a/scripts/tests/check-workflow-staleness-test.sh b/scripts/tests/check-workflow-staleness-test.sh new file mode 100755 index 00000000..8431569b --- /dev/null +++ b/scripts/tests/check-workflow-staleness-test.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# SPDX-License-Identifier: PMPL-1.0-or-later +set -euo pipefail + +# Regression test script for check-workflow-staleness.sh + +TEST_DIR=$(mktemp -d) +trap 'rm -rf "$TEST_DIR"' EXIT + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECK_SCRIPT="$SCRIPT_DIR/../check-workflow-staleness.sh" + +export STALENESS_EXPECTED_SHA="currentsha123" + +create_mock_repo() { + local repo_path="$1" + mkdir -p "$repo_path/.github/workflows" +} + +run_test_case() { + local desc="$1" + local expected_exit="$2" + local mock_repo="$3" + + echo "Running test: $desc" + set +e + GITHUB_REPOSITORY="hyperpolymath/test-repo" bash "$CHECK_SCRIPT" "$mock_repo" >/dev/null 2>&1 + local exit_code=$? + set -e + + if [ "$exit_code" -ne "$expected_exit" ]; then + echo "FAIL: $desc (expected exit $expected_exit, got $exit_code)" + exit 1 + else + echo "PASS: $desc" + fi +} + +# --- TEST 1: Stale Scorecard reusable pin must fail --- +REPO1="$TEST_DIR/repo1" +create_mock_repo "$REPO1" +cat << EOF > "$REPO1/.github/workflows/scorecard.yml" +name: Scorecard +on: push +jobs: + scorecard: + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@stalesha123 +EOF +run_test_case "Stale Scorecard reusable pin must fail" 1 "$REPO1" + +# --- TEST 2: Direct Scorecard SARIF upload must fail --- +REPO2="$TEST_DIR/repo2" +create_mock_repo "$REPO2" +cat << EOF > "$REPO2/.github/workflows/scorecard.yml" +name: Scorecard +on: push +jobs: + scorecard: + steps: + - uses: ossf/scorecard-action@abc + - uses: github/codeql-action/upload-sarif@xyz +EOF +run_test_case "Direct Scorecard SARIF upload must fail" 1 "$REPO2" + +# --- TEST 3: Stale Hypatia reusable pin must fail --- +REPO3="$TEST_DIR/repo3" +create_mock_repo "$REPO3" +cat << EOF > "$REPO3/.github/workflows/hypatia.yml" +name: Hypatia +on: push +jobs: + hypatia: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@stalehypatia123 +EOF +run_test_case "Stale Hypatia reusable pin must fail" 1 "$REPO3" + +# --- TEST 4: Clean current reusable pin must pass --- +REPO4="$TEST_DIR/repo4" +create_mock_repo "$REPO4" +cat << EOF > "$REPO4/.github/workflows/scorecard.yml" +name: Scorecard +on: push +jobs: + scorecard: + uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@currentsha123 +EOF +cat << EOF > "$REPO4/.github/workflows/hypatia.yml" +name: Hypatia +on: push +jobs: + hypatia: + uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@currentsha123 +EOF +run_test_case "Clean current reusable pin must pass" 0 "$REPO4" + +# --- TEST 5: scorecard-enforcer.yml in non-standards repo must fail --- +REPO5="$TEST_DIR/repo5" +create_mock_repo "$REPO5" +touch "$REPO5/.github/workflows/scorecard-enforcer.yml" +run_test_case "scorecard-enforcer.yml in non-standards repo must fail" 1 "$REPO5" + +echo "All regression tests passed!" +exit 0