From 94862adec5b9c483eaf09386fbedb4e13f701ed9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:45:18 +0100 Subject: [PATCH 1/2] fix(ci): reject invalid Hypatia reports and add blocking severity policy --- .github/workflows/hypatia-scan-reusable.yml | 74 +++++++++++++++------ .machine_readable/descriptiles/STATE.a2ml | 16 ++++- TOPOLOGY.adoc | 2 +- scripts/tests/hypatia-blocking-gate-test.sh | 50 ++++++++++++++ 4 files changed, 120 insertions(+), 22 deletions(-) create mode 100755 scripts/tests/hypatia-blocking-gate-test.sh diff --git a/.github/workflows/hypatia-scan-reusable.yml b/.github/workflows/hypatia-scan-reusable.yml index a3fa3dee2..bc486448f 100644 --- a/.github/workflows/hypatia-scan-reusable.yml +++ b/.github/workflows/hypatia-scan-reusable.yml @@ -5,6 +5,12 @@ name: Hypatia Reusable Scan on: workflow_call: + inputs: + block-on-high: + description: Refuse any high or critical finding, without requiring a baseline + type: boolean + required: false + default: false permissions: actions: read @@ -75,7 +81,6 @@ jobs: fi - name: Run Hypatia scan - id: scan env: # The scanner reads a single env var, GITHUB_TOKEN, to pull Dependabot, # code-scanning and secret-scanning alerts (lib/rules/{dependabot, @@ -102,26 +107,45 @@ jobs: # category ("hypatia") as the historical upload, so GitHub RECONCILES: # findings fixed in code since the last scan auto-close instead of # orphaning as stale open alerts. - HYPATIA_FORMAT=sarif "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia.sarif || echo '{"version":"2.1.0","runs":[]}' > hypatia.sarif + HYPATIA_FORMAT=sarif "$HOME/hypatia/hypatia-cli.sh" scan . --exit-zero > hypatia.sarif - FINDING_COUNT=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) - 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) + - name: Validate findings and count severities + id: scan + run: | + set -euo pipefail + # Exactly one JSON array, with a recognised severity on every finding. + # Missing/truncated output is a scanner error, never an empty clean scan. + if [ ! -s hypatia-findings.json ] || ! jq -e -s ' + length == 1 and (.[0] | type == "array" and all(.[]; + type == "object" and (.severity as $s | + ["critical", "high", "medium", "low", "info", "informational"] | index($s) != null))) + ' hypatia-findings.json >/dev/null; then + echo "::error::Hypatia did not produce one valid findings array" + exit 2 + fi - echo "findings_count=$FINDING_COUNT" >> $GITHUB_OUTPUT - echo "critical=$CRITICAL" >> $GITHUB_OUTPUT - echo "high=$HIGH" >> $GITHUB_OUTPUT - echo "medium=$MEDIUM" >> $GITHUB_OUTPUT + FINDING_COUNT=$(jq '. | length' hypatia-findings.json) + CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json) + MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json) - echo "## Hypatia Scan Results" >> $GITHUB_STEP_SUMMARY - 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 + { + echo "findings_count=$FINDING_COUNT" + echo "critical=$CRITICAL" + echo "high=$HIGH" + echo "medium=$MEDIUM" + } >> "$GITHUB_OUTPUT" + + { + echo "## Hypatia Scan Results" + echo "" + echo "| Severity | Count |" + echo "|----------|-------|" + echo "| Critical | $CRITICAL |" + echo "| High | $HIGH |" + echo "| Medium | $MEDIUM |" + echo "| **Total**| $FINDING_COUNT |" + } >> "$GITHUB_STEP_SUMMARY" - name: Relativize finding paths if: always() @@ -242,8 +266,20 @@ jobs: echo "No committed baseline — gate stays advisory (see next step)." fi + - name: Block high and critical findings when requested + id: blocking-findings + if: inputs.block-on-high + run: | + set -euo pipefail + count=$(jq '[.[] | select(.severity == "high" or .severity == "critical")] | length' hypatia-findings.json) + if [ "$count" -gt 0 ]; then + echo "::error::Hypatia found $count high or critical finding(s); see the scan artifact" + exit 1 + fi + echo "Hypatia blocking gate: no high or critical findings" + - name: Check for critical issues (ADVISORY — does not gate) - if: steps.scan.outputs.critical > 0 + if: '!inputs.block-on-high && steps.scan.outputs.critical > 0' run: | # This scan is ADVISORY / fix-forward WHEN NO BASELINE IS COMMITTED # (the step above becomes the blocking gate once .hypatia-baseline.json diff --git a/.machine_readable/descriptiles/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml index 5a5cdda6c..241918fe1 100644 --- a/.machine_readable/descriptiles/STATE.a2ml +++ b/.machine_readable/descriptiles/STATE.a2ml @@ -10,9 +10,9 @@ [metadata] project = "standards" version = "0.3.0" -last-updated = "2026-08-24T00:00:00Z" +last-updated = "2026-09-07T00:00:00Z" status = "active" -session = "2026-08-24 standards descriptiles canonical-path migration" +session = "2026-09-07 optional blocking Hypatia findings gate" [project-context] name = "Standards" @@ -42,6 +42,18 @@ actions = [ last-run-utc = "2026-08-24T00:00:00Z" last-result = "pass" # unknown | pass | warn | fail +[session-history-2026-09-07] +session = "Optional blocking Hypatia findings gate" +capabilities-added = [ + "Reusable Hypatia workflow accepts block-on-high to reject high and critical findings without a baseline.", + "Missing or malformed findings and failed SARIF generation now fail instead of reporting an empty successful scan." +] +verification = [ + "Actual workflow validator and blocking shell steps: 12 success and refusal controls passed locally.", + "Changed workflow actionlint and regression script ShellCheck: passed locally." +] +notes = ["Remote CI and deployment to consumers remain pending; this checkpoint does not claim a merged security gate."] + [session-history-2026-08-24] session = "2026-08-24 standards descriptiles canonical-path migration" capabilities-added = [ diff --git a/TOPOLOGY.adoc b/TOPOLOGY.adoc index 1937565c8..ce1ef6762 100644 --- a/TOPOLOGY.adoc +++ b/TOPOLOGY.adoc @@ -10,7 +10,7 @@ the canonical `+STATE.a2ml+` by `+scripts/build-registry.sh+`. It cannot freeze: every regeneration re-reads ground truth. Do not edit by hand. ____ -* *Phase:* active | *Maturity:* experimental | *STATE last-updated:* 2026-08-24T00:00:00Z +* *Phase:* active | *Maturity:* experimental | *STATE last-updated:* 2026-09-07T00:00:00Z * *Registry entries:* 33 specs across 6 streams * *Front door:* human → link:README.adoc[README.adoc]; machine → link:0-AI-MANIFEST.a2ml[0-AI-MANIFEST.a2ml] * *Registry:* link:.machine_readable/REGISTRY.a2ml[.machine_readable/REGISTRY.a2ml] (index + source hashes) · prose: link:REGISTRY.adoc[REGISTRY.adoc] diff --git a/scripts/tests/hypatia-blocking-gate-test.sh b/scripts/tests/hypatia-blocking-gate-test.sh new file mode 100755 index 000000000..15a137634 --- /dev/null +++ b/scripts/tests/hypatia-blocking-gate-test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# Execute the actual reusable-workflow steps against success and failure controls. +set -euo pipefail +repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +ruby -ryaml -e ' + workflow = YAML.load_file(ARGV[0]) + steps = workflow.fetch("jobs").fetch("scan").fetch("steps") + validator = steps.find { |step| step["id"] == "scan" } + gate = steps.find { |step| step["id"] == "blocking-findings" } + abort "blocking gate is not opt-in" unless gate.fetch("if") == "inputs.block-on-high" + File.write(ARGV[1] + "/validate.sh", validator.fetch("run")) + File.write(ARGV[1] + "/gate.sh", gate.fetch("run")) +' "$repo/.github/workflows/hypatia-scan-reusable.yml" "$tmp" +export GITHUB_OUTPUT="$tmp/output" GITHUB_STEP_SUMMARY="$tmp/summary" +cd "$tmp" +check() { + local name=$1 expected=$2 payload=$3 actual + if [ "$payload" = MISSING ]; then + rm -f hypatia-findings.json + else + printf '%s' "$payload" > hypatia-findings.json + fi + if bash validate.sh >result.log 2>&1; then + if bash gate.sh >>result.log 2>&1; then actual=0; else actual=$?; fi + else + actual=$? + fi + if [ "$actual" -ne "$expected" ]; then + printf 'FAIL: %s: expected %s, got %s\n' "$name" "$expected" "$actual" + cat result.log + exit 1 + fi + printf 'PASS: %s\n' "$name" +} +check 'empty findings are valid' 0 '[]' +check 'low and informational findings pass' 0 '[{"severity":"low"},{"severity":"info"}]' +check 'high finding blocks' 1 '[{"severity":"high"}]' +check 'critical finding blocks' 1 '[{"severity":"critical"}]' +check 'missing artifact refuses' 2 MISSING +check 'empty artifact refuses' 2 '' +check 'truncated JSON refuses' 2 '[{"severity":' +check 'object is not a findings array' 2 '{}' +check 'null is not a findings array' 2 'null' +check 'unknown severity refuses' 2 '[{"severity":"unknown"}]' +check 'missing severity refuses' 2 '[{}]' +check 'multiple JSON documents refuse' 2 '[] []' From fb3d371a5b18167cdefc48693cc8b2aa978477d5 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:52:43 +0100 Subject: [PATCH 2/2] fix(ci): use Bash conditionals in gate regression checks --- scripts/tests/hypatia-blocking-gate-test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/tests/hypatia-blocking-gate-test.sh b/scripts/tests/hypatia-blocking-gate-test.sh index 15a137634..7d383e73f 100755 --- a/scripts/tests/hypatia-blocking-gate-test.sh +++ b/scripts/tests/hypatia-blocking-gate-test.sh @@ -19,7 +19,7 @@ export GITHUB_OUTPUT="$tmp/output" GITHUB_STEP_SUMMARY="$tmp/summary" cd "$tmp" check() { local name=$1 expected=$2 payload=$3 actual - if [ "$payload" = MISSING ]; then + if [[ "$payload" = MISSING ]]; then rm -f hypatia-findings.json else printf '%s' "$payload" > hypatia-findings.json @@ -29,7 +29,7 @@ check() { else actual=$? fi - if [ "$actual" -ne "$expected" ]; then + if [[ "$actual" -ne "$expected" ]]; then printf 'FAIL: %s: expected %s, got %s\n' "$name" "$expected" "$actual" cat result.log exit 1