From 2b5a19d34a363597be753261f43b584448599df8 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 6 Jul 2026 15:11:35 +0800 Subject: [PATCH] wiki-guard: trust repo/org members with write access in all rule checks --- .github/workflows/issue.yml | 46 +++++ .github/workflows/wiki-guard.yml | 293 +++++++++++++++++++++++++++---- 2 files changed, 301 insertions(+), 38 deletions(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 37e95eba..bd6dc9e8 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -2,6 +2,12 @@ name: "Update issues" on: issues: types: [opened] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write jobs: comment: @@ -10,6 +16,46 @@ jobs: - uses: actions/github-script@v9 with: script: | + const item = context.payload.issue || context.payload.pull_request; + + // Close on sight anything opened by a user on the wiki Blacklist + // page (maintained by the Wiki Guard workflow). + let blacklist = []; + try { + const res = await fetch(`https://raw.githubusercontent.com/wiki/${context.repo.owner}/${context.repo.repo}/Blacklist.md`); + if (res.ok) { + blacklist = (await res.text()).split("\n") + .filter(l => l.startsWith("- ")) + .map(l => l.slice(2).trim().toLowerCase()) + .filter(Boolean); + } + } catch (e) { + core.warning(`Failed to fetch the blacklist: ${e}`); + } + if (blacklist.includes(item.user.login.toLowerCase())) { + if (context.payload.pull_request) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: item.number, + state: "closed" + }); + } else { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + state: "closed", + state_reason: "not_planned" + }); + } + return; + } + + if (context.payload.pull_request) { + return; + } + const issue = context.payload.issue; if (issue.title.startsWith("Report bugs to")) { // Tracking issue for a third-party dns/deploy/notify api: diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 356ff58a..a0199441 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -1,7 +1,19 @@ -name: Restore Wiki Pages Deleted by Others +name: Wiki Guard +# Rules enforced here: +# - Only the maintainer and write-access members may delete or rename wiki +# pages. Anyone else doing so gets blacklisted and the page restored to +# its last good revision. +# - Only the maintainer and write-access members may edit the Blacklist +# wiki page. Anyone else touching it gets blacklisted and the page +# reverted. +# - Any wiki change made by a blacklisted identity is reverted. +# A "good" revision is one authored by the maintainer, by this bot, or by +# a non-blacklisted user -- restoring from the deleted commit's parent is +# NOT safe, because vandals replace a page before destroying it and the +# parent would launder their version into a bot commit. # The gollum event only fires on page create/update, never on deletion, -# so deletions can only be caught by polling the wiki git history. +# so violations are caught by polling the wiki git history. on: schedule: @@ -18,7 +30,7 @@ concurrency: cancel-in-progress: false jobs: - restore: + guard: runs-on: ubuntu-latest steps: - name: Checkout wiki repository @@ -28,68 +40,273 @@ jobs: path: wiki fetch-depth: 0 - - name: Restore pages deleted by non-maintainer - id: restore + - name: Enforce wiki rules + id: guard + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Logins with write (push) access to the repository, including + # organization members -- they may delete/rename pages and edit + # the blacklist just like the maintainer. If the API call fails, + # the list stays empty and enforcement falls back to + # maintainer-only, which is the safe direction. + gh api "repos/${GITHUB_REPOSITORY}/collaborators?per_page=100" --paginate \ + -q '.[] | select(.permissions.push) | .login' 2>/dev/null \ + | tr 'A-Z' 'a-z' | sort -u > writers.txt || true cd wiki git config core.quotePath false - # Any author email under this domain is the maintainer and may delete pages. + # Any author email under this domain is the maintainer. OWNER_DOMAIN="neilpang.com" + # Our own enforcement commits. + BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com" + BL_PAGE="Blacklist.md" + # Rolling window; the cron runs every 10 minutes, so 7 days gives + # ample overlap without re-judging old changes the maintainer + # already accepted. + WINDOW="7 days ago" - : > ../restored.txt + : > ../actions.txt + : > ../bl_new.txt - # Paths deleted within the recent window (rolling; the cron runs - # every 10 minutes, so 7 days gives ample overlap without - # resurrecting old deletions the maintainer already accepted). - # --no-renames makes a rename count as a deletion of the old path, - # so a page renamed by a non-maintainer is restored under its - # original name (the new name stays -- creating pages is allowed). - git log --since="7 days ago" --diff-filter=D --no-renames --name-only --format= \ + is_owner() { + case "$1" in + *@"$OWNER_DOMAIN") return 0 ;; + esac + return 1 + } + + is_bot() { + [ "$1" = "$BOT_EMAIL" ] + } + + author_email() { + git show -s --format=%ae "$1" | tr 'A-Z' 'a-z' + } + + # Identity of a commit author: the GitHub login when the email is a + # users.noreply.github.com address, otherwise the email itself. + identity_of() { + case "$1" in + *+*@users.noreply.github.com) + printf '%s\n' "$1" | sed 's/^[^+]*+//; s/@users\.noreply\.github\.com$//' + ;; + *@users.noreply.github.com) + printf '%s\n' "$1" | sed 's/@users\.noreply\.github\.com$//' + ;; + *) + printf '%s\n' "$1" + ;; + esac + } + + is_blacklisted() { + grep -Fxq "$1" ../bl_all.txt + } + + # Trusted committers: the maintainer (by email domain), this bot, + # and anyone whose GitHub login has write access to the repo. + is_trusted() { + if is_owner "$1" || is_bot "$1"; then + return 0 + fi + grep -Fxq "$(identity_of "$1")" ../writers.txt + } + + # Newest commit on file $1 authored by a non-blacklisted user. + last_good_for() { + for g in $(git log --format=%H --no-renames -- "$1"); do + gae="$(author_email "$g")" + if is_trusted "$gae"; then + printf '%s\n' "$g" + return 0 + fi + gid="$(identity_of "$gae")" + if ! is_blacklisted "$gid" && ! is_blacklisted "$gae"; then + printf '%s\n' "$g" + return 0 + fi + done + return 0 + } + + if [ -e "$BL_PAGE" ]; then + page_existed=1 + else + page_existed="" + fi + + # ---- 1. Last good version of the blacklist page: the newest + # revision authored by the maintainer or by this bot. Everything + # else on that page is tampering and is discarded. + bl_good_commit="" + for c in $(git log --format=%H --no-renames -- "$BL_PAGE"); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + bl_good_commit="$c" + break + fi + done + if [ -n "$bl_good_commit" ] && git cat-file -e "$bl_good_commit:$BL_PAGE" 2>/dev/null; then + git show "$bl_good_commit:$BL_PAGE" > ../bl_page.txt + else + { + echo "# Blacklist" + echo "" + echo "Users listed below violated the wiki rules (deleted or renamed" + echo "pages, or tampered with this page). Their new issues and pull" + echo "requests are closed on sight and their wiki edits are reverted" + echo "automatically. Only the maintainer and write-access members" + echo "may edit this page." + echo "" + echo "To pardon a user while their violation is still inside the" + echo "scan window, replace their entry with: pardon: username" + echo "" + } > ../bl_page.txt + fi + sed -n 's/^- *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_good.txt + sed -n 's/^[Pp]ardon: *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_pardon.txt + + bl_add() { + if grep -Fxq "$1" ../bl_pardon.txt; then + return 0 + fi + if ! grep -Fxq "$1" ../bl_good.txt && ! grep -Fxq "$1" ../bl_new.txt; then + printf '%s\n' "$1" >> ../bl_new.txt + printf '%s\n' "- blacklisted \`$1\`: $2" >> ../actions.txt + fi + } + + # ---- 2. Blacklist everyone who deleted or renamed a page. + # --no-renames makes a rename count as a deletion of the old path. + for c in $(git log --since="$WINDOW" --diff-filter=D --no-renames --format=%H); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + an="$(git show -s --format=%an "$c")" + bl_add "$(identity_of "$ae")" "deleted or renamed pages in $c ($an <$ae>)" + done + + # ---- 3. Blacklist everyone else who touched the blacklist page. + # The revert of their tampering falls out of steps 5 and 6. + for c in $(git log --since="$WINDOW" --format=%H --no-renames -- "$BL_PAGE"); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + an="$(git show -s --format=%an "$c")" + bl_add "$(identity_of "$ae")" "tampered with \`$BL_PAGE\` in $c ($an <$ae>)" + done + + sort -u ../bl_new.txt > ../bl_new_u.txt + cat ../bl_good.txt ../bl_new_u.txt | sort -u > ../bl_all.txt + + # ---- 4. Restore pages that are currently missing because a + # non-maintainer deleted them, using the last good revision. + git log --since="$WINDOW" --diff-filter=D --no-renames --name-only --format= \ | sort -u \ | while IFS= read -r f; do - if [ -z "$f" ] || [ -e "$f" ]; then + if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ] || [ -e "$f" ]; then continue fi - # The most recent commit (in full history) that deleted this path. del="$(git log -1 --diff-filter=D --no-renames --format=%H -- "$f")" if [ -z "$del" ]; then continue fi - ae="$(git show -s --format=%ae "$del" | tr 'A-Z' 'a-z')" - case "$ae" in - *@"$OWNER_DOMAIN") - continue - ;; - esac - an="$(git show -s --format=%an "$del")" - echo "Restoring: $f (deleted in $del by $an <$ae>)" - git checkout "$del^" -- "$f" - printf '%s\n' "- \`$f\` deleted in $del by $an <$ae>" >> ../restored.txt + ae="$(author_email "$del")" + if is_trusted "$ae"; then + continue + fi + good="$(last_good_for "$f")" + if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then + git checkout "$good" -- "$f" + printf '%s\n' "- restored \`$f\` (deleted in $del) from its last good revision $good" >> ../actions.txt + fi done - if [ -s ../restored.txt ]; then + # ---- 5. Revert every recent change made by a blacklisted + # identity: each touched file goes back to its newest revision + # authored by a non-blacklisted user; a file that has no such + # revision (they created it) is removed. + if [ -s ../bl_all.txt ]; then + for c in $(git log --since="$WINDOW" --format=%H --no-renames); do + ae="$(author_email "$c")" + if is_trusted "$ae"; then + continue + fi + id="$(identity_of "$ae")" + if ! is_blacklisted "$id" && ! is_blacklisted "$ae"; then + continue + fi + git show --name-only --no-renames --format= "$c" \ + | while IFS= read -r f; do + if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ]; then + continue + fi + good="$(last_good_for "$f")" + if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then + want="$(git rev-parse "$good:$f")" + have="$(git hash-object -- "$f" 2>/dev/null || echo missing)" + if [ "$want" != "$have" ]; then + git checkout "$good" -- "$f" + printf '%s\n' "- reverted \`$f\` to its last good revision $good (undoing change by \`$id\` in $c)" >> ../actions.txt + fi + elif [ -e "$f" ]; then + git rm -q -- "$f" + printf '%s\n' "- removed \`$f\` created by blacklisted \`$id\` in $c" >> ../actions.txt + fi + done + done + fi + + # ---- 6. Regenerate the blacklist page: the last good text plus + # any newly blacklisted identities. This both reverts tampering + # and records new violators; manual edits by the maintainer are + # preserved as the new good text. + cp ../bl_page.txt ../bl_page_new.txt + if [ -s ../bl_page_new.txt ] && [ -n "$(tail -c1 ../bl_page_new.txt)" ]; then + echo >> ../bl_page_new.txt + fi + while IFS= read -r id; do + if [ -n "$id" ] && ! grep -Fxiq -- "- $id" ../bl_page_new.txt; then + printf -- '- %s\n' "$id" >> ../bl_page_new.txt + fi + done < ../bl_new_u.txt + if ! cmp -s ../bl_page_new.txt "$BL_PAGE" 2>/dev/null; then + cp ../bl_page_new.txt "$BL_PAGE" + git add -- "$BL_PAGE" + if [ -n "$page_existed" ] || [ -s ../bl_new_u.txt ]; then + printf '%s\n' "- updated \`$BL_PAGE\`" >> ../actions.txt + fi + fi + + # ---- 7. Commit, push, notify. + if [ -n "$(git status --porcelain)" ]; then git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "Restore pages deleted by non-maintainer" + git config user.email "$BOT_EMAIL" + git commit -m "wiki-guard: restore pages and enforce blacklist" git push origin HEAD || { git pull --rebase && git push origin HEAD; } + fi + if [ -s ../actions.txt ]; then { - echo "The following wiki pages were deleted or renamed by someone other than the maintainer and have been restored automatically:" + echo "The wiki guard handled the following rule violations:" echo "" - cat ../restored.txt + cat ../actions.txt echo "" + echo "Blacklist: https://github.com/${GITHUB_REPOSITORY}/wiki/Blacklist" echo "Wiki: https://github.com/${GITHUB_REPOSITORY}/wiki" - } > ../restore-msg.txt - echo "restored=true" >> "$GITHUB_OUTPUT" + } > ../guard-msg.txt + echo "acted=true" >> "$GITHUB_OUTPUT" else - echo "No unauthorized deletions found." - echo "restored=false" >> "$GITHUB_OUTPUT" + echo "No rule violations found." + echo "acted=false" >> "$GITHUB_OUTPUT" fi - name: Create issue to notify Neilpang - if: steps.restore.outputs.restored == 'true' + if: steps.guard.outputs.acted == 'true' uses: peter-evans/create-issue-from-file@v6 with: - title: "Wiki pages restored after unauthorized deletion" - content-filepath: ./restore-msg.txt + title: "Wiki guard: rule violations handled" + content-filepath: ./guard-msg.txt assignees: Neilpang