run.sh 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. #!/usr/bin/env bash
  2. # Offline self-test for github-ops scripts. No network required — exercises the
  3. # contract + the gate-safety skip paths (graceful exit 7), not live GitHub data.
  4. set -uo pipefail
  5. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  6. ROOT="$(cd "$HERE/.." && pwd)"
  7. SCRIPTS="$ROOT/scripts"
  8. CI="$SCRIPTS/check-issues.sh"
  9. SP="$SCRIPTS/check-security-posture.sh"
  10. pass=0; fail=0
  11. ok() { echo " PASS $1"; pass=$((pass+1)); }
  12. no() { echo " FAIL $1"; fail=$((fail+1)); }
  13. expect() { if [ "$2" = "$3" ]; then ok "$1 (exit $3)"; else no "$1 (want $2 got $3)"; fi; }
  14. echo "-- check-issues.sh (offline contract + skip paths) --"
  15. bash -n "$CI" && ok "bash -n clean" || no "bash -n"
  16. bash "$CI" --help >/dev/null 2>&1; expect "--help" 0 $?
  17. bash "$CI" --frobnicate >/dev/null 2>&1; expect "unknown flag -> usage" 2 $?
  18. # Non-github remote must skip with exit 7 and NEVER hit the network.
  19. T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
  20. git -C "$T" init -q
  21. git -C "$T" remote add origin "/some/local/path.git"
  22. ( cd "$T" && bash "$CI" --remote origin >/dev/null 2>&1 ); expect "non-github remote -> unavailable" 7 $?
  23. # Advisory mode on a non-github remote must be SILENT (no stderr) and exit 7 —
  24. # this is the gate-safety contract: an unusable check never disturbs a push.
  25. out="$( cd "$T" && bash "$CI" --advisory --remote origin 2>&1 )"; rc=$?
  26. if [ "$rc" -eq 7 ] && [ -z "$out" ]; then ok "advisory non-github -> silent exit 7"
  27. else no "advisory non-github (rc=$rc, stderr='$out')"; fi
  28. # Missing remote -> skip 7 (git remote get-url fails; no network).
  29. ( cd "$T" && bash "$CI" --remote nope-xyz >/dev/null 2>&1 ); expect "missing remote -> unavailable" 7 $?
  30. echo
  31. echo "-- check-security-posture.sh (offline contract + skip paths) --"
  32. bash -n "$SP" && ok "sp: bash -n clean" || no "sp: bash -n"
  33. bash "$SP" --help >/dev/null 2>&1; expect "sp: --help" 0 $?
  34. # --help must advertise EXAMPLES so the tool is discoverable.
  35. # Never assert via `producer | grep -q` in this suite: under `set -o pipefail`,
  36. # grep -q exits at the first match and the producer dies with SIGPIPE (141),
  37. # flaking the pipeline non-zero even when the pattern is present. Capture the
  38. # output once, then grep the variable (a here-string can't SIGPIPE).
  39. sp_help="$(bash "$SP" --help 2>&1)"
  40. if grep -q "Examples:" <<<"$sp_help"; then ok "sp: --help has EXAMPLES"
  41. else no "sp: --help missing EXAMPLES"; fi
  42. bash "$SP" --frobnicate >/dev/null 2>&1; expect "sp: unknown flag -> usage" 2 $?
  43. # Malformed OWNER/REPO is a usage error, never a network call.
  44. bash "$SP" --repo "not-a-valid-spec" >/dev/null 2>&1; expect "sp: bad --repo shape -> usage" 2 $?
  45. # --repo and --org are mutually exclusive.
  46. bash "$SP" --repo a/b --org c >/dev/null 2>&1; expect "sp: --repo + --org -> usage" 2 $?
  47. # Non-github remote must skip with exit 7 and NEVER hit the network.
  48. ( cd "$T" && bash "$SP" --remote origin >/dev/null 2>&1 ); expect "sp: non-github remote -> unavailable" 7 $?
  49. # Advisory mode on a non-github remote must be SILENT and exit 7.
  50. out="$( cd "$T" && bash "$SP" --advisory --remote origin 2>&1 )"; rc=$?
  51. if [ "$rc" -eq 7 ] && [ -z "$out" ]; then ok "sp: advisory non-github -> silent exit 7"
  52. else no "sp: advisory non-github (rc=$rc, stderr='$out')"; fi
  53. # Missing remote -> skip 7.
  54. ( cd "$T" && bash "$SP" --remote nope-xyz >/dev/null 2>&1 ); expect "sp: missing remote -> unavailable" 7 $?
  55. # --commands emits the review banner on stderr (offline path: banner prints before
  56. # any network work would, on a non-github remote it still skips — so assert the
  57. # banner via the bundled help text instead, which is fully offline).
  58. # The review banner string must be present in the source contract.
  59. if grep -q "review before running — these change repo settings" "$SP"; then ok "sp: review banner string present"
  60. else no "sp: review banner missing"; fi
  61. # The SECURITY.md template asset must exist and be non-trivial.
  62. if [ -s "$ROOT/assets/SECURITY.md.template" ] && grep -q "Reporting a Vulnerability" "$ROOT/assets/SECURITY.md.template"; then
  63. ok "sp: SECURITY.md.template asset present"
  64. else no "sp: SECURITY.md.template asset missing/empty"; fi
  65. # Read-only guarantee. The ONLY executor in this script is `runner gh api …`
  66. # (every -X PUT/PATCH lives inside an emitted *_cmd string, never executed). Assert
  67. # no `runner gh api` invocation carries a mutating verb.
  68. sp_api_calls="$(grep -E 'runner gh api' "$SP")" # captured, not piped — see SIGPIPE note above
  69. if grep -Eq '\-X (PUT|PATCH|POST|DELETE)' <<<"$sp_api_calls"; then
  70. no "sp: found an executed mutating gh api call (must be read-only)"
  71. else ok "sp: no executed mutating gh api call (read-only)"; fi
  72. # And every mutating verb that DOES appear must be inside a quoted command string
  73. # (assigned to a *_cmd var), proving it is emitted-as-text only.
  74. # Inverted greps (-v) need the emptiness guard: an empty capture would feed the
  75. # here-string's single empty line to grep -v, which would wrongly match.
  76. sp_mut="$(grep -nE '\-X (PUT|PATCH|POST|DELETE)' "$SP")"
  77. if [ -n "$sp_mut" ] && grep -vqE '_cmd=' <<<"$sp_mut"; then
  78. no "sp: a mutating verb appears outside an emitted *_cmd string"
  79. else ok "sp: all mutating verbs are emitted text only"; fi
  80. echo
  81. echo "-- repo-scorecard.sh (offline contract + orchestration + read-only proof) --"
  82. RS="$SCRIPTS/repo-scorecard.sh"
  83. bash -n "$RS" && ok "rs: bash -n clean" || no "rs: bash -n"
  84. bash "$RS" --help >/dev/null 2>&1; expect "rs: --help" 0 $?
  85. rs_help="$(bash "$RS" --help 2>&1)" # captured, not piped — see SIGPIPE note above
  86. if grep -q "Examples:" <<<"$rs_help"; then ok "rs: --help has EXAMPLES"
  87. else no "rs: --help missing EXAMPLES"; fi
  88. # The scoring rubric must be documented in the header (transparent, auditable).
  89. if grep -q "SCORING MODEL" <<<"$rs_help"; then ok "rs: --help documents SCORING MODEL"
  90. else no "rs: --help missing SCORING MODEL"; fi
  91. bash "$RS" --frobnicate >/dev/null 2>&1; expect "rs: unknown flag -> usage" 2 $?
  92. # Malformed OWNER/REPO is a usage error, never a network call.
  93. bash "$RS" --repo "not-a-valid-spec" >/dev/null 2>&1; expect "rs: bad --repo shape -> usage" 2 $?
  94. # --repo and --org are mutually exclusive.
  95. bash "$RS" --repo a/b --org c >/dev/null 2>&1; expect "rs: --repo + --org -> usage" 2 $?
  96. # --min-score must be an integer.
  97. bash "$RS" --min-score xx >/dev/null 2>&1; expect "rs: bad --min-score -> usage" 2 $?
  98. # Non-github remote must skip with exit 7 and NEVER hit the network.
  99. ( cd "$T" && bash "$RS" --remote origin >/dev/null 2>&1 ); expect "rs: non-github remote -> unavailable" 7 $?
  100. # Missing remote -> skip 7.
  101. ( cd "$T" && bash "$RS" --remote nope-xyz >/dev/null 2>&1 ); expect "rs: missing remote -> unavailable" 7 $?
  102. # Orchestration: it MUST call the sibling auditors by name (the reuse is the point).
  103. if grep -q "check-security-posture.sh" "$RS"; then ok "rs: references check-security-posture.sh"
  104. else no "rs: does not reference check-security-posture.sh"; fi
  105. if grep -q "check-issues.sh" "$RS"; then ok "rs: references check-issues.sh"
  106. else no "rs: does not reference check-issues.sh"; fi
  107. # Read-only guarantee: no executed mutating gh verb anywhere. Every gh call must
  108. # be a GET (the remediation pointers it prints are text, not executed). Assert no
  109. # `gh api -X PUT/PATCH/POST/DELETE` and no `gh repo edit`/`gh release create` etc.
  110. rs_mut="$(grep -E '\bgh (api )?-X (PUT|PATCH|POST|DELETE)' "$RS")" # captured — SIGPIPE note above
  111. if [ -n "$rs_mut" ] && grep -vqE '^\s*#' <<<"$rs_mut"; then
  112. no "rs: found an executed mutating gh -X call (must be read-only)"
  113. else ok "rs: no executed mutating gh -X call (read-only)"; fi
  114. # Belt-and-braces: every `runner gh …` (the only network executor) is a read-only
  115. # subcommand — `gh api <GET path>` or `gh repo list`. No mutating subcommand runs.
  116. rs_runner="$(grep -nE 'runner gh ' "$RS")"
  117. if [ -n "$rs_runner" ] && grep -Evq 'runner gh (api|repo list)' <<<"$rs_runner"; then
  118. no "rs: a 'runner gh' call uses a non-read-only subcommand"
  119. else ok "rs: every executed 'runner gh' is read-only (api / repo list)"; fi
  120. # And mutating gh subcommands, where they appear, are inside printed fix strings only
  121. # (the remediation pointers), never executed. Verify they sit on addfix/echo lines.
  122. rs_ghsub="$(grep -nE 'gh (release create|repo edit|release delete|secret set|pr merge)' "$RS")"
  123. if [ -n "$rs_ghsub" ] && grep -vqE 'addfix|→' <<<"$rs_ghsub"; then
  124. no "rs: a mutating gh subcommand appears outside a printed remediation string"
  125. else ok "rs: mutating gh subcommands only appear as printed remediation text"; fi
  126. echo
  127. echo "-- terminal design system (term.sh adoption + ASCII fallback) --"
  128. # All three auditors must source the shared toolkit, not hand-roll ANSI.
  129. for s in "$CI" "$SP" "$RS"; do
  130. b="$(basename "$s")"
  131. if grep -q '_lib/term.sh' "$s"; then ok "$b sources _lib/term.sh"
  132. else no "$b does not source _lib/term.sh"; fi
  133. done
  134. LIBTERM="$ROOT/../_lib/term.sh"
  135. if [ -f "$LIBTERM" ]; then
  136. ok "term.sh present"
  137. # Under TERM_ASCII=1 every framing primitive must fall back to pure ASCII
  138. # (design principle #3: every glyph has a registered ASCII proxy).
  139. marks="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; printf "%s%s%s%s%s%s%s%s%s%s%s" \
  140. "$(term_mark ok)" "$(term_mark bad)" "$(term_mark warn)" "$(term_mark na)" \
  141. "$(term_mark unknown)" "$(term_header hdr)" "$TERM_ARROW" \
  142. "$(term_panel_open github-ops PANEL meta)" "$(term_panel_line body)" \
  143. "$(term_section "" sect 3)" "$(term_panel_close hk "$(term_health warning x)")"')"
  144. if LC_ALL=C grep -q '[^[:print:][:cntrl:]]' <<<"$marks"; then
  145. no "term.sh TERM_ASCII=1 still emits non-ASCII bytes"
  146. else ok "term.sh TERM_ASCII=1 primitives are pure ASCII"; fi
  147. # A fallback that silently drops the glyph (empty) is a bug, not a fallback.
  148. m="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; term_mark ok')"
  149. [ -n "$m" ] && ok "term_mark renders non-empty in ASCII mode" || no "term_mark ok is empty"
  150. else
  151. no "term.sh missing at $LIBTERM"
  152. fi
  153. echo
  154. echo "=== $pass passed, $fail failed ==="
  155. [ "$fail" -eq 0 ]