From 9d6c504537e9096b86337e49ae8c92b2eeafc724 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 19:04:08 -0700 Subject: [PATCH 1/8] feat(offboard): print GitHub cleanup commands on each finding The audit still does not mutate GitHub. Org and team deletes are labeled as needing an owner; direct collaborator and environment reviewer commands are for repository admins. --- README.md | 3 ++- offboard/README.md | 2 +- offboard/github-drop-env-reviewer.sh | 36 ++++++++++++++++++++++++++++ offboard/offboard-github.sh | 24 ++++++++++++------- offboard/offboard.sh | 2 +- offboard/tests/offboard-github.bats | 20 ++++++++++++++++ offboard/tests/offboard.bats | 1 + offboard/tests/stubs/gh | 7 ++++-- 8 files changed, 81 insertions(+), 14 deletions(-) create mode 100755 offboard/github-drop-env-reviewer.sh diff --git a/README.md b/README.md index 909598d..c0af306 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) - [`offboard.sh`](offboard/offboard.sh): runs both audits, one block per person (`login` or `login=othername`). -- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. +- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. Prints cleanup commands; does not run them. +- [`github-drop-env-reviewer.sh`](offboard/github-drop-env-reviewer.sh): repo-admin helper to drop one login from an environment required-reviewers rule. - [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings whose user subject contains a given name. ## Checks diff --git a/offboard/README.md b/offboard/README.md index 522d92f..dd529c8 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -42,7 +42,7 @@ GitHub access and ownership, using your own `gh` login. | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -A login GitHub does not have is printed under that name and again under `Skipped, no GitHub account`. It is not sent to GitHub. The other logins still run. Exit codes: `0` nothing found, `1` access found, `2` usage or dependency error, `3` a GitHub API call failed. +Each finding prints a cleanup command. The audit does not run it. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. CODEOWNERS is an edit, not an API call. Access through a team or org is labeled `skip`. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. diff --git a/offboard/github-drop-env-reviewer.sh b/offboard/github-drop-env-reviewer.sh new file mode 100755 index 0000000..972d77c --- /dev/null +++ b/offboard/github-drop-env-reviewer.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Remove one GitHub user from an environment required-reviewers rule. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + github-drop-env-reviewer.sh OWNER/REPO ENVIRONMENT LOGIN + +Repo-admin: GET the environment, drop LOGIN from required reviewers, PUT the rest. +Does not change org membership, teams, or collaborators. +EOF +} + +[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; } +[[ $# -eq 3 ]] || { usage >&2; echo "github-drop-env-reviewer: need OWNER/REPO, environment, login" >&2; exit 2; } +repo="$1" +env="$2" +login="$3" +command -v gh >/dev/null 2>&1 || { echo "github-drop-env-reviewer: gh is required" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "github-drop-env-reviewer: jq is required" >&2; exit 2; } + +uid="$(gh api "users/${login}" | jq .id)" +body="$(gh api "repos/${repo}/environments/${env}" | jq -c --argjson uid "$uid" ' + (.protection_rules // []) as $rules + | { + wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0), + prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false), + reviewers: [ + $rules[] | select(.type == "required_reviewers") | .reviewers[]? + | select((.reviewer.id // .id) != $uid) + | {type, id: (.reviewer.id // .id)} + ] + } +')" +gh api -X PUT "repos/${repo}/environments/${env}" --input - <<<"$body" diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 73e5f85..75e2c9a 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -20,6 +20,9 @@ Options: -h, --help Show this help. A login GitHub does not have is listed and skipped. It is not queried. +Each finding includes a cleanup command. This script does not run those commands. +Org and team deletes need an org owner (or team admin). Direct collaborator and +environment-reviewer commands need repository admin. Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an API call failed. EOF @@ -86,7 +89,10 @@ ERRF="${TMPD}/err" USERS_FILE="${TMPD}/users" printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE" -finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_DROP="${DIR}/github-drop-env-reviewer.sh" + +finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; } note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } call() { @@ -202,7 +208,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi for u in "${LIVE[@]}"; do if grep -qxF "$(lower "$u")" "$TMPD/members"; then - finding "$u" org-membership "$o" "member" + finding "$u" org-membership "$o" "member" "org owner: gh api -X DELETE orgs/${o}/members/${u}" fi done @@ -226,7 +232,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then [[ -n "$idx" && -n "$slug" ]] || continue u="${LIVE[$idx]}" slug="$(lower "$slug")" - finding "$u" team "${o}/${slug}" "member" + finding "$u" team "${o}/${slug}" "member" "org owner or team admin: gh api -X DELETE orgs/${o}/teams/${slug}/memberships/${u}" done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" ' (.data.organization // {}) | to_entries[] | (.key | ltrimstr("u")) as $i @@ -241,18 +247,18 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then role="$(awk -F'\t' -v u="$lu" 'tolower($1) == u { print $2; exit }' "$d/all")" if [[ -n "$role" ]]; then if grep -qixF "$u" "$d/direct"; then - finding "$u" repo-collaborator "$r" "${role} (direct)" + finding "$u" repo-collaborator "$r" "${role} (direct)" "gh api -X DELETE repos/${r}/collaborators/${u}" else - finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" + finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" "skip: access is via team or org" fi fi fi while IFS=$'\t' read -r path lineno text; do - finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" + finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" "edit ${r} ${path} and remove @${u}" done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners") while IFS=$'\t' read -r env type who; do [[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "repo admin: ${ENV_DROP} ${r} ${env} ${u}" done < "$d/environments" done done @@ -271,7 +277,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then -H 'Accept: application/vnd.github.text-match+json' || { search_stop; break; } while IFS=$'\t' read -r repo path; do [[ -n "$repo" ]] || continue - finding "$u" codeowners-search "$repo" "$path" + finding "$u" codeowners-search "$repo" "$path" "edit ${repo} ${path} and remove @${u}" done < <(printf '%s' "$API_OUT" | jq -r --arg re "(^|[^A-Za-z0-9-])@${lu}([^A-Za-z0-9-]|$)" \ '.items[]? | select(any(.text_matches[]?.fragment; test($re; "i"))) | [.repository.full_name, .path] | @tsv' | sort -u) done @@ -306,7 +312,7 @@ else continue fi for c in org-membership team repo-collaborator codeowners codeowners-search environment-reviewer; do - lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" + lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)", (if .cmd != "" then " \(.cmd)" else empty end)' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" echo "$lines" diff --git a/offboard/offboard.sh b/offboard/offboard.sh index d5b3e7a..5176082 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -139,7 +139,7 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else block="$(jq -r --arg u "$part" "$gh_titles"' .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings - | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)") end + | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)", (if (.cmd // "") != "" then " \(.cmd)" else empty end)) end ' "$GH_OUT")" if [[ -n "$block" ]]; then echo "$block" diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 37817dd..14a7ad8 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -91,6 +91,9 @@ JSON [ "$(echo "$output" | jq -r '.repos_checked')" = 1 ] echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator" and .detail == "write (direct)")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("org owner: gh api -X DELETE")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @@ -101,6 +104,8 @@ JSON [[ "$output" == *"== example-user"* ]] [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] + [[ "$output" == *"org owner: gh api -X DELETE orgs/example-org/members/example-user"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] [[ "$output" == *"Environment required reviewers"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] @@ -175,3 +180,18 @@ JSON [[ "$output" == *"example-org/repo-one: write (direct)"* ]] [[ "$output" == *"search failed (HTTP 500)"* ]] } + +@test "github-drop-env-reviewer puts remaining reviewers" { + cat > "$FIXTURES/env-one" <<'JSON' +{"protection_rules":[ + {"type":"wait_timer","wait_timer":5}, + {"type":"required_reviewers","prevent_self_review":true,"reviewers":[ + {"type":"User","reviewer":{"id":1,"login":"example-user"}}, + {"type":"User","reviewer":{"id":2,"login":"keep-me"}} + ]} +]} +JSON + run "${BATS_TEST_DIRNAME}/../github-drop-env-reviewer.sh" example-org/repo-one prod example-user + [ "$status" -eq 0 ] + grep -q -- '-X PUT repos/example-org/repo-one/environments/prod' "$STUB_LOG" +} diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index 8490a45..1e8b82a 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -40,6 +40,7 @@ seed_github() { [[ "$output" == *"== example-user=first.last"* ]] [[ "$output" == *"GitHub: example-user"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] [[ "$output" == *"OpenShift: first.last"* ]] [[ "$output" == *"first.last@gov.bc.ca"* ]] [[ "$output" != *"== first.last"* ]] diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index f2bec0d..7807837 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -12,7 +12,7 @@ if [[ -n "${GH_FAIL_MATCH:-}" && "$args" == *"${GH_FAIL_MATCH}"* ]]; then echo "gh: Server Error (HTTP 500)" >&2; exit 1 fi case "$args" in - users/*) [[ "$args" == users/missing-user* ]] && not_found; echo '{}' ;; + users/*) [[ "$args" == users/missing-user* ]] && not_found; echo '{"id":1}' ;; *user/repos*) emit user-repos ;; *orgs/*/members\?per_page*) org="${args#*orgs/}"; org="${org%%/*}" @@ -56,7 +56,10 @@ case "$args" in fi ;; *collaborators\?affiliation=all*) repo="${args#*repos/*/}"; emit "collab-all-${repo%%/*}" 404 ;; *collaborators\?affiliation=direct*) repo="${args#*repos/*/}"; emit "collab-direct-${repo%%/*}" ;; - *environments*) repo="${args#*repos/*/}"; emit "env-${repo%%/*}" ;; + *environments*) + if [[ "$args" == *-X\ PUT* ]]; then echo '{}'; exit 0; fi + if [[ "$args" == *'environments?per_page'* ]]; then repo="${args#*repos/*/}"; emit "env-${repo%%/*}"; fi + emit env-one ;; *search/code*) [[ -f "${FIXTURES}/search-code" ]] && emit search-code; echo '{"items":[]}' ;; rate_limit*) date +%s ;; *) echo "stub gh: unexpected api call: $args" >&2; exit 98 ;; From 137810f7e17252e7a031a830b24664bf147fdad5 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 19:05:39 -0700 Subject: [PATCH 2/8] feat(offboard): print OpenShift RoleBinding cleanup commands Use remove-role-from-user so a shared RoleBinding is not deleted. The audit still does not run oc writes. --- offboard/README.md | 2 +- offboard/offboard-openshift.sh | 10 +++++++--- offboard/offboard.sh | 2 +- offboard/tests/offboard-openshift.bats | 1 + offboard/tests/offboard.bats | 1 + 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index dd529c8..987b115 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -79,7 +79,7 @@ oc login ... | `--name STRING` | Name to search for (repeatable). A User subject matches when it contains the name. | | `--json` | JSON output instead of text. | -Matching ignores case. No suffix is added. Each name is its own section, and the detail line shows the subject string that matched. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error. +Matching ignores case. No suffix is added. Each name is its own section, and the detail line shows the subject string that matched. Each finding prints `oc adm policy remove-role-from-user` for that subject; the audit does not run it. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error. Requires `oc` logged in, and `jq`. diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh index 9952820..e0ebdf4 100755 --- a/offboard/offboard-openshift.sh +++ b/offboard/offboard-openshift.sh @@ -16,6 +16,9 @@ Options: --json Print JSON instead of text. -h, --help Show this help. +Each finding includes an oc command to remove that User from that role in +the namespace. This script does not run those commands. + Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an oc call failed. EOF @@ -53,7 +56,7 @@ FINDINGS="${TMPD}/findings.jsonl" NOTES="${TMPD}/notes.jsonl" : > "$FINDINGS" : > "$NOTES" -finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } +finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; } note() { jq -nc --arg n "$1" '{note:$n}' >> "$NOTES"; } if ! projects="$(oc projects -q)"; then @@ -77,7 +80,8 @@ for ns in "${NAMESPACES[@]}"; do for name in "${NAMES[@]}"; do needle="$(lower "$name")" [[ "$subject_l" == *"$needle"* ]] || continue - finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" + cmd="$(printf 'oc adm policy remove-role-from-user %q %q -n %q' "$role" "$subject" "$ns")" + finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" "$cmd" done done < <(printf '%s' "$rb" | jq -r \ '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv') @@ -99,7 +103,7 @@ else continue fi echo " RoleBindings" - jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)"' "$FINDINGS" + jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)", (if .cmd != "" then " \(.cmd)" else empty end)' "$FINDINGS" done if [[ -s "$NOTES" ]]; then echo diff --git a/offboard/offboard.sh b/offboard/offboard.sh index 5176082..647fe08 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -157,7 +157,7 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else block="$(jq -r --arg u "$part" ' .sections[] | select(.name == $u) | .findings - | if length == 0 then empty else .[] | " - \(.target): \(.detail)" end + | if length == 0 then empty else .[] | " - \(.target): \(.detail)", (if (.cmd // "") != "" then " \(.cmd)" else empty end) end ' "$OC_OUT")" if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi fi diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index e1bff0e..e93b874 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -45,6 +45,7 @@ JSON [ "$(echo "$output" | jq '[.sections[] | select(.name == "first.last") | .findings[]] | length')" = 1 ] [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("someone-else"))] | length')" = 0 ] echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' + echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.cmd | test("oc adm policy remove-role-from-user"))' run grep -c 'oc get rolebindings' "$STUB_LOG" [ "$output" = 3 ] } diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index 1e8b82a..d176a61 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -43,6 +43,7 @@ seed_github() { [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] [[ "$output" == *"OpenShift: first.last"* ]] [[ "$output" == *"first.last@gov.bc.ca"* ]] + [[ "$output" == *"oc adm policy remove-role-from-user"* ]] [[ "$output" != *"== first.last"* ]] } From f9c18a04e047dac7e3f40a9db77c9955ba3dedb6 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 19:23:39 -0700 Subject: [PATCH 3/8] fix(offboard): print pasteable GitHub cleanup commands Drop the repo admin prefix so the helper path is the whole line. Notes that are not commands start with a hash. --- offboard/README.md | 2 +- offboard/offboard-github.sh | 14 +++++++------- offboard/offboard.sh | 2 +- offboard/tests/offboard-github.bats | 7 +++++-- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 987b115..f0ca5a0 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -42,7 +42,7 @@ GitHub access and ownership, using your own `gh` login. | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -Each finding prints a cleanup command. The audit does not run it. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. CODEOWNERS is an edit, not an API call. Access through a team or org is labeled `skip`. +Each finding prints a cleanup command. The audit does not run it. Notes that are not commands start with `#` so they are safe to paste. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 75e2c9a..e760e73 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -208,7 +208,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi for u in "${LIVE[@]}"; do if grep -qxF "$(lower "$u")" "$TMPD/members"; then - finding "$u" org-membership "$o" "member" "org owner: gh api -X DELETE orgs/${o}/members/${u}" + finding "$u" org-membership "$o" "member" $'# org owner\ngh api -X DELETE orgs/'"${o}"'/members/'"${u}" fi done @@ -232,7 +232,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then [[ -n "$idx" && -n "$slug" ]] || continue u="${LIVE[$idx]}" slug="$(lower "$slug")" - finding "$u" team "${o}/${slug}" "member" "org owner or team admin: gh api -X DELETE orgs/${o}/teams/${slug}/memberships/${u}" + finding "$u" team "${o}/${slug}" "member" $'# org owner or team admin\ngh api -X DELETE orgs/'"${o}"'/teams/'"${slug}"'/memberships/'"${u}" done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" ' (.data.organization // {}) | to_entries[] | (.key | ltrimstr("u")) as $i @@ -249,16 +249,16 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then if grep -qixF "$u" "$d/direct"; then finding "$u" repo-collaborator "$r" "${role} (direct)" "gh api -X DELETE repos/${r}/collaborators/${u}" else - finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" "skip: access is via team or org" + finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" "# skip: access is via team or org" fi fi fi while IFS=$'\t' read -r path lineno text; do - finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" "edit ${r} ${path} and remove @${u}" + finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" "# edit ${r} ${path} and remove @${u}" done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners") while IFS=$'\t' read -r env type who; do [[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "repo admin: ${ENV_DROP} ${r} ${env} ${u}" + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "${ENV_DROP} ${r} ${env} ${u}" done < "$d/environments" done done @@ -277,7 +277,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then -H 'Accept: application/vnd.github.text-match+json' || { search_stop; break; } while IFS=$'\t' read -r repo path; do [[ -n "$repo" ]] || continue - finding "$u" codeowners-search "$repo" "$path" "edit ${repo} ${path} and remove @${u}" + finding "$u" codeowners-search "$repo" "$path" "# edit ${repo} ${path} and remove @${u}" done < <(printf '%s' "$API_OUT" | jq -r --arg re "(^|[^A-Za-z0-9-])@${lu}([^A-Za-z0-9-]|$)" \ '.items[]? | select(any(.text_matches[]?.fragment; test($re; "i"))) | [.repository.full_name, .path] | @tsv' | sort -u) done @@ -312,7 +312,7 @@ else continue fi for c in org-membership team repo-collaborator codeowners codeowners-search environment-reviewer; do - lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)", (if .cmd != "" then " \(.cmd)" else empty end)' "$FINDINGS")" + lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)", (if .cmd != "" then (.cmd | split("\n")[] | " \(.)") else empty end)' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" echo "$lines" diff --git a/offboard/offboard.sh b/offboard/offboard.sh index 647fe08..3a9bd4c 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -139,7 +139,7 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else block="$(jq -r --arg u "$part" "$gh_titles"' .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings - | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)", (if (.cmd // "") != "" then " \(.cmd)" else empty end)) end + | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)", (if (.cmd // "") != "" then (.cmd | split("\n")[] | " \(.)") else empty end)) end ' "$GH_OUT")" if [[ -n "$block" ]]; then echo "$block" diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 14a7ad8..17e6e96 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -91,9 +91,10 @@ JSON [ "$(echo "$output" | jq -r '.repos_checked')" = 1 ] echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator" and .detail == "write (direct)")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' - echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("org owner: gh api -X DELETE")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("# org owner")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("repo admin") | not' [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @@ -105,7 +106,9 @@ JSON [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] - [[ "$output" == *"org owner: gh api -X DELETE orgs/example-org/members/example-user"* ]] + [[ "$output" == *"gh api -X DELETE orgs/example-org/members/example-user"* ]] + [[ "$output" == *"# org owner"* ]] + [[ "$output" != *"repo admin:"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] [[ "$output" == *"Environment required reviewers"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] From 5adaa6f15e52b1b2002c515ae84f9d861aa52a81 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 19:27:35 -0700 Subject: [PATCH 4/8] fix(offboard): print environment reviewer cleanup as gh PUT GitHub has no per-reviewer delete, so the report now prints a GET-then-PUT pipeline instead of a helper script. --- README.md | 1 - offboard/README.md | 2 +- offboard/github-drop-env-reviewer.sh | 36 ---------------------------- offboard/offboard-github.sh | 11 ++++++--- offboard/tests/offboard-github.bats | 14 +++++++---- 5 files changed, 19 insertions(+), 45 deletions(-) delete mode 100755 offboard/github-drop-env-reviewer.sh diff --git a/README.md b/README.md index c0af306..9c5bd9f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ Scripts a person runs from a workstation with their own login (for example an ac - [`offboard.sh`](offboard/offboard.sh): runs both audits, one block per person (`login` or `login=othername`). - [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. Prints cleanup commands; does not run them. -- [`github-drop-env-reviewer.sh`](offboard/github-drop-env-reviewer.sh): repo-admin helper to drop one login from an environment required-reviewers rule. - [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings whose user subject contains a given name. ## Checks diff --git a/offboard/README.md b/offboard/README.md index f0ca5a0..16413c9 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -42,7 +42,7 @@ GitHub access and ownership, using your own `gh` login. | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -Each finding prints a cleanup command. The audit does not run it. Notes that are not commands start with `#` so they are safe to paste. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. +Each finding prints a cleanup command. The audit does not run it. Notes that are not commands start with `#` so they are safe to paste. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and the environment PUT need repository admin. GitHub has no per-reviewer delete; that PUT sends the remaining required-reviewer list. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. diff --git a/offboard/github-drop-env-reviewer.sh b/offboard/github-drop-env-reviewer.sh deleted file mode 100755 index 972d77c..0000000 --- a/offboard/github-drop-env-reviewer.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Remove one GitHub user from an environment required-reviewers rule. Run with -h for usage. -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - github-drop-env-reviewer.sh OWNER/REPO ENVIRONMENT LOGIN - -Repo-admin: GET the environment, drop LOGIN from required reviewers, PUT the rest. -Does not change org membership, teams, or collaborators. -EOF -} - -[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; } -[[ $# -eq 3 ]] || { usage >&2; echo "github-drop-env-reviewer: need OWNER/REPO, environment, login" >&2; exit 2; } -repo="$1" -env="$2" -login="$3" -command -v gh >/dev/null 2>&1 || { echo "github-drop-env-reviewer: gh is required" >&2; exit 2; } -command -v jq >/dev/null 2>&1 || { echo "github-drop-env-reviewer: jq is required" >&2; exit 2; } - -uid="$(gh api "users/${login}" | jq .id)" -body="$(gh api "repos/${repo}/environments/${env}" | jq -c --argjson uid "$uid" ' - (.protection_rules // []) as $rules - | { - wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0), - prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false), - reviewers: [ - $rules[] | select(.type == "required_reviewers") | .reviewers[]? - | select((.reviewer.id // .id) != $uid) - | {type, id: (.reviewer.id // .id)} - ] - } -')" -gh api -X PUT "repos/${repo}/environments/${env}" --input - <<<"$body" diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index e760e73..b10c95a 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -89,8 +89,13 @@ ERRF="${TMPD}/err" USERS_FILE="${TMPD}/users" printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE" -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_DROP="${DIR}/github-drop-env-reviewer.sh" +# GitHub has no DELETE for one environment reviewer. PUT the remaining list. +env_drop_cmd() { + local jqf + jqf='(.protection_rules // []) as $rules | {wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0), prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false), reviewers: [$rules[] | select(.type == "required_reviewers") | .reviewers[]? | select((.reviewer.id // .id) != $uid) | {type, id: (.reviewer.id // .id)}]}' + printf 'uid=$(gh api users/%s | jq .id)\ngh api repos/%s/environments/%s | jq -c --argjson uid "$uid" '\''%s'\'' | gh api -X PUT repos/%s/environments/%s --input -\n' \ + "$3" "$1" "$2" "$jqf" "$1" "$2" +} finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; } note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } @@ -258,7 +263,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners") while IFS=$'\t' read -r env type who; do [[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "${ENV_DROP} ${r} ${env} ${u}" + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "$(env_drop_cmd "$r" "$env" "$u")" done < "$d/environments" done done diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 17e6e96..0d5abb1 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -93,8 +93,8 @@ JSON echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("# org owner")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' - echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' - echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("repo admin") | not' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("gh api -X PUT repos/example-org/repo-one/environments/prod")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer") | not' [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @@ -111,6 +111,7 @@ JSON [[ "$output" != *"repo admin:"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] [[ "$output" == *"Environment required reviewers"* ]] + [[ "$output" == *"gh api -X PUT repos/example-org/repo-one/environments/prod"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] } @@ -184,7 +185,8 @@ JSON [[ "$output" == *"search failed (HTTP 500)"* ]] } -@test "github-drop-env-reviewer puts remaining reviewers" { +@test "environment-reviewer cleanup command puts remaining reviewers" { + seed_findings cat > "$FIXTURES/env-one" <<'JSON' {"protection_rules":[ {"type":"wait_timer","wait_timer":5}, @@ -194,7 +196,11 @@ JSON ]} ]} JSON - run "${BATS_TEST_DIRNAME}/../github-drop-env-reviewer.sh" example-org/repo-one prod example-user + run --separate-stderr "$SCRIPT" --json example-user + [ "$status" -eq 1 ] + cmd="$(echo "$output" | jq -r '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd')" + [[ "$cmd" != *"github-drop-env-reviewer"* ]] + run bash -c "$cmd" [ "$status" -eq 0 ] grep -q -- '-X PUT repos/example-org/repo-one/environments/prod' "$STUB_LOG" } From 3973a4ed478d8bce0b4fdd63eab882d8971f4fc3 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 19:29:08 -0700 Subject: [PATCH 5/8] revert(offboard): restore environment reviewer helper The GET-then-PUT jq pipeline is not pasteable; the helper is the convenient command. --- README.md | 1 + offboard/README.md | 2 +- offboard/github-drop-env-reviewer.sh | 36 ++++++++++++++++++++++++++++ offboard/offboard-github.sh | 11 +++------ offboard/tests/offboard-github.bats | 15 ++++-------- 5 files changed, 46 insertions(+), 19 deletions(-) create mode 100755 offboard/github-drop-env-reviewer.sh diff --git a/README.md b/README.md index 9c5bd9f..c0af306 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Scripts a person runs from a workstation with their own login (for example an ac - [`offboard.sh`](offboard/offboard.sh): runs both audits, one block per person (`login` or `login=othername`). - [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. Prints cleanup commands; does not run them. +- [`github-drop-env-reviewer.sh`](offboard/github-drop-env-reviewer.sh): repo-admin helper to drop one login from an environment required-reviewers rule. - [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings whose user subject contains a given name. ## Checks diff --git a/offboard/README.md b/offboard/README.md index 16413c9..8b4a8bf 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -42,7 +42,7 @@ GitHub access and ownership, using your own `gh` login. | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -Each finding prints a cleanup command. The audit does not run it. Notes that are not commands start with `#` so they are safe to paste. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and the environment PUT need repository admin. GitHub has no per-reviewer delete; that PUT sends the remaining required-reviewer list. +Each finding prints a cleanup command. The audit does not run it. Notes that are not commands start with `#` so they are safe to paste. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. diff --git a/offboard/github-drop-env-reviewer.sh b/offboard/github-drop-env-reviewer.sh new file mode 100755 index 0000000..972d77c --- /dev/null +++ b/offboard/github-drop-env-reviewer.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Remove one GitHub user from an environment required-reviewers rule. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + github-drop-env-reviewer.sh OWNER/REPO ENVIRONMENT LOGIN + +Repo-admin: GET the environment, drop LOGIN from required reviewers, PUT the rest. +Does not change org membership, teams, or collaborators. +EOF +} + +[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; } +[[ $# -eq 3 ]] || { usage >&2; echo "github-drop-env-reviewer: need OWNER/REPO, environment, login" >&2; exit 2; } +repo="$1" +env="$2" +login="$3" +command -v gh >/dev/null 2>&1 || { echo "github-drop-env-reviewer: gh is required" >&2; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "github-drop-env-reviewer: jq is required" >&2; exit 2; } + +uid="$(gh api "users/${login}" | jq .id)" +body="$(gh api "repos/${repo}/environments/${env}" | jq -c --argjson uid "$uid" ' + (.protection_rules // []) as $rules + | { + wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0), + prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false), + reviewers: [ + $rules[] | select(.type == "required_reviewers") | .reviewers[]? + | select((.reviewer.id // .id) != $uid) + | {type, id: (.reviewer.id // .id)} + ] + } +')" +gh api -X PUT "repos/${repo}/environments/${env}" --input - <<<"$body" diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index b10c95a..e760e73 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -89,13 +89,8 @@ ERRF="${TMPD}/err" USERS_FILE="${TMPD}/users" printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE" -# GitHub has no DELETE for one environment reviewer. PUT the remaining list. -env_drop_cmd() { - local jqf - jqf='(.protection_rules // []) as $rules | {wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0), prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false), reviewers: [$rules[] | select(.type == "required_reviewers") | .reviewers[]? | select((.reviewer.id // .id) != $uid) | {type, id: (.reviewer.id // .id)}]}' - printf 'uid=$(gh api users/%s | jq .id)\ngh api repos/%s/environments/%s | jq -c --argjson uid "$uid" '\''%s'\'' | gh api -X PUT repos/%s/environments/%s --input -\n' \ - "$3" "$1" "$2" "$jqf" "$1" "$2" -} +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_DROP="${DIR}/github-drop-env-reviewer.sh" finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; } note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } @@ -263,7 +258,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners") while IFS=$'\t' read -r env type who; do [[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "$(env_drop_cmd "$r" "$env" "$u")" + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "${ENV_DROP} ${r} ${env} ${u}" done < "$d/environments" done done diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 0d5abb1..c04d229 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -93,8 +93,8 @@ JSON echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("# org owner")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' - echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("gh api -X PUT repos/example-org/repo-one/environments/prod")' - echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer") | not' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("repo admin") | not' [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @@ -111,7 +111,7 @@ JSON [[ "$output" != *"repo admin:"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] [[ "$output" == *"Environment required reviewers"* ]] - [[ "$output" == *"gh api -X PUT repos/example-org/repo-one/environments/prod"* ]] + [[ "$output" == *"github-drop-env-reviewer.sh"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] } @@ -185,8 +185,7 @@ JSON [[ "$output" == *"search failed (HTTP 500)"* ]] } -@test "environment-reviewer cleanup command puts remaining reviewers" { - seed_findings +@test "github-drop-env-reviewer puts remaining reviewers" { cat > "$FIXTURES/env-one" <<'JSON' {"protection_rules":[ {"type":"wait_timer","wait_timer":5}, @@ -196,11 +195,7 @@ JSON ]} ]} JSON - run --separate-stderr "$SCRIPT" --json example-user - [ "$status" -eq 1 ] - cmd="$(echo "$output" | jq -r '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd')" - [[ "$cmd" != *"github-drop-env-reviewer"* ]] - run bash -c "$cmd" + run "${BATS_TEST_DIRNAME}/../github-drop-env-reviewer.sh" example-org/repo-one prod example-user [ "$status" -eq 0 ] grep -q -- '-X PUT repos/example-org/repo-one/environments/prod' "$STUB_LOG" } From 875c1b215add98cc2f2910068fc825fefb3647bc Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 20:06:17 -0700 Subject: [PATCH 6/8] fix(offboard): print runnable cleanup commands at column 0 Indented commands never land in bash history. Notes stay under findings; gh/oc/helper lines are a paste block after each person. --- offboard/README.md | 4 ++-- offboard/offboard-github.sh | 20 +++++++++++++++++++- offboard/offboard-openshift.sh | 9 +++++++-- offboard/offboard.sh | 26 +++++++++++++++++++++++--- offboard/tests/offboard-github.bats | 5 +++++ offboard/tests/offboard-openshift.bats | 13 +++++++++++++ offboard/tests/offboard.bats | 5 +++++ 7 files changed, 74 insertions(+), 8 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 8b4a8bf..39d6d50 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -42,7 +42,7 @@ GitHub access and ownership, using your own `gh` login. | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -Each finding prints a cleanup command. The audit does not run it. Notes that are not commands start with `#` so they are safe to paste. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list. +Each finding includes a cleanup command in JSON. In text output, notes that are only `#` lines stay under the finding. Commands that can be run (`gh`, `oc`, the environment helper) are printed again at column 0 after that person, so they paste into a shell and into bash history. The audit does not run them. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. @@ -79,7 +79,7 @@ oc login ... | `--name STRING` | Name to search for (repeatable). A User subject matches when it contains the name. | | `--json` | JSON output instead of text. | -Matching ignores case. No suffix is added. Each name is its own section, and the detail line shows the subject string that matched. Each finding prints `oc adm policy remove-role-from-user` for that subject; the audit does not run it. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error. +Matching ignores case. No suffix is added. Each name is its own section, and the detail line is `binding -> role`. The `oc` command is printed at column 0 after that name; the audit does not run it. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error. Requires `oc` logged in, and `jq`. diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index e760e73..ae1f1b1 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -312,11 +312,29 @@ else continue fi for c in org-membership team repo-collaborator codeowners codeowners-search environment-reviewer; do - lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)", (if .cmd != "" then (.cmd | split("\n")[] | " \(.)") else empty end)' "$FINDINGS")" + lines="$(jq -r --arg u "$u" --arg c "$c" ' + def note: + (.cmd // "") as $c + | ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#")))); + select(.user == $u and .check == $c) + | " - \(.target): \(.detail)", + (if (.cmd // "") != "" and note then (.cmd | split("\n")[] | select(length > 0) | " \(.)") else empty end) + ' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" echo "$lines" done + paste="$(jq -r --arg u "$u" ' + def note: + (.cmd // "") as $c + | ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#")))); + select(.user == $u and (.cmd // "") != "" and (note | not)) + | .cmd | split("\n")[] | select(length > 0) + ' "$FINDINGS")" + if [[ -n "$paste" ]]; then + echo + echo "$paste" + fi done if [[ -s "$NOTES" ]]; then echo diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh index e0ebdf4..90662cc 100755 --- a/offboard/offboard-openshift.sh +++ b/offboard/offboard-openshift.sh @@ -81,7 +81,7 @@ for ns in "${NAMESPACES[@]}"; do needle="$(lower "$name")" [[ "$subject_l" == *"$needle"* ]] || continue cmd="$(printf 'oc adm policy remove-role-from-user %q %q -n %q' "$role" "$subject" "$ns")" - finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" "$cmd" + finding "$name" rolebinding "$ns" "${binding} -> ${role}" "$cmd" done done < <(printf '%s' "$rb" | jq -r \ '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv') @@ -103,7 +103,12 @@ else continue fi echo " RoleBindings" - jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)", (if .cmd != "" then " \(.cmd)" else empty end)' "$FINDINGS" + jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)"' "$FINDINGS" + paste="$(jq -r --arg u "$u" 'select(.user == $u and (.cmd // "") != "") | .cmd' "$FINDINGS")" + if [[ -n "$paste" ]]; then + echo + echo "$paste" + fi done if [[ -s "$NOTES" ]]; then echo diff --git a/offboard/offboard.sh b/offboard/offboard.sh index 3a9bd4c..c817377 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -94,7 +94,7 @@ ran_oc=false if ! command -v oc >/dev/null 2>&1 || ! oc whoami >/dev/null 2>&1; then echo "OpenShift skipped: oc is not logged in" echo "Run this where oc is logged in:" - printf ' %q' "$OC_SCRIPT" + printf '%q' "$OC_SCRIPT" for n in "${NEEDLES[@]}"; do printf ' --name %q' "$n"; done printf '\n' else @@ -117,6 +117,9 @@ gh_titles=' elif . == "codeowners-search" then "CODEOWNERS (code search)" elif . == "environment-reviewer" then "Environment required reviewers" else . end; + def note: + (.cmd // "") as $c + | ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#")))); ' i=0 @@ -125,6 +128,8 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do echo "== ${P_SPEC[$i]}" IFS=$'\t' read -r -a parts <<< "${P_NAMES[$i]}" shown=" " + paste_file="${TMPD}/paste-${i}" + : > "$paste_file" for part in "${parts[@]}"; do lpart="$(lower "$part")" if is_login "$part" && [[ "$shown" != *" ${lpart} "* ]]; then @@ -139,7 +144,9 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else block="$(jq -r --arg u "$part" "$gh_titles"' .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings - | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)", (if (.cmd // "") != "" then (.cmd | split("\n")[] | " \(.)") else empty end)) end + | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", + (.[] | " - \(.target): \(.detail)", + (if (.cmd // "") != "" and note then (.cmd | split("\n")[] | select(length > 0) | " \(.)") else empty end)) end ' "$GH_OUT")" if [[ -n "$block" ]]; then echo "$block" @@ -148,6 +155,11 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else echo " nothing found" fi + jq -r --arg u "$part" "$gh_titles"' + .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings[] + | select((.cmd // "") != "" and (note | not)) + | .cmd | split("\n")[] | select(length > 0) + ' "$GH_OUT" >> "$paste_file" fi fi if [[ "$ran_oc" == true ]]; then @@ -157,12 +169,20 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do else block="$(jq -r --arg u "$part" ' .sections[] | select(.name == $u) | .findings - | if length == 0 then empty else .[] | " - \(.target): \(.detail)", (if (.cmd // "") != "" then " \(.cmd)" else empty end) end + | if length == 0 then empty else .[] | " - \(.target): \(.detail)" end ' "$OC_OUT")" if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi + jq -r --arg u "$part" ' + .sections[] | select(.name == $u) | .findings[] + | select((.cmd // "") != "") | .cmd + ' "$OC_OUT" >> "$paste_file" fi fi done + if [[ -s "$paste_file" ]]; then + echo + cat "$paste_file" + fi i=$((i + 1)) done diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index c04d229..089cbe4 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -113,6 +113,11 @@ JSON [[ "$output" == *"Environment required reviewers"* ]] [[ "$output" == *"github-drop-env-reviewer.sh"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] + echo "$output" | grep -qx 'gh api -X DELETE repos/example-org/repo-one/collaborators/example-user' + echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' + echo "$output" | grep -qx '# org owner' + [ -z "$(echo "$output" | grep -E '^ +gh api' || true)" ] + echo "$output" | grep -qE '^ # edit ' } @test "--repo and --repo-file replace the default repo set" { diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index e93b874..f86b0cd 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -46,6 +46,8 @@ JSON [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("someone-else"))] | length')" = 0 ] echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.cmd | test("oc adm policy remove-role-from-user"))' + echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.detail == "rb1 -> admin")' + [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("subject"))] | length')" = 0 ] run grep -c 'oc get rolebindings' "$STUB_LOG" [ "$output" = 3 ] } @@ -66,6 +68,17 @@ JSON [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] } +@test "text output prints oc commands at column 0" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}]}' > "$FIXTURES/rb-ns-a" + run --separate-stderr "$SCRIPT" --name example-user + [ "$status" -eq 1 ] + [[ "$output" == *"rb1 -> admin"* ]] + [[ "$output" != *"(subject "* ]] + echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin example-user@github -n ns-a' + [ -z "$(echo "$output" | grep -E '^ +oc adm' || true)" ] +} + @test "only read-only calls are made" { printf 'ns-a\n' > "$FIXTURES/oc-projects" echo '{"items":[]}' > "$FIXTURES/rb-ns-a" diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index d176a61..47df1fc 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -45,6 +45,11 @@ seed_github() { [[ "$output" == *"first.last@gov.bc.ca"* ]] [[ "$output" == *"oc adm policy remove-role-from-user"* ]] [[ "$output" != *"== first.last"* ]] + echo "$output" | grep -qx 'gh api -X DELETE repos/example-org/repo-one/collaborators/example-user' + echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin example-user@github -n ns-a' + echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin first.last@gov.bc.ca -n ns-a' + [ -z "$(echo "$output" | grep -E '^ +(gh api|oc adm)' || true)" ] + [[ "$output" != *"(subject "* ]] } @test "a missing oc login still prints GitHub" { From 5555104e90cb2efee88a2d5f8958ca886b09298f Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Wed, 30 Sep 2026 09:57:41 -0700 Subject: [PATCH 7/8] fix(offboard): list CODEOWNERS as repo and path only The matching line named other owners. One entry per file; search skips files already listed. --- offboard/README.md | 4 ++-- offboard/offboard-github.sh | 14 +++++++++++--- offboard/tests/offboard-github.bats | 7 ++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 39d6d50..02b312a 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -53,8 +53,8 @@ The repository list, collaborator lists, and CODEOWNERS files are fetched once a | Organization membership | `GET /orgs/{org}/members`, then a local match | | Teams | One GraphQL call per organization for every live login, then a local match | | Repository access | Collaborator permission on each target repository, marked direct or through a team or organization role | -| CODEOWNERS | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored | -| CODEOWNERS (code search) | CODEOWNERS files across the organizations that mention the login, including repositories you do not admin | +| CODEOWNERS | CODEOWNERS files in the target repositories that mention `@user`. Listed as `OWNER/REPO: path`. The matching line is not printed. | +| CODEOWNERS (code search) | The same list for CODEOWNERS files across the organizations, including repositories you do not admin. A file already listed above is not repeated. | | Environment required reviewers | People listed on a repository environment protection rule. A team on that rule is not listed here. | The target repositories are those given with `--repo` or `--repo-file`. Without either, they are the repositories in the configured organizations where you have admin (`gh api user/repos` with `permissions.admin`). diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index ae1f1b1..a49b2fc 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -239,6 +239,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then | .value.nodes[]? | [$i, .slug] | @tsv') done + declare -A SEEN_CO=() for u in "${USERS[@]}"; do lu="$(lower "$u")" for r in "${REPOS[@]}"; do @@ -253,8 +254,12 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi fi fi - while IFS=$'\t' read -r path lineno text; do - finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" "# edit ${r} ${path} and remove @${u}" + while IFS=$'\t' read -r path _; do + [[ -n "$path" ]] || continue + key="$(lower "$u") $(lower "$r") $(lower "$path")" + [[ -n "${SEEN_CO[$key]:-}" ]] && continue + SEEN_CO[$key]=1 + finding "$u" codeowners "$r" "$path" "" done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners") while IFS=$'\t' read -r env type who; do [[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue @@ -277,7 +282,10 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then -H 'Accept: application/vnd.github.text-match+json' || { search_stop; break; } while IFS=$'\t' read -r repo path; do [[ -n "$repo" ]] || continue - finding "$u" codeowners-search "$repo" "$path" "# edit ${repo} ${path} and remove @${u}" + key="$(lower "$u") $(lower "$repo") $(lower "$path")" + [[ -n "${SEEN_CO[$key]:-}" ]] && continue + SEEN_CO[$key]=1 + finding "$u" codeowners-search "$repo" "$path" "" done < <(printf '%s' "$API_OUT" | jq -r --arg re "(^|[^A-Za-z0-9-])@${lu}([^A-Za-z0-9-]|$)" \ '.items[]? | select(any(.text_matches[]?.fragment; test($re; "i"))) | [.repository.full_name, .path] | @tsv' | sort -u) done diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 089cbe4..0c5d0b4 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -95,6 +95,8 @@ JSON echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("repo admin") | not' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "codeowners" and .detail == ".github/codeowners" and .cmd == "")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "codeowners-search" and .target == "example-org/repo-three" and .detail == ".github/CODEOWNERS")' [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @@ -110,6 +112,10 @@ JSON [[ "$output" == *"# org owner"* ]] [[ "$output" != *"repo admin:"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] + [[ "$output" == *"example-org/repo-one: .github/codeowners"* ]] + [[ "$output" == *"example-org/repo-three: .github/CODEOWNERS"* ]] + [[ "$output" != *"@example-admin"* ]] + [[ "$output" != *"# edit"* ]] [[ "$output" == *"Environment required reviewers"* ]] [[ "$output" == *"github-drop-env-reviewer.sh"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] @@ -117,7 +123,6 @@ JSON echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' echo "$output" | grep -qx '# org owner' [ -z "$(echo "$output" | grep -E '^ +gh api' || true)" ] - echo "$output" | grep -qE '^ # edit ' } @test "--repo and --repo-file replace the default repo set" { From c030121f4897c7a8a26a2ca050b9a577ab99f05b Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Wed, 30 Sep 2026 10:19:15 -0700 Subject: [PATCH 8/8] feat(offboard): hide org-owner GitHub DELETE commands unless --org-owner --org already selects which organizations to scan. Membership and teams still list; the DELETE lines need an org owner. --- offboard/README.md | 5 +++-- offboard/offboard-github.sh | 23 ++++++++++++++++++----- offboard/offboard.sh | 13 ++++++++++--- offboard/tests/offboard-github.bats | 21 ++++++++++++++++----- offboard/tests/offboard.bats | 10 ++++++++++ 5 files changed, 57 insertions(+), 15 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 02b312a..6f7488f 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -17,7 +17,7 @@ rmcampos ``` -Names joined with `=` belong to one person. Each name is searched for as written. An OpenShift subject matches when it contains the name. No suffix is added. A name that is a valid GitHub login is also sent to the GitHub audit. If `oc` is not logged in, the GitHub blocks are still printed and OpenShift is skipped. +Names joined with `=` belong to one person. Each name is searched for as written. An OpenShift subject matches when it contains the name. No suffix is added. A name that is a valid GitHub login is also sent to the GitHub audit. If `oc` is not logged in, the GitHub blocks are still printed and OpenShift is skipped. Pass `--org-owner` to include GitHub org and team DELETE commands. ## `offboard-github.sh` @@ -38,11 +38,12 @@ GitHub access and ownership, using your own `gh` login. | Option | Meaning | | --- | --- | | `--org ORG` | Organization to check (repeatable). Default: `OFFBOARD_ORGS` (space- or comma-separated), else `bcgov bcgov-c bcgov-nr`. | +| `--org-owner` | Include org and team DELETE commands (needs an org owner or team admin). Default: omit those commands. Membership is still listed. | | `--repo OWNER/NAME` | Repository for the per-repo checks (repeatable). | | `--repo-file FILE` | File with one `OWNER/NAME` per line. | | `--json` | JSON output instead of text. | -Each finding includes a cleanup command in JSON. In text output, notes that are only `#` lines stay under the finding. Commands that can be run (`gh`, `oc`, the environment helper) are printed again at column 0 after that person, so they paste into a shell and into bash history. The audit does not run them. Org and team deletes need an org owner (or team admin). Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list. +Each finding may include a cleanup command in JSON. In text output, notes that are only `#` lines stay under the finding. Commands that can be run (`gh`, `oc`, the environment helper) are printed again at column 0 after that person, so they paste into a shell and into bash history. The audit does not run them. Org and team DELETE commands are omitted unless `--org-owner` is set. Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list. Login, organization, team, and CODEOWNERS comparisons are case-insensitive. diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index a49b2fc..8968104 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -13,6 +13,9 @@ using your own gh login. OpenShift is a separate script. Options: --org ORG Organization to check (repeatable). Default: $OFFBOARD_ORGS, else "bcgov bcgov-c bcgov-nr". + --org-owner Include org and team DELETE commands (needs an org owner + or team admin). Default: omit those commands. Membership + is still listed. --repo OWNER/NAME Repository for the per-repo checks (repeatable). --repo-file FILE File with one OWNER/NAME per line (# comments allowed). Default repo set: repos in the orgs where you have admin. @@ -20,9 +23,9 @@ Options: -h, --help Show this help. A login GitHub does not have is listed and skipped. It is not queried. -Each finding includes a cleanup command. This script does not run those commands. -Org and team deletes need an org owner (or team admin). Direct collaborator and -environment-reviewer commands need repository admin. +Each finding may include a cleanup command. This script does not run those commands. +Org and team DELETE commands are omitted unless --org-owner is set. +Direct collaborator and environment-reviewer commands need repository admin. Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an API call failed. EOF @@ -36,11 +39,13 @@ ORGS=() REPOS=() REPO_FILE="" JSON=false +ORG_OWNER=false USERS=() while [[ $# -gt 0 ]]; do case "$1" in --org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;; + --org-owner) ORG_OWNER=true; shift ;; --repo) [[ $# -ge 2 ]] || die "--repo needs a value"; REPOS+=("$2"); shift 2 ;; --repo-file) [[ $# -ge 2 ]] || die "--repo-file needs a value"; REPO_FILE="$2"; shift 2 ;; --json) JSON=true; shift ;; @@ -208,7 +213,11 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi for u in "${LIVE[@]}"; do if grep -qxF "$(lower "$u")" "$TMPD/members"; then - finding "$u" org-membership "$o" "member" $'# org owner\ngh api -X DELETE orgs/'"${o}"'/members/'"${u}" + cmd="" + if [[ "$ORG_OWNER" == true ]]; then + cmd=$'# org owner\ngh api -X DELETE orgs/'"${o}"'/members/'"${u}" + fi + finding "$u" org-membership "$o" "member" "$cmd" fi done @@ -232,7 +241,11 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then [[ -n "$idx" && -n "$slug" ]] || continue u="${LIVE[$idx]}" slug="$(lower "$slug")" - finding "$u" team "${o}/${slug}" "member" $'# org owner or team admin\ngh api -X DELETE orgs/'"${o}"'/teams/'"${slug}"'/memberships/'"${u}" + cmd="" + if [[ "$ORG_OWNER" == true ]]; then + cmd=$'# org owner or team admin\ngh api -X DELETE orgs/'"${o}"'/teams/'"${slug}"'/memberships/'"${u}" + fi + finding "$u" team "${o}/${slug}" "member" "$cmd" done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" ' (.data.organization // {}) | to_entries[] | (.key | ltrimstr("u")) as $i diff --git a/offboard/offboard.sh b/offboard/offboard.sh index c817377..bfe7611 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -9,8 +9,7 @@ OC_SCRIPT="${DIR}/offboard-openshift.sh" usage() { cat <<'EOF' Usage: - offboard.sh - offboard.sh PERSON [PERSON...] + offboard.sh [--org-owner] PERSON [PERSON...] A person is a GitHub login, or several names joined with = : gpascucci=greg.pascucci @@ -19,6 +18,9 @@ Each name is searched for as written. OpenShift matches when the User subject contains the name. No suffix is added. Names that are valid GitHub logins are also sent to the GitHub audit. Matching ignores case. +--org-owner includes GitHub org and team DELETE commands (needs an org +owner or team admin). Default: omit those commands. Membership is still listed. + With no arguments in a terminal, asks for the people. If oc is not logged in, the GitHub report is still printed and OpenShift is skipped. Exit 1 if either report found access, 3 if either call failed. @@ -29,8 +31,10 @@ lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } is_login() { [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]]; } PERSONS=() +ORG_OWNER=false while [[ $# -gt 0 ]]; do case "$1" in + --org-owner) ORG_OWNER=true; shift ;; -h|--help) usage; exit 0 ;; --) shift; PERSONS+=("$@"); break ;; -*) usage >&2; die "unknown option: $1" ;; @@ -83,7 +87,10 @@ echo '{"sections":[],"notes":[]}' > "$OC_OUT" gh_rc=0 if [[ ${#LOGINS[@]} -gt 0 ]]; then set +e - "$GH_SCRIPT" --json -- "${LOGINS[@]}" > "$GH_OUT" + gh_cmd=("$GH_SCRIPT" --json) + [[ "$ORG_OWNER" == true ]] && gh_cmd+=(--org-owner) + gh_cmd+=(-- "${LOGINS[@]}") + "${gh_cmd[@]}" > "$GH_OUT" gh_rc=$? set -e jq -e . "$GH_OUT" >/dev/null 2>&1 || echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT" diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 0c5d0b4..02878f8 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -91,7 +91,8 @@ JSON [ "$(echo "$output" | jq -r '.repos_checked')" = 1 ] echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator" and .detail == "write (direct)")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' - echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("# org owner")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership" and .cmd == "")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "team" and .cmd == "")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "repo-collaborator") | .cmd | test("gh api -X DELETE repos/example-org/repo-one/collaborators/example-user")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("github-drop-env-reviewer.sh")' echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer") | .cmd | test("repo admin") | not' @@ -108,8 +109,8 @@ JSON [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] [[ "$output" == *"gh api -X DELETE repos/example-org/repo-one/collaborators/example-user"* ]] - [[ "$output" == *"gh api -X DELETE orgs/example-org/members/example-user"* ]] - [[ "$output" == *"# org owner"* ]] + [[ "$output" != *"gh api -X DELETE orgs/"* ]] + [[ "$output" != *"# org owner"* ]] [[ "$output" != *"repo admin:"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] [[ "$output" == *"example-org/repo-one: .github/codeowners"* ]] @@ -120,8 +121,6 @@ JSON [[ "$output" == *"github-drop-env-reviewer.sh"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] echo "$output" | grep -qx 'gh api -X DELETE repos/example-org/repo-one/collaborators/example-user' - echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' - echo "$output" | grep -qx '# org owner' [ -z "$(echo "$output" | grep -E '^ +gh api' || true)" ] } @@ -144,6 +143,18 @@ JSON [ "$output" = 0 ] } +@test "--org-owner prints org and team DELETE commands" { + seed_findings + run --separate-stderr "$SCRIPT" --json --org-owner example-user + [ "$status" -eq 1 ] + echo "$output" | jq -e '.users[0].findings[] | select(.check == "org-membership") | .cmd | test("# org owner")' + echo "$output" | jq -e '.users[0].findings[] | select(.check == "team") | .cmd | test("teams/.*/memberships/")' + run --separate-stderr "$SCRIPT" --org-owner example-user + [ "$status" -eq 1 ] + echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' + echo "$output" | grep -qx '# org owner' +} + @test "only read-only GitHub calls are made" { seed_findings run "$SCRIPT" --json example-user diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index 47df1fc..815e58d 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -50,6 +50,16 @@ seed_github() { echo "$output" | grep -qx 'oc adm policy remove-role-from-user admin first.last@gov.bc.ca -n ns-a' [ -z "$(echo "$output" | grep -E '^ +(gh api|oc adm)' || true)" ] [[ "$output" != *"(subject "* ]] + [[ "$output" != *"gh api -X DELETE orgs/"* ]] +} + +@test "--org-owner includes org DELETE commands" { + seed_github + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[]}' > "$FIXTURES/rb-ns-a" + run "$SCRIPT" --org-owner example-user + [ "$status" -eq 1 ] + echo "$output" | grep -qx 'gh api -X DELETE orgs/example-org/members/example-user' } @test "a missing oc login still prints GitHub" {