Skip to content
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ Scripts a person runs from a workstation with their own login (for example an ac
### Offboarding ([`offboard/`](offboard))

- [`offboard.sh`](offboard/offboard.sh): runs both audits, one block per person (`login` or `login=othername`).
- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins.
- [`offboard-github.sh`](offboard/offboard-github.sh): read-only report of GitHub access and ownership for one or more logins. Prints cleanup commands; does not run them.
- [`github-drop-env-reviewer.sh`](offboard/github-drop-env-reviewer.sh): repo-admin helper to drop one login from an environment required-reviewers rule.
- [`offboard-openshift.sh`](offboard/offboard-openshift.sh): read-only report of OpenShift RoleBindings whose user subject contains a given name.

## Checks
Expand Down
11 changes: 6 additions & 5 deletions offboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
rmcampos
```

Names joined with `=` belong to one person. Each name is searched for as written. An OpenShift subject matches when it contains the name. No suffix is added. A name that is a valid GitHub login is also sent to the GitHub audit. If `oc` is not logged in, the GitHub blocks are still printed and OpenShift is skipped.
Names joined with `=` belong to one person. Each name is searched for as written. An OpenShift subject matches when it contains the name. No suffix is added. A name that is a valid GitHub login is also sent to the GitHub audit. If `oc` is not logged in, the GitHub blocks are still printed and OpenShift is skipped. Pass `--org-owner` to include GitHub org and team DELETE commands.

## `offboard-github.sh`

Expand All @@ -38,11 +38,12 @@ GitHub access and ownership, using your own `gh` login.
| Option | Meaning |
| --- | --- |
| `--org ORG` | Organization to check (repeatable). Default: `OFFBOARD_ORGS` (space- or comma-separated), else `bcgov bcgov-c bcgov-nr`. |
| `--org-owner` | Include org and team DELETE commands (needs an org owner or team admin). Default: omit those commands. Membership is still listed. |
| `--repo OWNER/NAME` | Repository for the per-repo checks (repeatable). |
| `--repo-file FILE` | File with one `OWNER/NAME` per line. |
| `--json` | JSON output instead of text. |

A login GitHub does not have is printed under that name and again under `Skipped, no GitHub account`. It is not sent to GitHub. The other logins still run. Exit codes: `0` nothing found, `1` access found, `2` usage or dependency error, `3` a GitHub API call failed.
Each finding may include a cleanup command in JSON. In text output, notes that are only `#` lines stay under the finding. Commands that can be run (`gh`, `oc`, the environment helper) are printed again at column 0 after that person, so they paste into a shell and into bash history. The audit does not run them. Org and team DELETE commands are omitted unless `--org-owner` is set. Direct collaborator deletes and `github-drop-env-reviewer.sh` need repository admin. GitHub has no per-reviewer delete; the helper PUTs the remaining required-reviewer list.

Login, organization, team, and CODEOWNERS comparisons are case-insensitive.

Expand All @@ -53,8 +54,8 @@ The repository list, collaborator lists, and CODEOWNERS files are fetched once a
| Organization membership | `GET /orgs/{org}/members`, then a local match |
| Teams | One GraphQL call per organization for every live login, then a local match |
| Repository access | Collaborator permission on each target repository, marked direct or through a team or organization role |
| CODEOWNERS | `@user` entries in the target repositories' CODEOWNERS file (`.github/`, root or `docs/`), comments ignored |
| CODEOWNERS (code search) | CODEOWNERS files across the organizations that mention the login, including repositories you do not admin |
| CODEOWNERS | CODEOWNERS files in the target repositories that mention `@user`. Listed as `OWNER/REPO: path`. The matching line is not printed. |
| CODEOWNERS (code search) | The same list for CODEOWNERS files across the organizations, including repositories you do not admin. A file already listed above is not repeated. |
| Environment required reviewers | People listed on a repository environment protection rule. A team on that rule is not listed here. |

The target repositories are those given with `--repo` or `--repo-file`. Without either, they are the repositories in the configured organizations where you have admin (`gh api user/repos` with `permissions.admin`).
Expand All @@ -79,7 +80,7 @@ oc login ...
| `--name STRING` | Name to search for (repeatable). A User subject matches when it contains the name. |
| `--json` | JSON output instead of text. |

Matching ignores case. No suffix is added. Each name is its own section, and the detail line shows the subject string that matched. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error.
Matching ignores case. No suffix is added. Each name is its own section, and the detail line is `binding -> role`. The `oc` command is printed at column 0 after that name; the audit does not run it. RoleBindings are read once per namespace. Exit codes match the GitHub script, except `3` means an `oc` call failed. `oc` not logged in is a usage error.

Requires `oc` logged in, and `jq`.

Expand Down
36 changes: 36 additions & 0 deletions offboard/github-drop-env-reviewer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Remove one GitHub user from an environment required-reviewers rule. Run with -h for usage.
set -euo pipefail

usage() {
cat <<'EOF'
Usage:
github-drop-env-reviewer.sh OWNER/REPO ENVIRONMENT LOGIN

Repo-admin: GET the environment, drop LOGIN from required reviewers, PUT the rest.
Does not change org membership, teams, or collaborators.
EOF
}

[[ "${1:-}" == "-h" || "${1:-}" == "--help" ]] && { usage; exit 0; }
[[ $# -eq 3 ]] || { usage >&2; echo "github-drop-env-reviewer: need OWNER/REPO, environment, login" >&2; exit 2; }
repo="$1"
env="$2"
login="$3"
command -v gh >/dev/null 2>&1 || { echo "github-drop-env-reviewer: gh is required" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "github-drop-env-reviewer: jq is required" >&2; exit 2; }

uid="$(gh api "users/${login}" | jq .id)"
body="$(gh api "repos/${repo}/environments/${env}" | jq -c --argjson uid "$uid" '
(.protection_rules // []) as $rules
| {
wait_timer: ([$rules[] | select(.type == "wait_timer") | .wait_timer][0] // 0),
prevent_self_review: ([$rules[] | select(.type == "required_reviewers") | .prevent_self_review][0] // false),
reviewers: [
$rules[] | select(.type == "required_reviewers") | .reviewers[]?
| select((.reviewer.id // .id) != $uid)
| {type, id: (.reviewer.id // .id)}
]
}
')"
gh api -X PUT "repos/${repo}/environments/${env}" --input - <<<"$body"
65 changes: 55 additions & 10 deletions offboard/offboard-github.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@ using your own gh login. OpenShift is a separate script.
Options:
--org ORG Organization to check (repeatable). Default: $OFFBOARD_ORGS,
else "bcgov bcgov-c bcgov-nr".
--org-owner Include org and team DELETE commands (needs an org owner
or team admin). Default: omit those commands. Membership
is still listed.
--repo OWNER/NAME Repository for the per-repo checks (repeatable).
--repo-file FILE File with one OWNER/NAME per line (# comments allowed).
Default repo set: repos in the orgs where you have admin.
--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.
Each finding may include a cleanup command. This script does not run those commands.
Org and team DELETE commands are omitted unless --org-owner is set.
Direct collaborator and environment-reviewer commands need repository admin.
Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error,
3 an API call failed.
EOF
Expand All @@ -33,11 +39,13 @@ ORGS=()
REPOS=()
REPO_FILE=""
JSON=false
ORG_OWNER=false
USERS=()

while [[ $# -gt 0 ]]; do
case "$1" in
--org) [[ $# -ge 2 ]] || die "--org needs a value"; ORGS+=("$2"); shift 2 ;;
--org-owner) ORG_OWNER=true; shift ;;
--repo) [[ $# -ge 2 ]] || die "--repo needs a value"; REPOS+=("$2"); shift 2 ;;
--repo-file) [[ $# -ge 2 ]] || die "--repo-file needs a value"; REPO_FILE="$2"; shift 2 ;;
--json) JSON=true; shift ;;
Expand Down Expand Up @@ -86,7 +94,10 @@ ERRF="${TMPD}/err"
USERS_FILE="${TMPD}/users"
printf '%s\n' "${USERS[@]}" | tr '[:upper:]' '[:lower:]' > "$USERS_FILE"

finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; }
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_DROP="${DIR}/github-drop-env-reviewer.sh"

finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; }
note() { jq -nc --arg u "$1" --arg n "$2" '{user:$u, note:$n}' >> "$NOTES"; }

call() {
Expand Down Expand Up @@ -202,7 +213,11 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then
fi
for u in "${LIVE[@]}"; do
if grep -qxF "$(lower "$u")" "$TMPD/members"; then
finding "$u" org-membership "$o" "member"
cmd=""
if [[ "$ORG_OWNER" == true ]]; then
cmd=$'# org owner\ngh api -X DELETE orgs/'"${o}"'/members/'"${u}"
fi
finding "$u" org-membership "$o" "member" "$cmd"
fi
done

Expand All @@ -226,13 +241,18 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then
[[ -n "$idx" && -n "$slug" ]] || continue
u="${LIVE[$idx]}"
slug="$(lower "$slug")"
finding "$u" team "${o}/${slug}" "member"
cmd=""
if [[ "$ORG_OWNER" == true ]]; then
cmd=$'# org owner or team admin\ngh api -X DELETE orgs/'"${o}"'/teams/'"${slug}"'/memberships/'"${u}"
fi
finding "$u" team "${o}/${slug}" "member" "$cmd"
done < <(printf '%s' "$API_OUT" | jq -r --argjson users "$live_json" '
(.data.organization // {}) | to_entries[]
| (.key | ltrimstr("u")) as $i
| .value.nodes[]? | [$i, .slug] | @tsv')
done

declare -A SEEN_CO=()
for u in "${USERS[@]}"; do
lu="$(lower "$u")"
for r in "${REPOS[@]}"; do
Expand All @@ -241,18 +261,22 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then
role="$(awk -F'\t' -v u="$lu" 'tolower($1) == u { print $2; exit }' "$d/all")"
if [[ -n "$role" ]]; then
if grep -qixF "$u" "$d/direct"; then
finding "$u" repo-collaborator "$r" "${role} (direct)"
finding "$u" repo-collaborator "$r" "${role} (direct)" "gh api -X DELETE repos/${r}/collaborators/${u}"
else
finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)"
finding "$u" repo-collaborator "$r" "${role} (through a team or organization role)" "# skip: access is via team or org"
fi
fi
fi
while IFS=$'\t' read -r path lineno text; do
finding "$u" codeowners "$r" "${path}:${lineno}: ${text}"
while IFS=$'\t' read -r path _; do
[[ -n "$path" ]] || continue
key="$(lower "$u") $(lower "$r") $(lower "$path")"
[[ -n "${SEEN_CO[$key]:-}" ]] && continue
SEEN_CO[$key]=1
finding "$u" codeowners "$r" "$path" ""
done < <(awk -F'\t' -v u="$lu" '{ l = tolower($3); sub(/#.*/, "", l); n = split(l, w, /[ \t]+/); for (k = 1; k <= n; k++) if (w[k] == "@" u) { print; next } }' "$d/codeowners")
while IFS=$'\t' read -r env type who; do
[[ "$type" == "User" && "$(lower "$who")" == "$lu" ]] || continue
finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer"
finding "$u" environment-reviewer "$r" "environment ${env}: required reviewer" "${ENV_DROP} ${r} ${env} ${u}"
done < "$d/environments"
done
done
Expand All @@ -271,7 +295,10 @@ if [[ ${#LIVE[@]} -gt 0 ]]; then
-H 'Accept: application/vnd.github.text-match+json' || { search_stop; break; }
while IFS=$'\t' read -r repo path; do
[[ -n "$repo" ]] || continue
finding "$u" codeowners-search "$repo" "$path"
key="$(lower "$u") $(lower "$repo") $(lower "$path")"
[[ -n "${SEEN_CO[$key]:-}" ]] && continue
SEEN_CO[$key]=1
finding "$u" codeowners-search "$repo" "$path" ""
done < <(printf '%s' "$API_OUT" | jq -r --arg re "(^|[^A-Za-z0-9-])@${lu}([^A-Za-z0-9-]|$)" \
'.items[]? | select(any(.text_matches[]?.fragment; test($re; "i"))) | [.repository.full_name, .path] | @tsv' | sort -u)
done
Expand Down Expand Up @@ -306,11 +333,29 @@ else
continue
fi
for c in org-membership team repo-collaborator codeowners codeowners-search environment-reviewer; do
lines="$(jq -r --arg u "$u" --arg c "$c" 'select(.user == $u and .check == $c) | " - \(.target): \(.detail)"' "$FINDINGS")"
lines="$(jq -r --arg u "$u" --arg c "$c" '
def note:
(.cmd // "") as $c
| ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#"))));
select(.user == $u and .check == $c)
| " - \(.target): \(.detail)",
(if (.cmd // "") != "" and note then (.cmd | split("\n")[] | select(length > 0) | " \(.)") else empty end)
' "$FINDINGS")"
[[ -n "$lines" ]] || continue
echo " ${TITLE[$c]}"
echo "$lines"
done
paste="$(jq -r --arg u "$u" '
def note:
(.cmd // "") as $c
| ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#"))));
select(.user == $u and (.cmd // "") != "" and (note | not))
| .cmd | split("\n")[] | select(length > 0)
' "$FINDINGS")"
if [[ -n "$paste" ]]; then
echo
echo "$paste"
fi
done
if [[ -s "$NOTES" ]]; then
echo
Expand Down
13 changes: 11 additions & 2 deletions offboard/offboard-openshift.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Options:
--json Print JSON instead of text.
-h, --help Show this help.

Each finding includes an oc command to remove that User from that role in
the namespace. This script does not run those commands.

Exit codes: 0 nothing found, 1 access found, 2 usage or dependency error,
3 an oc call failed.
EOF
Expand Down Expand Up @@ -53,7 +56,7 @@ FINDINGS="${TMPD}/findings.jsonl"
NOTES="${TMPD}/notes.jsonl"
: > "$FINDINGS"
: > "$NOTES"
finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" '{user:$u, check:$c, target:$t, detail:$d}' >> "$FINDINGS"; }
finding() { jq -nc --arg u "$1" --arg c "$2" --arg t "$3" --arg d "$4" --arg cmd "${5:-}" '{user:$u, check:$c, target:$t, detail:$d, cmd:$cmd}' >> "$FINDINGS"; }
note() { jq -nc --arg n "$1" '{note:$n}' >> "$NOTES"; }

if ! projects="$(oc projects -q)"; then
Expand All @@ -77,7 +80,8 @@ for ns in "${NAMESPACES[@]}"; do
for name in "${NAMES[@]}"; do
needle="$(lower "$name")"
[[ "$subject_l" == *"$needle"* ]] || continue
finding "$name" rolebinding "$ns" "${binding} -> ${role} (subject ${subject_l})"
cmd="$(printf 'oc adm policy remove-role-from-user %q %q -n %q' "$role" "$subject" "$ns")"
finding "$name" rolebinding "$ns" "${binding} -> ${role}" "$cmd"
done
done < <(printf '%s' "$rb" | jq -r \
'.items[] | .metadata.name as $b | .roleRef.name as $r | .subjects[]? | select(.kind == "User") | [.name, $b, $r] | @tsv')
Expand All @@ -100,6 +104,11 @@ else
fi
echo " RoleBindings"
jq -r --arg u "$u" 'select(.user == $u) | " - \(.target): \(.detail)"' "$FINDINGS"
paste="$(jq -r --arg u "$u" 'select(.user == $u and (.cmd // "") != "") | .cmd' "$FINDINGS")"
if [[ -n "$paste" ]]; then
echo
echo "$paste"
fi
done
if [[ -s "$NOTES" ]]; then
echo
Expand Down
37 changes: 32 additions & 5 deletions offboard/offboard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ OC_SCRIPT="${DIR}/offboard-openshift.sh"
usage() {
cat <<'EOF'
Usage:
offboard.sh
offboard.sh PERSON [PERSON...]
offboard.sh [--org-owner] PERSON [PERSON...]

A person is a GitHub login, or several names joined with = :
gpascucci=greg.pascucci
Expand All @@ -19,6 +18,9 @@ Each name is searched for as written. OpenShift matches when the User
subject contains the name. No suffix is added. Names that are valid GitHub
logins are also sent to the GitHub audit. Matching ignores case.

--org-owner includes GitHub org and team DELETE commands (needs an org
owner or team admin). Default: omit those commands. Membership is still listed.

With no arguments in a terminal, asks for the people. If oc is not logged
in, the GitHub report is still printed and OpenShift is skipped.
Exit 1 if either report found access, 3 if either call failed.
Expand All @@ -29,8 +31,10 @@ lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; }
is_login() { [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,38})$ ]]; }

PERSONS=()
ORG_OWNER=false
while [[ $# -gt 0 ]]; do
case "$1" in
--org-owner) ORG_OWNER=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; PERSONS+=("$@"); break ;;
-*) usage >&2; die "unknown option: $1" ;;
Expand Down Expand Up @@ -83,7 +87,10 @@ echo '{"sections":[],"notes":[]}' > "$OC_OUT"
gh_rc=0
if [[ ${#LOGINS[@]} -gt 0 ]]; then
set +e
"$GH_SCRIPT" --json -- "${LOGINS[@]}" > "$GH_OUT"
gh_cmd=("$GH_SCRIPT" --json)
[[ "$ORG_OWNER" == true ]] && gh_cmd+=(--org-owner)
gh_cmd+=(-- "${LOGINS[@]}")
"${gh_cmd[@]}" > "$GH_OUT"
gh_rc=$?
set -e
jq -e . "$GH_OUT" >/dev/null 2>&1 || echo '{"users":[],"skipped":[],"notes":[]}' > "$GH_OUT"
Expand All @@ -94,7 +101,7 @@ ran_oc=false
if ! command -v oc >/dev/null 2>&1 || ! oc whoami >/dev/null 2>&1; then
echo "OpenShift skipped: oc is not logged in"
echo "Run this where oc is logged in:"
printf ' %q' "$OC_SCRIPT"
printf '%q' "$OC_SCRIPT"
for n in "${NEEDLES[@]}"; do printf ' --name %q' "$n"; done
printf '\n'
else
Expand All @@ -117,6 +124,9 @@ gh_titles='
elif . == "codeowners-search" then "CODEOWNERS (code search)"
elif . == "environment-reviewer" then "Environment required reviewers"
else . end;
def note:
(.cmd // "") as $c
| ($c | split("\n") | map(select(length > 0)) | (length == 0 or all(test("^#"))));
'

i=0
Expand All @@ -125,6 +135,8 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do
echo "== ${P_SPEC[$i]}"
IFS=$'\t' read -r -a parts <<< "${P_NAMES[$i]}"
shown=" "
paste_file="${TMPD}/paste-${i}"
: > "$paste_file"
for part in "${parts[@]}"; do
lpart="$(lower "$part")"
if is_login "$part" && [[ "$shown" != *" ${lpart} "* ]]; then
Expand All @@ -139,7 +151,9 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do
else
block="$(jq -r --arg u "$part" "$gh_titles"'
.users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings
| if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)", (.[] | " - \(.target): \(.detail)") end
| if length == 0 then empty else group_by(.check)[] | " \(.[0].check | title)",
(.[] | " - \(.target): \(.detail)",
(if (.cmd // "") != "" and note then (.cmd | split("\n")[] | select(length > 0) | " \(.)") else empty end)) end
' "$GH_OUT")"
if [[ -n "$block" ]]; then
echo "$block"
Expand All @@ -148,6 +162,11 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do
else
echo " nothing found"
fi
jq -r --arg u "$part" "$gh_titles"'
.users[] | select((.user | ascii_downcase) == ($u | ascii_downcase)) | .findings[]
| select((.cmd // "") != "" and (note | not))
| .cmd | split("\n")[] | select(length > 0)
' "$GH_OUT" >> "$paste_file"
fi
fi
if [[ "$ran_oc" == true ]]; then
Expand All @@ -160,9 +179,17 @@ while [[ $i -lt ${#P_SPEC[@]} ]]; do
| if length == 0 then empty else .[] | " - \(.target): \(.detail)" end
' "$OC_OUT")"
if [[ -n "$block" ]]; then echo "$block"; else echo " nothing found"; fi
jq -r --arg u "$part" '
.sections[] | select(.name == $u) | .findings[]
| select((.cmd // "") != "") | .cmd
' "$OC_OUT" >> "$paste_file"
fi
fi
done
if [[ -s "$paste_file" ]]; then
echo
cat "$paste_file"
fi
i=$((i + 1))
done

Expand Down
Loading
Loading