#!/bin/bash

## Copyright (C) 2026 - 2026 ENCRYPTED SUPPORT LLC <adrelanos@whonix.org>
## See the file COPYING for copying conditions.

## AI-Assisted

## Apply per-repo lockdown policy to every public repo owned by a
## given GitHub user account. Mirrors the per-repo subset of
## dm-github-org-policy for individual user accounts. Idempotent and
## apply-only.

set -o errexit
set -o nounset
set -o pipefail
set -o errtrace
shopt -s inherit_errexit
shopt -s shift_verbose

# shellcheck source=../libexec/developer-meta-files/github-org-lib.bsh
source /usr/libexec/developer-meta-files/github-org-lib.bsh
# shellcheck source=../libexec/developer-meta-files/github-policy-lib.bsh
source /usr/libexec/developer-meta-files/github-policy-lib.bsh
# shellcheck source=../libexec/developer-meta-files/github-policy-data.bsh
source /usr/libexec/developer-meta-files/github-policy-data.bsh
## R-082: source directly so a future refactor that drops the transitive
## source through github-policy-lib.bsh does not break check_variable_name.
# shellcheck source=../../../helper-scripts/usr/libexec/helper-scripts/strings.bsh
source "${HELPER_SCRIPTS_PATH:-}"/usr/libexec/helper-scripts/strings.bsh

dry_run=0
verbose=0
mode=''
mode_set=0
positional=()
target_user=''

# shellcheck disable=SC2317  # invoked dynamically by policy_parse_mode_args
show_help() {
   cat <<'EOF'
Apply per-repo lockdown policy to every public repo owned by a
GitHub user account. Designed for hardening a personal account
against malicious fork PRs that could otherwise burn Actions minutes.

Usage (one mode flag is required - no implicit default):
  dm-github-personal-policy <username> --apply         apply
  dm-github-personal-policy <username> --dry-run       report planned changes
  dm-github-personal-policy <username> --audit         read-only inspection
  dm-github-personal-policy --policy-dump              print the active policy table
                                                       (cats github-policy-data.bsh)
  dm-github-personal-policy --help

Run 'dm-github-personal-policy --policy-dump' for the full table of
what gets applied (bodies, endpoints, methods, labels).

Auth:
  ${GITHUB_TOKEN} env var, or chmod-600 ~/.config/github-token.
  Token needs 'repo' (classic) or 'Administration: write' (fine-grained).
  To also hide the primary email from the public profile, the token
  needs 'user:email' (classic) or 'Email addresses: write'
  (fine-grained); without it the email-visibility step prints a
  warn and the rest of --apply continues. The token must belong
  to the same login as the target user; otherwise the email step
  is skipped (with a clear notice) since /user/email/visibility
  acts on the token-authed user.

Repos in scope:
  Public, non-archived repos owned by <username>, INCLUDING forks
  (they own their own .github/workflows + ruleset settings, so the
  malicious-fork-PR vector exists on them too). Archived excluded
  (HTTP 403 on PATCH/PUT). Private excluded (external contributors
  cannot open PRs against private repos).
EOF
}

policy_tool_init 1 "$@"

target_user="${positional[0]}"
ghorg_validate_name "${target_user}" user

## Refuse to run against an Organization. ghorg_validate_name only
## checks string format - both 'alice' and 'whonix' pass. Without
## this guard, an org-name typo would have the personal-mirror
## lockdown applied to every public repo in that org.
target_account_type="$(ghorg_account_type "${target_user}")" \
   || die 1 "could not determine account type for '${target_user}'"
if [ "${target_account_type}" != 'User' ]; then
   die 64 "refusing to run against '${target_user}' (account type '${target_account_type}', expected 'User'); use dm-github-org-policy for organizations"
fi

## ===== Per-user policy distinction =====
##
## PERSON = real maintainer's personal account. Some side trackers
##          stay open (has_issues=on - human can triage incoming
##          reports filed against the personal mirror).
## BOT    = AI-assisted-work bot account. All side trackers closed;
##          identical lockdown to a MIRROR org repo.
##
## Vocabulary aligns with SOURCE_ORGS / MIRROR_ORGS in
## dm-github-org-policy. PERSON-vs-BOT diff table in
## github-policy-data.bsh.
readonly PERSON_USERS=(
   'adrelanos'
   ## Test fixtures referenced by ci/tests/test_dm_personal_*.sh.
   ## Extra entries don't change production behaviour.
   'personal-test-user'
)
readonly BOT_USERS=(
   'assisted-by-ai'
   'bot-test-user'
)

user_kind() {
   local user name
   user="$1"
   for name in "${PERSON_USERS[@]}"; do
      [ "${name}" = "${user}" ] && { printf '%s\n' person; return 0; }
   done
   for name in "${BOT_USERS[@]}"; do
      [ "${name}" = "${user}" ] && { printf '%s\n' bot; return 0; }
   done
   log error "user_kind: '${user}' not in PERSON_USERS or BOT_USERS"
   return 1
}

target_kind="$(user_kind "${target_user}")" \
   || die 64 "refusing to run against '${target_user}': not in PERSON_USERS or BOT_USERS at the top of this script. Add it there if it should be in scope."

## Per-repo ruleset bypass actor list.
##
## PERSON only: resolve target_user's numeric GitHub user_id and
## inject a [{User, target_user_id, always}] bypass. On user-owned
## repos there is no OrganizationAdmin to reuse (no org); the User
## actor_type is the repo-owner-bypass equivalent and gives the
## maintainer an escape hatch for required_signatures (the only
## rule on PERSON that would otherwise block their own unsigned
## pushes). The maintainer can ALSO bypass deletion and
## non_fast_forward via this same actor, but that's fine: they
## already have repo-owner privileges and can do those via the
## GitHub UI anyway.
##
## BOT: empty bypass. The BOT rules (deletion + non_fast_forward,
## no required_signatures) do not block legitimate bot pushes;
## granting the bot itself a User bypass would let it
## force-push and delete branches on its own repos, defeating
## the whole point of those rules. Normal automated pushes do
## not need a bypass.
case "${target_kind}" in
   person)
      target_user_result="$(ghorg_api GET "/users/${target_user}")" \
         || die 1 "could not GET /users/${target_user} to resolve user_id for ruleset bypass"
      target_user_status="$(ghorg_status_of "${target_user_result}")"
      if [ "${target_user_status}" != '200' ]; then
         die 1 "user_id lookup for '${target_user}': HTTP ${target_user_status}"
      fi
      target_user_id="$(ghorg_body_of "${target_user_result}" \
         | ghorg_jq_capped --raw-output -- '.id // ""')"
      if ! [[ "${target_user_id}" =~ ^[0-9]+$ ]] || \
         [ "${#target_user_id}" -gt "${GHORG_MAX_ID_LEN}" ]; then
         die 1 "user_id for '${target_user}' is not a valid numeric id: '${target_user_id}'"
      fi
      ## --argjson injects target_user_id as a JSON number (not a
      ## string) so GitHub's schema validator accepts the actor_id
      ## type.
      personal_bypass="$(ghorg_jq -n --argjson id "${target_user_id}" -- \
         '[{actor_id: $id, actor_type: "User", bypass_mode: "always"}]')"
      ;;
   bot)
      personal_bypass='[]'
      ;;
   *)
      die 1 "unexpected target_kind='${target_kind}' (expected person|bot)"
      ;;
esac
readonly personal_bypass

readonly RULESET_NAME_BRANCH='dm-github-personal-policy default-branch protection'
readonly RULESET_NAME_TAG='dm-github-personal-policy tag protection'

## --verbose suppresses the 'ok:' line from policy_api_call.
[ "${verbose}" = '1' ] || POLICY_QUIET_OK=1

apply_repo_policy() {
   local repo pages_endpoint rules_var

   repo="$1"

   policy_apply_repo "${target_user}" "${repo}" FORK_PR_APPROVAL

   policy_apply_repo "${target_user}" "${repo}" WORKFLOW_PERMS

   policy_apply_repo "${target_user}" "${repo}" ACTIONS_DISABLED

   ## target_kind pivots PERSON vs BOT via the constant naming
   ## convention.
   policy_apply_repo "${target_user}" "${repo}" "PERSONAL_REPO_${target_kind^^}"

   ## DELETE + extra-ok-status; the simple dispatcher does not handle
   ## the 4th policy_api_call arg.
   pages_endpoint="${POLICY_PAGES_DELETE_ENDPOINT_REPO}"
   pages_endpoint="${pages_endpoint//__OWNER__/${target_user}}"
   pages_endpoint="${pages_endpoint//__REPO__/${repo}}"
   policy_api_call "${target_user}/${repo}: ${POLICY_PAGES_DELETE_LABEL}" \
      "${POLICY_PAGES_DELETE_METHOD}" "${pages_endpoint}" '' \
      "${POLICY_PAGES_DELETE_EXTRA_OK_STATUS}" \
      || true

   ## PERSON / BOT ruleset-rules split (required_signatures on /
   ## off respectively) and the per-kind bypass split (User actor
   ## on PERSON, '[]' on BOT) both live in agents/github-policy-
   ## canonical-vs-mirror.md.
   rules_var="POLICY_RULESET_RULES_${target_kind^^}"

   if check_variable_name "${rules_var}"; then
      policy_upsert_repo_ruleset "${target_user}" "${repo}" \
         "${RULESET_NAME_BRANCH}" \
         "$(policy_branch_ruleset_body "${RULESET_NAME_BRANCH}" repo "${personal_bypass}" "${!rules_var}")" \
         || true

      policy_upsert_repo_ruleset "${target_user}" "${repo}" \
         "${RULESET_NAME_TAG}" \
         "$(policy_tag_ruleset_body "${RULESET_NAME_TAG}" repo "${personal_bypass}" "${!rules_var}")" \
         || true
   fi

   ## DELIBERATELY DISABLED: Vulnerability alerts + Dependabot
   ## security updates. Personal account is a backup/mirror; enabling
   ## here would duplicate notifications for findings already raised
   ## on the upstream org-side repo. Uncomment if/when this becomes
   ## the canonical home of a repo (not a mirror).
   ##
   ## policy_api_call "${target_user}/${repo}: enable vulnerability alerts" \
   ##    PUT "/repos/${target_user}/${repo}/vulnerability-alerts" \
   ##    || true
   ## policy_api_call "${target_user}/${repo}: enable Dependabot security updates" \
   ##    PUT "/repos/${target_user}/${repo}/automated-security-fixes" \
   ##    || true
}

audit_one() {
   local repo result status body
   local fork_approval workflow_perms allowed_actions

   repo="$1"

   log notice "${target_user}/${repo}:"

   result="$(ghorg_api GET \
      "/repos/${target_user}/${repo}/actions/permissions/fork-pr-contributor-approval")" \
      || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      fork_approval="$(printf '%s' "${body}" | ghorg_jq_capped --raw-output -- '.approval_policy // "?"')"
      log notice "  fork-PR approval: ${fork_approval}"
   else
      log notice "  fork-PR approval: HTTP '${status}'"
   fi

   result="$(ghorg_api GET "/repos/${target_user}/${repo}/actions/permissions/workflow")" \
      || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      workflow_perms="$(printf '%s' "${body}" | ghorg_jq_capped --raw-output -- '"default=" + (.default_workflow_permissions // "?") + ", can_approve_PRs=" + ((.can_approve_pull_request_reviews // false) | tostring)')"
      log notice "  workflow GITHUB_TOKEN: ${workflow_perms}"
   else
      log notice "  workflow GITHUB_TOKEN: HTTP '${status}'"
   fi

   result="$(ghorg_api GET "/repos/${target_user}/${repo}/actions/permissions")" \
      || true
   status="$(ghorg_status_of "${result}")"
   if [ "${status}" = '200' ]; then
      body="$(ghorg_body_of "${result}")"
      allowed_actions="$(printf '%s' "${body}" | ghorg_jq_capped --raw-output -- '"enabled=" + ((.enabled // false) | tostring) + ", allowed_actions=" + (.allowed_actions // "?")')"
      log notice "  actions: ${allowed_actions}"
   else
      log notice "  actions: HTTP '${status}'"
   fi
}

## inc_private=0, inc_archived=0, inc_forks=1: forks need the same
## per-repo lockdown as non-fork repos (malicious-fork-PR vector
## exists on a fork too). Archived excluded (HTTP 403 on PATCH/PUT).
## Private excluded (external contributors cannot open PRs against
## private repos).
## Account-wide settings (applied once per --apply, not per repo).
##
## /user/* endpoints implicitly act on the token-authed user; guard
## "authed user == target_user" first - otherwise a token belonging
## to A running this against B would mutate A's email visibility.
if [ "${mode}" != 'audit' ]; then
   authed_login=''
   authed_result="$(ghorg_api GET /user)" || true
   authed_status="$(ghorg_status_of "${authed_result}")"
   if [ "${authed_status}" = '200' ]; then
      authed_login="$(ghorg_body_of "${authed_result}" \
         | ghorg_jq_capped --raw-output -- '.login // ""')"
   fi
   if [ -z "${authed_login}" ]; then
      log notice "skip: ${target_user}: ${POLICY_USER_EMAIL_PRIVATE_LABEL} - cannot determine authed user (HTTP ${authed_status})"
   elif [ "${authed_login}" != "${target_user}" ]; then
      log notice "skip: ${target_user}: ${POLICY_USER_EMAIL_PRIVATE_LABEL} - token belongs to '${authed_login}', not '${target_user}'; account-wide PATCHes would target the wrong account"
   else
      ## /user/email/visibility takes no placeholder; call the lib
      ## directly rather than through policy_apply_repo.
      policy_api_call "${target_user}: ${POLICY_USER_EMAIL_PRIVATE_LABEL}" \
         "${POLICY_USER_EMAIL_PRIVATE_METHOD}" \
         "${POLICY_USER_EMAIL_PRIVATE_ENDPOINT}" \
         "$(ghorg_jq -n -- "${POLICY_USER_EMAIL_PRIVATE}")" \
         || true
   fi
fi

repos="$(ghorg_list_repos "${target_user}" 'false' 'false' 'true' \
   | sort --unique)"

if [ -z "${repos}" ]; then
   log notice "no public non-archived non-fork repos for '${target_user}'"
   exit 0
fi

case "${mode}" in
   apply)
      while IFS= read -r repo; do
         [ -z "${repo}" ] && continue
         apply_repo_policy "${repo}"
      done <<< "${repos}"
      ;;
   audit)
      while IFS= read -r repo; do
         [ -z "${repo}" ] && continue
         audit_one "${repo}" || true
      done <<< "${repos}"
      ;;
esac

[ "${policy_warn_seen}" -eq 1 ] && exit 1
exit 0
