From 853105ad822973942261b8c119206a173eae20b1 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:44:34 +0000 Subject: [PATCH] feat(rsr): detect rule-table drift against the estate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RSR rule table in bots/rhodibot/src/rsr.rs is the source of truth for compliance, and it has rotted twice: once by requiring LICENSE.txt (0 of 269 repositories carried it) and once by holding the .well-known/security.txt checks advisory after the migration they were waiting on had finished. There is no deployed rhodibot anywhere in the estate, so nothing was going to catch either one. Adds: * scripts/detect-rsr-drift.sh — parses the rule table from source and measures it against a census of the estate. Flags DEAD (no repository has the path), ROT (a Required check almost nothing satisfies) and PENDING-MIGRATION (the destination of an in-flight, source-declared migration window). Offline via --trees-dir; can also probe live check runs to establish whether any runner is attributed to the app. Parser is defensive: line 365 of rsr.rs is `}, BannedPattern {`, a record closer and opener sharing a line, and brace counting silently drops the .well-known/security.txt ban because of it. A self-check compares parsed record counts against literal openings and fails loudly, and severity tuples are shape-asserted because they are read positionally downstream. A census below MIN_CENSUS (50) is treated as a sample: flags are marked (sample), the migration-window verdict refuses to be drawn, and the run exits 2 (indeterminate) rather than 1. Concluding "window closed" from a sample would advise raising severities that most repositories would then fail. * scripts/rsr-census.sh — builds the cached census the detector measures against. One API request per repository, atomic writes, reuse unless --refresh. * bots/rhodibot/hooks/validate-rsr-drift.sh — pre-commit hook, runs the detector when src/rsr.rs changes. Without a census it validates the table itself and says the estate comparison was skipped. * Justfile — validate-rsr-drift and refresh-rsr-census recipes. Shell only: Python is a banned language in this estate. --- Justfile | 9 + bots/rhodibot/hooks/validate-rsr-drift.sh | 78 ++++ scripts/detect-rsr-drift.sh | 458 ++++++++++++++++++++++ scripts/rsr-census.sh | 100 +++++ 4 files changed, 645 insertions(+) create mode 100755 bots/rhodibot/hooks/validate-rsr-drift.sh create mode 100755 scripts/detect-rsr-drift.sh create mode 100755 scripts/rsr-census.sh diff --git a/Justfile b/Justfile index 7c87b261..2c3b52ec 100644 --- a/Justfile +++ b/Justfile @@ -104,6 +104,15 @@ check-scm: done @echo "PASS: No SCM files in root" +# Check the RSR rule table against the estate (drift: rules nobody can satisfy) +validate-rsr-drift: + @bash bots/rhodibot/hooks/validate-rsr-drift.sh + +# Refresh the cached estate census that validate-rsr-drift measures against. +# ~1 API request per repository, so it is refreshed on demand, not per commit. +refresh-rsr-census: + @bash scripts/rsr-census.sh --refresh + # Clean all build artifacts clean: cd robot-repo-automaton && cargo clean diff --git a/bots/rhodibot/hooks/validate-rsr-drift.sh b/bots/rhodibot/hooks/validate-rsr-drift.sh new file mode 100755 index 00000000..9a0601f5 --- /dev/null +++ b/bots/rhodibot/hooks/validate-rsr-drift.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# +# Pre-commit hook: a change to the RSR rule table is checked against the estate. +# +# The rule table in src/rsr.rs is the source of truth for RSR compliance, and it +# has rotted twice before: once by demanding LICENSE.txt (0 of 269 repositories +# carried it) and once by holding the .well-known/security.txt checks advisory +# after the migration they were waiting on had finished. +# +# This hook runs scripts/detect-rsr-drift.sh whenever src/rsr.rs is part of the +# change, so that a rule-table edit cannot land without being measured against +# the repositories it will judge. +# +# The estate census is cached, not fetched here: a 269-repository crawl has no +# business inside a commit. Refresh it first with: +# +# just refresh-rsr-census +# +# If no census is present the hook still validates the table itself (it parses, +# its severity tuples are well-formed, its migration posture is coherent) and +# says loudly that the estate comparison was skipped. It does not fail the +# commit for a missing census; it does fail it for actual drift. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +DETECTOR="$REPO_ROOT/scripts/detect-rsr-drift.sh" +RULE_TABLE="bots/rhodibot/src/rsr.rs" +CENSUS="${RSR_CENSUS_DIR:-$REPO_ROOT/.rsr-census}" + +cd "$REPO_ROOT" + +if [ ! -x "$DETECTOR" ]; then + echo "ERROR: detector not found or not executable: $DETECTOR" >&2 + exit 1 +fi + +# Only run when the rule table is actually part of this change. +staged=$(git diff --cached --name-only 2>/dev/null || true) +if ! printf '%s\n' "$staged" | grep -qxF "$RULE_TABLE"; then + # Also run when the detector itself is being changed, since its verdicts are + # only as good as its parser. + if ! printf '%s\n' "$staged" | grep -qxF "scripts/detect-rsr-drift.sh"; then + exit 0 + fi +fi + +echo "=== RSR rule-table drift check ===" + +if [ -d "$CENSUS" ] && [ -n "$(ls -A "$CENSUS" 2>/dev/null)" ]; then + repos=$(ls -1 "$CENSUS" | wc -l | tr -d ' ') + echo "using cached census: $CENSUS ($repos repositories)" + echo " (age: $(find "$CENSUS" -maxdepth 1 -type f -printf '%TY-%Tm-%Td\n' 2>/dev/null | sort | head -1))" + set +e + "$DETECTOR" --source "$RULE_TABLE" --trees-dir "$CENSUS" + rc=$? + set -e +else + echo "no census at $CENSUS — validating the table only" + echo "the estate comparison is SKIPPED; run: just refresh-rsr-census" + set +e + "$DETECTOR" --source "$RULE_TABLE" + rc=$? + set -e + # Exit 1 from a source-only run means the table itself is broken, which + # must block. Exit 2 means indeterminate. +fi + +case "$rc" in + 0) echo "PASS: no rule-table drift" ;; + 1) echo "FAIL: rule-table drift found — see the report above" >&2; exit 1 ;; + 2) echo "WARN: drift check indeterminate (some inputs unavailable)" >&2; exit 0 ;; + *) echo "FAIL: drift check errored (rc=$rc)" >&2; exit 1 ;; +esac + +exit 0 diff --git a/scripts/detect-rsr-drift.sh b/scripts/detect-rsr-drift.sh new file mode 100755 index 00000000..6a650b84 --- /dev/null +++ b/scripts/detect-rsr-drift.sh @@ -0,0 +1,458 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# +# detect-rsr-drift.sh — is the RSR rule table still describing the estate? +# +# Rhodibot validates repositories against the RSR rule table in +# bots/rhodibot/src/rsr.rs. That table is the *source of truth*, and a rule +# table can rot in two distinct ways: +# +# 1. FALSE DEMAND — a Required check names a path that (almost) no repository +# has. The historical case is LICENSE.txt: 0 of 269 repositories carried +# it, so every repo failed a "Required" check it could not satisfy. +# 2. STALE WINDOW — a check is deliberately held at an advisory severity +# while a migration window is open, and the window closed without anyone +# raising the severity. The live case is .well-known/security.txt, whose +# canonical location moved to www/.well-known/ (#53). +# +# This script measures the estate against the source table and reports both. +# It is deliberately offline-first: given a directory of per-repo file listings +# it needs no network and no token at all. +# +# It can additionally probe the live GitHub API for evidence of a *deployed* +# runner, because a rule table that is not running anywhere enforces nothing. +# +# Exit codes: 0 = clean, 1 = drift/rot found, 2 = could not determine. + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +log_info() { printf '%b[INFO]%b %s\n' "$BLUE" "$NC" "$*" >&2; } +log_warn() { printf '%b[WARN]%b %s\n' "$YELLOW" "$NC" "$*" >&2; } +log_error() { printf '%b[FAIL]%b %s\n' "$RED" "$NC" "$*" >&2; } + +# ---------------------------------------------------------------- defaults --- +SOURCE="" +TREES_DIR="" +ESTATE_LIST="" +PROBE_OWNER="" +PROBE_COUNT=5 +APP_SLUG="rhodibot" +JSON=0 +ROT_THRESHOLD=50 # Required check below this prevalence = rot +MIN_CENSUS="${MIN_CENSUS:-50}" # below this the census is a sample, not a census +MIGRATION_TARGET="www/.well-known/security.txt" +MIGRATION_LEGACY=".well-known/security.txt" +DRIFT_FOUND=0 +INDETERMINATE=0 +n_rot_real=0 + +while [ $# -gt 0 ]; do + case "$1" in + --source) SOURCE="$2"; shift 2 ;; + --trees-dir) TREES_DIR="$2"; shift 2 ;; + --estate-list) ESTATE_LIST="$2"; shift 2 ;; + --probe) PROBE_OWNER="$2"; shift 2 ;; + --probe-count) PROBE_COUNT="$2"; shift 2 ;; + --app-slug) APP_SLUG="$2"; shift 2 ;; + --rot-threshold) ROT_THRESHOLD="$2"; shift 2 ;; + --json) JSON=1; shift ;; + -h|--help) + sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) log_error "unknown option: $1"; exit 2 ;; + esac +done + +# Locate the rule table if not given explicitly. +if [ -z "$SOURCE" ]; then + for cand in "bots/rhodibot/src/rsr.rs" "$(dirname "$0")/../bots/rhodibot/src/rsr.rs"; do + [ -f "$cand" ] && SOURCE="$cand" && break + done +fi +if [ -z "$SOURCE" ] || [ ! -f "$SOURCE" ]; then + log_error "cannot find rhodibot rule table (--source bots/rhodibot/src/rsr.rs)" + exit 2 +fi + +TMPDIR_RUN="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_RUN"' EXIT + +printf '%b%s%b\n' "$BOLD" "RSR rule-table drift detector" "$NC" >&2 +printf ' source: %s\n' "$SOURCE" >&2 +printf '\n' >&2 + +# ---------------------------------------------------- 1. parse the source --- +# Records open on `CheckDef {` / `BannedPattern {` and close on a line that is +# just `},`. Within a record the first `name: "…"` and first `severity: (…)` +# belong together. Brace depth is tracked so nested braces cannot confuse it. + +parse_table() { + local array_name="$1" struct_name="$2" + # Record boundaries cannot be found by brace counting alone: line 365 of the + # canonical rsr.rs is `}, BannedPattern {` — a closer and an opener on one + # line. Counting braces there opens and immediately closes a record with no + # name, silently dropping the entry that follows (historically the + # .well-known/security.txt ban, i.e. the migration's own kill-switch). + # + # So: a record opens on any line containing ` {`, flushing any + # record still open; and closes on a line that is nothing but `},` / `}`. + awk -v arr="$array_name" -v st="$struct_name" ' + function flush() { + if (open && name != "") print name "\t" sev + open=0; name=""; sev="" + } + $0 ~ "^pub const " arr { inarr=1; next } + inarr && /^\];/ { flush(); inarr=0 } + !inarr { next } + $0 ~ st "[ \t]*\\{" { + flush() + open=1 + } + open { + if (name == "" && match($0, /name: "[^"]+"/)) + name = substr($0, RSTART+7, RLENGTH-8) + if (sev == "" && match($0, /severity: \([^)]*\)/)) { + # The match is `severity: (A, B, C, D)`; take only what is + # between the parens. "severity: " is 10 chars, plus the "(". + sev = substr($0, RSTART + 11, RLENGTH - 12) + gsub(/Severity::/, "", sev); gsub(/[ \t]/, "", sev) + } + if ($0 ~ /^[ \t]*\},?[ \t]*$/) flush() + } + END { flush() } + ' "$SOURCE" +} + +parse_table "REQUIRED_FILES" "CheckDef" > "$TMPDIR_RUN/required.tsv" +parse_table "BANNED_PATTERNS" "BannedPattern" > "$TMPDIR_RUN/banned.tsv" + +n_req=$(wc -l < "$TMPDIR_RUN/required.tsv" | tr -d ' ') +n_ban=$(wc -l < "$TMPDIR_RUN/banned.tsv" | tr -d ' ') + +# Severity is read positionally later (field 1,2 = minimal,standard), so a +# malformed tuple silently shifts every downstream verdict. Assert the shape +# rather than trusting the substring arithmetic. +sev_shape='^(Optional|Recommended|Required),(Optional|Recommended|Required),(Optional|Recommended|Required),(Optional|Recommended|Required)$' +bad_sev=$( { cut -f2 "$TMPDIR_RUN/required.tsv"; cut -f2 "$TMPDIR_RUN/banned.tsv"; } \ + | grep -vcE "$sev_shape" || true ) +if [ "$bad_sev" != "0" ]; then + log_error "severity tuples did not parse cleanly ($bad_sev malformed):" + { cut -f1,2 "$TMPDIR_RUN/required.tsv"; cut -f1,2 "$TMPDIR_RUN/banned.tsv"; } \ + | grep -vE " $sev_shape" | sed 's/^/ /' >&2 || true + exit 2 +fi + +# Self-check: the number of parsed records must equal the number of literal +# record openings, or we are reading a different table than we think. +# Self-check: the number of parsed records must equal the number of literal +# record openings, or we are reading a different table than we think. The +# opener is matched anywhere on the line, because an opener can share a line +# with the previous record's closer; struct/impl definitions are excluded. +expect_req=$(grep -E 'CheckDef \{' "$SOURCE" | grep -vcE '^pub struct|^impl ' || true) +expect_ban=$(grep -E 'BannedPattern \{' "$SOURCE" | grep -vcE '^pub struct|^impl ' || true) + +if [ "$n_req" != "$expect_req" ] || [ "$n_ban" != "$expect_ban" ]; then + log_error "parser self-check failed: parsed $n_req/$expect_req CheckDef" \ + "and $n_ban/$expect_ban BannedPattern records" + exit 2 +fi +log_info "rule table: $n_req required-file checks, $n_ban banned patterns" + +# Required workflows live in a plain tuple list inside the checks function. +grep -oE '^\s+\("[a-z-]+\.yml",' "$SOURCE" | grep -oE '"[a-z-]+\.yml"' | tr -d '"' \ + | sort -u > "$TMPDIR_RUN/workflows.txt" +n_wf=$(wc -l < "$TMPDIR_RUN/workflows.txt" | tr -d ' ') +log_info "rule table: $n_wf required workflows" + +# --------------------------------------------------- 2. estate conformance --- +# A repository is represented by a plain file listing (one path per line), as +# produced by `git ls-tree -r --name-only` or the GitHub trees API. + +census_total=0 +if [ -n "$TREES_DIR" ] && [ -d "$TREES_DIR" ]; then + if [ -n "$ESTATE_LIST" ] && [ -f "$ESTATE_LIST" ]; then + mapfile -t REPOS < <(sed '/^$/d' "$ESTATE_LIST") + else + mapfile -t REPOS < <(find "$TREES_DIR" -maxdepth 1 -type f -printf '%f\n' | sort) + fi + + : > "$TMPDIR_RUN/present.tsv" + : > "$TMPDIR_RUN/rootlegacy.tsv" + + for repo in "${REPOS[@]}"; do + tree="$TREES_DIR/$repo" + [ -f "$tree" ] || { INDETERMINATE=1; continue; } + census_total=$((census_total + 1)) + + while IFS=$'\t' read -r path sev; do + [ -z "$path" ] && continue + if grep -qxF "$path" "$tree"; then + printf '%s\t%s\n' "$path" "$repo" >> "$TMPDIR_RUN/present.tsv" + fi + done < "$TMPDIR_RUN/required.tsv" + + # Legacy location residue: the migration's own finish line. + if grep -qxF '.well-known/security.txt' "$tree" \ + && ! grep -qxF 'www/.well-known/security.txt' "$tree"; then + printf '%s\n' "$repo" >> "$TMPDIR_RUN/rootlegacy.tsv" + fi + + while IFS=$'\t' read -r path sev; do + [ -z "$path" ] && continue + if grep -qxF "$path" "$tree"; then + printf '%s\t%s\n' "$path" "$repo" >> "$TMPDIR_RUN/bannedpresent.tsv" + fi + done < "$TMPDIR_RUN/banned.tsv" + done + + log_info "census: $census_total repositories measured" +fi + +# ------------------------------------------------------- 3. live deployment --- +# The rule table only matters if something is running it. Look for the bot's +# observable footprint: check runs attributed to its GitHub App. +probe_apps="" +probe_runs=0 +probe_repos=0 +if [ -n "$PROBE_OWNER" ]; then + token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [ -z "$token" ]; then + log_warn "--probe needs GH_TOKEN or GITHUB_TOKEN; skipping live probe" + INDETERMINATE=1 + else + mapfile -t PROBE_REPOS < <(sed '/^$/d' "${ESTATE_LIST:-/dev/null}" | head -n "$PROBE_COUNT") + if [ "${#PROBE_REPOS[@]}" -eq 0 ]; then + log_warn "--probe needs --estate-list to choose repos; skipping" + INDETERMINATE=1 + fi + : > "$TMPDIR_RUN/apps.txt" + for repo in "${PROBE_REPOS[@]}"; do + resp=$(curl -sS \ + -H "Authorization: Bearer $token" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$PROBE_OWNER/$repo/commits/HEAD/check-runs?per_page=100" \ + 2>/dev/null || echo '{}') + probe_repos=$((probe_repos + 1)) + got=$(printf '%s' "$resp" | jq -r '.check_runs | length' 2>/dev/null || echo 0) + case "$got" in ''|*[!0-9]*) got=0; INDETERMINATE=1 ;; esac + probe_runs=$((probe_runs + got)) + printf '%s' "$resp" | jq -r '.check_runs[]?.app.slug // empty' 2>/dev/null \ + >> "$TMPDIR_RUN/apps.txt" || true + done + probe_apps=$(sort -u "$TMPDIR_RUN/apps.txt" | paste -sd, - ) + log_info "live probe: $probe_repos repos, $probe_runs check runs, apps=[$probe_apps]" + fi +fi + +deploy_verdict="NOT-PROBED" +if [ -n "$probe_apps" ]; then + if printf '%s' ",$probe_apps," | grep -qF ",$APP_SLUG,"; then + deploy_verdict="LIVE" + else + deploy_verdict="ABSENT" + fi +fi + +# --------------------------------------------------------------- 4. report --- +# The window posture is declared in the source table itself. +window_posture="unknown" +canon_sev=$(grep -P '^www/\.well-known/security\.txt\t' "$TMPDIR_RUN/required.tsv" | cut -f2 || true) +ban_sev=$(grep -P '^\.well-known/security\.txt\t' "$TMPDIR_RUN/banned.tsv" | cut -f2 || true) +if [ -n "$canon_sev" ] && [ -n "$ban_sev" ]; then + canon_min_std=$(printf '%s' "$canon_sev" | cut -d, -f1,2) + ban_min_std=$(printf '%s' "$ban_sev" | cut -d, -f1,2) + if [ "$ban_min_std" = "Optional,Optional" ] || [ "$canon_min_std" = "Optional,Optional" ]; then + window_posture="advisory" + else + window_posture="enforced" + fi +fi + +declare -a ROT_ROWS=() +while IFS=$'\t' read -r path sev; do + [ -z "$path" ] && continue + std="$(printf '%s' "$sev" | cut -d, -f2)" + count=0 + [ -f "$TMPDIR_RUN/present.tsv" ] && \ + count=$(grep -cF "$(printf '%s\t' "$path")" "$TMPDIR_RUN/present.tsv" || true) + pct=0 + [ "$census_total" -gt 0 ] && pct=$(( count * 100 / census_total )) + + flag="" + if [ "$census_total" -gt 0 ]; then + if [ "$count" -eq 0 ]; then + flag="DEAD" + elif [ "$std" = "Required" ] && [ "$pct" -lt "$ROT_THRESHOLD" ]; then + flag="ROT" + fi + fi + # A zero-prevalence check that names the destination of a migration the + # source itself still declares advisory is pending work, not rot. Reporting + # it as rot would make this detector cry wolf on every sweep in progress. + if [ "$flag" = "DEAD" ] && [ "$path" = "$MIGRATION_TARGET" ] && [ "$window_posture" = "advisory" ]; then + flag="PENDING-MIGRATION" + fi + # A small census cannot distinguish "absent from the estate" from "absent + # from this sample": .machine_readable/STATE.a2ml is 13/269 estate-wide but + # 0/8 in a sample of eight. Flags are reported either way, but only a real + # census is allowed to fail the run — weak evidence must not block. + if [ "$census_total" -gt 0 ] && [ "$census_total" -lt "$MIN_CENSUS" ] \ + && { [ "$flag" = "ROT" ] || [ "$flag" = "DEAD" ]; }; then + flag="$flag(sample)" + INDETERMINATE=1 + fi + [ "$flag" = "ROT" ] || [ "$flag" = "DEAD" ] && n_rot_real=$((n_rot_real + 1)) || true + [ -n "$flag" ] && ROT_ROWS+=("$(printf '%s\t%s\t%s\t%s\t%s\t%s' "$flag" "$path" "$count" "$census_total" "$pct" "$std")") +done < "$TMPDIR_RUN/required.tsv" + +n_root_legacy=0 +[ -f "$TMPDIR_RUN/rootlegacy.tsv" ] && n_root_legacy=$(wc -l < "$TMPDIR_RUN/rootlegacy.tsv" | tr -d ' ') + +migrated=0 +if [ "$census_total" -gt 0 ]; then + migrated=$(( census_total - n_root_legacy )) +fi +mig_pct=0 +[ "$census_total" -gt 0 ] && mig_pct=$(( migrated * 100 / census_total )) + +if [ "$census_total" -gt 0 ] && [ "$census_total" -lt "$MIN_CENSUS" ]; then + # A sample of mostly-migrated repos reaches mig_pct=100 and would then + # advise raising .well-known checks to Required — advice that would break + # every repository the sample missed. Refuse to draw that conclusion. + drift_window="SAMPLE-TOO-SMALL" + INDETERMINATE=1 +elif [ "$mig_pct" -eq 100 ] && [ "$census_total" -gt 0 ] && [ "$window_posture" = "advisory" ]; then + drift_window="WINDOW-CLOSED-BUT-STILL-ADVISORY" +elif [ "$mig_pct" -lt 100 ] && [ "$window_posture" = "enforced" ]; then + drift_window="ENFORCED-BEFORE-MIGRATION-COMPLETE" +else + drift_window="OK" +fi + +# SAMPLE-TOO-SMALL is indeterminate, not drift: refusing to judge is not the +# same as finding something wrong, and it must not block a commit. +if [ "$n_rot_real" -gt 0 ] \ + || { [ "$drift_window" != "OK" ] && [ "$drift_window" != "SAMPLE-TOO-SMALL" ]; } \ + || [ "$deploy_verdict" = "ABSENT" ]; then + DRIFT_FOUND=1 +fi + +if [ "$JSON" -eq 1 ]; then + jq -n \ + --arg source "$SOURCE" \ + --argjson required "$n_req" \ + --argjson banned "$n_ban" \ + --argjson workflows "$n_wf" \ + --argjson census "$census_total" \ + --arg deploy "$deploy_verdict" \ + --arg apps "$probe_apps" \ + --argjson runs "$probe_runs" \ + --argjson rootlegacy "$n_root_legacy" \ + --argjson migrated_pct "$mig_pct" \ + --arg window "$drift_window" \ + --arg posture "$window_posture" \ + --argjson rot "$(printf '%s\n' "${ROT_ROWS[@]:-}" | jq -R -s 'split("\n") | map(select(length>0) | split("\t") | {flag:.[0],path:.[1],present:(.[2]|tonumber),total:(.[3]|tonumber),percent:(.[4]|tonumber),severity:.[5]})')" \ + '{source:$source, rule_table:{required_files:$required,banned_patterns:$banned,required_workflows:$workflows}, + estate:{measured:$census}, deployment:{verdict:$deploy, apps:$apps, check_runs:$runs}, + migration:{repos_on_legacy_root:$rootlegacy, migrated_percent:$migrated_pct, window_posture:$posture, verdict:$window}, + rot:$rot, drift_found:($rot|length>0)}' + exit $([ "$DRIFT_FOUND" -eq 1 ] && echo 1 || { [ "$INDETERMINATE" -eq 1 ] && echo 2 || echo 0; }) +fi + +printf '%b1. DEPLOYMENT%b\n' "$BOLD" "$NC" +printf ' verdict: %s\n' "$deploy_verdict" +if [ -n "$probe_apps" ]; then + printf ' evidence: %s repos, %s check runs; apps seen: %s\n' \ + "$probe_repos" "$probe_runs" "$probe_apps" + if [ "$deploy_verdict" = "ABSENT" ]; then + printf ' %bno check run in the estate is attributed to %s.%b\n' "$YELLOW" "$APP_SLUG" "$NC" + printf ' RSR compliance is therefore enforced only by in-repo workflows,\n' + printf ' and the rule table below is a specification, not a running control.\n' + fi +fi +printf '\n' + +printf '%b2. RULE TABLE%b (source of truth)\n' "$BOLD" "$NC" +printf ' %s required-file checks, %s banned patterns, %s required workflows\n' \ + "$n_req" "$n_ban" "$n_wf" +printf '\n' + +printf '%b3. RULE TABLE vs ESTATE%b\n' "$BOLD" "$NC" +if [ "$census_total" -eq 0 ]; then + printf ' (no census — pass --trees-dir to measure)\n' +else + printf ' measured against %s repositories\n' "$census_total" + if [ "$census_total" -lt "$MIN_CENSUS" ]; then + printf ' %bSAMPLE, not a census: below %s repos, absence proves little.%b\n' \ + "$YELLOW" "$MIN_CENSUS" "$NC" + printf ' Flags are marked (sample) and cannot fail the run.\n' + fi + printf ' %-34s %7s %6s %s\n' "CHECK" "PRESENT" "PCT" "SEVERITY" + while IFS=$'\t' read -r path sev; do + [ -z "$path" ] && continue + std="$(printf '%s' "$sev" | cut -d, -f2)" + count=$(grep -cF "$(printf '%s\t' "$path")" "$TMPDIR_RUN/present.tsv" 2>/dev/null || true) + count=${count:-0}; count=$(printf '%s' "$count" | head -n1) + pct=$(( count * 100 / census_total )) + mark="" + if [ "$count" -eq 0 ]; then mark="${RED}DEAD${NC}" + elif [ "$std" = "Required" ] && [ "$pct" -lt "$ROT_THRESHOLD" ]; then mark="${YELLOW}ROT${NC}" + fi + [ "$path" = "$MIGRATION_TARGET" ] && [ "$count" -eq 0 ] && [ "$window_posture" = "advisory" ] \ + && mark="${BLUE}PENDING-MIGRATION${NC}" + if [ -n "$mark" ] && [ "$census_total" -lt "$MIN_CENSUS" ]; then + mark="$mark${YELLOW}(sample)${NC}" + fi + printf ' %-34s %4s/%-4s %5s%% %-12s %b\n' "$path" "$count" "$census_total" "$pct" "$std" "$mark" + done < "$TMPDIR_RUN/required.tsv" +fi +printf '\n' + +printf '%b4. MIGRATION WINDOW%b (root .well-known/ -> www/.well-known/)\n' "$BOLD" "$NC" +printf ' repos still on legacy root path: %s\n' "$n_root_legacy" +printf ' migrated: %s%%\n' "$mig_pct" +printf ' declared posture in source: %s\n' "$window_posture" +case "$drift_window" in + WINDOW-CLOSED-BUT-STILL-ADVISORY) + printf ' %bVERDICT: the migration is complete but the checks are still advisory.%b\n' "$YELLOW" "$NC" + printf ' Raise www/.well-known/security.txt to Required at minimal/standard,\n' + printf ' and .well-known/security.txt to Required at minimal/standard,\n' + printf ' as the comment in %s instructs.\n' "$SOURCE" ;; + ENFORCED-BEFORE-MIGRATION-COMPLETE) + printf ' %bVERDICT: enforcing before the sweep finished — %s repos would fail.%b\n' \ + "$RED" "$n_root_legacy" "$NC" ;; + SAMPLE-TOO-SMALL) + printf ' %bVERDICT: cannot judge — the census is a sample of %s repos.%b\n' \ + "$YELLOW" "$census_total" "$NC" + printf ' Concluding "window closed" from a sample would advise raising\n' + printf ' severities that most repositories would then fail.\n' ;; + OK) + printf ' %bVERDICT: posture matches estate reality.%b\n' "$GREEN" "$NC" ;; +esac +if [ "$n_root_legacy" -gt 0 ] && [ "$n_root_legacy" -le 25 ]; then + printf ' remaining repos:\n' + sed 's/^/ - /' "$TMPDIR_RUN/rootlegacy.tsv" +fi +printf '\n' + +printf '%bSUMMARY%b\n' "$BOLD" "$NC" +if [ "$DRIFT_FOUND" -eq 1 ]; then + printf ' %bDRIFT FOUND%b\n' "$RED" "$NC" +else + printf ' %bno drift%b\n' "$GREEN" "$NC" +fi +if [ "$INDETERMINATE" -eq 1 ]; then + printf ' %bsome inputs were unavailable — result is partial%b\n' "$YELLOW" "$NC" +fi + +if [ "$DRIFT_FOUND" -eq 1 ]; then exit 1; fi +if [ "$INDETERMINATE" -eq 1 ]; then exit 2; fi +exit 0 diff --git a/scripts/rsr-census.sh b/scripts/rsr-census.sh new file mode 100755 index 00000000..8ba20f5e --- /dev/null +++ b/scripts/rsr-census.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell +# +# rsr-census.sh — snapshot every estate repository's file list. +# +# detect-rsr-drift.sh measures the RSR rule table against the estate, but it +# needs a cheap representation of the estate to do so: one file per repository, +# one path per line. That is this script's only job. It is deliberately +# network-only and slow, so run it on a schedule and reuse the output. +# +# Output layout (what --trees-dir expects): +# / # newline-separated paths, no leading "./" +# +# Usage: +# rsr-census.sh [--out DIR] [--repos-file FILE] [--refresh] +# +# Env: GH_TOKEN or GITHUB_TOKEN (required — 1 request per repository). + +set -euo pipefail + +TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +OUT="${RSR_CENSUS_DIR:-.rsr-census}" +REPOS_FILE="" +ORG="${ORG:-hyperpolymath}" +REFRESH=0 + +while [ $# -gt 0 ]; do + case "$1" in + --out) OUT="$2"; shift 2 ;; + --repos-file) REPOS_FILE="$2"; shift 2 ;; + --org) ORG="$2"; shift 2 ;; + --refresh) REFRESH=1; shift ;; + -h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +[ -n "$TOKEN" ] || { echo "need GH_TOKEN or GITHUB_TOKEN" >&2; exit 2; } +mkdir -p "$OUT" + +fetch_tree() { + # One recursive tree call per repo. Returns newline-separated blob paths. + local repo="$1" + curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$ORG/$repo/git/trees/HEAD?recursive=1" \ + | jq -r ' + if .tree then + .tree[] | select(.type == "blob") | .path + else + "__ERROR__ \(.message // "unknown")" + end' +} + +if [ -n "$REPOS_FILE" ]; then + mapfile -t REPOS < <(sed '/^$/d' "$REPOS_FILE") +else + echo "fetching repository list for $ORG ..." >&2 + mapfile -t REPOS < <(curl -sS \ + -H "Authorization: Bearer $TOKEN" \ + "https://api.github.com/orgs/$ORG/repos?per_page=100&page=1" \ + | jq -r '.[].name' | sort) +fi + +echo "census target: ${#REPOS[@]} repositories -> $OUT" >&2 + +n=0; skipped=0; fresh=0; failed=0 +for repo in "${REPOS[@]}"; do + [ -z "$repo" ] && continue + dest="$OUT/$repo" + + if [ -f "$dest" ] && [ "$REFRESH" -eq 0 ]; then + fresh=$((fresh + 1)); continue + fi + + body=$(fetch_tree "$repo") + if printf '%s' "$body" | head -n1 | grep -q '^__ERROR__'; then + printf ' WARN %s: %s\n' "$repo" "$(printf '%s' "$body" | head -n1)" >&2 + failed=$((failed + 1)) + continue + fi + if [ -z "$body" ]; then + printf ' WARN %s: empty tree (rate limited or empty repo)\n' "$repo" >&2 + failed=$((failed + 1)) + continue + fi + + # Write atomically so a killed run cannot leave a half census that the + # detector would read as a repository missing most of its files. + printf '%s\n' "$body" | sort -u > "$dest.tmp" + mv "$dest.tmp" "$dest" + n=$((n + 1)) + [ $((n % 25)) -eq 0 ] && echo " ...$n fetched" >&2 +done + +echo "" >&2 +echo "wrote $n, reused $fresh, failed $failed" >&2 +echo "census dir: $OUT" >&2