diff --git a/.machine_readable/hypatia-baseline.schema.json b/.machine_readable/hypatia-baseline.schema.json index 2772ce94f..939c4cb57 100644 --- a/.machine_readable/hypatia-baseline.schema.json +++ b/.machine_readable/hypatia-baseline.schema.json @@ -18,7 +18,7 @@ ], "properties": { "severity": { - "description": "Severity of the finding as reported by Hypatia. Must match the finding's severity exactly for the entry to apply.", + "description": "Severity of the finding as reported by Hypatia. Must match the finding's severity for the entry to apply, except legacy `warn` and current `medium` are treated as equivalent by the gate.", "type": "string", "enum": ["critical", "high", "medium", "warn", "low", "info", "informational"] }, diff --git a/docs/HYPATIA-BASELINE-FORMAT.adoc b/docs/HYPATIA-BASELINE-FORMAT.adoc index 6bbcfbe8a..895994d9e 100644 --- a/docs/HYPATIA-BASELINE-FORMAT.adoc +++ b/docs/HYPATIA-BASELINE-FORMAT.adoc @@ -53,9 +53,9 @@ Hypatia finding, plus optional metadata. == Required fields `severity`:: One of `critical`, `high`, `medium`, `warn`, `low`, `info`, -`informational` — the scanner's full vocabulary (`warn` is what the research -rules emit; it ranks with `medium`). Must -match the finding's reported severity exactly for the entry to apply. +`informational` — the scanner's full vocabulary. `warn` is retained for +legacy findings and baseline records; it is matched as the same tier as the +current `medium` output. Other severity labels must match exactly. `rule_module`:: Hypatia rule module (e.g. `cicd_rules`, `code_safety`, `migration_rules`) — **a string, or a list of strings**. A list matches when diff --git a/scripts/apply-baseline.sh b/scripts/apply-baseline.sh index 85ad9c83c..9237e356a 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -82,11 +82,13 @@ echo "$BASELINE_JSON" | jq -e 'type == "array"' >/dev/null || { SCHEMA_ERRORS="$(jq -r ' def known: ["severity","rule_module","type","file","file_pattern", "severity_override","expires_at","note","tracking_issue"]; - # Scanner vocabulary, mirroring the hypatia-scan-reusable validator: the - # research rules emit `warn` (ranked with medium) and some rules emit - # `informational`. Without these, research findings can never be - # acknowledged and the baseline gate can never go green on them. + # Baseline severity vocabulary mirrors both the scanner validator and the + # authoritative schema. `warn` is retained for old records; matching treats + # it as the same tier as current scanner `medium` output. def sevs: ["critical","high","medium","warn","low","info","informational"]; + # Overrides are a distinct schema contract: aliases are valid source + # severities, not valid override targets. + def override_sevs: ["critical","high","medium","low","info","advisory"]; [ to_entries[] | .key as $i | .value as $e | if ($e|type) != "object" then "entry[\($i)]: not an object" else ( @@ -104,6 +106,10 @@ SCHEMA_ERRORS="$(jq -r ' (if ($e.rule_module|type) == "array" and ($e.rule_module|length) == 0 then "entry[\($i)]: rule_module list is empty — an entry that names no module matches nothing" else empty end), + (if ($e.rule_module|type) == "array" + and (($e.rule_module | unique | length) != ($e.rule_module | length)) + then "entry[\($i)]: rule_module list contains duplicate members" + else empty end), (if ($e.rule_module|type) == "array" then ($e.rule_module[] | select(type != "string") @@ -149,14 +155,17 @@ SCHEMA_ERRORS="$(jq -r ' then "entry[\($i)]: file_pattern must be a non-empty string" else empty end), (if ($e|has("severity_override")) - and (((sevs + ["advisory"])|index($e.severity_override))|not) - then "entry[\($i)]: invalid severity_override \($e.severity_override)" + and ((($e.severity_override|type) != "string") + or ((override_sevs|index($e.severity_override))|not)) + then "entry[\($i)]: invalid severity_override \($e.severity_override|tojson)" else empty end), (if ($e|has("expires_at")) and ((($e.expires_at|type) != "string") or (($e.expires_at|test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$"))|not)) then "entry[\($i)]: expires_at must be an ISO date (YYYY-MM-DD)" else empty end), + (if ($e|has("note")) and (($e.note|type) != "string") + then "entry[\($i)]: note must be a string" else empty end), (if ($e|has("tracking_issue")) and ((($e.tracking_issue|type) != "string") or (($e.tracking_issue|test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+$"))|not)) @@ -207,9 +216,8 @@ ANNOTATED="$(jq -n \ # string". Capture the entry pattern first, then reference $pat # inside the test() regex argument. # Hypatia emitted `warn` for advisory findings until hypatia#895 - # (2026-10-01), which now emits them as `medium`. Baselines written before - # then acknowledge `warn`; treating the two as one tier keeps those - # acknowledgements matching instead of re-reporting every one as new. + # (2026-10-01), which now emits them as `medium`. Normalize only this + # historical transition; all other severity labels remain distinct. def sev_tier: if . == "warn" then "medium" else . end; def match_entry(f): f as $finding diff --git a/scripts/plan-ruleset-constraint-repair.rb b/scripts/plan-ruleset-constraint-repair.rb index 0f1fe7a55..a95568158 100644 --- a/scripts/plan-ruleset-constraint-repair.rb +++ b/scripts/plan-ruleset-constraint-repair.rb @@ -8,16 +8,10 @@ module RulesetConstraintRepair RETIRED = %w[update required_deployments code_quality code_coverage].freeze WRITABLE = %w[name target enforcement conditions bypass_actors rules].freeze - # Build a new ruleset update body with the four retired constraints removed, - # preserving the other writable fields and leaving +source+ unchanged. - # - # When +uninstalled_app_id+ is supplied, also remove only that integration's - # bypass. Supply an ID only after GitHub rejects it as no longer installed; - # never infer app availability from its name. - # - # Raises ArgumentError unless the source is an active default-branch ruleset - # with pull-request protection, or if the requested integration ID is invalid - # or absent. A supplied ID also requires +source+ to contain +bypass_actors+. + # Build a PUT-ready ruleset body from a live ruleset: strip the retired + # constraint rules and, optionally, a confirmed-uninstalled app bypass. + # Supply an app ID only after GitHub rejects it as no longer installed. Removing + # a stale bypass tightens access; never infer app availability from its name. def self.plan(source, uninstalled_app_id: nil) unless source.is_a?(Hash) && source['target'] == 'branch' && source['enforcement'] == 'active' && source.dig('conditions', 'ref_name', 'include') == ['~DEFAULT_BRANCH'] && diff --git a/scripts/tests/apply-baseline-test.sh b/scripts/tests/apply-baseline-test.sh index 10cc079e7..3cc980054 100755 --- a/scripts/tests/apply-baseline-test.sh +++ b/scripts/tests/apply-baseline-test.sh @@ -21,12 +21,13 @@ trap 'rm -rf "$WORK"' EXIT pass=0 fail=0 +COUNTS_JQ='"\(.findings_suppressed | length),\(.findings_kept | length)"' assert_status() { local label="$1" findings="$2" baseline="$3" expected="$4" local got got=$(bash "$APPLY" "$findings" "$baseline" advisory \ - | jq -r '"\(.findings_suppressed | length),\(.findings_kept | length)"') + | jq -r "$COUNTS_JQ") if [ "$got" = "$expected" ]; then echo "PASS: $label (suppressed,kept=$got)" pass=$((pass + 1)) @@ -81,6 +82,131 @@ assert_status "medium acknowledgement matches a warn finding" \ assert_status "a different tier does not match" \ "$WORK/findings2b.json" "$WORK/baseline2b-high.json" "0,1" +# Exercise baseline mode too: aliases must suppress before thresholding, but +# only `warn`/`medium` are compatibility-equivalent. `info` and +# `informational` remain distinct emitted labels. +printf '[]\n' > "$WORK/empty.json" +assert_gate() { + local label="$1" findings="$2" baseline="$3" threshold="$4" expected_status="$5" expected_counts="$6" + local status=0 got + BLOCKING_THRESHOLD="$threshold" bash "$APPLY" "$findings" "$baseline" blocking \ + >"$WORK/gate.out" 2>"$WORK/gate.err" || status=$? + got=$(jq -r "$COUNTS_JQ" "$WORK/gate.out") + if [[ "$status" -eq "$expected_status" && "$got" == "$expected_counts" ]]; then + echo "PASS: $label (exit=$status, suppressed/kept=$got)" + pass=$((pass + 1)) + else + echo "FAIL: $label expected exit=$expected_status counts=$expected_counts; got exit=$status counts=$got" + cat "$WORK/gate.err" + fail=$((fail + 1)) + fi +} + +assert_gate "legacy warn ack suppresses current medium at blocking threshold" \ + "$WORK/findings2b.json" "$WORK/baseline2b-warn.json" high 0 "1,0" +assert_gate "legacy medium ack suppresses warn at blocking threshold" \ + "$WORK/findings2b-warn.json" "$WORK/baseline2b-medium.json" high 0 "1,0" +cat > "$WORK/findings-informational.json" <<'EOF' +[{"severity":"informational","rule_module":"research_extensions","type":"RE001","file":"ci.yml"}] +EOF +cat > "$WORK/baseline-info.json" <<'EOF' +[{"severity":"info","rule_module":"research_extensions","type":"RE001","file":"ci.yml"}] +EOF +cat > "$WORK/baseline-informational.json" <<'EOF' +[{"severity":"informational","rule_module":"research_extensions","type":"RE001","file":"ci.yml"}] +EOF +assert_status "informational baseline severity is accepted and matches exactly" \ + "$WORK/findings-informational.json" "$WORK/baseline-informational.json" "1,0" +assert_gate "info and informational are not conflated" \ + "$WORK/findings-informational.json" "$WORK/baseline-info.json" info 1 "0,1" +printf '%s\n' '[{"severity":"warn"}]' > "$WORK/findings-warn.json" +printf '%s\n' '[{"severity":"informational"}]' > "$WORK/findings-informational-only.json" +assert_gate "warn blocks at medium threshold" \ + "$WORK/findings-warn.json" "$WORK/empty.json" medium 1 "0,1" +assert_gate "warn does not block at high threshold" \ + "$WORK/findings-warn.json" "$WORK/empty.json" high 0 "0,1" +assert_gate "informational blocks at info threshold" \ + "$WORK/findings-informational-only.json" "$WORK/empty.json" info 1 "0,1" +assert_gate "informational does not block at low threshold" \ + "$WORK/findings-informational-only.json" "$WORK/empty.json" low 0 "0,1" + +# Source severities and override targets are separate schema enums. A valid +# downgrade is carried through the output and then ranked for the gate. +cat > "$WORK/findings-override.json" <<'EOF' +[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt"}] +EOF +cat > "$WORK/baseline-override-low.json" <<'EOF' +[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt","severity_override":"low"}] +EOF +assert_gate "low override downgrades a matching high finding" \ + "$WORK/findings-override.json" "$WORK/baseline-override-low.json" high 0 "0,1" +if [[ "$(jq -r '.findings_kept[0] | [.severity,.baseline_status] | join(",")' "$WORK/gate.out")" == "low,downgraded" ]]; then + echo "PASS: low override is visible as a downgraded finding" + pass=$((pass + 1)) +else + echo "FAIL: low override was not preserved in output" + fail=$((fail + 1)) +fi +cat > "$WORK/baseline-override-info.json" <<'EOF' +[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt","severity_override":"info"}] +EOF +assert_gate "info override blocks at info but not low threshold" \ + "$WORK/findings-override.json" "$WORK/baseline-override-info.json" info 1 "0,1" +assert_gate "info override remains below low threshold" \ + "$WORK/findings-override.json" "$WORK/baseline-override-info.json" low 0 "0,1" +cat > "$WORK/baseline-override-advisory.json" <<'EOF' +[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt","severity_override":"advisory"}] +EOF +assert_gate "advisory override does not block info threshold" \ + "$WORK/findings-override.json" "$WORK/baseline-override-advisory.json" info 0 "0,1" +for override in warn informational 7; do + jq -n --arg override "$override" '[{severity:"high",rule_module:"cicd_rules",type:"banned_language_file",file:"src/Legacy.kt",severity_override:$override}]' > "$WORK/baseline-invalid-override.json" + status=0 + bash "$APPLY" "$WORK/findings-override.json" "$WORK/baseline-invalid-override.json" advisory >/dev/null 2>&1 || status=$? + if [[ "$status" -eq 2 ]]; then + echo "PASS: invalid severity_override rejected with schema exit 2: $override" + pass=$((pass + 1)) + else + echo "FAIL: severity_override $override returned $status (expected 2)" + fail=$((fail + 1)) + fi +done + +assert_invalid_baseline() { + local label="$1" content="$2" status=0 + printf '%s\n' "$content" > "$WORK/baseline-invalid.json" + bash "$APPLY" "$WORK/findings-override.json" "$WORK/baseline-invalid.json" advisory >/dev/null 2>&1 || status=$? + if [[ "$status" -eq 2 ]]; then + echo "PASS: invalid baseline rejected with schema exit 2: $label" + pass=$((pass + 1)) + else + echo "FAIL: invalid baseline $label returned $status (expected 2)" + fail=$((fail + 1)) + fi +} +assert_invalid_baseline "duplicate rule_module array member" \ + '[{"severity":"high","rule_module":["cicd_rules","cicd_rules"],"type":"banned_language_file","file":"src/Legacy.kt"}]' +assert_invalid_baseline "non-string note" \ + '[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt","note":1}]' +assert_invalid_baseline "unknown source severity" \ + '[{"severity":"urgent","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt"}]' + +# The gate message counts only findings at/above the threshold, while still +# reporting how many total findings remain for context. +printf '%s\n' '[{"severity":"high"},{"severity":"info"}]' > "$WORK/findings-mixed.json" +echo '[]' > "$WORK/empty-baseline.json" +if BLOCKING_THRESHOLD=high bash "$APPLY" "$WORK/findings-mixed.json" "$WORK/empty-baseline.json" blocking >"$WORK/gate.out" 2>"$WORK/gate.err"; then + echo "FAIL: high finding did not block" + fail=$((fail + 1)) +elif grep -Fq "1 unfiltered finding(s) at or above 'high' (2 kept in total)" "$WORK/gate.err"; then + echo "PASS: blocking diagnostic distinguishes threshold findings from total kept" + pass=$((pass + 1)) +else + echo "FAIL: blocking diagnostic count is inaccurate" + cat "$WORK/gate.err" + fail=$((fail + 1)) +fi + # === Case 3: file_pattern MUST NOT match unrelated file (regression # against the always-matches bug from `.file_pattern` inside test()) === cat > "$WORK/findings3.json" <<'EOF' @@ -146,7 +272,7 @@ done assert_invalid_option() { local label="$1" mode="$2" threshold="$3" status=0 BLOCKING_THRESHOLD="$threshold" bash "$APPLY" "$WORK/findings1.json" "$WORK/empty.json" "$mode" > "$WORK/invalid.out" 2> "$WORK/invalid.err" || status=$? - if [ "$status" -eq 2 ]; then + if [[ "$status" -eq 2 ]]; then echo "PASS: $label rejected as invalid configuration" pass=$((pass + 1)) else @@ -236,7 +362,7 @@ elif ! bash -n "$MUTANT" 2>/dev/null; then else # Non-first member must now be MISSED. mutant_got=$(bash "$MUTANT" "$WORK/findings-mod2only.json" "$WORK/baseline-2mod.json" advisory \ - | jq -r '"\(.findings_suppressed | length),\(.findings_kept | length)"') + | jq -r "$COUNTS_JQ") if [ "$mutant_got" = "0,1" ]; then echo "PASS: MUTANT (first element only) misses the non-first module — any() IS load-bearing" pass=$((pass + 1)) @@ -247,7 +373,7 @@ else # And the two-module case must regress to half-suppressed: exactly the # #966 symptom, reproduced on demand. mutant_both=$(bash "$MUTANT" "$WORK/findings-2mod.json" "$WORK/baseline-2mod.json" advisory \ - | jq -r '"\(.findings_suppressed | length),\(.findings_kept | length)"') + | jq -r "$COUNTS_JQ") if [ "$mutant_both" = "1,1" ]; then echo "PASS: MUTANT reproduces the #966 symptom (half-suppressed, entry looks correct)" pass=$((pass + 1)) diff --git a/standards-map.toml b/standards-map.toml index 900178ff5..8092e887f 100644 --- a/standards-map.toml +++ b/standards-map.toml @@ -33,7 +33,7 @@ [map] repo = "hyperpolymath/standards" version = "1.0.0" -entry_count = 128 +entry_count = 129 generated = false # hand-curated; the district index.adoc files ARE generated checked_by = "scripts/check-standards-map.sh" @@ -1608,6 +1608,19 @@ canonical_doc = "" gate = "" note = "Machine-readable CRG grade declaration ('**Current Grade:** X' line), as component-readiness-grades/COMPONENT-READINESS-GRADES.adoc asks for; read by just crg-grade / crg-badge. Added 2026-10-13 by owner ruling (#1235); mapped 2026-10-14 (GATE D read it as an unmapped root)." +[[entry]] +from = ".devcontainer" +target = ".devcontainer" +district = "root" +kind = "machinery" +files = 2 +lifecycle = "versioned" +canonical = false +canon_slot = "" +canonical_doc = "" +gate = "canon-spine-lockstep" +note = "Developer-container configuration and its Wolfi Containerfile, added in standards#1282. Mapped as a root-level developer-tooling entry so Gate D accounts for it bidirectionally." + [[entry]] from = ".editorconfig" target = ".editorconfig"