|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: MPL-2.0 |
| 3 | +# Copyright (c) 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 4 | +# |
| 5 | +# hcg-surface-drift-check.sh — Asserts that every wired BoJ HTTP route |
| 6 | +# is covered by at least one rule in the HCG live Verb Governance Spec. |
| 7 | +# |
| 8 | +# The HCG live policy file (`config/gateway-policy-boj.yaml`) carries a |
| 9 | +# manual re-verification stamp ("Re-verified DATE against BojRest.Router |
| 10 | +# for HCG tier-2 rollout"). The ADR's largest declared risk is "policy |
| 11 | +# lagging the surface": a wired route landing without a matching policy |
| 12 | +# rule would default-deny in production (an outage on a route that |
| 13 | +# should be live), and the manual stamp is the only check today that |
| 14 | +# catches this. |
| 15 | +# |
| 16 | +# This script automates the same check so the stamp becomes machine- |
| 17 | +# checkable. Algorithm: |
| 18 | +# |
| 19 | +# 1. Extract (verb, path-template) tuples from |
| 20 | +# `elixir/lib/boj_rest/router.ex` — every `get "/..."`, |
| 21 | +# `post "/..."`, etc. at the top level of the router module. |
| 22 | +# 2. Extract (verb, path-pattern) tuples from |
| 23 | +# `config/gateway-policy-boj.yaml` — every `- path: "..."` / |
| 24 | +# `verbs: [...]` pair under `governance.routes`. |
| 25 | +# 3. For each wired route, concretise its `:name`-style placeholders |
| 26 | +# with a known probe segment (`probe`, matching the smoke |
| 27 | +# script) and assert at least one policy rule matches: |
| 28 | +# * literal policy path → exact equality with the concrete URL |
| 29 | +# * regex policy path (leading `^`) → `grep -E` match against |
| 30 | +# the concrete URL |
| 31 | +# The router method must be in the policy rule's verb list. |
| 32 | +# 4. Report any wired-but-ungoverned routes (drift) and exit 1; or |
| 33 | +# exit 0 if every wired route is covered. |
| 34 | +# |
| 35 | +# Bracket-style relationship with `scripts/hcg-policy-smoke.sh`: |
| 36 | +# * Smoke script runs against a *live gateway* to confirm the policy |
| 37 | +# enforces as declared (deny path / allow path / stealth status). |
| 38 | +# * Drift check runs against the *source files* to confirm the policy |
| 39 | +# still covers the wired surface. |
| 40 | +# Together they cover both halves of the §1.5 pre-rollout verification: |
| 41 | +# surface→policy coverage (this script) and policy→gateway enforcement |
| 42 | +# (the smoke script). |
| 43 | +# |
| 44 | +# Usage: |
| 45 | +# ./scripts/hcg-surface-drift-check.sh # uses repo defaults |
| 46 | +# ./scripts/hcg-surface-drift-check.sh -v # verbose; list matches |
| 47 | +# |
| 48 | +# Exit codes: |
| 49 | +# 0 — no drift; every wired route is covered. |
| 50 | +# 1 — drift detected; at least one wired route has no matching rule. |
| 51 | +# 64 — bad usage. |
| 52 | +# |
| 53 | +# Limitations (called out so the operator does not over-trust the |
| 54 | +# OK result): |
| 55 | +# * Parses the router with regex, not the Elixir AST. Routes declared |
| 56 | +# via macros, list comprehensions, or runtime registration are NOT |
| 57 | +# seen. The current router declares every route at the top level |
| 58 | +# with the Plug.Router DSL, which the regex handles correctly; a |
| 59 | +# future indirection would require this script to evolve. |
| 60 | +# * The "concretise `:name` with a fixed probe" step assumes the |
| 61 | +# policy regex character class accepts the probe segment. The |
| 62 | +# current policy uses `[A-Za-z0-9_.-]+`, which accepts `probe`; |
| 63 | +# a tightened class might not. Change the probe via `PROBE=` env |
| 64 | +# var if needed. |
| 65 | +# * Does NOT report orphan policy rules (rules with no matching |
| 66 | +# router route). This is intentional: the policy governs declared- |
| 67 | +# not-yet-wired routes per contract §8 (`http-capability-gateway- |
| 68 | +# boj-contract.md` §8). An orphan-rule check would have to also |
| 69 | +# consult `docs/specification/openapi.yaml` to suppress those |
| 70 | +# intentional cases; out of scope for this script. |
| 71 | +# |
| 72 | +# Cross-refs: |
| 73 | +# docs/integration/hcg-tier2-rollout-runbook.md §1.5 |
| 74 | +# docs/integration/http-capability-gateway-boj-contract.md §8 ("Surface drift caveat") |
| 75 | +# docs/integration/http-capability-gateway-policy-authoring.md §5 ("Review & versioning discipline") |
| 76 | +# config/gateway-policy-boj.yaml source of truth |
| 77 | +# scripts/hcg-policy-smoke.sh companion enforcement check |
| 78 | +# standards#100 tracking issue |
| 79 | + |
| 80 | +set -euo pipefail |
| 81 | + |
| 82 | +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" |
| 83 | +ROUTER_FILE="${ROUTER_FILE:-${REPO_ROOT}/elixir/lib/boj_rest/router.ex}" |
| 84 | +POLICY_FILE="${POLICY_FILE:-${REPO_ROOT}/config/gateway-policy-boj.yaml}" |
| 85 | +PROBE="${PROBE:-probe}" |
| 86 | +VERBOSE=0 |
| 87 | + |
| 88 | +usage() { |
| 89 | + cat >&2 <<'EOF' |
| 90 | +hcg-surface-drift-check.sh — assert wired-router-route ⊆ policy-rules. |
| 91 | +
|
| 92 | +USAGE: |
| 93 | + hcg-surface-drift-check.sh [-v] [-h] |
| 94 | +
|
| 95 | +OPTIONS: |
| 96 | + -v Verbose; print each wired route and the policy rule |
| 97 | + that matches it. |
| 98 | + -h Show this help. |
| 99 | +
|
| 100 | +ENV: |
| 101 | + ROUTER_FILE Override router path (default elixir/lib/boj_rest/router.ex). |
| 102 | + POLICY_FILE Override policy path (default config/gateway-policy-boj.yaml). |
| 103 | + PROBE Placeholder segment substituted for `:name`-style |
| 104 | + router parameters (default "probe"). |
| 105 | +
|
| 106 | +EXIT CODES: |
| 107 | + 0 no drift; every wired route covered by a policy rule. |
| 108 | + 1 drift detected (at least one wired route has no match). |
| 109 | + 64 bad usage. |
| 110 | +
|
| 111 | +Cross-refs: |
| 112 | + docs/integration/hcg-tier2-rollout-runbook.md §1.5 |
| 113 | + scripts/hcg-policy-smoke.sh companion check |
| 114 | +EOF |
| 115 | + exit 64 |
| 116 | +} |
| 117 | + |
| 118 | +while [ $# -gt 0 ]; do |
| 119 | + case "$1" in |
| 120 | + -v) VERBOSE=1; shift ;; |
| 121 | + -h|--help) usage ;; |
| 122 | + *) echo "unknown arg: $1" >&2; usage ;; |
| 123 | + esac |
| 124 | +done |
| 125 | + |
| 126 | +[ -f "$ROUTER_FILE" ] || { echo "router file not found: $ROUTER_FILE" >&2; exit 1; } |
| 127 | +[ -f "$POLICY_FILE" ] || { echo "policy file not found: $POLICY_FILE" >&2; exit 1; } |
| 128 | + |
| 129 | +# 1. Wired routes from router.ex. |
| 130 | +# |
| 131 | +# Plug.Router DSL: top-level `get "/foo"` / `post "/foo"` etc. Output |
| 132 | +# format: VERB<TAB>/path/template, one route per line. The router's |
| 133 | +# `match _` catch-all is intentionally not captured. |
| 134 | +wired=$( |
| 135 | + grep -E '^[[:space:]]*(get|post|put|delete|patch|head|options)[[:space:]]+"[^"]+"' "$ROUTER_FILE" \ |
| 136 | + | awk '{ |
| 137 | + verb_lc = $1 |
| 138 | + # POSIX-portable upper-case via tr in a subshell. |
| 139 | + cmd = "printf %s " verb_lc " | tr a-z A-Z" |
| 140 | + cmd | getline VERB |
| 141 | + close(cmd) |
| 142 | + # Extract the path inside the first pair of double quotes. |
| 143 | + line = $0 |
| 144 | + sub(/^[^"]*"/, "", line) |
| 145 | + sub(/".*/, "", line) |
| 146 | + print VERB "\t" line |
| 147 | + }' |
| 148 | +) |
| 149 | + |
| 150 | +# 2. Policy rules from gateway-policy-boj.yaml. |
| 151 | +# |
| 152 | +# Each rule is a `- path: "..."` line under `governance.routes:`, |
| 153 | +# followed (on a later indented line) by `verbs: [GET, POST, ...]`. |
| 154 | +# We expand the verb list to one row per (verb, path) so the matching |
| 155 | +# loop below stays a single nested for-loop. Comments outside the |
| 156 | +# routes block are skipped because the regexes do not match them. |
| 157 | +policy=$( |
| 158 | + awk ' |
| 159 | + /^[[:space:]]*-[[:space:]]+path:[[:space:]]*"/ { |
| 160 | + line = $0 |
| 161 | + sub(/^[^"]*"/, "", line) |
| 162 | + sub(/".*/, "", line) |
| 163 | + cur_path = line |
| 164 | + next |
| 165 | + } |
| 166 | + /^[[:space:]]+verbs:[[:space:]]*\[/ { |
| 167 | + line = $0 |
| 168 | + sub(/^[^\[]*\[/, "", line) |
| 169 | + sub(/\].*/, "", line) |
| 170 | + gsub(/[[:space:]]/, "", line) |
| 171 | + n = split(line, vs, ",") |
| 172 | + for (i = 1; i <= n; i++) { |
| 173 | + print vs[i] "\t" cur_path |
| 174 | + } |
| 175 | + } |
| 176 | + ' "$POLICY_FILE" |
| 177 | +) |
| 178 | + |
| 179 | +# 3. For each wired route, find a covering policy rule. |
| 180 | +# |
| 181 | +# Concretise `:name`-style segments with $PROBE so regex policy paths |
| 182 | +# (`^/cartridge/[A-Za-z0-9_.-]+/invoke$` etc.) can be tested against |
| 183 | +# a real URL string. The PROBE default ("probe") is shared with the |
| 184 | +# smoke script so a future tightening of the regex character class |
| 185 | +# fails both checks in lock-step instead of one silently. |
| 186 | +drift=0 |
| 187 | +drift_msgs=() |
| 188 | +match_msgs=() |
| 189 | +while IFS=$'\t' read -r verb tmpl; do |
| 190 | + [ -z "${verb:-}" ] && continue |
| 191 | + # Substitute `:identifier` segments with the probe placeholder. |
| 192 | + # `:name` → `probe`, `:cartridge_id` → `probe`, etc. |
| 193 | + concrete=$(printf '%s' "$tmpl" | sed -E "s|:[a-zA-Z_][a-zA-Z0-9_]*|${PROBE}|g") |
| 194 | + |
| 195 | + matched_rule="" |
| 196 | + while IFS=$'\t' read -r p_verb p_path; do |
| 197 | + [ -z "${p_verb:-}" ] && continue |
| 198 | + [ "$verb" = "$p_verb" ] || continue |
| 199 | + case "$p_path" in |
| 200 | + \^*) |
| 201 | + # Regex pattern — ERE match against the concrete URL. |
| 202 | + if printf '%s' "$concrete" | grep -qE "$p_path"; then |
| 203 | + matched_rule="$p_verb $p_path" |
| 204 | + break |
| 205 | + fi |
| 206 | + ;; |
| 207 | + *) |
| 208 | + # Literal pattern — exact string equality. |
| 209 | + if [ "$concrete" = "$p_path" ]; then |
| 210 | + matched_rule="$p_verb $p_path" |
| 211 | + break |
| 212 | + fi |
| 213 | + ;; |
| 214 | + esac |
| 215 | + done <<< "$policy" |
| 216 | + |
| 217 | + if [ -z "$matched_rule" ]; then |
| 218 | + drift_msgs+=("$verb $tmpl (concrete: $concrete)") |
| 219 | + drift=$((drift + 1)) |
| 220 | + else |
| 221 | + match_msgs+=("$verb $tmpl → $matched_rule") |
| 222 | + fi |
| 223 | +done <<< "$wired" |
| 224 | + |
| 225 | +echo "==> HCG surface drift check" |
| 226 | +echo " Router file: $ROUTER_FILE" |
| 227 | +echo " Policy file: $POLICY_FILE" |
| 228 | +echo " Probe placeholder: '$PROBE'" |
| 229 | +wired_count=$(printf '%s\n' "$wired" | grep -c . || true) |
| 230 | +policy_count=$(printf '%s\n' "$policy" | grep -c . || true) |
| 231 | +echo " Wired (router) routes: $wired_count" |
| 232 | +echo " Policy (verb,path) rules: $policy_count" |
| 233 | +echo |
| 234 | + |
| 235 | +if [ "$VERBOSE" = "1" ] && [ ${#match_msgs[@]} -gt 0 ]; then |
| 236 | + echo "Matched:" |
| 237 | + for m in "${match_msgs[@]}"; do |
| 238 | + printf ' %s\n' "$m" |
| 239 | + done |
| 240 | + echo |
| 241 | +fi |
| 242 | + |
| 243 | +if [ "$drift" -eq 0 ]; then |
| 244 | + echo "OK: every wired router route is covered by at least one policy rule." |
| 245 | + echo "The §1.5 re-verification stamp in config/gateway-policy-boj.yaml" |
| 246 | + echo "can be advanced safely after manual review of any policy edits." |
| 247 | + exit 0 |
| 248 | +fi |
| 249 | + |
| 250 | +echo "DRIFT: $drift wired router route(s) are not covered by any policy rule:" |
| 251 | +for m in "${drift_msgs[@]}"; do |
| 252 | + printf ' - %s\n' "$m" |
| 253 | +done |
| 254 | +echo |
| 255 | +echo "Resolution: add a matching rule to config/gateway-policy-boj.yaml" |
| 256 | +echo "(and config/gateway-policy-boj-example.yaml if the route is part of" |
| 257 | +echo "the pedagogical surface). See docs/integration/http-capability-" |
| 258 | +echo "gateway-policy-authoring.md §5 for the co-change discipline." |
| 259 | +exit 1 |
0 commit comments