Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 55 additions & 19 deletions .github/workflows/hypatia-scan-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions .machine_readable/descriptiles/STATE.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion TOPOLOGY.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
50 changes: 50 additions & 0 deletions scripts/tests/hypatia-blocking-gate-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: MPL-2.0
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
# 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 '[] []'
Loading