From 1ba829fa7ce05f5355f425b8448a3898f7eb8c6a Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 09:47:13 -0700 Subject: [PATCH 01/10] feat(offboard): split GitHub and OpenShift audits A missing GitHub login is reported and skipped, and the GitHub checks that can be shared are fetched once for the whole login list. --- README.md | 3 +- offboard/README.md | 73 ++-- offboard/offboard-audit.sh | 336 ---------------- offboard/offboard-github.sh | 370 ++++++++++++++++++ offboard/offboard-openshift.sh | 133 +++++++ ...fboard-audit.bats => offboard-github.bats} | 71 ++-- offboard/tests/offboard-openshift.bats | 59 +++ offboard/tests/stubs/gh | 33 +- 8 files changed, 670 insertions(+), 408 deletions(-) delete mode 100755 offboard/offboard-audit.sh create mode 100755 offboard/offboard-github.sh create mode 100755 offboard/offboard-openshift.sh rename offboard/tests/{offboard-audit.bats => offboard-github.bats} (72%) create mode 100644 offboard/tests/offboard-openshift.bats diff --git a/README.md b/README.md index b6158a6..87e0dd4 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) -- [`offboard-audit.sh`](offboard/offboard-audit.sh): read-only report of where a departed person still has GitHub or OpenShift access or ownership. +- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. +- [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings for GitHub ids, `name@github` subjects, and email addresses. ## Checks diff --git a/offboard/README.md b/offboard/README.md index 960e751..30de9d5 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -1,25 +1,21 @@ # Offboarding -## `offboard-audit.sh` +Two read-only reports. They share no calls. A GitHub login and an OpenShift subject often look alike, and that is the only overlap. -Read-only report of every place a departed person still has access or ownership. It uses your own `gh` login, and your own `oc` login when one is active. It never changes anything and never prints tokens. +## `offboard-github.sh` + +GitHub access and ownership, using your own `gh` login. ```bash -./offboard/offboard-audit.sh [options] [more usernames] +./offboard/offboard-github.sh [options] [more usernames] -# Default organizations and repositories -./offboard/offboard-audit.sh example-user +./offboard/offboard-github.sh example-user -# JSON, one organization, two repositories -./offboard/offboard-audit.sh --json --org bcgov \ +./offboard/offboard-github.sh --json --org bcgov \ --repo bcgov/example-repo --repo bcgov/another-repo example-user -# Repository list from a file (one OWNER/NAME per line, # comments allowed) -./offboard/offboard-audit.sh --repo-file repos.txt example-user - -# Also match an IDIR name in OpenShift RoleBindings -oc login ... -./offboard/offboard-audit.sh --idir EXAMPLEIDIR example-user +./offboard/offboard-github.sh --repo-file repos.txt \ + ianliuwk1019 franTarkenton DBAJohnL Mitchiavelli gpascucci MCatherine1994 thermcampos rmcampos ``` | Option | Meaning | @@ -27,48 +23,57 @@ oc login ... | `--org ORG` | Organization to check (repeatable). Default: `OFFBOARD_ORGS` (space- or comma-separated), else `bcgov bcgov-c bcgov-nr`. | | `--repo OWNER/NAME` | Repository for the per-repo checks (repeatable). | | `--repo-file FILE` | File with one `OWNER/NAME` per line. | -| `--idir NAME` | Also match `NAME` and `NAME@idir` in OpenShift RoleBindings. Single username only. | | `--json` | JSON output instead of text. | -Exit codes: `0` nothing found, `1` access found, `2` usage or dependency error, `3` a GitHub API call failed. +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. -## Checks +The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search and assignee search run in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Review requests stay one query per live login, because a batched result does not say who was requested. | Check | Source | | --- | --- | -| Organization membership | `GET /orgs/{org}/members/{user}` for each organization | -| Teams | Teams in each organization that you can see and that list the user | +| 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 (checked repositories) | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored | | Environment required reviewers | Deployment environments in the target repositories that list the user, or one of the user's teams, as a required reviewer | -| CODEOWNERS (code search) | Code search for `@user` in CODEOWNERS files across the organizations | +| CODEOWNERS (code search) | Code search for the logins in CODEOWNERS files across the organizations | | Assigned | Open issues and pull requests assigned to the user | | Review requested | Open pull requests waiting on the user's review | -| OpenShift RoleBindings | Only when `oc whoami` succeeds: RoleBindings in the namespaces listed by `oc projects` whose `User` subjects are `user`, `user@github`, or the `--idir` name. Otherwise a skip note is printed. | 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`). -## Requirements +Requires `gh` (scopes `repo` and `read:org`) and `jq`. + +The per-repo checks make 3 to 4 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes about 7 minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. -- `gh`, logged in with the `repo` and `read:org` scopes (`gh auth status` lists them; add with `gh auth refresh -s read:org`) -- `jq` -- Optional: `oc`, logged in (`oc whoami`) +Limits: only what your login can see; repository access needs push access; code search hits default branches and only when `@user` is in the returned text fragment (10 requests a minute); issue search returns at most 1,000 results per query; a login on more than 100 teams is noted and the rest of that login's teams are not listed. -## Speed +## `offboard-openshift.sh` -The per-repo checks make 3 to 4 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes about 7 minutes; `--repo` or `--repo-file` narrows it. The other checks take a few seconds per user. Progress goes to stderr, the report to stdout. +RoleBindings on the cluster your `oc` login points at. Run this on the machine where that login exists. -## Limits +```bash +oc login ... +./offboard/offboard-openshift.sh [options] [github-username ...] -- Only what your login can see is reported: teams you cannot see, and repositories you cannot read, are not covered. -- Repository access needs push access to the repository. Repositories given with `--repo` that you cannot push to get a note instead of a result. -- Code search covers default branches of indexed repositories, and matches only when the `@user` entry is in the returned text fragment. It is limited to 10 requests a minute; the script waits when the limit is reached. -- Issue and pull request search returns at most 1,000 results per query. -- OpenShift covers the cluster your `oc` login points at, and namespaces where you can read RoleBindings; unreadable namespaces are counted in a note. Group memberships are not expanded. +./offboard/offboard-openshift.sh thermcampos rmcampos --email first.last@gov.bc.ca -## Tests +./offboard/offboard-openshift.sh --idir EXAMPLEIDIR --email first.last@gov.bc.ca example-user +``` -`tests/offboard-audit.bats` runs the script against stubbed `gh` and `oc` commands in `tests/stubs/`: +| Option | Meaning | +| --- | --- | +| `--email ADDR` | Match this address as a User subject (repeatable). | +| `--idir NAME` | Also match `NAME` and `NAME@idir` (repeatable). | +| `--json` | JSON output instead of text. | + +Each GitHub username is matched as that name and as `name@github`. An email is matched only as itself. Each input 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. + +Requires `oc` logged in, and `jq`. + +Limits: namespaces you can read; unreadable namespaces are counted in a note. Group subjects are not read. + +## Tests ```bash bats offboard/tests diff --git a/offboard/offboard-audit.sh b/offboard/offboard-audit.sh deleted file mode 100755 index e82081d..0000000 --- a/offboard/offboard-audit.sh +++ /dev/null @@ -1,336 +0,0 @@ -#!/usr/bin/env bash -# -# Usage: -# ./offboard-audit.sh [options] [more usernames] -# -# Read-only report of the places a departed person still has access or -# ownership, using your own gh login (and your own oc login, if active). -# -# Options: -# --org ORG Organization to check (repeatable). Default: $OFFBOARD_ORGS, -# else "bcgov bcgov-c bcgov-nr". -# --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. -# --idir NAME Also match this IDIR name in OpenShift RoleBindings -# (only with a single username). -# --json Print JSON instead of text. -# -h, --help Show this help. -# -# Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, -# 3 an API call failed. - -set -euo pipefail - -usage() { - grep -v '^#!' "${0}" | awk '/^#/ { sub(/^# ?/, ""); print; next } NF==0 { exit }' -} -die() { echo "offboard-audit: $*" >&2; exit 2; } -fail() { echo "offboard-audit: $*" >&2; exit 3; } -progress() { echo "offboard-audit: $*" >&2; } - -ORGS=() -REPOS=() -REPO_FILE="" -IDIR="" -JSON=false -USERS=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;; - --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 ;; - --idir) [[ $# -ge 2 ]] || die "--idir needs a value"; IDIR="$2"; shift 2 ;; - --json) JSON=true; shift ;; - -h|--help) usage; exit 0 ;; - --) shift; USERS+=("$@"); break ;; - -*) usage >&2; die "unknown option: $1" ;; - *) USERS+=("$1"); shift ;; - esac -done - -[[ ${#USERS[@]} -gt 0 ]] || { usage >&2; die "at least one GitHub username is required"; } -for u in "${USERS[@]}"; do - [[ "$u" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub username: $u" -done -[[ -z "$IDIR" || ${#USERS[@]} -eq 1 ]] || die "--idir can only be used with a single username" -[[ -z "$IDIR" || "$IDIR" =~ ^[A-Za-z0-9._-]+$ ]] || die "not a valid IDIR name: $IDIR" - -if [[ ${#ORGS[@]} -eq 0 ]]; then - read -r -a ORGS <<< "${OFFBOARD_ORGS:-bcgov bcgov-c bcgov-nr}" - ORGS=("${ORGS[@]//,/ }") - read -r -a ORGS <<< "${ORGS[*]}" -fi -[[ ${#ORGS[@]} -gt 0 ]] || die "no organizations configured" - -if [[ -n "$REPO_FILE" ]]; then - [[ -r "$REPO_FILE" ]] || die "cannot read repo file: $REPO_FILE" - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%%#*}" - line="$(echo "$line" | tr -d '[:space:]')" - if [[ -n "$line" ]]; then REPOS+=("$line"); fi - done < "$REPO_FILE" -fi -for r in "${REPOS[@]}"; do - [[ "$r" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "not an OWNER/NAME repository: $r" -done - -command -v gh >/dev/null 2>&1 || die "gh is required" -command -v jq >/dev/null 2>&1 || die "jq is required" -gh auth status >/dev/null 2>&1 || die "gh is not logged in (run: gh auth login)" - -TMPD="$(mktemp -d)" -trap 'rm -rf "${TMPD}"' EXIT -FINDINGS="${TMPD}/findings.jsonl" -NOTES="${TMPD}/notes.jsonl" -ERRF="${TMPD}/err" -: > "$FINDINGS" -: > "$NOTES" -USERS_FILE="${TMPD}/users" -printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE" - -# finding USER CHECK TARGET DETAIL -finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } -# note USER NOTE (USER may be empty) -note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } - -# call ARGS... : run "gh api ARGS"; output in API_OUT, HTTP status of a failure in API_STATUS. -# Server errors (HTTP 5xx) are retried up to three times. -call() { - local attempt - for attempt in 1 2 3; do - API_STATUS=0 - if API_OUT="$(gh api "$@" 2>"$ERRF")"; then - return 0 - fi - API_STATUS="$(grep -oE 'HTTP [0-9]{3}' "$ERRF" | tail -n 1 | cut -d' ' -f2 || true)" - API_STATUS="${API_STATUS:-000}" - [[ "$API_STATUS" =~ ^5[0-9][0-9]$ && "$attempt" -lt 3 ]] || return 1 - sleep "$((attempt * 2))" - done - return 1 -} -api_error() { fail "gh api $1 failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")"; } - -# search_call KIND ARGS... : like call, waiting out search rate limits (KIND: search or code_search) -search_call() { - local kind="$1" attempt reset now - shift - for attempt in 1 2 3 4 5; do - call "$@" && return 0 - if [[ "$API_STATUS" =~ ^(403|429)$ ]] && grep -qi 'rate limit' "$ERRF"; then - call rate_limit --jq ".resources.${kind}.reset" || api_error rate_limit - reset="$API_OUT" - now="$(date +%s)" - progress "search rate limit reached; waiting $(( reset > now ? reset - now + 1 : 5 ))s (attempt ${attempt})" - sleep "$(( reset > now ? reset - now + 1 : 5 ))" - continue - fi - return 1 - done - return 1 -} - -# Users must exist -for u in "${USERS[@]}"; do - if ! call "users/${u}"; then - [[ "$API_STATUS" == 404 ]] && die "no such GitHub user: $u" - api_error "users/${u}" - fi -done - -# ---- Target repo set (shared by all users) -if [[ ${#REPOS[@]} -eq 0 ]]; then - progress "listing repositories where you have admin in: ${ORGS[*]}" - call --paginate 'user/repos?affiliation=owner,collaborator,organization_member&per_page=100' \ - --jq '.[] | select(.permissions.admin) | .full_name' || api_error user/repos - orgs_json="$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc 'map(ascii_downcase)')" - mapfile -t REPOS < <(printf '%s\n' "$API_OUT" | jq -Rr --argjson o "$orgs_json" \ - 'select(length > 0) | select((split("/")[0] | ascii_downcase) as $x | $o | index($x)) ' | sort -u) -fi -progress "per-repo checks on ${#REPOS[@]} repositories" - -CO_QUERY='query($o:String!,$n:String!){repository(owner:$o,name:$n){ - a:object(expression:"HEAD:.github/CODEOWNERS"){...on Blob{text}} - b:object(expression:"HEAD:.github/codeowners"){...on Blob{text}} - c:object(expression:"HEAD:CODEOWNERS"){...on Blob{text}} - d:object(expression:"HEAD:codeowners"){...on Blob{text}} - e:object(expression:"HEAD:docs/CODEOWNERS"){...on Blob{text}} - f:object(expression:"HEAD:docs/codeowners"){...on Blob{text}}}}' -CO_JQ='{a:".github/CODEOWNERS",b:".github/codeowners",c:"CODEOWNERS",d:"codeowners",e:"docs/CODEOWNERS",f:"docs/codeowners"} as $p - | (.data.repository // {}) | [to_entries[] | select(.value.text != null) | {path: $p[.key], text: .value.text}] | first // empty - | .path as $path | .text | split("\n") | to_entries[] | [$path, (.key + 1 | tostring), .value] | @tsv' - -i=0 -for r in "${REPOS[@]}"; do - i=$((i + 1)) - d="${TMPD}/repos/${r//\//__}" - mkdir -p "$d" - if (( i % 25 == 0 )); then progress "repo ${i}/${#REPOS[@]}"; fi - if call --paginate "repos/${r}/collaborators?affiliation=all&per_page=100" --jq '.[] | [.login, .role_name] | @tsv'; then - printf '%s\n' "$API_OUT" > "$d/all" - : > "$d/direct" - # Only ask for direct collaborators when one of the users has access - if awk -F'\t' '{ print tolower($1) }' "$d/all" | grep -qxF -f "$USERS_FILE"; then - call --paginate "repos/${r}/collaborators?affiliation=direct&per_page=100" --jq '.[] | .login' || api_error "repos/${r}/collaborators" - printf '%s\n' "$API_OUT" > "$d/direct" - fi - elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then - note "" "${r}: collaborators not checked (needs push access to the repository, or it does not exist)" - else - api_error "repos/${r}/collaborators" - fi - call graphql -f query="$CO_QUERY" -f o="${r%%/*}" -f n="${r#*/}" || api_error "graphql CODEOWNERS ${r}" - printf '%s' "$API_OUT" | jq -r "$CO_JQ" > "$d/codeowners" - if call --paginate "repos/${r}/environments?per_page=100" \ - --jq '.environments[]? | .name as $e | .protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | [$e, .type, (.reviewer.login // .reviewer.slug)] | @tsv'; then - printf '%s\n' "$API_OUT" > "$d/environments" - elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then - : > "$d/environments" - note "" "${r}: environments not checked (repository not found or not readable)" - else - api_error "repos/${r}/environments" - fi -done - -orgs_q="" -for o in "${ORGS[@]}"; do orgs_q+=" org:${o}"; done - -# ---- Per-user checks -for u in "${USERS[@]}"; do - lu="$(echo "$u" | tr '[:upper:]' '[:lower:]')" - progress "checking ${u}" - declare -A TEAMS=() - - for o in "${ORGS[@]}"; do - # Organization membership - if call "orgs/${o}/members/${u}"; then - finding "$u" org-membership "$o" "member" - elif [[ "$API_STATUS" != 404 ]]; then - api_error "orgs/${o}/members/${u}" - fi - # Teams visible to you - call graphql --paginate -f o="$o" -f u="$u" -f query='query($o:String!,$u:String!,$endCursor:String){organization(login:$o){teams(first:100,userLogins:[$u],after:$endCursor){pageInfo{hasNextPage endCursor} nodes{slug}}}}' \ - --jq '.data.organization.teams.nodes[]?.slug' || api_error "graphql teams ${o}" - lo="$(echo "$o" | tr '[:upper:]' '[:lower:]')" - TEAMS["$lo"]="$(echo "$API_OUT" | tr '[:upper:]' '[:lower:]' | xargs)" - for t in ${TEAMS["$lo"]}; do finding "$u" team "${o}/${t}" "member"; done - done - - # Per-repo checks (cached data) - for r in "${REPOS[@]}"; do - d="${TMPD}/repos/${r//\//__}" - owner="$(echo "${r%%/*}" | tr '[:upper:]' '[:lower:]')" - if [[ -f "$d/all" ]]; 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)" - else - finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" - fi - fi - fi - while IFS=$'\t' read -r path lineno text; do - finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" - 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 - lw="$(echo "$who" | tr '[:upper:]' '[:lower:]')" - if [[ "$type" == "User" && "$lw" == "$lu" ]]; then - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" - elif [[ "$type" == "Team" && " ${TEAMS[$owner]:-} " == *" ${lw} "* ]]; then - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer through team ${lw}" - fi - done < "$d/environments" - done - - # CODEOWNERS code search across the orgs - search_call code_search --paginate -X GET search/code -f q="${u} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ - -H 'Accept: application/vnd.github.text-match+json' || api_error "search/code" - while IFS=$'\t' read -r repo path; do - 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) - - # Open issues and PRs assigned; PRs waiting on their review - search_call search --paginate -X GET search/issues -f q="is:open assignee:${u}${orgs_q}" -f per_page=100 \ - --jq '.items[] | [.html_url, (if .pull_request then "pull request" else "issue" end), .title] | @tsv' || api_error "search/issues" - while IFS=$'\t' read -r url kind title; do - if [[ -n "$url" ]]; then finding "$u" assigned "$url" "${kind}: ${title}"; fi - done <<< "$API_OUT" - search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:${u}${orgs_q}" -f per_page=100 \ - --jq '.items[] | [.html_url, .title] | @tsv' || api_error "search/issues" - while IFS=$'\t' read -r url title; do - if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi - done <<< "$API_OUT" - unset TEAMS -done - -# ---- OpenShift RoleBindings (only with an active oc login) -if command -v oc >/dev/null 2>&1 && oc whoami >/dev/null 2>&1; then - names=() - for u in "${USERS[@]}"; do names+=("$u" "${u}@github"); done - [[ -n "$IDIR" ]] && names+=("$IDIR" "${IDIR}@idir") - names_json="$(printf '%s\n' "${names[@]}" | jq -R 'ascii_downcase' | jq -sc .)" - unreadable=0 - mapfile -t NAMESPACES < <(oc projects -q) - progress "OpenShift: checking RoleBindings in ${#NAMESPACES[@]} namespaces" - for ns in "${NAMESPACES[@]}"; do - if ! rb="$(oc get rolebindings -n "$ns" -o json 2>/dev/null)"; then - unreadable=$((unreadable + 1)) - continue - fi - while IFS=$'\t' read -r subject binding role; do - owner_user="" - for u in "${USERS[@]}"; do - lu="$(echo "$u" | tr '[:upper:]' '[:lower:]')" - if [[ "$subject" == "$lu" || "$subject" == "${lu}@github" ]]; then owner_user="$u"; fi - done - if [[ -z "$owner_user" ]]; then owner_user="${USERS[0]}"; fi - finding "$owner_user" openshift-rolebinding "$ns" "${binding} -> ${role} (subject ${subject})" - done < <(printf '%s' "$rb" | jq -r --argjson n "$names_json" \ - '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | (.name | ascii_downcase) as $s | select($n | index($s)) | [$s, $b, $r] | @tsv') - done - if (( unreadable > 0 )); then note "" "OpenShift: RoleBindings not readable in ${unreadable} namespace(s)"; fi -else - note "" "OpenShift check skipped: oc is not installed or not logged in" -fi - -# ---- Report -count="$(wc -l < "$FINDINGS" | tr -d ' ')" -users_json="$(printf '%s\n' "${USERS[@]}" | jq -R . | jq -sc .)" -if [[ "$JSON" == "true" ]]; then - jq -n --argjson users "$users_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" --argjson o "$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc .)" --argjson rc "${#REPOS[@]}" \ - '{orgs: $o, repos_checked: $rc, users: [$users[] as $u | {user: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' -else - declare -A TITLE=( - [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" - [codeowners]="CODEOWNERS (checked repositories)" [environment-reviewer]="Environment required reviewers" - [codeowners-search]="CODEOWNERS (code search)" [assigned]="Open issues and pull requests assigned" - [review-requested]="Pull requests waiting on their review" [openshift-rolebinding]="OpenShift RoleBindings" - ) - echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" - for u in "${USERS[@]}"; do - echo - echo "== ${u}" - if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then - echo " nothing found" - continue - fi - for c in org-membership team repo-collaborator codeowners environment-reviewer codeowners-search assigned review-requested openshift-rolebinding; do - lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" - [[ -n "$lines" ]] || continue - echo " ${TITLE[$c]}" - echo "$lines" - done - done - if [[ -s "$NOTES" ]]; then - echo - echo "Notes:" - jq -r '" - " + (if .user != "" then .user + ": " else "" end) + .note' "$NOTES" - fi -fi - -[[ "$count" -eq 0 ]] || exit 1 -exit 0 diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh new file mode 100755 index 0000000..354381c --- /dev/null +++ b/offboard/offboard-github.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash +# Read-only GitHub offboarding audit. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + offboard-github.sh [options] [more usernames] + +Read-only report of GitHub access and ownership for departed accounts, +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". + --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. + --json Print JSON instead of text. + -h, --help Show this help. + +A login GitHub does not have is listed and skipped. It is not queried. +Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, + 3 an API call failed. +EOF +} +die() { echo "offboard-github: $*" >&2; exit 2; } +fail() { echo "offboard-github: $*" >&2; exit 3; } +progress() { echo "offboard-github: $*" >&2; } +lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +ORGS=() +REPOS=() +REPO_FILE="" +JSON=false +USERS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;; + --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 ;; + -h|--help) usage; exit 0 ;; + --) shift; USERS+=("$@"); break ;; + -*) usage >&2; die "unknown option: $1" ;; + *) USERS+=("$1"); shift ;; + esac +done + +[[ ${#USERS[@]} -gt 0 ]] || { usage >&2; die "at least one GitHub username is required"; } +for u in "${USERS[@]}"; do + [[ "$u" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub username: $u" +done + +if [[ ${#ORGS[@]} -eq 0 ]]; then + read -r -a ORGS <<< "${OFFBOARD_ORGS:-bcgov bcgov-c bcgov-nr}" + ORGS=("${ORGS[@]//,/ }") + read -r -a ORGS <<< "${ORGS[*]}" +fi +[[ ${#ORGS[@]} -gt 0 ]] || die "no organizations configured" + +if [[ -n "$REPO_FILE" ]]; then + [[ -r "$REPO_FILE" ]] || die "cannot read repo file: $REPO_FILE" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(echo "$line" | tr -d '[:space:]')" + if [[ -n "$line" ]]; then REPOS+=("$line"); fi + done < "$REPO_FILE" +fi +for r in "${REPOS[@]}"; do + [[ "$r" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "not an OWNER/NAME repository: $r" +done + +command -v gh >/dev/null 2>&1 || die "gh is required" +command -v jq >/dev/null 2>&1 || die "jq is required" +gh auth status >/dev/null 2>&1 || die "gh is not logged in (run: gh auth login)" + +TMPD="$(mktemp -d)" +trap 'rm -rf "${TMPD}"' EXIT +FINDINGS="${TMPD}/findings.jsonl" +NOTES="${TMPD}/notes.jsonl" +ERRF="${TMPD}/err" +: > "$FINDINGS" +: > "$NOTES" +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"; } +note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; } + +call() { + local attempt + for attempt in 1 2 3; do + API_STATUS=0 + if API_OUT="$(gh api "$@" 2>"$ERRF")"; then + return 0 + fi + API_STATUS="$(grep -oE 'HTTP [0-9]{3}' "$ERRF" | tail -n 1 | cut -d' ' -f2 || true)" + API_STATUS="${API_STATUS:-000}" + [[ "$API_STATUS" =~ ^5[0-9][0-9]$ && "$attempt" -lt 3 ]] || return 1 + sleep "$((attempt * 2))" + done + return 1 +} +api_error() { fail "gh api $1 failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")"; } + +search_call() { + local kind="$1" attempt reset now + shift + for attempt in 1 2 3 4 5; do + call "$@" && return 0 + if [[ "$API_STATUS" =~ ^(403|429)$ ]] && grep -qi 'rate limit' "$ERRF"; then + call rate_limit --jq ".resources.${kind}.reset" || api_error rate_limit + reset="$API_OUT" + now="$(date +%s)" + progress "search rate limit reached; waiting $(( reset > now ? reset - now + 1 : 5 ))s (attempt ${attempt})" + sleep "$(( reset > now ? reset - now + 1 : 5 ))" + continue + fi + return 1 + done + return 1 +} + +# GitHub allows five OR operators, so a search covers at most six logins. +search_expr() { + local out="" w + for w in "$@"; do out+="${out:+ OR }${w}"; done + if [[ $# -gt 1 ]]; then printf '(%s)' "$out"; else printf '%s' "$out"; fi +} + +LIVE=() +SKIPPED=() +declare -A SKIPPED_SET=() +for u in "${USERS[@]}"; do + if call "users/${u}"; then + LIVE+=("$u") + elif [[ "$API_STATUS" == 404 ]]; then + SKIPPED+=("$u") + SKIPPED_SET["$u"]=1 + progress "no GitHub account: ${u}" + else + api_error "users/${u}" + fi +done + +declare -A USER_TEAMS=() + +if [[ ${#LIVE[@]} -gt 0 ]]; then + if [[ ${#REPOS[@]} -eq 0 ]]; then + progress "listing repositories where you have admin in: ${ORGS[*]}" + call --paginate 'user/repos?affiliation=owner,collaborator,organization_member&per_page=100' \ + --jq '.[] | select(.permissions.admin) | .full_name' || api_error user/repos + orgs_json="$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc 'map(ascii_downcase)')" + mapfile -t REPOS < <(printf '%s\n' "$API_OUT" | jq -Rr --argjson o "$orgs_json" \ + 'select(length > 0) | select((split("/")[0] | ascii_downcase) as $x | $o | index($x)) ' | sort -u) + fi + progress "per-repo checks on ${#REPOS[@]} repositories" + + CO_QUERY='query($o:String!,$n:String!){repository(owner:$o,name:$n){ + a:object(expression:"HEAD:.github/CODEOWNERS"){...on Blob{text}} + b:object(expression:"HEAD:.github/codeowners"){...on Blob{text}} + c:object(expression:"HEAD:CODEOWNERS"){...on Blob{text}} + d:object(expression:"HEAD:codeowners"){...on Blob{text}} + e:object(expression:"HEAD:docs/CODEOWNERS"){...on Blob{text}} + f:object(expression:"HEAD:docs/codeowners"){...on Blob{text}}}}' + CO_JQ='{a:".github/CODEOWNERS",b:".github/codeowners",c:"CODEOWNERS",d:"codeowners",e:"docs/CODEOWNERS",f:"docs/codeowners"} as $p + | (.data.repository // {}) | [to_entries[] | select(.value.text != null) | {path: $p[.key], text: .value.text}] | first // empty + | .path as $path | .text | split("\n") | to_entries[] | [$path, (.key + 1 | tostring), .value] | @tsv' + + i=0 + for r in "${REPOS[@]}"; do + i=$((i + 1)) + d="${TMPD}/repos/${r//\//__}" + mkdir -p "$d" + if (( i % 25 == 0 )); then progress "repo ${i}/${#REPOS[@]}"; fi + if call --paginate "repos/${r}/collaborators?affiliation=all&per_page=100" --jq '.[] | [.login, .role_name] | @tsv'; then + printf '%s\n' "$API_OUT" > "$d/all" + : > "$d/direct" + if awk -F'\t' '{ print tolower($1) }' "$d/all" | grep -qxF -f "$USERS_FILE"; then + call --paginate "repos/${r}/collaborators?affiliation=direct&per_page=100" --jq '.[] | .login' || api_error "repos/${r}/collaborators" + printf '%s\n' "$API_OUT" > "$d/direct" + fi + elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then + note "" "${r}: collaborators not checked (needs push access to the repository, or it does not exist)" + else + api_error "repos/${r}/collaborators" + fi + call graphql -f query="$CO_QUERY" -f o="${r%%/*}" -f n="${r#*/}" || api_error "graphql CODEOWNERS ${r}" + printf '%s' "$API_OUT" | jq -r "$CO_JQ" > "$d/codeowners" + if call --paginate "repos/${r}/environments?per_page=100" \ + --jq '.environments[]? | .name as $e | .protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | [$e, .type, (.reviewer.login // .reviewer.slug)] | @tsv'; then + printf '%s\n' "$API_OUT" > "$d/environments" + elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then + : > "$d/environments" + note "" "${r}: environments not checked (repository not found or not readable)" + else + api_error "repos/${r}/environments" + fi + done + + orgs_q="" + for o in "${ORGS[@]}"; do orgs_q+=" org:${o}"; done + + for o in "${ORGS[@]}"; do + if call --paginate "orgs/${o}/members?per_page=100" --jq '.[].login'; then + printf '%s\n' "$API_OUT" | tr '[:upper:]' '[:lower:]' > "$TMPD/members" + elif [[ "$API_STATUS" == 404 ]]; then + : > "$TMPD/members" + else + api_error "orgs/${o}/members" + fi + for u in "${LIVE[@]}"; do + if grep -qxF "$(lower "$u")" "$TMPD/members"; then + finding "$u" org-membership "$o" "member" + fi + done + + # ponytail: one GraphQL document per org, 100 teams per login. hasNextPage is noted and not followed. + tq='query($o:String!){organization(login:$o){' + ti=0 + for u in "${LIVE[@]}"; do + tq+="u${ti}:teams(first:100,userLogins:[\"${u}\"]){pageInfo{hasNextPage}nodes{slug}}" + ti=$((ti + 1)) + done + tq+='}}' + call graphql -f query="$tq" -f o="$o" || api_error "graphql teams ${o}" + live_json="$(printf '%s\n' "${LIVE[@]}" | jq -R . | jq -sc .)" + while IFS=$'\t' read -r idx more; do + [[ "$more" == "true" ]] || continue + note "" "${LIVE[$idx]}: team list truncated at 100 in ${o}" + done < <(printf '%s' "$API_OUT" | jq -r ' + (.data.organization // {}) | to_entries[] + | [(.key | ltrimstr("u")), (.value.pageInfo.hasNextPage | tostring)] | @tsv') + while IFS=$'\t' read -r idx slug; do + [[ -n "$idx" && -n "$slug" ]] || continue + u="${LIVE[$idx]}" + slug="$(lower "$slug")" + lo="$(lower "$o")" + lu="$(lower "$u")" + finding "$u" team "${o}/${slug}" "member" + USER_TEAMS["${lu}|${lo}"]="${USER_TEAMS["${lu}|${lo}"]:-} ${slug}" + done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" ' + (.data.organization // {}) | to_entries[] + | (.key | ltrimstr("u")) as $i + | .value.nodes[]? | [$i, .slug] | @tsv') + done + + for u in "${USERS[@]}"; do + lu="$(lower "$u")" + for r in "${REPOS[@]}"; do + d="${TMPD}/repos/${r//\//__}" + owner="$(lower "${r%%/*}")" + if [[ -f "$d/all" ]]; 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)" + else + finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" + fi + fi + fi + while IFS=$'\t' read -r path lineno text; do + finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" + 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 + lw="$(lower "$who")" + if [[ "$type" == "User" && "$lw" == "$lu" ]]; then + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" + elif [[ "$type" == "Team" && " ${USER_TEAMS["${lu}|${owner}"]:-} " == *" ${lw} "* ]]; then + finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer through team ${lw}" + fi + done < "$d/environments" + done + done + + i=0 + while [[ $i -lt ${#LIVE[@]} ]]; do + chunk=("${LIVE[@]:i:6}") + i=$((i + 6)) + expr="$(search_expr "${chunk[@]}")" + search_call code_search --paginate -X GET search/code -f q="${expr} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ + -H 'Accept: application/vnd.github.text-match+json' || api_error "search/code" + for u in "${chunk[@]}"; do + lu="$(lower "$u")" + while IFS=$'\t' read -r repo path; do + [[ -n "$repo" ]] || continue + 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 + + prefixed=() + for u in "${chunk[@]}"; do prefixed+=("assignee:${u}"); done + expr="$(search_expr "${prefixed[@]}")" + search_call search --paginate -X GET search/issues -f q="is:open ${expr}${orgs_q}" -f per_page=100 \ + --jq '.items[]? | .html_url as $u | (if .pull_request then "pull request" else "issue" end) as $k | .title as $t | (.assignees // [])[]? | [$u, $k, $t, .login] | @tsv' \ + || api_error "search/issues" + while IFS=$'\t' read -r url kind title login; do + [[ -n "$url" && -n "$login" ]] || continue + llogin="$(lower "$login")" + for u in "${chunk[@]}"; do + if [[ "$(lower "$u")" == "$llogin" ]]; then + finding "$u" assigned "$url" "${kind}: ${title}" + fi + done + done <<< "$API_OUT" + done + + for u in "${LIVE[@]}"; do + search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:${u}${orgs_q}" -f per_page=100 \ + --jq '.items[] | [.html_url, .title] | @tsv' || api_error "search/issues" + while IFS=$'\t' read -r url title; do + if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi + done <<< "$API_OUT" + done +fi + +count="$(wc -l < "$FINDINGS" | tr -d ' ')" +users_json="$(printf '%s\n' "${USERS[@]}" | jq -R . | jq -sc .)" +if [[ ${#SKIPPED[@]} -eq 0 ]]; then + skipped_json='[]' +else + skipped_json="$(printf '%s\n' "${SKIPPED[@]}" | jq -R . | jq -sc .)" +fi +if [[ "$JSON" == "true" ]]; then + jq -n --argjson users "$users_json" --argjson skipped "$skipped_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" \ + --argjson o "$(printf '%s\n' "${ORGS[@]}" | jq -R . | jq -sc .)" --argjson rc "${#REPOS[@]}" \ + '{orgs: $o, repos_checked: $rc, skipped: $skipped, users: [$users[] as $u | {user: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' +else + declare -A TITLE=( + [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" + [codeowners]="CODEOWNERS (checked repositories)" [environment-reviewer]="Environment required reviewers" + [codeowners-search]="CODEOWNERS (code search)" [assigned]="Open issues and pull requests assigned" + [review-requested]="Pull requests waiting on their review" + ) + echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" + for u in "${USERS[@]}"; do + echo + echo "== ${u}" + if [[ -n "${SKIPPED_SET[$u]:-}" ]]; then + echo " GitHub account not found" + fi + if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then + [[ -n "${SKIPPED_SET[$u]:-}" ]] || echo " nothing found" + continue + fi + for c in org-membership team repo-collaborator codeowners environment-reviewer codeowners-search assigned review-requested; do + lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" + [[ -n "$lines" ]] || continue + echo " ${TITLE[$c]}" + echo "$lines" + done + done + if [[ -s "$NOTES" ]]; then + echo + echo "Notes:" + jq -r '" - " + (if .user != "" then .user + ": " else "" end) + .note' "$NOTES" + fi + if [[ ${#SKIPPED[@]} -gt 0 ]]; then + echo + echo "Skipped, no GitHub account:" + for u in "${SKIPPED[@]}"; do echo " - ${u}"; done + fi +fi + +[[ "$count" -eq 0 ]] || exit 1 +exit 0 diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh new file mode 100755 index 0000000..4e6c801 --- /dev/null +++ b/offboard/offboard-openshift.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Read-only OpenShift offboarding audit. Run with -h for usage. +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + offboard-openshift.sh [options] [github-username ...] + +Read-only report of OpenShift RoleBindings for departed people, using your +own oc login. GitHub is a separate script. + +Options: + --email ADDR Match this address as a User subject (repeatable). + --idir NAME Also match NAME and NAME@idir (repeatable). + --json Print JSON instead of text. + -h, --help Show this help. + +Each GitHub username is matched as that name and as name@github. +Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, + 3 an oc call failed. +EOF +} +die() { echo "offboard-openshift: $*" >&2; exit 2; } +fail() { echo "offboard-openshift: $*" >&2; exit 3; } +progress() { echo "offboard-openshift: $*" >&2; } +lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +EMAILS=() +IDIRS=() +JSON=false +USERS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --email) [[ $# -ge 2 ]] || die "--email needs a value"; EMAILS+=("$2"); shift 2 ;; + --idir) [[ $# -ge 2 ]] || die "--idir needs a value"; IDIRS+=("$2"); shift 2 ;; + --json) JSON=true; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; USERS+=("$@"); break ;; + -*) usage >&2; die "unknown option: $1" ;; + *) USERS+=("$1"); shift ;; + esac +done + +[[ ${#USERS[@]} -gt 0 || ${#EMAILS[@]} -gt 0 || ${#IDIRS[@]} -gt 0 ]] \ + || { usage >&2; die "at least one GitHub username, --email, or --idir is required"; } +for u in "${USERS[@]}"; do + [[ "$u" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub username: $u" +done +for e in "${EMAILS[@]}"; do + [[ "$e" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || die "not an email address: $e" +done +for i in "${IDIRS[@]}"; do + [[ "$i" =~ ^[A-Za-z0-9._-]+$ ]] || die "not a valid IDIR name: $i" +done + +command -v oc >/dev/null 2>&1 || die "oc is required" +command -v jq >/dev/null 2>&1 || die "jq is required" +oc whoami >/dev/null 2>&1 || die "oc is not logged in (run: oc login)" + +TMPD="$(mktemp -d)" +trap 'rm -rf "${TMPD}"' EXIT +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"; } +note() { jq -nc --arg n "$1" '{note:$n}' >> "$NOTES"; } + +declare -A LABEL=() +remember() { LABEL["$(lower "$1")"]="$2"; } +for u in "${USERS[@]}"; do + remember "$u" "$u" + remember "${u}@github" "$u" +done +for e in "${EMAILS[@]}"; do remember "$e" "$e"; done +for i in "${IDIRS[@]}"; do + remember "$i" "$i" + remember "${i}@idir" "$i" +done + +names_json="$(printf '%s\n' "${!LABEL[@]}" | jq -R . | jq -sc .)" +if ! projects="$(oc projects -q)"; then + fail "oc projects failed" +fi +NAMESPACES=() +if [[ -n "$projects" ]]; then + mapfile -t NAMESPACES <<< "$projects" +fi +progress "checking RoleBindings in ${#NAMESPACES[@]} namespaces" +unreadable=0 +for ns in "${NAMESPACES[@]}"; do + [[ -n "$ns" ]] || continue + if ! rb="$(oc get rolebindings -n "$ns" -o json 2>/dev/null)"; then + unreadable=$((unreadable + 1)) + continue + fi + while IFS=$'\t' read -r subject binding role; do + [[ -n "$subject" ]] || continue + finding "${LABEL[$subject]}" rolebinding "$ns" "${binding} -> ${role} (subject ${subject})" + done < <(printf '%s' "$rb" | jq -r --argjson n "$names_json" \ + '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | (.name | ascii_downcase) as $s | select($n | index($s)) | [$s, $b, $r] | @tsv') +done +if (( unreadable > 0 )); then note "RoleBindings not readable in ${unreadable} namespace(s)"; fi + +SECTIONS=("${USERS[@]}" "${EMAILS[@]}" "${IDIRS[@]}") +count="$(wc -l < "$FINDINGS" | tr -d ' ')" +sections_json="$(printf '%s\n' "${SECTIONS[@]}" | jq -R . | jq -sc .)" +if [[ "$JSON" == "true" ]]; then + jq -n --argjson sections "$sections_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" --argjson ns "${#NAMESPACES[@]}" \ + '{namespaces_checked: $ns, sections: [$sections[] as $u | {name: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' +else + echo "Namespaces checked: ${#NAMESPACES[@]}" + for u in "${SECTIONS[@]}"; do + echo + echo "== ${u}" + if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then + echo " nothing found" + continue + fi + echo " RoleBindings" + jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)"' "$FINDINGS" + done + if [[ -s "$NOTES" ]]; then + echo + echo "Notes:" + jq -r '" - " + .note' "$NOTES" + fi +fi + +[[ "$count" -eq 0 ]] || exit 1 +exit 0 diff --git a/offboard/tests/offboard-audit.bats b/offboard/tests/offboard-github.bats similarity index 72% rename from offboard/tests/offboard-audit.bats rename to offboard/tests/offboard-github.bats index f4b272c..a82d462 100644 --- a/offboard/tests/offboard-audit.bats +++ b/offboard/tests/offboard-github.bats @@ -1,10 +1,10 @@ #!/usr/bin/env bats -# Tests for offboard-audit.sh with stubbed gh and oc on PATH. No network access. +# Tests for offboard-github.sh with stubbed gh on PATH. No network access. bats_require_minimum_version 1.5.0 setup() { - SCRIPT="${BATS_TEST_DIRNAME}/../offboard-audit.sh" + SCRIPT="${BATS_TEST_DIRNAME}/../offboard-github.sh" export FIXTURES="${BATS_TEST_TMPDIR}/fx" export STUB_LOG="${BATS_TEST_TMPDIR}/calls.log" mkdir -p "$FIXTURES" @@ -30,7 +30,7 @@ seed_findings() { {"repository":{"full_name":"example-org/repo-four"},"path":"CODEOWNERS","text_matches":[{"fragment":"* @example-user-two"}]} ]} JSON - printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\nhttps://github.com/example-org/repo-one/pull/2\tpull request\tA change\n' > "$FIXTURES/search-assigned" + printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\texample-user\nhttps://github.com/example-org/repo-one/pull/2\tpull request\tA change\texample-user\n' > "$FIXTURES/search-assigned" printf 'https://github.com/example-org/repo-one/pull/3\tNeeds review\n' > "$FIXTURES/search-review" } @@ -50,11 +50,6 @@ JSON [ "$status" -eq 2 ] } -@test "--idir with more than one user is a usage error" { - run "$SCRIPT" --idir someone example-user example-user-two - [ "$status" -eq 2 ] -} - @test "missing jq is a dependency error" { nojq="${BATS_TEST_TMPDIR}/nojq" mkdir -p "$nojq" @@ -70,19 +65,23 @@ JSON [[ "$output" == *"not logged in"* ]] } -@test "unknown GitHub user is a usage error" { +@test "unknown GitHub user is skipped and the other checks do not run" { run "$SCRIPT" missing-user - [ "$status" -eq 2 ] - [[ "$output" == *"no such GitHub user"* ]] + [ "$status" -eq 0 ] + [[ "$output" == *"GitHub account not found"* ]] + [[ "$output" == *"Skipped, no GitHub account:"* ]] + [[ "$output" == *"missing-user"* ]] + run grep -c 'user/repos' "$STUB_LOG" + [ "$output" = 0 ] } -@test "nothing found exits 0 and notes the OpenShift skip" { +@test "nothing found exits 0" { printf 'example-org/repo-one\n' > "$FIXTURES/user-repos" printf 'example-admin\tadmin\n' > "$FIXTURES/collab-all-repo-one" run "$SCRIPT" example-user [ "$status" -eq 0 ] [[ "$output" == *"nothing found"* ]] - [[ "$output" == *"OpenShift check skipped"* ]] + [[ "$output" != *"OpenShift"* ]] } @test "findings in every GitHub check exit 1 (json)" { @@ -126,30 +125,34 @@ JSON [ "$output" = 0 ] } -@test "OpenShift RoleBindings are matched when oc is logged in" { - printf 'ns-a\nns-b\nns-c\n' > "$FIXTURES/oc-projects" - cat > "$FIXTURES/rb-ns-a" <<'JSON' -{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}, - {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"someone-else"}]}]} -JSON - cat > "$FIXTURES/rb-ns-b" <<'JSON' -{"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"EXAMPLEIDIR@idir"},{"kind":"Group","name":"example-user"}]}]} -JSON - OC_WHOAMI_RC=0 run --separate-stderr "$SCRIPT" --json --idir exampleidir example-user - [ "$status" -eq 1 ] - [ "$(echo "$output" | jq '[.users[0].findings[] | select(.check == "openshift-rolebinding")] | length')" = 2 ] - echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' -} - -@test "only read-only calls are made" { +@test "only read-only GitHub calls are made" { seed_findings - printf 'ns-a\n' > "$FIXTURES/oc-projects" - echo '{"items":[]}' > "$FIXTURES/rb-ns-a" - OC_WHOAMI_RC=0 run "$SCRIPT" --json example-user - grep -q '^oc get rolebindings' "$STUB_LOG" + run "$SCRIPT" --json example-user [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input|-F ' "$STUB_LOG")" ] [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|search/|-X GET')" ] - [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] + [ -z "$(grep '^oc ' "$STUB_LOG" || true)" ] +} + +@test "live logins share one fetch and a missing login is skipped" { + seed_findings + run --separate-stderr "$SCRIPT" --json example-user example-user-two missing-user + [ "$status" -eq 1 ] + [ "$(echo "$output" | jq -c '.skipped')" = '["missing-user"]' ] + [ "$(echo "$output" | jq '[.users[] | select(.user == "example-user") | .findings[] | select(.check == "org-membership")] | length')" = 1 ] + [ "$(echo "$output" | jq '[.users[] | select(.user == "example-user-two") | .findings[] | select(.check == "team")] | length')" = 0 ] + [ "$(echo "$output" | jq '[.users[] | select(.user == "missing-user") | .findings[]] | length')" = 0 ] + run grep -Fc 'members?per_page' "$STUB_LOG" + [ "$output" = 1 ] + run grep -c 'userLogins:' "$STUB_LOG" + [ "$output" = 1 ] + run grep -c 'search/code' "$STUB_LOG" + [ "$output" = 1 ] + run grep -c 'assignee:' "$STUB_LOG" + [ "$output" = 1 ] + run grep -c 'user-review-requested:' "$STUB_LOG" + [ "$output" = 2 ] + run grep -c 'userLogins:\["missing-user"\]' "$STUB_LOG" + [ "$output" = 0 ] } @test "an API failure exits 3" { diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats new file mode 100644 index 0000000..491831f --- /dev/null +++ b/offboard/tests/offboard-openshift.bats @@ -0,0 +1,59 @@ +#!/usr/bin/env bats +# Tests for offboard-openshift.sh with stubbed oc on PATH. No network access. + +bats_require_minimum_version 1.5.0 + +setup() { + SCRIPT="${BATS_TEST_DIRNAME}/../offboard-openshift.sh" + export FIXTURES="${BATS_TEST_TMPDIR}/fx" + export STUB_LOG="${BATS_TEST_TMPDIR}/calls.log" + mkdir -p "$FIXTURES" + : > "$STUB_LOG" + PATH="${BATS_TEST_DIRNAME}/stubs:${PATH}" + export PATH + export OC_WHOAMI_RC=0 +} + +@test "no subjects is a usage error" { + run "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"at least one GitHub username"* ]] +} + +@test "oc not logged in is an error" { + OC_WHOAMI_RC=1 run "$SCRIPT" example-user + [ "$status" -eq 2 ] + [[ "$output" == *"not logged in"* ]] +} + +@test "github id, email, and idir are separate sections from one namespace read" { + printf 'ns-a\nns-b\nns-c\n' > "$FIXTURES/oc-projects" + cat > "$FIXTURES/rb-ns-a" <<'JSON' +{"items":[ + {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}, + {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"First.Last@gov.bc.ca"}]}, + {"metadata":{"name":"rb4"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"someone-else"}]} +]} +JSON + cat > "$FIXTURES/rb-ns-b" <<'JSON' +{"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"EXAMPLEIDIR@idir"},{"kind":"Group","name":"example-user"}]}]} +JSON + run --separate-stderr "$SCRIPT" --json --idir exampleidir --email First.Last@gov.bc.ca example-user + [ "$status" -eq 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "example-user") | .findings[]] | length')" = 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "First.Last@gov.bc.ca") | .findings[]] | length')" = 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "exampleidir") | .findings[]] | length')" = 1 ] + echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.detail | test("subject example-user@github"))' + echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' + run grep -c 'oc get rolebindings' "$STUB_LOG" + [ "$output" = 3 ] +} + +@test "only read-only calls are made" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[]}' > "$FIXTURES/rb-ns-a" + run "$SCRIPT" example-user + [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input' "$STUB_LOG")" ] + [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] + [ -z "$(grep '^gh ' "$STUB_LOG" || true)" ] +} diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index 585dbc3..2b6a377 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -14,12 +14,39 @@ fi case "$args" in users/*) [[ "$args" == users/missing-user* ]] && not_found; echo '{}' ;; *user/repos*) emit user-repos ;; - *orgs/*/members/*) + *orgs/*/members\?per_page*) org="${args#*orgs/}"; org="${org%%/*}" - grep -qxF "$org" "${FIXTURES}/member-orgs" 2>/dev/null || not_found ;; + if grep -qxF "$org" "${FIXTURES}/member-orgs" 2>/dev/null; then echo example-user; fi + exit 0 ;; graphql*teams*) org="$(printf '%s\n' "$@" | sed -n 's/^o=//p')" - emit "teams-${org}" ;; + query="$(printf '%s\n' "$@" | sed -n 's/^query=//p')" + slugs='[]' + if [[ -f "${FIXTURES}/teams-${org}" ]]; then + slugs="$(jq -Rsc 'split("\n") | map(select(length > 0))' "${FIXTURES}/teams-${org}")" + fi + if [[ -f "${FIXTURES}/team-logins-${org}" ]]; then + members="$(jq -Rsc 'split("\n") | map(select(length > 0))' "${FIXTURES}/team-logins-${org}")" + else + members='["example-user"]' + fi + entries=() + while [[ "$query" =~ (u[0-9]+):teams\(first:100,userLogins:\[\"([A-Za-z0-9-]+)\"\]\) ]]; do + alias="${BASH_REMATCH[1]}" + login="${BASH_REMATCH[2]}" + nodes='[]' + if printf '%s\n' "$members" | jq -e --arg l "$login" 'index($l) != null' >/dev/null; then + nodes="$(printf '%s\n' "$slugs" | jq 'map({slug:.})')" + fi + entries+=("$(jq -nc --arg a "$alias" --argjson n "$nodes" '{key:$a,value:{pageInfo:{hasNextPage:false},nodes:$n}}')") + query="${query#*"${BASH_REMATCH[0]}"}" + done + if [[ ${#entries[@]} -eq 0 ]]; then + echo '{"data":{"organization":{}}}' + else + printf '%s\n' "${entries[@]}" | jq -sc '{data:{organization:from_entries}}' + fi + exit 0 ;; graphql*CODEOWNERS*) repo="$(printf '%s\n' "$@" | sed -n 's/^n=//p')" if [[ -f "${FIXTURES}/codeowners-${repo}" ]]; then From e1b595385c0d3e4823e19411a86a2fa6aeed938a Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 09:50:30 -0700 Subject: [PATCH 02/10] fix(offboard): compare logins and subjects without case Search queries are sent in lowercase, and a mixed-case login still matches teams, CODEOWNERS, and RoleBindings. --- offboard/README.md | 4 +++- offboard/offboard-github.sh | 16 +++++++++------- offboard/tests/offboard-github.bats | 12 ++++++++++++ offboard/tests/offboard-openshift.bats | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 30de9d5..3c99237 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -27,6 +27,8 @@ GitHub access and ownership, using your own `gh` login. 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. +Login, organization, team, CODEOWNERS, and search comparisons are case-insensitive. Search queries are sent in lowercase. + The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search and assignee search run in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Review requests stay one query per live login, because a batched result does not say who was requested. | Check | Source | @@ -67,7 +69,7 @@ oc login ... | `--idir NAME` | Also match `NAME` and `NAME@idir` (repeatable). | | `--json` | JSON output instead of text. | -Each GitHub username is matched as that name and as `name@github`. An email is matched only as itself. Each input 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. +Each GitHub username is matched as that name and as `name@github`. An email is matched only as itself. Matching ignores case. Each input 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. Requires `oc` logged in, and `jq`. diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 354381c..4f0d49c 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -201,10 +201,10 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done orgs_q="" - for o in "${ORGS[@]}"; do orgs_q+=" org:${o}"; done + for o in "${ORGS[@]}"; do orgs_q+=" org:$(lower "$o")"; done for o in "${ORGS[@]}"; do - if call --paginate "orgs/${o}/members?per_page=100" --jq '.[].login'; then + if call --paginate "orgs/$(lower "$o")/members?per_page=100" --jq '.[].login'; then printf '%s\n' "$API_OUT" | tr '[:upper:]' '[:lower:]' > "$TMPD/members" elif [[ "$API_STATUS" == 404 ]]; then : > "$TMPD/members" @@ -221,11 +221,11 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then tq='query($o:String!){organization(login:$o){' ti=0 for u in "${LIVE[@]}"; do - tq+="u${ti}:teams(first:100,userLogins:[\"${u}\"]){pageInfo{hasNextPage}nodes{slug}}" + tq+="u${ti}:teams(first:100,userLogins:[\"$(lower "$u")\"]){pageInfo{hasNextPage}nodes{slug}}" ti=$((ti + 1)) done tq+='}}' - call graphql -f query="$tq" -f o="$o" || api_error "graphql teams ${o}" + call graphql -f query="$tq" -f o="$(lower "$o")" || api_error "graphql teams ${o}" live_json="$(printf '%s\n' "${LIVE[@]}" | jq -R . | jq -sc .)" while IFS=$'\t' read -r idx more; do [[ "$more" == "true" ]] || continue @@ -280,7 +280,9 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then while [[ $i -lt ${#LIVE[@]} ]]; do chunk=("${LIVE[@]:i:6}") i=$((i + 6)) - expr="$(search_expr "${chunk[@]}")" + terms=() + for u in "${chunk[@]}"; do terms+=("$(lower "$u")"); done + expr="$(search_expr "${terms[@]}")" search_call code_search --paginate -X GET search/code -f q="${expr} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ -H 'Accept: application/vnd.github.text-match+json' || api_error "search/code" for u in "${chunk[@]}"; do @@ -293,7 +295,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done prefixed=() - for u in "${chunk[@]}"; do prefixed+=("assignee:${u}"); done + for u in "${chunk[@]}"; do prefixed+=("assignee:$(lower "$u")"); done expr="$(search_expr "${prefixed[@]}")" search_call search --paginate -X GET search/issues -f q="is:open ${expr}${orgs_q}" -f per_page=100 \ --jq '.items[]? | .html_url as $u | (if .pull_request then "pull request" else "issue" end) as $k | .title as $t | (.assignees // [])[]? | [$u, $k, $t, .login] | @tsv' \ @@ -310,7 +312,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done for u in "${LIVE[@]}"; do - search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:${u}${orgs_q}" -f per_page=100 \ + search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:$(lower "$u")${orgs_q}" -f per_page=100 \ --jq '.items[] | [.html_url, .title] | @tsv' || api_error "search/issues" while IFS=$'\t' read -r url title; do if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index a82d462..960913d 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -155,6 +155,18 @@ JSON [ "$output" = 0 ] } +@test "matching is case insensitive" { + seed_findings + run --separate-stderr "$SCRIPT" --json Example-User + [ "$status" -eq 1 ] + counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" + [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"review-requested":1,"team":1}' ] + grep -q 'userLogins:\["example-user"\]' "$STUB_LOG" + grep -q 'assignee:example-user' "$STUB_LOG" + grep -q 'user-review-requested:example-user' "$STUB_LOG" + grep -q 'example-user filename:CODEOWNERS' "$STUB_LOG" +} + @test "an API failure exits 3" { seed_findings GH_FAIL_MATCH='environments' run "$SCRIPT" example-user diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index 491831f..c44e992 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -49,6 +49,20 @@ JSON [ "$output" = 3 ] } +@test "subject matching is case insensitive" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + cat > "$FIXTURES/rb-ns-a" <<'JSON' +{"items":[ + {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@GITHUB"}]}, + {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"first.last@gov.bc.ca"}]} +]} +JSON + run --separate-stderr "$SCRIPT" --json --email FIRST.LAST@GOV.BC.CA Example-User + [ "$status" -eq 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "FIRST.LAST@GOV.BC.CA") | .findings[]] | length')" = 1 ] +} + @test "only read-only calls are made" { printf 'ns-a\n' > "$FIXTURES/oc-projects" echo '{"items":[]}' > "$FIXTURES/rb-ns-a" From 34f2e2ca1c354a8e1c8f2df8e84bc16e831b85c1 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 11:12:49 -0700 Subject: [PATCH 03/10] feat(offboard): add a front door over the two audits OpenShift takes a GitHub login list and a gov.bc.ca name list. offboard.sh runs both reports, and still prints the GitHub half when oc is not logged in. --- README.md | 3 +- offboard/README.md | 25 +++-- offboard/offboard-openshift.sh | 111 +++++++++++++-------- offboard/offboard.sh | 127 +++++++++++++++++++++++++ offboard/tests/offboard-openshift.bats | 34 ++++--- offboard/tests/offboard.bats | 61 ++++++++++++ 6 files changed, 297 insertions(+), 64 deletions(-) create mode 100755 offboard/offboard.sh create mode 100644 offboard/tests/offboard.bats diff --git a/README.md b/README.md index 87e0dd4..9024e7c 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) +- [`offboard.sh`](offboard/offboard.sh): runs the GitHub audit, then the OpenShift audit. Asks for the two name lists, or takes `--github-file` and `--gov-file`. - [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. -- [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings for GitHub ids, `name@github` subjects, and email addresses. +- [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings for `name@github` and `name@gov.bc.ca` subjects. ## Checks diff --git a/offboard/README.md b/offboard/README.md index 3c99237..13af19f 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -1,6 +1,17 @@ # Offboarding -Two read-only reports. They share no calls. A GitHub login and an OpenShift subject often look alike, and that is the only overlap. +`offboard.sh` runs both reports. The two scripts underneath share no calls. + +```bash +./offboard/offboard.sh +# asks: GitHub logins, then gov.bc.ca names (the part before the @) + +./offboard/offboard.sh --github-file github.txt --gov-file gov.txt +``` + +One name per line in each file. A gov file is optional. If `oc` is not logged in, the GitHub report is still printed and OpenShift is skipped, with the command to run where that login exists. + +## `offboard-github.sh` ## `offboard-github.sh` @@ -56,20 +67,16 @@ RoleBindings on the cluster your `oc` login points at. Run this on the machine w ```bash oc login ... -./offboard/offboard-openshift.sh [options] [github-username ...] - -./offboard/offboard-openshift.sh thermcampos rmcampos --email first.last@gov.bc.ca - -./offboard/offboard-openshift.sh --idir EXAMPLEIDIR --email first.last@gov.bc.ca example-user +./offboard/offboard-openshift.sh --github-file github.txt --gov-file gov.txt ``` | Option | Meaning | | --- | --- | -| `--email ADDR` | Match this address as a User subject (repeatable). | -| `--idir NAME` | Also match `NAME` and `NAME@idir` (repeatable). | +| `--github-file FILE` | One GitHub login per line. Matches that name and `name@github`. | +| `--gov-file FILE` | One gov.bc.ca name per line, the part before the `@`. Matches `name@gov.bc.ca` only. | | `--json` | JSON output instead of text. | -Each GitHub username is matched as that name and as `name@github`. An email is matched only as itself. Matching ignores case. Each input 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. +A GitHub login is not compared to `@gov.bc.ca`, and a gov name is not compared to `@github`. Matching ignores case. 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. Requires `oc` logged in, and `jq`. diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh index 4e6c801..35b0fe0 100755 --- a/offboard/offboard-openshift.sh +++ b/offboard/offboard-openshift.sh @@ -5,18 +5,21 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - offboard-openshift.sh [options] [github-username ...] + offboard-openshift.sh --github-file FILE [--gov-file FILE] + offboard-openshift.sh --gov-file FILE Read-only report of OpenShift RoleBindings for departed people, using your own oc login. GitHub is a separate script. Options: - --email ADDR Match this address as a User subject (repeatable). - --idir NAME Also match NAME and NAME@idir (repeatable). - --json Print JSON instead of text. - -h, --help Show this help. + --github-file FILE One GitHub login per line. Matches that name and name@github. + --gov-file FILE One gov.bc.ca name per line, the part before the @. + Matches name@gov.bc.ca only. + --json Print JSON instead of text. + -h, --help Show this help. -Each GitHub username is matched as that name and as name@github. +Matching ignores case. A GitHub login is not compared to @gov.bc.ca, and a +gov name is not compared to @github. # comments and blank lines are ignored. Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an oc call failed. EOF @@ -26,34 +29,55 @@ fail() { echo "offboard-openshift: $*" >&2; exit 3; } progress() { echo "offboard-openshift: $*" >&2; } lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } -EMAILS=() -IDIRS=() +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +GITHUB_FILE="" +GOV_FILE="" JSON=false -USERS=() while [[ $# -gt 0 ]]; do case "$1" in - --email) [[ $# -ge 2 ]] || die "--email needs a value"; EMAILS+=("$2"); shift 2 ;; - --idir) [[ $# -ge 2 ]] || die "--idir needs a value"; IDIRS+=("$2"); shift 2 ;; + --github-file) [[ $# -ge 2 ]] || die "--github-file needs a value"; GITHUB_FILE="$2"; shift 2 ;; + --gov-file) [[ $# -ge 2 ]] || die "--gov-file needs a value"; GOV_FILE="$2"; shift 2 ;; --json) JSON=true; shift ;; -h|--help) usage; exit 0 ;; - --) shift; USERS+=("$@"); break ;; - -*) usage >&2; die "unknown option: $1" ;; - *) USERS+=("$1"); shift ;; + *) usage >&2; die "unknown argument: $1" ;; esac done -[[ ${#USERS[@]} -gt 0 || ${#EMAILS[@]} -gt 0 || ${#IDIRS[@]} -gt 0 ]] \ - || { usage >&2; die "at least one GitHub username, --email, or --idir is required"; } -for u in "${USERS[@]}"; do - [[ "$u" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub username: $u" -done -for e in "${EMAILS[@]}"; do - [[ "$e" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || die "not an email address: $e" -done -for i in "${IDIRS[@]}"; do - [[ "$i" =~ ^[A-Za-z0-9._-]+$ ]] || die "not a valid IDIR name: $i" -done +[[ -n "$GITHUB_FILE" || -n "$GOV_FILE" ]] || { usage >&2; die "pass --github-file or --gov-file"; } + +GH=() +GOV=() +declare -A GH_LABEL=() GOV_LABEL=() + +load_file() { + local file="$1" kind="$2" re="$3" line key + [[ -r "$file" ]] || die "cannot read ${kind} file: $file" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(trim "$line")" + [[ -n "$line" ]] || continue + [[ "$line" =~ $re ]] || die "not a valid ${kind} name: $line" + key="$(lower "$line")" + if [[ "$kind" == "GitHub" ]]; then + GH+=("$line") + GH_LABEL["$key"]="$line" + else + GOV+=("$line") + GOV_LABEL["$key"]="$line" + fi + done < "$file" +} + +[[ -z "$GITHUB_FILE" ]] || load_file "$GITHUB_FILE" GitHub '^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$' +[[ -z "$GOV_FILE" ]] || load_file "$GOV_FILE" "gov.bc.ca" '^[A-Za-z0-9]([A-Za-z0-9._-]{0,63})$' +[[ ${#GH[@]} -gt 0 || ${#GOV[@]} -gt 0 ]] || die "no names in the list files" command -v oc >/dev/null 2>&1 || die "oc is required" command -v jq >/dev/null 2>&1 || die "jq is required" @@ -68,19 +92,6 @@ NOTES="${TMPD}/notes.jsonl" finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; } note() { jq -nc --arg n "$1" '{note:$n}' >> "$NOTES"; } -declare -A LABEL=() -remember() { LABEL["$(lower "$1")"]="$2"; } -for u in "${USERS[@]}"; do - remember "$u" "$u" - remember "${u}@github" "$u" -done -for e in "${EMAILS[@]}"; do remember "$e" "$e"; done -for i in "${IDIRS[@]}"; do - remember "$i" "$i" - remember "${i}@idir" "$i" -done - -names_json="$(printf '%s\n' "${!LABEL[@]}" | jq -R . | jq -sc .)" if ! projects="$(oc projects -q)"; then fail "oc projects failed" fi @@ -98,13 +109,29 @@ for ns in "${NAMESPACES[@]}"; do fi while IFS=$'\t' read -r subject binding role; do [[ -n "$subject" ]] || continue - finding "${LABEL[$subject]}" rolebinding "$ns" "${binding} -> ${role} (subject ${subject})" - done < <(printf '%s' "$rb" | jq -r --argjson n "$names_json" \ - '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | (.name | ascii_downcase) as $s | select($n | index($s)) | [$s, $b, $r] | @tsv') + subject_l="$(lower "$subject")" + if [[ "$subject_l" == *"@"* ]]; then + local_part="${subject_l%%@*}" + domain="${subject_l#*@}" + else + local_part="$subject_l" + domain="" + fi + owner="" + case "$domain" in + "" | github) owner="${GH_LABEL[$local_part]:-}" ;; + gov.bc.ca) owner="${GOV_LABEL[$local_part]:-}" ;; + esac + [[ -n "$owner" ]] || continue + finding "$owner" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" + done < <(printf '%s' "$rb" | jq -r \ + '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv') done if (( unreadable > 0 )); then note "RoleBindings not readable in ${unreadable} namespace(s)"; fi -SECTIONS=("${USERS[@]}" "${EMAILS[@]}" "${IDIRS[@]}") +SECTIONS=() +[[ ${#GH[@]} -eq 0 ]] || SECTIONS+=("${GH[@]}") +[[ ${#GOV[@]} -eq 0 ]] || SECTIONS+=("${GOV[@]}") count="$(wc -l < "$FINDINGS" | tr -d ' ')" sections_json="$(printf '%s\n' "${SECTIONS[@]}" | jq -R . | jq -sc .)" if [[ "$JSON" == "true" ]]; then diff --git a/offboard/offboard.sh b/offboard/offboard.sh new file mode 100755 index 0000000..d72fb70 --- /dev/null +++ b/offboard/offboard.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Run the GitHub audit, then the OpenShift audit. Run with -h for usage. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GH_SCRIPT="${DIR}/offboard-github.sh" +OC_SCRIPT="${DIR}/offboard-openshift.sh" + +usage() { + cat <<'EOF' +Usage: + offboard.sh + offboard.sh --github-file FILE [--gov-file FILE] + +Runs the GitHub audit, then the OpenShift audit. + +With no arguments in a terminal, asks for GitHub logins and then gov.bc.ca +names (the part before the @). Otherwise pass the two lists as files, one +name per line. A gov list is optional. + +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. +EOF +} +die() { echo "offboard: $*" >&2; exit 2; } + +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +names_from_file() { + local file="$1" line + [[ -r "$file" ]] || die "cannot read $file" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(trim "$line")" + [[ -n "$line" ]] && printf '%s\n' "$line" + done < "$file" +} + +GITHUB_FILE="" +GOV_FILE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --github-file) [[ $# -ge 2 ]] || die "--github-file needs a value"; GITHUB_FILE="$2"; shift 2 ;; + --gov-file) [[ $# -ge 2 ]] || die "--gov-file needs a value"; GOV_FILE="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "unknown argument: $1" ;; + esac +done + +split_words() { + local line="$1" w + local -a words=() + line="${line//,/ }" + read -r -a words <<< "$line" + for w in "${words[@]+"${words[@]}"}"; do + [[ -n "$w" ]] && printf '%s\n' "$w" + done +} + +USERS=() +GOV=() +PROMPTED=false +if [[ -z "$GITHUB_FILE" && -z "$GOV_FILE" ]]; then + [[ -t 0 ]] || { usage >&2; die "pass --github-file, or run from a terminal to be asked"; } + PROMPTED=true + read -r -p "GitHub logins: " gh_line || die "no GitHub logins entered" + read -r -p "gov.bc.ca names: " gov_line || true + mapfile -t USERS < <(split_words "$gh_line") + mapfile -t GOV < <(split_words "${gov_line:-}") + TMPD="$(mktemp -d)" + trap 'rm -rf "${TMPD}"' EXIT + GITHUB_FILE="${TMPD}/github.txt" + printf '%s\n' "${USERS[@]}" > "$GITHUB_FILE" + if [[ ${#GOV[@]} -gt 0 ]]; then + GOV_FILE="${TMPD}/gov.txt" + printf '%s\n' "${GOV[@]}" > "$GOV_FILE" + fi +else + [[ -n "$GITHUB_FILE" ]] || die "pass --github-file" + mapfile -t USERS < <(names_from_file "$GITHUB_FILE") + if [[ -n "$GOV_FILE" ]]; then + mapfile -t GOV < <(names_from_file "$GOV_FILE") + fi +fi +[[ ${#USERS[@]} -gt 0 ]] || die "at least one GitHub login is required" + +echo "=== GitHub ===" +set +e +"$GH_SCRIPT" -- "${USERS[@]}" +gh_rc=$? +set -e + +echo +echo "=== OpenShift ===" +oc_rc=0 +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:" + if [[ "$PROMPTED" == true ]]; then + echo " $(printf '%q' "$OC_SCRIPT") --github-file github.txt${GOV_FILE:+ --gov-file gov.txt}" + echo "GitHub logins: ${USERS[*]}" + [[ ${#GOV[@]} -eq 0 ]] || echo "gov.bc.ca names: ${GOV[*]}" + else + cmd="$(printf '%q' "$OC_SCRIPT") --github-file $(printf '%q' "$GITHUB_FILE")" + [[ -z "$GOV_FILE" ]] || cmd+=" --gov-file $(printf '%q' "$GOV_FILE")" + echo " ${cmd}" + fi +else + set +e + if [[ -n "$GOV_FILE" ]]; then + "$OC_SCRIPT" --github-file "$GITHUB_FILE" --gov-file "$GOV_FILE" + else + "$OC_SCRIPT" --github-file "$GITHUB_FILE" + fi + oc_rc=$? + set -e +fi + +if [[ "$gh_rc" -eq 3 || "$oc_rc" -eq 3 ]]; then exit 3; fi +if [[ "$gh_rc" -eq 1 || "$oc_rc" -eq 1 ]]; then exit 1; fi +if [[ "$gh_rc" -eq 2 || "$oc_rc" -eq 2 ]]; then exit 2; fi +exit 0 diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index c44e992..93a3632 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -12,38 +12,44 @@ setup() { PATH="${BATS_TEST_DIRNAME}/stubs:${PATH}" export PATH export OC_WHOAMI_RC=0 + printf 'example-user\n' > "${BATS_TEST_TMPDIR}/github.txt" + printf 'first.last\n' > "${BATS_TEST_TMPDIR}/gov.txt" } -@test "no subjects is a usage error" { +@test "no lists is a usage error" { run "$SCRIPT" [ "$status" -eq 2 ] - [[ "$output" == *"at least one GitHub username"* ]] + [[ "$output" == *"--github-file or --gov-file"* ]] } @test "oc not logged in is an error" { - OC_WHOAMI_RC=1 run "$SCRIPT" example-user + OC_WHOAMI_RC=1 run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" [ "$status" -eq 2 ] [[ "$output" == *"not logged in"* ]] } -@test "github id, email, and idir are separate sections from one namespace read" { +@test "github and gov lists stay on their own domains" { printf 'ns-a\nns-b\nns-c\n' > "$FIXTURES/oc-projects" cat > "$FIXTURES/rb-ns-a" <<'JSON' {"items":[ {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}, {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"First.Last@gov.bc.ca"}]}, - {"metadata":{"name":"rb4"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"someone-else"}]} + {"metadata":{"name":"rb4"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"example-user@gov.bc.ca"}]}, + {"metadata":{"name":"rb5"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"first.last@github"}]}, + {"metadata":{"name":"rb6"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"someone-else"}]} ]} JSON cat > "$FIXTURES/rb-ns-b" <<'JSON' -{"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"EXAMPLEIDIR@idir"},{"kind":"Group","name":"example-user"}]}]} +{"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"Group","name":"example-user"}]}]} JSON - run --separate-stderr "$SCRIPT" --json --idir exampleidir --email First.Last@gov.bc.ca example-user + run --separate-stderr "$SCRIPT" --json \ + --github-file "${BATS_TEST_TMPDIR}/github.txt" \ + --gov-file "${BATS_TEST_TMPDIR}/gov.txt" [ "$status" -eq 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "example-user") | .findings[]] | length')" = 1 ] - [ "$(echo "$output" | jq '[.sections[] | select(.name == "First.Last@gov.bc.ca") | .findings[]] | length')" = 1 ] - [ "$(echo "$output" | jq '[.sections[] | select(.name == "exampleidir") | .findings[]] | length')" = 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "first.last") | .findings[]] | length')" = 1 ] echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.detail | test("subject example-user@github"))' + [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("example-user@gov.bc.ca|first.last@github|someone-else"))] | length')" = 0 ] echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' run grep -c 'oc get rolebindings' "$STUB_LOG" [ "$output" = 3 ] @@ -51,22 +57,26 @@ JSON @test "subject matching is case insensitive" { printf 'ns-a\n' > "$FIXTURES/oc-projects" + printf 'Example-User\n' > "${BATS_TEST_TMPDIR}/github.txt" + printf 'First.Last\n' > "${BATS_TEST_TMPDIR}/gov.txt" cat > "$FIXTURES/rb-ns-a" <<'JSON' {"items":[ {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@GITHUB"}]}, {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"first.last@gov.bc.ca"}]} ]} JSON - run --separate-stderr "$SCRIPT" --json --email FIRST.LAST@GOV.BC.CA Example-User + run --separate-stderr "$SCRIPT" --json \ + --github-file "${BATS_TEST_TMPDIR}/github.txt" \ + --gov-file "${BATS_TEST_TMPDIR}/gov.txt" [ "$status" -eq 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] - [ "$(echo "$output" | jq '[.sections[] | select(.name == "FIRST.LAST@GOV.BC.CA") | .findings[]] | length')" = 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "First.Last") | .findings[]] | length')" = 1 ] } @test "only read-only calls are made" { printf 'ns-a\n' > "$FIXTURES/oc-projects" echo '{"items":[]}' > "$FIXTURES/rb-ns-a" - run "$SCRIPT" example-user + run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input' "$STUB_LOG")" ] [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] [ -z "$(grep '^gh ' "$STUB_LOG" || true)" ] diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats new file mode 100644 index 0000000..c159b40 --- /dev/null +++ b/offboard/tests/offboard.bats @@ -0,0 +1,61 @@ +#!/usr/bin/env bats +# Tests for offboard.sh. Stubbed gh and oc. No network access. + +bats_require_minimum_version 1.5.0 + +setup() { + SCRIPT="${BATS_TEST_DIRNAME}/../offboard.sh" + export FIXTURES="${BATS_TEST_TMPDIR}/fx" + export STUB_LOG="${BATS_TEST_TMPDIR}/calls.log" + mkdir -p "$FIXTURES" + : > "$STUB_LOG" + PATH="${BATS_TEST_DIRNAME}/stubs:${PATH}" + export PATH + export OFFBOARD_ORGS="example-org" + export OC_WHOAMI_RC=0 + unset GH_AUTH_RC GH_FAIL_MATCH + printf 'example-user\n' > "${BATS_TEST_TMPDIR}/github.txt" + printf 'first.last\n' > "${BATS_TEST_TMPDIR}/gov.txt" +} + +seed_github() { + printf 'example-org\n' > "$FIXTURES/member-orgs" + printf 'team-a\n' > "$FIXTURES/teams-example-org" + printf 'example-org/repo-one\n' > "$FIXTURES/user-repos" + printf 'example-user\twrite\n' > "$FIXTURES/collab-all-repo-one" + printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" + printf 'prod\tUser\texample-user\n' > "$FIXTURES/env-repo-one" + printf '* @example-user\n' > "$FIXTURES/codeowners-repo-one" + echo '{"items":[]}' > "$FIXTURES/search-code" + printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\texample-user\n' > "$FIXTURES/search-assigned" + printf 'https://github.com/example-org/repo-one/pull/3\tNeeds review\n' > "$FIXTURES/search-review" +} + +@test "no arguments off a terminal is a usage error" { + run "$SCRIPT" + [ "$status" -eq 2 ] + [[ "$output" == *"--github-file"* ]] +} + +@test "both reports run and a missing oc login still prints GitHub" { + seed_github + OC_WHOAMI_RC=1 run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" --gov-file "${BATS_TEST_TMPDIR}/gov.txt" + [ "$status" -eq 1 ] + [[ "$output" == *"=== GitHub ==="* ]] + [[ "$output" == *"=== OpenShift ==="* ]] + [[ "$output" == *"OpenShift skipped: oc is not logged in"* ]] + [[ "$output" == *"--github-file ${BATS_TEST_TMPDIR}/github.txt"* ]] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [ -z "$(grep 'oc get rolebindings' "$STUB_LOG" || true)" ] +} + +@test "a GitHub API failure still runs OpenShift" { + seed_github + 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" + GH_FAIL_MATCH=environments run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" + [ "$status" -eq 3 ] + [[ "$output" == *"=== OpenShift ==="* ]] + [[ "$output" == *"example-user@github"* ]] + grep -q 'oc get rolebindings' "$STUB_LOG" +} From 337201ba7f2c859f76a5e9b3375bb09cdc08405e Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 11:18:36 -0700 Subject: [PATCH 04/10] feat(offboard): take GitHub and gov names as repeated flags The front door no longer reads list files. One command carries every login and every gov.bc.ca name. --- README.md | 2 +- offboard/README.md | 14 +++-- offboard/offboard-openshift.sh | 70 +++++++-------------- offboard/offboard.sh | 84 +++++++------------------- offboard/tests/offboard-openshift.bats | 18 ++---- offboard/tests/offboard.bats | 11 ++-- 6 files changed, 64 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 9024e7c..67276b7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) -- [`offboard.sh`](offboard/offboard.sh): runs the GitHub audit, then the OpenShift audit. Asks for the two name lists, or takes `--github-file` and `--gov-file`. +- [`offboard.sh`](offboard/offboard.sh): runs the GitHub audit, then the OpenShift audit. Asks for the two name lists, or takes repeatable `--github` and `--gov`. - [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. - [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings for `name@github` and `name@gov.bc.ca` subjects. diff --git a/offboard/README.md b/offboard/README.md index 13af19f..07b9a81 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -6,10 +6,14 @@ ./offboard/offboard.sh # asks: GitHub logins, then gov.bc.ca names (the part before the @) -./offboard/offboard.sh --github-file github.txt --gov-file gov.txt +./offboard/offboard.sh \ + --github ianliuwk1019 --github franTarkenton --github DBAJohnL \ + --github Mitchiavelli --github gpascucci --github MCatherine1994 \ + --github thermcampos --github rmcampos \ + --gov first.last ``` -One name per line in each file. A gov file is optional. If `oc` is not logged in, the GitHub report is still printed and OpenShift is skipped, with the command to run where that login exists. +Repeat `--github` and `--gov`. A gov name is optional. If `oc` is not logged in, the GitHub report is still printed and OpenShift is skipped, with the command to run where that login exists. ## `offboard-github.sh` @@ -67,13 +71,13 @@ RoleBindings on the cluster your `oc` login points at. Run this on the machine w ```bash oc login ... -./offboard/offboard-openshift.sh --github-file github.txt --gov-file gov.txt +./offboard/offboard-openshift.sh --github thermcampos --github rmcampos --gov first.last ``` | Option | Meaning | | --- | --- | -| `--github-file FILE` | One GitHub login per line. Matches that name and `name@github`. | -| `--gov-file FILE` | One gov.bc.ca name per line, the part before the `@`. Matches `name@gov.bc.ca` only. | +| `--github LOGIN` | GitHub login (repeatable). Matches that name and `name@github`. | +| `--gov NAME` | gov.bc.ca name, the part before the `@` (repeatable). Matches `name@gov.bc.ca` only. | | `--json` | JSON output instead of text. | A GitHub login is not compared to `@gov.bc.ca`, and a gov name is not compared to `@github`. Matching ignores case. 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. diff --git a/offboard/offboard-openshift.sh b/offboard/offboard-openshift.sh index 35b0fe0..df69087 100755 --- a/offboard/offboard-openshift.sh +++ b/offboard/offboard-openshift.sh @@ -5,21 +5,20 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - offboard-openshift.sh --github-file FILE [--gov-file FILE] - offboard-openshift.sh --gov-file FILE + offboard-openshift.sh [--github LOGIN]... [--gov NAME]... Read-only report of OpenShift RoleBindings for departed people, using your own oc login. GitHub is a separate script. Options: - --github-file FILE One GitHub login per line. Matches that name and name@github. - --gov-file FILE One gov.bc.ca name per line, the part before the @. - Matches name@gov.bc.ca only. - --json Print JSON instead of text. - -h, --help Show this help. + --github LOGIN GitHub login (repeatable). Matches that name and name@github. + --gov NAME gov.bc.ca name, the part before the @ (repeatable). + Matches name@gov.bc.ca only. + --json Print JSON instead of text. + -h, --help Show this help. Matching ignores case. A GitHub login is not compared to @gov.bc.ca, and a -gov name is not compared to @github. # comments and blank lines are ignored. +gov name is not compared to @github. Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an oc call failed. EOF @@ -29,55 +28,32 @@ fail() { echo "offboard-openshift: $*" >&2; exit 3; } progress() { echo "offboard-openshift: $*" >&2; } lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } -trim() { - local s="$1" - s="${s#"${s%%[![:space:]]*}"}" - s="${s%"${s##*[![:space:]]}"}" - printf '%s' "$s" -} - -GITHUB_FILE="" -GOV_FILE="" +GH=() +GOV=() +declare -A GH_LABEL=() GOV_LABEL=() JSON=false while [[ $# -gt 0 ]]; do case "$1" in - --github-file) [[ $# -ge 2 ]] || die "--github-file needs a value"; GITHUB_FILE="$2"; shift 2 ;; - --gov-file) [[ $# -ge 2 ]] || die "--gov-file needs a value"; GOV_FILE="$2"; shift 2 ;; + --github) + [[ $# -ge 2 ]] || die "--github needs a value" + [[ "$2" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub name: $2" + GH+=("$2") + GH_LABEL["$(lower "$2")"]="$2" + shift 2 ;; + --gov) + [[ $# -ge 2 ]] || die "--gov needs a value" + [[ "$2" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]{0,63})$ ]] || die "not a valid gov.bc.ca name: $2" + GOV+=("$2") + GOV_LABEL["$(lower "$2")"]="$2" + shift 2 ;; --json) JSON=true; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; die "unknown argument: $1" ;; esac done -[[ -n "$GITHUB_FILE" || -n "$GOV_FILE" ]] || { usage >&2; die "pass --github-file or --gov-file"; } - -GH=() -GOV=() -declare -A GH_LABEL=() GOV_LABEL=() - -load_file() { - local file="$1" kind="$2" re="$3" line key - [[ -r "$file" ]] || die "cannot read ${kind} file: $file" - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%%#*}" - line="$(trim "$line")" - [[ -n "$line" ]] || continue - [[ "$line" =~ $re ]] || die "not a valid ${kind} name: $line" - key="$(lower "$line")" - if [[ "$kind" == "GitHub" ]]; then - GH+=("$line") - GH_LABEL["$key"]="$line" - else - GOV+=("$line") - GOV_LABEL["$key"]="$line" - fi - done < "$file" -} - -[[ -z "$GITHUB_FILE" ]] || load_file "$GITHUB_FILE" GitHub '^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$' -[[ -z "$GOV_FILE" ]] || load_file "$GOV_FILE" "gov.bc.ca" '^[A-Za-z0-9]([A-Za-z0-9._-]{0,63})$' -[[ ${#GH[@]} -gt 0 || ${#GOV[@]} -gt 0 ]] || die "no names in the list files" +[[ ${#GH[@]} -gt 0 || ${#GOV[@]} -gt 0 ]] || { usage >&2; die "pass --github or --gov"; } command -v oc >/dev/null 2>&1 || die "oc is required" command -v jq >/dev/null 2>&1 || die "jq is required" diff --git a/offboard/offboard.sh b/offboard/offboard.sh index d72fb70..e154c50 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -10,13 +10,13 @@ usage() { cat <<'EOF' Usage: offboard.sh - offboard.sh --github-file FILE [--gov-file FILE] + offboard.sh --github LOGIN [--github LOGIN]... [--gov NAME]... Runs the GitHub audit, then the OpenShift audit. With no arguments in a terminal, asks for GitHub logins and then gov.bc.ca -names (the part before the @). Otherwise pass the two lists as files, one -name per line. A gov list is optional. +names (the part before the @). Otherwise repeat --github and --gov. +A gov name is optional. 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. @@ -24,34 +24,6 @@ EOF } die() { echo "offboard: $*" >&2; exit 2; } -trim() { - local s="$1" - s="${s#"${s%%[![:space:]]*}"}" - s="${s%"${s##*[![:space:]]}"}" - printf '%s' "$s" -} - -names_from_file() { - local file="$1" line - [[ -r "$file" ]] || die "cannot read $file" - while IFS= read -r line || [[ -n "$line" ]]; do - line="${line%%#*}" - line="$(trim "$line")" - [[ -n "$line" ]] && printf '%s\n' "$line" - done < "$file" -} - -GITHUB_FILE="" -GOV_FILE="" -while [[ $# -gt 0 ]]; do - case "$1" in - --github-file) [[ $# -ge 2 ]] || die "--github-file needs a value"; GITHUB_FILE="$2"; shift 2 ;; - --gov-file) [[ $# -ge 2 ]] || die "--gov-file needs a value"; GOV_FILE="$2"; shift 2 ;; - -h|--help) usage; exit 0 ;; - *) usage >&2; die "unknown argument: $1" ;; - esac -done - split_words() { local line="$1" w local -a words=() @@ -64,28 +36,21 @@ split_words() { USERS=() GOV=() -PROMPTED=false -if [[ -z "$GITHUB_FILE" && -z "$GOV_FILE" ]]; then - [[ -t 0 ]] || { usage >&2; die "pass --github-file, or run from a terminal to be asked"; } - PROMPTED=true +while [[ $# -gt 0 ]]; do + case "$1" in + --github) [[ $# -ge 2 ]] || die "--github needs a value"; USERS+=("$2"); shift 2 ;; + --gov) [[ $# -ge 2 ]] || die "--gov needs a value"; GOV+=("$2"); shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "unknown argument: $1" ;; + esac +done + +if [[ ${#USERS[@]} -eq 0 && ${#GOV[@]} -eq 0 ]]; then + [[ -t 0 ]] || { usage >&2; die "pass --github, or run from a terminal to be asked"; } read -r -p "GitHub logins: " gh_line || die "no GitHub logins entered" read -r -p "gov.bc.ca names: " gov_line || true mapfile -t USERS < <(split_words "$gh_line") mapfile -t GOV < <(split_words "${gov_line:-}") - TMPD="$(mktemp -d)" - trap 'rm -rf "${TMPD}"' EXIT - GITHUB_FILE="${TMPD}/github.txt" - printf '%s\n' "${USERS[@]}" > "$GITHUB_FILE" - if [[ ${#GOV[@]} -gt 0 ]]; then - GOV_FILE="${TMPD}/gov.txt" - printf '%s\n' "${GOV[@]}" > "$GOV_FILE" - fi -else - [[ -n "$GITHUB_FILE" ]] || die "pass --github-file" - mapfile -t USERS < <(names_from_file "$GITHUB_FILE") - if [[ -n "$GOV_FILE" ]]; then - mapfile -t GOV < <(names_from_file "$GOV_FILE") - fi fi [[ ${#USERS[@]} -gt 0 ]] || die "at least one GitHub login is required" @@ -98,25 +63,18 @@ set -e echo echo "=== OpenShift ===" oc_rc=0 +oc_cmd=("$OC_SCRIPT") +for u in "${USERS[@]}"; do oc_cmd+=(--github "$u"); done +for g in "${GOV[@]+"${GOV[@]}"}"; do oc_cmd+=(--gov "$g"); done 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:" - if [[ "$PROMPTED" == true ]]; then - echo " $(printf '%q' "$OC_SCRIPT") --github-file github.txt${GOV_FILE:+ --gov-file gov.txt}" - echo "GitHub logins: ${USERS[*]}" - [[ ${#GOV[@]} -eq 0 ]] || echo "gov.bc.ca names: ${GOV[*]}" - else - cmd="$(printf '%q' "$OC_SCRIPT") --github-file $(printf '%q' "$GITHUB_FILE")" - [[ -z "$GOV_FILE" ]] || cmd+=" --gov-file $(printf '%q' "$GOV_FILE")" - echo " ${cmd}" - fi + printf ' ' + printf ' %q' "${oc_cmd[@]}" + printf '\n' else set +e - if [[ -n "$GOV_FILE" ]]; then - "$OC_SCRIPT" --github-file "$GITHUB_FILE" --gov-file "$GOV_FILE" - else - "$OC_SCRIPT" --github-file "$GITHUB_FILE" - fi + "${oc_cmd[@]}" oc_rc=$? set -e fi diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index 93a3632..d93a45e 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -12,18 +12,16 @@ setup() { PATH="${BATS_TEST_DIRNAME}/stubs:${PATH}" export PATH export OC_WHOAMI_RC=0 - printf 'example-user\n' > "${BATS_TEST_TMPDIR}/github.txt" - printf 'first.last\n' > "${BATS_TEST_TMPDIR}/gov.txt" } @test "no lists is a usage error" { run "$SCRIPT" [ "$status" -eq 2 ] - [[ "$output" == *"--github-file or --gov-file"* ]] + [[ "$output" == *"--github or --gov"* ]] } @test "oc not logged in is an error" { - OC_WHOAMI_RC=1 run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" + OC_WHOAMI_RC=1 run "$SCRIPT" --github example-user [ "$status" -eq 2 ] [[ "$output" == *"not logged in"* ]] } @@ -42,9 +40,7 @@ JSON cat > "$FIXTURES/rb-ns-b" <<'JSON' {"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"Group","name":"example-user"}]}]} JSON - run --separate-stderr "$SCRIPT" --json \ - --github-file "${BATS_TEST_TMPDIR}/github.txt" \ - --gov-file "${BATS_TEST_TMPDIR}/gov.txt" + run --separate-stderr "$SCRIPT" --json --github example-user --gov first.last [ "$status" -eq 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "example-user") | .findings[]] | length')" = 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "first.last") | .findings[]] | length')" = 1 ] @@ -57,17 +53,13 @@ JSON @test "subject matching is case insensitive" { printf 'ns-a\n' > "$FIXTURES/oc-projects" - printf 'Example-User\n' > "${BATS_TEST_TMPDIR}/github.txt" - printf 'First.Last\n' > "${BATS_TEST_TMPDIR}/gov.txt" cat > "$FIXTURES/rb-ns-a" <<'JSON' {"items":[ {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@GITHUB"}]}, {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"first.last@gov.bc.ca"}]} ]} JSON - run --separate-stderr "$SCRIPT" --json \ - --github-file "${BATS_TEST_TMPDIR}/github.txt" \ - --gov-file "${BATS_TEST_TMPDIR}/gov.txt" + run --separate-stderr "$SCRIPT" --json --github Example-User --gov First.Last [ "$status" -eq 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "First.Last") | .findings[]] | length')" = 1 ] @@ -76,7 +68,7 @@ JSON @test "only read-only calls are made" { printf 'ns-a\n' > "$FIXTURES/oc-projects" echo '{"items":[]}' > "$FIXTURES/rb-ns-a" - run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" + run "$SCRIPT" --github example-user [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input' "$STUB_LOG")" ] [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] [ -z "$(grep '^gh ' "$STUB_LOG" || true)" ] diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index c159b40..96f46f5 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -14,8 +14,6 @@ setup() { export OFFBOARD_ORGS="example-org" export OC_WHOAMI_RC=0 unset GH_AUTH_RC GH_FAIL_MATCH - printf 'example-user\n' > "${BATS_TEST_TMPDIR}/github.txt" - printf 'first.last\n' > "${BATS_TEST_TMPDIR}/gov.txt" } seed_github() { @@ -34,17 +32,18 @@ seed_github() { @test "no arguments off a terminal is a usage error" { run "$SCRIPT" [ "$status" -eq 2 ] - [[ "$output" == *"--github-file"* ]] + [[ "$output" == *"--github"* ]] } @test "both reports run and a missing oc login still prints GitHub" { seed_github - OC_WHOAMI_RC=1 run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" --gov-file "${BATS_TEST_TMPDIR}/gov.txt" + OC_WHOAMI_RC=1 run "$SCRIPT" --github example-user --gov first.last [ "$status" -eq 1 ] [[ "$output" == *"=== GitHub ==="* ]] [[ "$output" == *"=== OpenShift ==="* ]] [[ "$output" == *"OpenShift skipped: oc is not logged in"* ]] - [[ "$output" == *"--github-file ${BATS_TEST_TMPDIR}/github.txt"* ]] + [[ "$output" == *"--github example-user"* ]] + [[ "$output" == *"--gov first.last"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] [ -z "$(grep 'oc get rolebindings' "$STUB_LOG" || true)" ] } @@ -53,7 +52,7 @@ seed_github() { seed_github 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" - GH_FAIL_MATCH=environments run "$SCRIPT" --github-file "${BATS_TEST_TMPDIR}/github.txt" + GH_FAIL_MATCH=environments run "$SCRIPT" --github example-user [ "$status" -eq 3 ] [[ "$output" == *"=== OpenShift ==="* ]] [[ "$output" == *"example-user@github"* ]] From 2cd6c459bf4b402e9d83f3359dbe4acfbc03a155 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 13:56:58 -0700 Subject: [PATCH 05/10] feat(offboard): group a person's names with equals Search each written name as a substring so related spellings stay in one report without a suffix rule. --- README.md | 4 +- offboard/README.md | 27 ++-- offboard/offboard-openshift.sh | 68 +++------ offboard/offboard.sh | 187 +++++++++++++++++++------ offboard/tests/offboard-openshift.bats | 36 ++--- offboard/tests/offboard.bats | 33 +++-- 6 files changed, 223 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 67276b7..909598d 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ Scripts a person runs from a workstation with their own login (for example an ac ### Offboarding ([`offboard/`](offboard)) -- [`offboard.sh`](offboard/offboard.sh): runs the GitHub audit, then the OpenShift audit. Asks for the two name lists, or takes repeatable `--github` and `--gov`. +- [`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-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings for `name@github` and `name@gov.bc.ca` subjects. +- [`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 07b9a81..a7f9ffb 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -1,21 +1,23 @@ # Offboarding -`offboard.sh` runs both reports. The two scripts underneath share no calls. +`offboard.sh` runs both reports, one block per person. The two scripts underneath share no calls. ```bash ./offboard/offboard.sh -# asks: GitHub logins, then gov.bc.ca names (the part before the @) +# asks for people ./offboard/offboard.sh \ - --github ianliuwk1019 --github franTarkenton --github DBAJohnL \ - --github Mitchiavelli --github gpascucci --github MCatherine1994 \ - --github thermcampos --github rmcampos \ - --gov first.last + gpascucci=greg.pascucci \ + ianliuwk1019 \ + franTarkenton \ + DBAJohnL \ + Mitchiavelli \ + MCatherine1994 \ + thermcampos \ + rmcampos ``` -Repeat `--github` and `--gov`. A gov name is optional. If `oc` is not logged in, the GitHub report is still printed and OpenShift is skipped, with the command to run where that login exists. - -## `offboard-github.sh` +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. ## `offboard-github.sh` @@ -71,16 +73,15 @@ RoleBindings on the cluster your `oc` login points at. Run this on the machine w ```bash oc login ... -./offboard/offboard-openshift.sh --github thermcampos --github rmcampos --gov first.last +./offboard/offboard-openshift.sh --name thermcampos --name rmcampos --name greg.pascucci ``` | Option | Meaning | | --- | --- | -| `--github LOGIN` | GitHub login (repeatable). Matches that name and `name@github`. | -| `--gov NAME` | gov.bc.ca name, the part before the `@` (repeatable). Matches `name@gov.bc.ca` only. | +| `--name STRING` | Name to search for (repeatable). A User subject matches when it contains the name. | | `--json` | JSON output instead of text. | -A GitHub login is not compared to `@gov.bc.ca`, and a gov name is not compared to `@github`. Matching ignores case. 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. 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 df69087..9952820 100755 --- a/offboard/offboard-openshift.sh +++ b/offboard/offboard-openshift.sh @@ -5,20 +5,17 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - offboard-openshift.sh [--github LOGIN]... [--gov NAME]... + offboard-openshift.sh --name STRING [--name STRING]... -Read-only report of OpenShift RoleBindings for departed people, using your -own oc login. GitHub is a separate script. +Read-only report of OpenShift RoleBindings whose User subject contains one +of the names, using your own oc login. Matching ignores case. No suffix is +added. GitHub is a separate script. Options: - --github LOGIN GitHub login (repeatable). Matches that name and name@github. - --gov NAME gov.bc.ca name, the part before the @ (repeatable). - Matches name@gov.bc.ca only. - --json Print JSON instead of text. - -h, --help Show this help. + --name STRING Name to search for (repeatable). + --json Print JSON instead of text. + -h, --help Show this help. -Matching ignores case. A GitHub login is not compared to @gov.bc.ca, and a -gov name is not compared to @github. Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error, 3 an oc call failed. EOF @@ -28,24 +25,15 @@ fail() { echo "offboard-openshift: $*" >&2; exit 3; } progress() { echo "offboard-openshift: $*" >&2; } lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } -GH=() -GOV=() -declare -A GH_LABEL=() GOV_LABEL=() +NAMES=() JSON=false while [[ $# -gt 0 ]]; do case "$1" in - --github) - [[ $# -ge 2 ]] || die "--github needs a value" - [[ "$2" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]] || die "not a valid GitHub name: $2" - GH+=("$2") - GH_LABEL["$(lower "$2")"]="$2" - shift 2 ;; - --gov) - [[ $# -ge 2 ]] || die "--gov needs a value" - [[ "$2" =~ ^[A-Za-z0-9]([A-Za-z0-9._-]{0,63})$ ]] || die "not a valid gov.bc.ca name: $2" - GOV+=("$2") - GOV_LABEL["$(lower "$2")"]="$2" + --name) + [[ $# -ge 2 ]] || die "--name needs a value" + [[ "$2" =~ ^[A-Za-z0-9][A-Za-z0-9._@+-]*$ ]] || die "not a valid name: $2" + NAMES+=("$2") shift 2 ;; --json) JSON=true; shift ;; -h|--help) usage; exit 0 ;; @@ -53,7 +41,7 @@ while [[ $# -gt 0 ]]; do esac done -[[ ${#GH[@]} -gt 0 || ${#GOV[@]} -gt 0 ]] || { usage >&2; die "pass --github or --gov"; } +[[ ${#NAMES[@]} -gt 0 ]] || { usage >&2; die "pass --name"; } command -v oc >/dev/null 2>&1 || die "oc is required" command -v jq >/dev/null 2>&1 || die "jq is required" @@ -86,36 +74,24 @@ for ns in "${NAMESPACES[@]}"; do while IFS=$'\t' read -r subject binding role; do [[ -n "$subject" ]] || continue subject_l="$(lower "$subject")" - if [[ "$subject_l" == *"@"* ]]; then - local_part="${subject_l%%@*}" - domain="${subject_l#*@}" - else - local_part="$subject_l" - domain="" - fi - owner="" - case "$domain" in - "" | github) owner="${GH_LABEL[$local_part]:-}" ;; - gov.bc.ca) owner="${GOV_LABEL[$local_part]:-}" ;; - esac - [[ -n "$owner" ]] || continue - finding "$owner" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" + for name in "${NAMES[@]}"; do + needle="$(lower "$name")" + [[ "$subject_l" == *"$needle"* ]] || continue + finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})" + done done < <(printf '%s' "$rb" | jq -r \ '.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv') done if (( unreadable > 0 )); then note "RoleBindings not readable in ${unreadable} namespace(s)"; fi -SECTIONS=() -[[ ${#GH[@]} -eq 0 ]] || SECTIONS+=("${GH[@]}") -[[ ${#GOV[@]} -eq 0 ]] || SECTIONS+=("${GOV[@]}") count="$(wc -l < "$FINDINGS" | tr -d ' ')" -sections_json="$(printf '%s\n' "${SECTIONS[@]}" | jq -R . | jq -sc .)" +names_json="$(printf '%s\n' "${NAMES[@]}" | jq -R . | jq -sc .)" if [[ "$JSON" == "true" ]]; then - jq -n --argjson sections "$sections_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" --argjson ns "${#NAMESPACES[@]}" \ - '{namespaces_checked: $ns, sections: [$sections[] as $u | {name: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' + jq -n --argjson names "$names_json" --slurpfile f "$FINDINGS" --slurpfile n "$NOTES" --argjson ns "${#NAMESPACES[@]}" \ + '{namespaces_checked: $ns, sections: [$names[] as $u | {name: $u, findings: [$f[] | select(.user == $u) | del(.user)]}], notes: [$n[] | .note]}' else echo "Namespaces checked: ${#NAMESPACES[@]}" - for u in "${SECTIONS[@]}"; do + for u in "${NAMES[@]}"; do echo echo "== ${u}" if ! jq -e --arg u "$u" 'select(.user == $u)' "$FINDINGS" >/dev/null 2>&1; then diff --git a/offboard/offboard.sh b/offboard/offboard.sh index e154c50..15d8e2d 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run the GitHub audit, then the OpenShift audit. Run with -h for usage. +# Run the GitHub audit and the OpenShift audit as one report per person. set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -10,73 +10,174 @@ usage() { cat <<'EOF' Usage: offboard.sh - offboard.sh --github LOGIN [--github LOGIN]... [--gov NAME]... + offboard.sh PERSON [PERSON...] -Runs the GitHub audit, then the OpenShift audit. +A person is a GitHub login, or several names joined with = : + gpascucci=greg.pascucci -With no arguments in a terminal, asks for GitHub logins and then gov.bc.ca -names (the part before the @). Otherwise repeat --github and --gov. -A gov name is optional. +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. -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. +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. EOF } die() { echo "offboard: $*" >&2; exit 2; } +lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } +is_login() { [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]]; } -split_words() { - local line="$1" w - local -a words=() - line="${line//,/ }" - read -r -a words <<< "$line" - for w in "${words[@]+"${words[@]}"}"; do - [[ -n "$w" ]] && printf '%s\n' "$w" - done -} - -USERS=() -GOV=() +PERSONS=() while [[ $# -gt 0 ]]; do case "$1" in - --github) [[ $# -ge 2 ]] || die "--github needs a value"; USERS+=("$2"); shift 2 ;; - --gov) [[ $# -ge 2 ]] || die "--gov needs a value"; GOV+=("$2"); shift 2 ;; -h|--help) usage; exit 0 ;; - *) usage >&2; die "unknown argument: $1" ;; + --) shift; PERSONS+=("$@"); break ;; + -*) usage >&2; die "unknown option: $1" ;; + *) PERSONS+=("$1"); shift ;; esac done -if [[ ${#USERS[@]} -eq 0 && ${#GOV[@]} -eq 0 ]]; then - [[ -t 0 ]] || { usage >&2; die "pass --github, or run from a terminal to be asked"; } - read -r -p "GitHub logins: " gh_line || die "no GitHub logins entered" - read -r -p "gov.bc.ca names: " gov_line || true - mapfile -t USERS < <(split_words "$gh_line") - mapfile -t GOV < <(split_words "${gov_line:-}") +if [[ ${#PERSONS[@]} -eq 0 ]]; then + [[ -t 0 ]] || { usage >&2; die "pass at least one person, or run from a terminal to be asked"; } + read -r -p "People: " line || die "no people entered" + line="${line//,/ }" + read -r -a PERSONS <<< "$line" fi -[[ ${#USERS[@]} -gt 0 ]] || die "at least one GitHub login is required" +[[ ${#PERSONS[@]} -gt 0 ]] || die "at least one person is required" -echo "=== GitHub ===" -set +e -"$GH_SCRIPT" -- "${USERS[@]}" -gh_rc=$? -set -e +declare -a P_SPEC=() +declare -a P_NAMES=() +LOGINS=() +NEEDLES=() +declare -A SEEN_LOGIN=() SEEN_NEEDLE=() +for spec in "${PERSONS[@]}"; do + [[ "$spec" == *'=='* || "$spec" == '='* || "$spec" == *'=' ]] && die "empty name in: $spec" + IFS='=' read -r -a parts <<< "$spec" + [[ ${#parts[@]} -gt 0 ]] || die "empty name in: $spec" + names="" + for part in "${parts[@]}"; do + [[ "$part" =~ ^[A-Za-z0-9][A-Za-z0-9._@+-]*$ ]] || die "not a valid name: $part" + names+="${names:+$'\t'}${part}" + if [[ -z "${SEEN_NEEDLE[$part]:-}" ]]; then + SEEN_NEEDLE[$part]=1 + NEEDLES+=("$part") + fi + key="$(lower "$part")" + if is_login "$part" && [[ -z "${SEEN_LOGIN[$key]:-}" ]]; then + SEEN_LOGIN[$key]=1 + LOGINS+=("$part") + fi + done + P_SPEC+=("$spec") + P_NAMES+=("$names") +done + +TMPD="$(mktemp -d)" +trap 'rm -rf "${TMPD}"' EXIT +GH_OUT="${TMPD}/github.json" +OC_OUT="${TMPD}/openshift.json" +echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT" +echo '{"sections":[],"notes":[]}' > "$OC_OUT" + +gh_rc=0 +if [[ ${#LOGINS[@]} -gt 0 ]]; then + set +e + "$GH_SCRIPT" --json -- "${LOGINS[@]}" > "$GH_OUT" + gh_rc=$? + set -e + jq -e . "$GH_OUT" >/dev/null 2>&1 || echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT" +fi -echo -echo "=== OpenShift ===" oc_rc=0 -oc_cmd=("$OC_SCRIPT") -for u in "${USERS[@]}"; do oc_cmd+=(--github "$u"); done -for g in "${GOV[@]+"${GOV[@]}"}"; do oc_cmd+=(--gov "$g"); done +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 ' ' - printf ' %q' "${oc_cmd[@]}" + printf ' %q' "$OC_SCRIPT" + for n in "${NEEDLES[@]}"; do printf ' --name %q' "$n"; done printf '\n' else + ran_oc=true set +e - "${oc_cmd[@]}" + oc_cmd=("$OC_SCRIPT" --json) + for n in "${NEEDLES[@]}"; do oc_cmd+=(--name "$n"); done + "${oc_cmd[@]}" > "$OC_OUT" oc_rc=$? set -e + jq -e . "$OC_OUT" >/dev/null 2>&1 || echo '{"sections":[],"notes":[]}' > "$OC_OUT" +fi + +gh_titles=' + def title: + if . == "org-membership" then "Organization membership" + elif . == "team" then "Teams" + elif . == "repo-collaborator" then "Repository access" + elif . == "codeowners" then "CODEOWNERS (checked repositories)" + elif . == "environment-reviewer" then "Environment required reviewers" + elif . == "codeowners-search" then "CODEOWNERS (code search)" + elif . == "assigned" then "Open issues and pull requests assigned" + elif . == "review-requested" then "Pull requests waiting on their review" + else . end; +' + +i=0 +while [[ $i -lt ${#P_SPEC[@]} ]]; do + echo + echo "== ${P_SPEC[$i]}" + IFS=$'\t' read -r -a parts <<< "${P_NAMES[$i]}" + shown=" " + for part in "${parts[@]}"; do + lpart="$(lower "$part")" + if is_login "$part" && [[ "$shown" != *" ${lpart} "* ]]; then + shown+="${lpart} " + echo " GitHub: ${part}" + if [[ "$gh_rc" -eq 3 ]]; then + echo " GitHub audit failed" + elif jq -e --arg u "$part" 'any(.skipped[]?; ascii_downcase == ($u | ascii_downcase))' "$GH_OUT" >/dev/null; then + echo " GitHub account not found" + 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 + ' "$GH_OUT")" + if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi + fi + fi + if [[ "$ran_oc" == true ]]; then + echo " OpenShift: ${part}" + if [[ "$oc_rc" -eq 3 ]]; then + echo " OpenShift audit failed" + else + block="$(jq -r --arg u "$part" ' + .sections[] | select(.name == $u) | .findings + | if length == 0 then empty else .[] | " - \(.target): \(.detail)" end + ' "$OC_OUT")" + if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi + fi + fi + done + i=$((i + 1)) +done + +if [[ "$gh_rc" -ne 3 ]]; then + skipped="$(jq -r '.skipped[]?' "$GH_OUT")" + if [[ -n "$skipped" ]]; then + echo + echo "Skipped, no GitHub account:" + printf '%s\n' "$skipped" | sed 's/^/ - /' + fi +fi + +if [[ "$ran_oc" == true && "$oc_rc" -ne 3 ]] || [[ "$gh_rc" -ne 3 ]]; then + notes="$(jq -rn --slurpfile g "$GH_OUT" --slurpfile o "$OC_OUT" ' + [$g[0].notes[]?, $o[0].notes[]?] | .[] | select(length > 0) + ')" + if [[ -n "$notes" ]]; then + echo + echo "Notes:" + printf '%s\n' "$notes" | sed 's/^/ - /' + fi fi if [[ "$gh_rc" -eq 3 || "$oc_rc" -eq 3 ]]; then exit 3; fi diff --git a/offboard/tests/offboard-openshift.bats b/offboard/tests/offboard-openshift.bats index d93a45e..e1bff0e 100644 --- a/offboard/tests/offboard-openshift.bats +++ b/offboard/tests/offboard-openshift.bats @@ -14,61 +14,61 @@ setup() { export OC_WHOAMI_RC=0 } -@test "no lists is a usage error" { +@test "no names is a usage error" { run "$SCRIPT" [ "$status" -eq 2 ] - [[ "$output" == *"--github or --gov"* ]] + [[ "$output" == *"pass --name"* ]] } @test "oc not logged in is an error" { - OC_WHOAMI_RC=1 run "$SCRIPT" --github example-user + OC_WHOAMI_RC=1 run "$SCRIPT" --name example-user [ "$status" -eq 2 ] [[ "$output" == *"not logged in"* ]] } -@test "github and gov lists stay on their own domains" { +@test "a name matches every subject that contains it" { printf 'ns-a\nns-b\nns-c\n' > "$FIXTURES/oc-projects" cat > "$FIXTURES/rb-ns-a" <<'JSON' {"items":[ {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"}]}, {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"First.Last@gov.bc.ca"}]}, {"metadata":{"name":"rb4"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"example-user@gov.bc.ca"}]}, - {"metadata":{"name":"rb5"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"first.last@github"}]}, {"metadata":{"name":"rb6"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"someone-else"}]} ]} JSON cat > "$FIXTURES/rb-ns-b" <<'JSON' {"items":[{"metadata":{"name":"rb3"},"roleRef":{"name":"view"},"subjects":[{"kind":"Group","name":"example-user"}]}]} JSON - run --separate-stderr "$SCRIPT" --json --github example-user --gov first.last + run --separate-stderr "$SCRIPT" --json --name example-user --name first.last [ "$status" -eq 1 ] - [ "$(echo "$output" | jq '[.sections[] | select(.name == "example-user") | .findings[]] | length')" = 1 ] + [ "$(echo "$output" | jq '[.sections[] | select(.name == "example-user") | .findings[]] | length')" = 2 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "first.last") | .findings[]] | length')" = 1 ] - echo "$output" | jq -e '.sections[] | select(.name == "example-user") | .findings[] | select(.detail | test("subject example-user@github"))' - [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("example-user@gov.bc.ca|first.last@github|someone-else"))] | length')" = 0 ] + [ "$(echo "$output" | jq '[.sections[].findings[] | select(.detail | test("someone-else"))] | length')" = 0 ] echo "$output" | jq -e '.notes[] | select(test("not readable in 1 namespace"))' run grep -c 'oc get rolebindings' "$STUB_LOG" [ "$output" = 3 ] } +@test "a different spelling does not match" { + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"view"},"subjects":[{"kind":"User","name":"greg.pascucci@gov.bc.ca"}]}]}' > "$FIXTURES/rb-ns-a" + run --separate-stderr "$SCRIPT" --json --name greg.pascucchi + [ "$status" -eq 0 ] + [ "$(echo "$output" | jq '[.sections[].findings[]] | length')" = 0 ] +} + @test "subject matching is case insensitive" { printf 'ns-a\n' > "$FIXTURES/oc-projects" - cat > "$FIXTURES/rb-ns-a" <<'JSON' -{"items":[ - {"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@GITHUB"}]}, - {"metadata":{"name":"rb2"},"roleRef":{"name":"edit"},"subjects":[{"kind":"User","name":"first.last@gov.bc.ca"}]} -]} -JSON - run --separate-stderr "$SCRIPT" --json --github Example-User --gov First.Last + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@GITHUB"}]}]}' > "$FIXTURES/rb-ns-a" + run --separate-stderr "$SCRIPT" --json --name Example-User [ "$status" -eq 1 ] [ "$(echo "$output" | jq '[.sections[] | select(.name == "Example-User") | .findings[]] | length')" = 1 ] - [ "$(echo "$output" | jq '[.sections[] | select(.name == "First.Last") | .findings[]] | length')" = 1 ] } @test "only read-only calls are made" { printf 'ns-a\n' > "$FIXTURES/oc-projects" echo '{"items":[]}' > "$FIXTURES/rb-ns-a" - run "$SCRIPT" --github example-user + run "$SCRIPT" --name example-user [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input' "$STUB_LOG")" ] [ -z "$(grep -E '^oc ' "$STUB_LOG" | grep -vE '^oc (whoami|projects -q|get rolebindings -n [a-z0-9-]+ -o json)$')" ] [ -z "$(grep '^gh ' "$STUB_LOG" || true)" ] diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index 96f46f5..b737891 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -32,19 +32,32 @@ seed_github() { @test "no arguments off a terminal is a usage error" { run "$SCRIPT" [ "$status" -eq 2 ] - [[ "$output" == *"--github"* ]] + [[ "$output" == *"at least one person"* ]] } -@test "both reports run and a missing oc login still prints GitHub" { +@test "one person groups GitHub and OpenShift" { seed_github - OC_WHOAMI_RC=1 run "$SCRIPT" --github example-user --gov first.last + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[{"metadata":{"name":"rb1"},"roleRef":{"name":"admin"},"subjects":[{"kind":"User","name":"example-user@github"},{"kind":"User","name":"first.last@gov.bc.ca"}]}]}' > "$FIXTURES/rb-ns-a" + run "$SCRIPT" 'example-user=first.last' [ "$status" -eq 1 ] - [[ "$output" == *"=== GitHub ==="* ]] - [[ "$output" == *"=== OpenShift ==="* ]] - [[ "$output" == *"OpenShift skipped: oc is not logged in"* ]] - [[ "$output" == *"--github example-user"* ]] - [[ "$output" == *"--gov first.last"* ]] + [[ "$output" == *"== example-user=first.last"* ]] + [[ "$output" == *"GitHub: example-user"* ]] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"OpenShift: first.last"* ]] + [[ "$output" == *"first.last@gov.bc.ca"* ]] + [[ "$output" != *"== first.last"* ]] +} + +@test "a missing oc login still prints GitHub" { + seed_github + OC_WHOAMI_RC=1 run "$SCRIPT" 'example-user=first.last' + [ "$status" -eq 1 ] + [[ "$output" == *"GitHub: example-user"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"OpenShift skipped: oc is not logged in"* ]] + [[ "$output" == *"--name example-user"* ]] + [[ "$output" == *"--name first.last"* ]] [ -z "$(grep 'oc get rolebindings' "$STUB_LOG" || true)" ] } @@ -52,9 +65,9 @@ seed_github() { seed_github 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" - GH_FAIL_MATCH=environments run "$SCRIPT" --github example-user + GH_FAIL_MATCH=environments run "$SCRIPT" example-user [ "$status" -eq 3 ] - [[ "$output" == *"=== OpenShift ==="* ]] + [[ "$output" == *"GitHub audit failed"* ]] [[ "$output" == *"example-user@github"* ]] grep -q 'oc get rolebindings' "$STUB_LOG" } From ebacf718bc0d550a527c947e73062ca09b21a947 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 16:47:56 -0700 Subject: [PATCH 06/10] fix(offboard): query code search one login at a time A grouped OR is rejected by the code search API, and that failure was discarding the repository scan already finished. --- offboard/README.md | 2 +- offboard/offboard-github.sh | 58 +++++++++++++++++------------ offboard/offboard.sh | 40 +++++++++++--------- offboard/tests/offboard-github.bats | 11 +++++- offboard/tests/offboard.bats | 10 +++++ 5 files changed, 77 insertions(+), 44 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index a7f9ffb..2cfa1b6 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -46,7 +46,7 @@ A login GitHub does not have is printed under that name and again under `Skipped Login, organization, team, CODEOWNERS, and search comparisons are case-insensitive. Search queries are sent in lowercase. -The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search and assignee search run in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Review requests stay one query per live login, because a batched result does not say who was requested. +The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search is one query per live login: a grouped `OR` is rejected by that API. Assignee search runs in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Review requests stay one query per live login, because a batched result does not say who was requested. If a search call fails after those checks, the report still includes them and the run exits `3`. | Check | Source | | --- | --- | diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 4f0d49c..e4687ba 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -123,7 +123,8 @@ search_call() { return 1 } -# GitHub allows five OR operators, so a search covers at most six logins. +# Issue search allows five OR operators, so an assignee query covers at most six logins. +# Code search rejects a parenthesized OR and returns no hits for a bare OR, so it is one query per login. search_expr() { local out="" w for w in "$@"; do out+="${out:+ OR }${w}"; done @@ -132,6 +133,7 @@ search_expr() { LIVE=() SKIPPED=() +SEARCH_OK=true declare -A SKIPPED_SET=() for u in "${USERS[@]}"; do if call "users/${u}"; then @@ -276,30 +278,34 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done done + search_stop() { + echo "offboard-github: search failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")" >&2 + note "" "search failed (HTTP ${API_STATUS})" + SEARCH_OK=false + } + + for u in "${LIVE[@]}"; do + [[ "$SEARCH_OK" == true ]] || break + lu="$(lower "$u")" + search_call code_search --paginate -X GET search/code -f q="${lu} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ + -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" + 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 + i=0 - while [[ $i -lt ${#LIVE[@]} ]]; do + while [[ "$SEARCH_OK" == true && $i -lt ${#LIVE[@]} ]]; do chunk=("${LIVE[@]:i:6}") i=$((i + 6)) - terms=() - for u in "${chunk[@]}"; do terms+=("$(lower "$u")"); done - expr="$(search_expr "${terms[@]}")" - search_call code_search --paginate -X GET search/code -f q="${expr} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ - -H 'Accept: application/vnd.github.text-match+json' || api_error "search/code" - for u in "${chunk[@]}"; do - lu="$(lower "$u")" - while IFS=$'\t' read -r repo path; do - [[ -n "$repo" ]] || continue - 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 - prefixed=() for u in "${chunk[@]}"; do prefixed+=("assignee:$(lower "$u")"); done expr="$(search_expr "${prefixed[@]}")" search_call search --paginate -X GET search/issues -f q="is:open ${expr}${orgs_q}" -f per_page=100 \ --jq '.items[]? | .html_url as $u | (if .pull_request then "pull request" else "issue" end) as $k | .title as $t | (.assignees // [])[]? | [$u, $k, $t, .login] | @tsv' \ - || api_error "search/issues" + || { search_stop; break; } while IFS=$'\t' read -r url kind title login; do [[ -n "$url" && -n "$login" ]] || continue llogin="$(lower "$login")" @@ -311,13 +317,16 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done <<< "$API_OUT" done - for u in "${LIVE[@]}"; do - search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:$(lower "$u")${orgs_q}" -f per_page=100 \ - --jq '.items[] | [.html_url, .title] | @tsv' || api_error "search/issues" - while IFS=$'\t' read -r url title; do - if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi - done <<< "$API_OUT" - done + if [[ "$SEARCH_OK" == true ]]; then + for u in "${LIVE[@]}"; do + [[ "$SEARCH_OK" == true ]] || break + search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:$(lower "$u")${orgs_q}" -f per_page=100 \ + --jq '.items[] | [.html_url, .title] | @tsv' || { search_stop; break; } + while IFS=$'\t' read -r url title; do + if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi + done <<< "$API_OUT" + done + fi fi count="$(wc -l < "$FINDINGS" | tr -d ' ')" @@ -368,5 +377,6 @@ else fi fi +if [[ "$SEARCH_OK" != true ]]; then exit 3; fi [[ "$count" -eq 0 ]] || exit 1 exit 0 diff --git a/offboard/offboard.sh b/offboard/offboard.sh index 15d8e2d..5ed2e8f 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -132,7 +132,9 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do if is_login "$part" && [[ "$shown" != *" ${lpart} "* ]]; then shown+="${lpart} " echo " GitHub: ${part}" - if [[ "$gh_rc" -eq 3 ]]; then + has_user=false + jq -e --arg u "$part" 'any(.users[]?; (.user | ascii_downcase) == ($u | ascii_downcase))' "$GH_OUT" >/dev/null && has_user=true + if [[ "$gh_rc" -eq 3 && "$has_user" == false ]]; then echo " GitHub audit failed" elif jq -e --arg u "$part" 'any(.skipped[]?; ascii_downcase == ($u | ascii_downcase))' "$GH_OUT" >/dev/null; then echo " GitHub account not found" @@ -141,7 +143,13 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do .users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings | if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)") end ' "$GH_OUT")" - if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi + if [[ -n "$block" ]]; then + echo "$block" + elif [[ "$gh_rc" -eq 3 ]]; then + echo " GitHub audit did not finish" + else + echo " nothing found" + fi fi fi if [[ "$ran_oc" == true ]]; then @@ -160,24 +168,20 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do i=$((i + 1)) done -if [[ "$gh_rc" -ne 3 ]]; then - skipped="$(jq -r '.skipped[]?' "$GH_OUT")" - if [[ -n "$skipped" ]]; then - echo - echo "Skipped, no GitHub account:" - printf '%s\n' "$skipped" | sed 's/^/ - /' - fi +skipped="$(jq -r '.skipped[]?' "$GH_OUT")" +if [[ -n "$skipped" ]]; then + echo + echo "Skipped, no GitHub account:" + printf '%s\n' "$skipped" | sed 's/^/ - /' fi -if [[ "$ran_oc" == true && "$oc_rc" -ne 3 ]] || [[ "$gh_rc" -ne 3 ]]; then - notes="$(jq -rn --slurpfile g "$GH_OUT" --slurpfile o "$OC_OUT" ' - [$g[0].notes[]?, $o[0].notes[]?] | .[] | select(length > 0) - ')" - if [[ -n "$notes" ]]; then - echo - echo "Notes:" - printf '%s\n' "$notes" | sed 's/^/ - /' - fi +notes="$(jq -rn --slurpfile g "$GH_OUT" --slurpfile o "$OC_OUT" ' + [$g[0].notes[]?, $o[0].notes[]?] | .[] | select(length > 0) +')" +if [[ -n "$notes" ]]; then + echo + echo "Notes:" + printf '%s\n' "$notes" | sed 's/^/ - /' fi if [[ "$gh_rc" -eq 3 || "$oc_rc" -eq 3 ]]; then exit 3; fi diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 960913d..ffcc541 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -146,7 +146,8 @@ JSON run grep -c 'userLogins:' "$STUB_LOG" [ "$output" = 1 ] run grep -c 'search/code' "$STUB_LOG" - [ "$output" = 1 ] + [ "$output" = 2 ] + [ -z "$(grep 'search/code' "$STUB_LOG" | grep '(' || true)" ] run grep -c 'assignee:' "$STUB_LOG" [ "$output" = 1 ] run grep -c 'user-review-requested:' "$STUB_LOG" @@ -173,3 +174,11 @@ JSON [ "$status" -eq 3 ] [[ "$output" == *"HTTP 500"* ]] } + +@test "a search failure still prints the checks already done" { + seed_findings + GH_FAIL_MATCH='search/code' run "$SCRIPT" example-user + [ "$status" -eq 3 ] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"search failed (HTTP 500)"* ]] +} diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index b737891..1c23bcb 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -61,6 +61,16 @@ seed_github() { [ -z "$(grep 'oc get rolebindings' "$STUB_LOG" || true)" ] } +@test "a late GitHub search failure still prints earlier GitHub findings" { + seed_github + printf 'ns-a\n' > "$FIXTURES/oc-projects" + echo '{"items":[]}' > "$FIXTURES/rb-ns-a" + GH_FAIL_MATCH='search/code' run "$SCRIPT" example-user + [ "$status" -eq 3 ] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"search failed (HTTP 500)"* ]] +} + @test "a GitHub API failure still runs OpenShift" { seed_github printf 'ns-a\n' > "$FIXTURES/oc-projects" From a66bf8ebdc24ddbedb9010bfd09a6afc90731a70 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 18:05:02 -0700 Subject: [PATCH 07/10] fix(offboard): drop review-requested PRs from the report Those are leftover workflow, not leftover access, so they are out of the cleanup audit. --- offboard/README.md | 3 +-- offboard/offboard-github.sh | 14 +------------- offboard/offboard.sh | 1 - offboard/tests/offboard-github.bats | 11 +++++------ offboard/tests/offboard.bats | 1 - offboard/tests/stubs/gh | 1 - 6 files changed, 7 insertions(+), 24 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 2cfa1b6..2fbedaa 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -46,7 +46,7 @@ A login GitHub does not have is printed under that name and again under `Skipped Login, organization, team, CODEOWNERS, and search comparisons are case-insensitive. Search queries are sent in lowercase. -The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search is one query per live login: a grouped `OR` is rejected by that API. Assignee search runs in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Review requests stay one query per live login, because a batched result does not say who was requested. If a search call fails after those checks, the report still includes them and the run exits `3`. +The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search is one query per live login: a grouped `OR` is rejected by that API. Assignee search runs in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Open pull requests waiting on a review are not reported. If a search call fails after those checks, the report still includes them and the run exits `3`. | Check | Source | | --- | --- | @@ -57,7 +57,6 @@ The repository list, collaborator lists, CODEOWNERS files, and environment revie | Environment required reviewers | Deployment environments in the target repositories that list the user, or one of the user's teams, as a required reviewer | | CODEOWNERS (code search) | Code search for the logins in CODEOWNERS files across the organizations | | Assigned | Open issues and pull requests assigned to the user | -| Review requested | Open pull requests waiting on the user's review | 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 e4687ba..78f7c71 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -316,17 +316,6 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then done done <<< "$API_OUT" done - - if [[ "$SEARCH_OK" == true ]]; then - for u in "${LIVE[@]}"; do - [[ "$SEARCH_OK" == true ]] || break - search_call search --paginate -X GET search/issues -f q="is:open is:pr user-review-requested:$(lower "$u")${orgs_q}" -f per_page=100 \ - --jq '.items[] | [.html_url, .title] | @tsv' || { search_stop; break; } - while IFS=$'\t' read -r url title; do - if [[ -n "$url" ]]; then finding "$u" review-requested "$url" "$title"; fi - done <<< "$API_OUT" - done - fi fi count="$(wc -l < "$FINDINGS" | tr -d ' ')" @@ -345,7 +334,6 @@ else [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" [codeowners]="CODEOWNERS (checked repositories)" [environment-reviewer]="Environment required reviewers" [codeowners-search]="CODEOWNERS (code search)" [assigned]="Open issues and pull requests assigned" - [review-requested]="Pull requests waiting on their review" ) echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" for u in "${USERS[@]}"; do @@ -358,7 +346,7 @@ else [[ -n "${SKIPPED_SET[$u]:-}" ]] || echo " nothing found" continue fi - for c in org-membership team repo-collaborator codeowners environment-reviewer codeowners-search assigned review-requested; do + for c in org-membership team repo-collaborator codeowners environment-reviewer codeowners-search assigned; do lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" diff --git a/offboard/offboard.sh b/offboard/offboard.sh index 5ed2e8f..d314f28 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -117,7 +117,6 @@ gh_titles=' elif . == "environment-reviewer" then "Environment required reviewers" elif . == "codeowners-search" then "CODEOWNERS (code search)" elif . == "assigned" then "Open issues and pull requests assigned" - elif . == "review-requested" then "Pull requests waiting on their review" else . end; ' diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index ffcc541..59f5171 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -31,7 +31,6 @@ seed_findings() { ]} JSON printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\texample-user\nhttps://github.com/example-org/repo-one/pull/2\tpull request\tA change\texample-user\n' > "$FIXTURES/search-assigned" - printf 'https://github.com/example-org/repo-one/pull/3\tNeeds review\n' > "$FIXTURES/search-review" } @test "no arguments is a usage error" { @@ -89,7 +88,7 @@ JSON run --separate-stderr "$SCRIPT" --json example-user [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"review-requested":1,"team":1}' ] + [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"team":1}' ] [ "$(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("through team team-a")))' @@ -103,7 +102,7 @@ JSON [[ "$output" == *"== example-user"* ]] [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] - [[ "$output" == *"Pull requests waiting on their review"* ]] + [[ "$output" != *"Pull requests waiting on their review"* ]] } @test "--repo and --repo-file replace the default repo set" { @@ -151,7 +150,7 @@ JSON run grep -c 'assignee:' "$STUB_LOG" [ "$output" = 1 ] run grep -c 'user-review-requested:' "$STUB_LOG" - [ "$output" = 2 ] + [ "$output" = 0 ] run grep -c 'userLogins:\["missing-user"\]' "$STUB_LOG" [ "$output" = 0 ] } @@ -161,10 +160,10 @@ JSON run --separate-stderr "$SCRIPT" --json Example-User [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"review-requested":1,"team":1}' ] + [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"team":1}' ] grep -q 'userLogins:\["example-user"\]' "$STUB_LOG" grep -q 'assignee:example-user' "$STUB_LOG" - grep -q 'user-review-requested:example-user' "$STUB_LOG" + [ -z "$(grep 'user-review-requested:' "$STUB_LOG" || true)" ] grep -q 'example-user filename:CODEOWNERS' "$STUB_LOG" } diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index 1c23bcb..e142c32 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -26,7 +26,6 @@ seed_github() { printf '* @example-user\n' > "$FIXTURES/codeowners-repo-one" echo '{"items":[]}' > "$FIXTURES/search-code" printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\texample-user\n' > "$FIXTURES/search-assigned" - printf 'https://github.com/example-org/repo-one/pull/3\tNeeds review\n' > "$FIXTURES/search-review" } @test "no arguments off a terminal is a usage error" { diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index 2b6a377..11d529e 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -58,7 +58,6 @@ case "$args" in *collaborators\?affiliation=direct*) repo="${args#*repos/*/}"; emit "collab-direct-${repo%%/*}" ;; *environments*) repo="${args#*repos/*/}"; emit "env-${repo%%/*}" ;; *search/code*) [[ -f "${FIXTURES}/search-code" ]] && emit search-code; echo '{"items":[]}' ;; - *search/issues*review-requested*) emit search-review ;; *search/issues*) emit search-assigned ;; rate_limit*) date +%s ;; *) echo "stub gh: unexpected api call: $args" >&2; exit 98 ;; From 2c938e4b8cf3d9d9c95d8cf074144d4497475084 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 18:47:54 -0700 Subject: [PATCH 08/10] fix(offboard): report only leftover access Drop assigned issues, CODEOWNERS code search, and environment reviewers. Keep org, teams, collaborators, CODEOWNERS files, and OpenShift. --- offboard/README.md | 13 ++-- offboard/offboard-github.sh | 98 +---------------------------- offboard/offboard.sh | 5 +- offboard/tests/offboard-github.bats | 46 ++++---------- offboard/tests/offboard.bats | 15 +---- offboard/tests/stubs/gh | 4 -- 6 files changed, 20 insertions(+), 161 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 2fbedaa..c9608e4 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -44,27 +44,24 @@ GitHub access and ownership, using your own `gh` login. 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. -Login, organization, team, CODEOWNERS, and search comparisons are case-insensitive. Search queries are sent in lowercase. +Login, organization, team, and CODEOWNERS comparisons are case-insensitive. -The repository list, collaborator lists, CODEOWNERS files, and environment reviewers are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. Code search is one query per live login: a grouped `OR` is rejected by that API. Assignee search runs in batches of six logins, which is as many as GitHub's five-`OR` limit allows. Open pull requests waiting on a review are not reported. If a search call fails after those checks, the report still includes them and the run exits `3`. +The repository list, collaborator lists, and CODEOWNERS files are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. | Check | Source | | --- | --- | | 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 (checked repositories) | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored | -| Environment required reviewers | Deployment environments in the target repositories that list the user, or one of the user's teams, as a required reviewer | -| CODEOWNERS (code search) | Code search for the logins in CODEOWNERS files across the organizations | -| Assigned | Open issues and pull requests assigned to the user | +| CODEOWNERS | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored | 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`). Requires `gh` (scopes `repo` and `read:org`) and `jq`. -The per-repo checks make 3 to 4 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes about 7 minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. +The per-repo checks make 2 to 3 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes several minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. -Limits: only what your login can see; repository access needs push access; code search hits default branches and only when `@user` is in the returned text fragment (10 requests a minute); issue search returns at most 1,000 results per query; a login on more than 100 teams is noted and the rest of that login's teams are not listed. +Limits: only what your login can see; repository access needs push access; a login on more than 100 teams is noted and the rest of that login's teams are not listed. ## `offboard-openshift.sh` diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 78f7c71..c8adee3 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -105,35 +105,8 @@ call() { } api_error() { fail "gh api $1 failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")"; } -search_call() { - local kind="$1" attempt reset now - shift - for attempt in 1 2 3 4 5; do - call "$@" && return 0 - if [[ "$API_STATUS" =~ ^(403|429)$ ]] && grep -qi 'rate limit' "$ERRF"; then - call rate_limit --jq ".resources.${kind}.reset" || api_error rate_limit - reset="$API_OUT" - now="$(date +%s)" - progress "search rate limit reached; waiting $(( reset > now ? reset - now + 1 : 5 ))s (attempt ${attempt})" - sleep "$(( reset > now ? reset - now + 1 : 5 ))" - continue - fi - return 1 - done - return 1 -} - -# Issue search allows five OR operators, so an assignee query covers at most six logins. -# Code search rejects a parenthesized OR and returns no hits for a bare OR, so it is one query per login. -search_expr() { - local out="" w - for w in "$@"; do out+="${out:+ OR }${w}"; done - if [[ $# -gt 1 ]]; then printf '(%s)' "$out"; else printf '%s' "$out"; fi -} - LIVE=() SKIPPED=() -SEARCH_OK=true declare -A SKIPPED_SET=() for u in "${USERS[@]}"; do if call "users/${u}"; then @@ -147,8 +120,6 @@ for u in "${USERS[@]}"; do fi done -declare -A USER_TEAMS=() - if [[ ${#LIVE[@]} -gt 0 ]]; then if [[ ${#REPOS[@]} -eq 0 ]]; then progress "listing repositories where you have admin in: ${ORGS[*]}" @@ -191,20 +162,8 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi call graphql -f query="$CO_QUERY" -f o="${r%%/*}" -f n="${r#*/}" || api_error "graphql CODEOWNERS ${r}" printf '%s' "$API_OUT" | jq -r "$CO_JQ" > "$d/codeowners" - if call --paginate "repos/${r}/environments?per_page=100" \ - --jq '.environments[]? | .name as $e | .protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | [$e, .type, (.reviewer.login // .reviewer.slug)] | @tsv'; then - printf '%s\n' "$API_OUT" > "$d/environments" - elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then - : > "$d/environments" - note "" "${r}: environments not checked (repository not found or not readable)" - else - api_error "repos/${r}/environments" - fi done - orgs_q="" - for o in "${ORGS[@]}"; do orgs_q+=" org:$(lower "$o")"; done - for o in "${ORGS[@]}"; do if call --paginate "orgs/$(lower "$o")/members?per_page=100" --jq '.[].login'; then printf '%s\n' "$API_OUT" | tr '[:upper:]' '[:lower:]' > "$TMPD/members" @@ -239,10 +198,7 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then [[ -n "$idx" && -n "$slug" ]] || continue u="${LIVE[$idx]}" slug="$(lower "$slug")" - lo="$(lower "$o")" - lu="$(lower "$u")" finding "$u" team "${o}/${slug}" "member" - USER_TEAMS["${lu}|${lo}"]="${USER_TEAMS["${lu}|${lo}"]:-} ${slug}" done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" ' (.data.organization // {}) | to_entries[] | (.key | ltrimstr("u")) as $i @@ -253,7 +209,6 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then lu="$(lower "$u")" for r in "${REPOS[@]}"; do d="${TMPD}/repos/${r//\//__}" - owner="$(lower "${r%%/*}")" if [[ -f "$d/all" ]]; then role="$(awk -F'\t' -v u="$lu" 'tolower($1) == u { print $2; exit }' "$d/all")" if [[ -n "$role" ]]; then @@ -267,55 +222,8 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then while IFS=$'\t' read -r path lineno text; do finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" 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 - lw="$(lower "$who")" - if [[ "$type" == "User" && "$lw" == "$lu" ]]; then - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" - elif [[ "$type" == "Team" && " ${USER_TEAMS["${lu}|${owner}"]:-} " == *" ${lw} "* ]]; then - finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer through team ${lw}" - fi - done < "$d/environments" done done - - search_stop() { - echo "offboard-github: search failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")" >&2 - note "" "search failed (HTTP ${API_STATUS})" - SEARCH_OK=false - } - - for u in "${LIVE[@]}"; do - [[ "$SEARCH_OK" == true ]] || break - lu="$(lower "$u")" - search_call code_search --paginate -X GET search/code -f q="${lu} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ - -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" - 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 - - i=0 - while [[ "$SEARCH_OK" == true && $i -lt ${#LIVE[@]} ]]; do - chunk=("${LIVE[@]:i:6}") - i=$((i + 6)) - prefixed=() - for u in "${chunk[@]}"; do prefixed+=("assignee:$(lower "$u")"); done - expr="$(search_expr "${prefixed[@]}")" - search_call search --paginate -X GET search/issues -f q="is:open ${expr}${orgs_q}" -f per_page=100 \ - --jq '.items[]? | .html_url as $u | (if .pull_request then "pull request" else "issue" end) as $k | .title as $t | (.assignees // [])[]? | [$u, $k, $t, .login] | @tsv' \ - || { search_stop; break; } - while IFS=$'\t' read -r url kind title login; do - [[ -n "$url" && -n "$login" ]] || continue - llogin="$(lower "$login")" - for u in "${chunk[@]}"; do - if [[ "$(lower "$u")" == "$llogin" ]]; then - finding "$u" assigned "$url" "${kind}: ${title}" - fi - done - done <<< "$API_OUT" - done fi count="$(wc -l < "$FINDINGS" | tr -d ' ')" @@ -332,8 +240,7 @@ if [[ "$JSON" == "true" ]]; then else declare -A TITLE=( [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" - [codeowners]="CODEOWNERS (checked repositories)" [environment-reviewer]="Environment required reviewers" - [codeowners-search]="CODEOWNERS (code search)" [assigned]="Open issues and pull requests assigned" + [codeowners]="CODEOWNERS" ) echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" for u in "${USERS[@]}"; do @@ -346,7 +253,7 @@ else [[ -n "${SKIPPED_SET[$u]:-}" ]] || echo " nothing found" continue fi - for c in org-membership team repo-collaborator codeowners environment-reviewer codeowners-search assigned; do + for c in org-membership team repo-collaborator codeowners; do lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" @@ -365,6 +272,5 @@ else fi fi -if [[ "$SEARCH_OK" != true ]]; then exit 3; fi [[ "$count" -eq 0 ]] || exit 1 exit 0 diff --git a/offboard/offboard.sh b/offboard/offboard.sh index d314f28..c4b737b 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -113,10 +113,7 @@ gh_titles=' if . == "org-membership" then "Organization membership" elif . == "team" then "Teams" elif . == "repo-collaborator" then "Repository access" - elif . == "codeowners" then "CODEOWNERS (checked repositories)" - elif . == "environment-reviewer" then "Environment required reviewers" - elif . == "codeowners-search" then "CODEOWNERS (code search)" - elif . == "assigned" then "Open issues and pull requests assigned" + elif . == "codeowners" then "CODEOWNERS" else . end; ' diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index 59f5171..d1c787a 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -22,15 +22,7 @@ seed_findings() { printf 'example-org/repo-one\nother-org/repo-two\n' > "$FIXTURES/user-repos" printf 'example-user\twrite\nexample-admin\tadmin\n' > "$FIXTURES/collab-all-repo-one" printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" - printf 'prod\tUser\texample-user\ntest\tTeam\tteam-a\nuat\tTeam\tteam-b\n' > "$FIXTURES/env-repo-one" printf '# @example-user in a comment\n* @example-admin @example-user\n/docs/ @example-user-two\n' > "$FIXTURES/codeowners-repo-one" - cat > "$FIXTURES/search-code" <<'JSON' -{"items":[ - {"repository":{"full_name":"example-org/repo-three"},"path":".github/CODEOWNERS","text_matches":[{"fragment":"* @example-user"}]}, - {"repository":{"full_name":"example-org/repo-four"},"path":"CODEOWNERS","text_matches":[{"fragment":"* @example-user-two"}]} -]} -JSON - printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\texample-user\nhttps://github.com/example-org/repo-one/pull/2\tpull request\tA change\texample-user\n' > "$FIXTURES/search-assigned" } @test "no arguments is a usage error" { @@ -88,11 +80,10 @@ JSON run --separate-stderr "$SCRIPT" --json example-user [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"team":1}' ] + [ "$counts" = '{"codeowners":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] [ "$(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("through team team-a")))' - [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-b|repo-four|example-user-two"))] | length')" = 0 ] + [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("example-user-two"))] | length')" = 0 ] } @test "text output groups findings by user and check" { @@ -102,7 +93,9 @@ JSON [[ "$output" == *"== example-user"* ]] [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] - [[ "$output" != *"Pull requests waiting on their review"* ]] + [[ "$output" != *"Environment required reviewers"* ]] + [[ "$output" != *"CODEOWNERS (code search)"* ]] + [[ "$output" != *"Open issues and pull requests assigned"* ]] } @test "--repo and --repo-file replace the default repo set" { @@ -119,8 +112,8 @@ JSON seed_findings run --separate-stderr "$SCRIPT" --json --org other-org example-user [ "$(echo "$output" | jq -c '.orgs')" = '["other-org"]' ] - grep -q 'org:other-org' "$STUB_LOG" - run grep -c 'org:example-org' "$STUB_LOG" + grep -q 'orgs/other-org/members' "$STUB_LOG" + run grep -c 'orgs/example-org/members' "$STUB_LOG" [ "$output" = 0 ] } @@ -128,7 +121,7 @@ JSON seed_findings run "$SCRIPT" --json example-user [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input|-F ' "$STUB_LOG")" ] - [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|search/|-X GET')" ] + [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|-X GET')" ] [ -z "$(grep '^oc ' "$STUB_LOG" || true)" ] } @@ -144,13 +137,7 @@ JSON [ "$output" = 1 ] run grep -c 'userLogins:' "$STUB_LOG" [ "$output" = 1 ] - run grep -c 'search/code' "$STUB_LOG" - [ "$output" = 2 ] - [ -z "$(grep 'search/code' "$STUB_LOG" | grep '(' || true)" ] - run grep -c 'assignee:' "$STUB_LOG" - [ "$output" = 1 ] - run grep -c 'user-review-requested:' "$STUB_LOG" - [ "$output" = 0 ] + [ -z "$(grep 'search/' "$STUB_LOG" || true)" ] run grep -c 'userLogins:\["missing-user"\]' "$STUB_LOG" [ "$output" = 0 ] } @@ -160,24 +147,13 @@ JSON run --separate-stderr "$SCRIPT" --json Example-User [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"assigned":2,"codeowners":1,"codeowners-search":1,"environment-reviewer":2,"org-membership":1,"repo-collaborator":1,"team":1}' ] + [ "$counts" = '{"codeowners":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] grep -q 'userLogins:\["example-user"\]' "$STUB_LOG" - grep -q 'assignee:example-user' "$STUB_LOG" - [ -z "$(grep 'user-review-requested:' "$STUB_LOG" || true)" ] - grep -q 'example-user filename:CODEOWNERS' "$STUB_LOG" } @test "an API failure exits 3" { seed_findings - GH_FAIL_MATCH='environments' run "$SCRIPT" example-user + GH_FAIL_MATCH='CODEOWNERS' run "$SCRIPT" example-user [ "$status" -eq 3 ] [[ "$output" == *"HTTP 500"* ]] } - -@test "a search failure still prints the checks already done" { - seed_findings - GH_FAIL_MATCH='search/code' run "$SCRIPT" example-user - [ "$status" -eq 3 ] - [[ "$output" == *"example-org/repo-one: write (direct)"* ]] - [[ "$output" == *"search failed (HTTP 500)"* ]] -} diff --git a/offboard/tests/offboard.bats b/offboard/tests/offboard.bats index e142c32..8490a45 100644 --- a/offboard/tests/offboard.bats +++ b/offboard/tests/offboard.bats @@ -22,10 +22,7 @@ seed_github() { printf 'example-org/repo-one\n' > "$FIXTURES/user-repos" printf 'example-user\twrite\n' > "$FIXTURES/collab-all-repo-one" printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" - printf 'prod\tUser\texample-user\n' > "$FIXTURES/env-repo-one" printf '* @example-user\n' > "$FIXTURES/codeowners-repo-one" - echo '{"items":[]}' > "$FIXTURES/search-code" - printf 'https://github.com/example-org/repo-one/issues/1\tissue\tAn issue\texample-user\n' > "$FIXTURES/search-assigned" } @test "no arguments off a terminal is a usage error" { @@ -60,21 +57,11 @@ seed_github() { [ -z "$(grep 'oc get rolebindings' "$STUB_LOG" || true)" ] } -@test "a late GitHub search failure still prints earlier GitHub findings" { - seed_github - printf 'ns-a\n' > "$FIXTURES/oc-projects" - echo '{"items":[]}' > "$FIXTURES/rb-ns-a" - GH_FAIL_MATCH='search/code' run "$SCRIPT" example-user - [ "$status" -eq 3 ] - [[ "$output" == *"example-org/repo-one: write (direct)"* ]] - [[ "$output" == *"search failed (HTTP 500)"* ]] -} - @test "a GitHub API failure still runs OpenShift" { seed_github 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" - GH_FAIL_MATCH=environments run "$SCRIPT" example-user + GH_FAIL_MATCH=CODEOWNERS run "$SCRIPT" example-user [ "$status" -eq 3 ] [[ "$output" == *"GitHub audit failed"* ]] [[ "$output" == *"example-user@github"* ]] diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index 11d529e..40ce93c 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -56,9 +56,5 @@ 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%%/*}" ;; - *search/code*) [[ -f "${FIXTURES}/search-code" ]] && emit search-code; echo '{"items":[]}' ;; - *search/issues*) emit search-assigned ;; - rate_limit*) date +%s ;; *) echo "stub gh: unexpected api call: $args" >&2; exit 98 ;; esac From 645b7af406df20bd32eeae94fa45b39e426cea1d Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 18:50:55 -0700 Subject: [PATCH 09/10] fix(offboard): restore CODEOWNERS code search That search finds CODEOWNERS to edit outside the admin repo set. Assigned issues and environment reviewers stay out. --- offboard/README.md | 5 ++-- offboard/offboard-github.sh | 43 +++++++++++++++++++++++++++-- offboard/offboard.sh | 1 + offboard/tests/offboard-github.bats | 29 +++++++++++++++---- offboard/tests/stubs/gh | 2 ++ 5 files changed, 70 insertions(+), 10 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index c9608e4..0ed360a 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -46,7 +46,7 @@ A login GitHub does not have is printed under that name and again under `Skipped Login, organization, team, and CODEOWNERS comparisons are case-insensitive. -The repository list, collaborator lists, and CODEOWNERS files are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. +The repository list, collaborator lists, and CODEOWNERS files are fetched once and matched against every login. Organization members are one list per organization. Teams are one GraphQL call per organization. CODEOWNERS code search is one query per live login. | Check | Source | | --- | --- | @@ -54,6 +54,7 @@ The repository list, collaborator lists, and CODEOWNERS files are fetched once a | 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 | 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`). @@ -61,7 +62,7 @@ Requires `gh` (scopes `repo` and `read:org`) and `jq`. The per-repo checks make 2 to 3 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes several minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. -Limits: only what your login can see; repository access needs push access; a login on more than 100 teams is noted and the rest of that login's teams are not listed. +Limits: only what your login can see; repository access needs push access; code search is one query per live login (10 requests a minute) and only matches when `@user` is in the returned text fragment; a login on more than 100 teams is noted and the rest of that login's teams are not listed. ## `offboard-openshift.sh` diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index c8adee3..63e451e 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -105,8 +105,27 @@ call() { } api_error() { fail "gh api $1 failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")"; } +search_call() { + local kind="$1" attempt reset now + shift + for attempt in 1 2 3 4 5; do + call "$@" && return 0 + if [[ "$API_STATUS" =~ ^(403|429)$ ]] && grep -qi 'rate limit' "$ERRF"; then + call rate_limit --jq ".resources.${kind}.reset" || api_error rate_limit + reset="$API_OUT" + now="$(date +%s)" + progress "search rate limit reached; waiting $(( reset > now ? reset - now + 1 : 5 ))s (attempt ${attempt})" + sleep "$(( reset > now ? reset - now + 1 : 5 ))" + continue + fi + return 1 + done + return 1 +} + LIVE=() SKIPPED=() +SEARCH_OK=true declare -A SKIPPED_SET=() for u in "${USERS[@]}"; do if call "users/${u}"; then @@ -224,6 +243,25 @@ 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") done done + + orgs_q="" + for o in "${ORGS[@]}"; do orgs_q+=" org:$(lower "$o")"; done + search_stop() { + echo "offboard-github: search failed (HTTP ${API_STATUS}): $(tail -n 1 "$ERRF")" >&2 + note "" "search failed (HTTP ${API_STATUS})" + SEARCH_OK=false + } + for u in "${LIVE[@]}"; do + [[ "$SEARCH_OK" == true ]] || break + lu="$(lower "$u")" + search_call code_search --paginate -X GET search/code -f q="${lu} filename:CODEOWNERS${orgs_q}" -f per_page=100 \ + -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" + 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 fi count="$(wc -l < "$FINDINGS" | tr -d ' ')" @@ -240,7 +278,7 @@ if [[ "$JSON" == "true" ]]; then else declare -A TITLE=( [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" - [codeowners]="CODEOWNERS" + [codeowners]="CODEOWNERS" [codeowners-search]="CODEOWNERS (code search)" ) echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" for u in "${USERS[@]}"; do @@ -253,7 +291,7 @@ else [[ -n "${SKIPPED_SET[$u]:-}" ]] || echo " nothing found" continue fi - for c in org-membership team repo-collaborator codeowners; do + for c in org-membership team repo-collaborator codeowners codeowners-search; do lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" @@ -272,5 +310,6 @@ else fi fi +if [[ "$SEARCH_OK" != true ]]; then exit 3; fi [[ "$count" -eq 0 ]] || exit 1 exit 0 diff --git a/offboard/offboard.sh b/offboard/offboard.sh index c4b737b..068f6eb 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -114,6 +114,7 @@ gh_titles=' elif . == "team" then "Teams" elif . == "repo-collaborator" then "Repository access" elif . == "codeowners" then "CODEOWNERS" + elif . == "codeowners-search" then "CODEOWNERS (code search)" else . end; ' diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index d1c787a..c0d2d5a 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -23,6 +23,12 @@ seed_findings() { printf 'example-user\twrite\nexample-admin\tadmin\n' > "$FIXTURES/collab-all-repo-one" printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" printf '# @example-user in a comment\n* @example-admin @example-user\n/docs/ @example-user-two\n' > "$FIXTURES/codeowners-repo-one" + cat > "$FIXTURES/search-code" <<'JSON' +{"items":[ + {"repository":{"full_name":"example-org/repo-three"},"path":".github/CODEOWNERS","text_matches":[{"fragment":"* @example-user"}]}, + {"repository":{"full_name":"example-org/repo-four"},"path":"CODEOWNERS","text_matches":[{"fragment":"* @example-user-two"}]} +]} +JSON } @test "no arguments is a usage error" { @@ -80,10 +86,10 @@ seed_findings() { run --separate-stderr "$SCRIPT" --json example-user [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"codeowners":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] + [ "$counts" = '{"codeowners":1,"codeowners-search":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] [ "$(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 '[.users[0].findings[] | select(.detail | test("example-user-two"))] | length')" = 0 ] + [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("repo-four|example-user-two"))] | length')" = 0 ] } @test "text output groups findings by user and check" { @@ -93,8 +99,8 @@ seed_findings() { [[ "$output" == *"== example-user"* ]] [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"CODEOWNERS (code search)"* ]] [[ "$output" != *"Environment required reviewers"* ]] - [[ "$output" != *"CODEOWNERS (code search)"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] } @@ -121,7 +127,7 @@ seed_findings() { seed_findings run "$SCRIPT" --json example-user [ -z "$(grep -E -- '-X (POST|PUT|PATCH|DELETE)|--method|--input|-F ' "$STUB_LOG")" ] - [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|-X GET')" ] + [ -z "$(grep -E '^gh api' "$STUB_LOG" | grep -E -- ' -f ' | grep -vE 'graphql|search/|-X GET')" ] [ -z "$(grep '^oc ' "$STUB_LOG" || true)" ] } @@ -137,7 +143,9 @@ seed_findings() { [ "$output" = 1 ] run grep -c 'userLogins:' "$STUB_LOG" [ "$output" = 1 ] - [ -z "$(grep 'search/' "$STUB_LOG" || true)" ] + run grep -c 'search/code' "$STUB_LOG" + [ "$output" = 2 ] + [ -z "$(grep 'search/code' "$STUB_LOG" | grep '(' || true)" ] run grep -c 'userLogins:\["missing-user"\]' "$STUB_LOG" [ "$output" = 0 ] } @@ -147,8 +155,9 @@ seed_findings() { run --separate-stderr "$SCRIPT" --json Example-User [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"codeowners":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] + [ "$counts" = '{"codeowners":1,"codeowners-search":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] grep -q 'userLogins:\["example-user"\]' "$STUB_LOG" + grep -q 'example-user filename:CODEOWNERS' "$STUB_LOG" } @test "an API failure exits 3" { @@ -157,3 +166,11 @@ seed_findings() { [ "$status" -eq 3 ] [[ "$output" == *"HTTP 500"* ]] } + +@test "a search failure still prints the checks already done" { + seed_findings + GH_FAIL_MATCH='search/code' run "$SCRIPT" example-user + [ "$status" -eq 3 ] + [[ "$output" == *"example-org/repo-one: write (direct)"* ]] + [[ "$output" == *"search failed (HTTP 500)"* ]] +} diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index 40ce93c..6a58851 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -56,5 +56,7 @@ 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%%/*}" ;; + *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 ;; esac From b99c4c3c5df4ccfd65ddb0a593567a11a43608d4 Mon Sep 17 00:00:00 2001 From: Derek Roberts Date: Tue, 29 Sep 2026 18:55:47 -0700 Subject: [PATCH 10/10] fix(offboard): report people on environment protection rules List a login when it is a required reviewer on a repository environment. Team reviewers stay in the Teams section. --- offboard/README.md | 3 ++- offboard/offboard-github.sh | 16 +++++++++++++++- offboard/offboard.sh | 1 + offboard/tests/offboard-github.bats | 11 ++++++----- offboard/tests/stubs/gh | 1 + 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/offboard/README.md b/offboard/README.md index 0ed360a..522d92f 100644 --- a/offboard/README.md +++ b/offboard/README.md @@ -55,12 +55,13 @@ The repository list, collaborator lists, and CODEOWNERS files are fetched once a | 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 | +| 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`). Requires `gh` (scopes `repo` and `read:org`) and `jq`. -The per-repo checks make 2 to 3 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes several minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. +The per-repo checks make 3 to 4 API calls per repository, about 2 seconds per repository. With around 200 admin repositories a run takes several minutes, whatever the length of the login list. `--repo` or `--repo-file` narrows it. Progress goes to stderr, the report to stdout. Limits: only what your login can see; repository access needs push access; code search is one query per live login (10 requests a minute) and only matches when `@user` is in the returned text fragment; a login on more than 100 teams is noted and the rest of that login's teams are not listed. diff --git a/offboard/offboard-github.sh b/offboard/offboard-github.sh index 63e451e..73e5f85 100755 --- a/offboard/offboard-github.sh +++ b/offboard/offboard-github.sh @@ -181,6 +181,15 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then fi call graphql -f query="$CO_QUERY" -f o="${r%%/*}" -f n="${r#*/}" || api_error "graphql CODEOWNERS ${r}" printf '%s' "$API_OUT" | jq -r "$CO_JQ" > "$d/codeowners" + if call --paginate "repos/${r}/environments?per_page=100" \ + --jq '.environments[]? | .name as $e | .protection_rules[]? | select(.type == "required_reviewers") | .reviewers[]? | [$e, .type, (.reviewer.login // .reviewer.slug)] | @tsv'; then + printf '%s\n' "$API_OUT" > "$d/environments" + elif [[ "$API_STATUS" =~ ^(403|404)$ ]]; then + : > "$d/environments" + note "" "${r}: environments not checked (repository not found or not readable)" + else + api_error "repos/${r}/environments" + fi done for o in "${ORGS[@]}"; do @@ -241,6 +250,10 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then while IFS=$'\t' read -r path lineno text; do finding "$u" codeowners "$r" "${path}:${lineno}: ${text}" 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" + done < "$d/environments" done done @@ -279,6 +292,7 @@ else declare -A TITLE=( [org-membership]="Organization membership" [team]="Teams" [repo-collaborator]="Repository access" [codeowners]="CODEOWNERS" [codeowners-search]="CODEOWNERS (code search)" + [environment-reviewer]="Environment required reviewers" ) echo "Organizations: ${ORGS[*]}; repositories checked: ${#REPOS[@]}" for u in "${USERS[@]}"; do @@ -291,7 +305,7 @@ else [[ -n "${SKIPPED_SET[$u]:-}" ]] || echo " nothing found" continue fi - for c in org-membership team repo-collaborator codeowners codeowners-search; do + 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")" [[ -n "$lines" ]] || continue echo " ${TITLE[$c]}" diff --git a/offboard/offboard.sh b/offboard/offboard.sh index 068f6eb..d5b3e7a 100755 --- a/offboard/offboard.sh +++ b/offboard/offboard.sh @@ -115,6 +115,7 @@ gh_titles=' elif . == "repo-collaborator" then "Repository access" elif . == "codeowners" then "CODEOWNERS" elif . == "codeowners-search" then "CODEOWNERS (code search)" + elif . == "environment-reviewer" then "Environment required reviewers" else . end; ' diff --git a/offboard/tests/offboard-github.bats b/offboard/tests/offboard-github.bats index c0d2d5a..37817dd 100644 --- a/offboard/tests/offboard-github.bats +++ b/offboard/tests/offboard-github.bats @@ -22,6 +22,7 @@ seed_findings() { printf 'example-org/repo-one\nother-org/repo-two\n' > "$FIXTURES/user-repos" printf 'example-user\twrite\nexample-admin\tadmin\n' > "$FIXTURES/collab-all-repo-one" printf 'example-user\n' > "$FIXTURES/collab-direct-repo-one" + printf 'prod\tUser\texample-user\ntest\tTeam\tteam-a\n' > "$FIXTURES/env-repo-one" printf '# @example-user in a comment\n* @example-admin @example-user\n/docs/ @example-user-two\n' > "$FIXTURES/codeowners-repo-one" cat > "$FIXTURES/search-code" <<'JSON' {"items":[ @@ -86,10 +87,11 @@ JSON run --separate-stderr "$SCRIPT" --json example-user [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"codeowners":1,"codeowners-search":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] + [ "$counts" = '{"codeowners":1,"codeowners-search":1,"environment-reviewer":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] [ "$(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 '[.users[0].findings[] | select(.detail | test("repo-four|example-user-two"))] | length')" = 0 ] + echo "$output" | jq -e '.users[0].findings[] | select(.check == "environment-reviewer" and (.detail | test("environment prod")))' + [ "$(echo "$output" | jq '[.users[0].findings[] | select(.detail | test("team-a|repo-four|example-user-two"))] | length')" = 0 ] } @test "text output groups findings by user and check" { @@ -100,7 +102,7 @@ JSON [[ "$output" == *"Repository access"* ]] [[ "$output" == *"example-org/repo-one: write (direct)"* ]] [[ "$output" == *"CODEOWNERS (code search)"* ]] - [[ "$output" != *"Environment required reviewers"* ]] + [[ "$output" == *"Environment required reviewers"* ]] [[ "$output" != *"Open issues and pull requests assigned"* ]] } @@ -155,8 +157,7 @@ JSON run --separate-stderr "$SCRIPT" --json Example-User [ "$status" -eq 1 ] counts="$(echo "$output" | jq -c '.users[0].findings | group_by(.check) | map({(.[0].check): length}) | add')" - [ "$counts" = '{"codeowners":1,"codeowners-search":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] - grep -q 'userLogins:\["example-user"\]' "$STUB_LOG" + [ "$counts" = '{"codeowners":1,"codeowners-search":1,"environment-reviewer":1,"org-membership":1,"repo-collaborator":1,"team":1}' ] grep -q 'example-user filename:CODEOWNERS' "$STUB_LOG" } diff --git a/offboard/tests/stubs/gh b/offboard/tests/stubs/gh index 6a58851..f2bec0d 100755 --- a/offboard/tests/stubs/gh +++ b/offboard/tests/stubs/gh @@ -56,6 +56,7 @@ 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%%/*}" ;; *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 ;;