From 9415ded2205bd823c647182f969638fc886a2e50 Mon Sep 17 00:00:00 2001 From: Lyra Bot Date: Wed, 26 Aug 2026 04:12:39 -0400 Subject: [PATCH 1/2] feat(gitea): enforce pre-receive hook in secure-gitea.py Adds pre-receive hook management to the existing hardening script: - Defines GIT_HOOKS with the canonical guard content (rejects workflow changes arriving via untrusted branches) from pre-receive-guard.sh. - Reads the current hook via the git-hook API; treats is_active=false as not set. - Sets/updates the hook via PATCH only when the content differs, using exact-match comparison (Gitea stores hook content verbatim). - Mirrors the existing branch/tag protection reporting (dry-run vs --apply) and adds hooks to the summary counters. - Skips archived repos and surfaces API failures like the rest of the script. --- secure-gitea.py | 183 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/secure-gitea.py b/secure-gitea.py index 9155159..2a3860d 100644 --- a/secure-gitea.py +++ b/secure-gitea.py @@ -94,6 +94,106 @@ TAG_DESIRED = { } +# --------------------------------------------------------------------------- +# Desired Git hook configuration +# +# The pre-receive hook refuses any push that would add/modify/delete files +# under the workflow directories unless the ref is a trusted one (main / v*), +# which is already locked down at the permission layer. It is the +# server-side backstop that makes it impossible for a feature-branch push to +# smuggle in a workflow that could read secrets. +# +# Gitea stores this content at hooks/pre-receive.d/pre-receive. The +# generated hooks/pre-receive wrapper runs every executable file in that +# directory, so setting this content is all that is needed for it to take +# effect. The API returns the content verbatim, so "already set properly" +# is an exact-content match against this constant. +# --------------------------------------------------------------------------- + +GIT_HOOKS = { + "pre-receive": r'''#!/usr/bin/env bash +# ============================================================================ +# pre-receive hook: "No workflow changes from untrusted branches" +# +# WHERE: Gitea Repo -> Settings -> Git Hooks -> "pre-receive" -> +# paste this -> Update. (No restart needed; Gitea installs it +# server-side in hooks/pre-receive.d/ and its wrapper runs it.) +# +# WHAT: Any push that would ADD / MODIFY / DELETE files under the +# workflow directories is REJECTED before the ref is updated -- +# UNLESS the ref is one of TRUSTED_REFS (main branch / v* tags), +# which you keep locked down at the permission layer. +# +# WHY: A workflow is code that runs with access to repo/org secrets. +# A feature-branch / PR push is untrusted input, so a workflow +# arriving that way must never exist server-side. It can't leak +# secrets if it was never accepted. Workflow changes can only +# land via main or a v* tag -- the refs you already protect. +# +# NOTES: +# * Rejects ANY pushed history that touched the workflow dirs -- even an +# add-then-revert pair. The server never records that content. (If you +# later want "only the resulting tree matters", switch the existing-ref +# branch to a bare `git diff --name-only` and drop the `--not --all` +# history scan for new branches.) +# * Deletion of any ref is always allowed. +# ============================================================================ + +WORKFLOW_DIRS=( ".gitea/workflows" ".github/workflows" ) # paths to guard +TRUSTED_REFS=( "refs/heads/main" "refs/tags/v*" ) # may touch them + +ZERO="0000000000000000000000000000000000000000" + +is_trusted() { + local ref="$1" pat + for pat in "${TRUSTED_REFS[@]}"; do + # shellcheck disable=SC2254 + case "$ref" in $pat) return 0 ;; esac + done + return 1 +} + +# Prints (one per line) the workflow files a ref update would change. +workflow_changes() { + local old="$1" new="$2" + local args=() i + for i in "${WORKFLOW_DIRS[@]}"; do + args+=( "$i" ) + done + + if [ "$old" = "$ZERO" ]; then + # New ref: only the commits this push actually introduces matter. + git log --format= --name-only --no-renames "$new" --not --all -- "${args[@]}" 2>/dev/null + else + # Existing ref: net diff between what is there and what will be. + git diff --name-only --no-renames "$old" "$new" -- "${args[@]}" 2>/dev/null + fi +} + +rejected=0 +while read -r old new ref; do + [ -n "$ref" ] || continue + # deletion of a ref is always allowed + [ "$new" = "$ZERO" ] && continue + # trusted refs (main / v*) may modify workflows + is_trusted "$ref" && continue + + changes="$(workflow_changes "$old" "$new")" + if [ -n "$changes" ]; then + echo "*** [pre-receive] PUSH REJECTED ***" >&2 + echo "Ref '$ref' changes files under the workflow dirs, which is not allowed." >&2 + echo "Workflow changes may only arrive via a trusted ref (main / v*):" >&2 + printf '%s\n' "$changes" | sed 's/^/ /' >&2 + echo "The push was not accepted; no refs were updated." >&2 + rejected=1 + fi +done +# shellcheck disable=SC2317 +exit "$rejected" +''', +} + + # --------------------------------------------------------------------------- # API session # --------------------------------------------------------------------------- @@ -332,6 +432,53 @@ def apply_tag_protection(owner, repo, existing): return "UPDATED" +# --------------------------------------------------------------------------- +# Git hooks +# --------------------------------------------------------------------------- + +def get_git_hook(owner, repo, hook_name): + """ + Return the existing {hook_name} hook, or None if it is not set. + + Gitea returns is_active=false with empty content when the hook is not + configured. Treat that as "not set" so it is reported and applied + like the branch/tag protections below. + """ + + hook = api( + "GET", + f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/" + f"{quote(hook_name)}", + ) + + if not hook.get("is_active"): + return None + + return hook + + +def describe_git_hook(hook): + """Return a concise description of the current hook state.""" + + if hook is None: + return "NOT SET" + + return "SET" + + +def apply_git_hook(owner, repo, hook_name): + """Set the hook content to the desired value.""" + + api( + "PATCH", + f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/" + f"{quote(hook_name)}", + json={"content": GIT_HOOKS[hook_name]}, + ) + + return "UPDATED" + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -402,6 +549,9 @@ def main(): tag_changed = 0 tag_unchanged = 0 + hook_changed = 0 + hook_unchanged = 0 + skipped = 0 failed = 0 @@ -531,6 +681,36 @@ def main(): tag_changed += 1 + # --------------------------------------------------------------- + # Pre-receive Git hook + # --------------------------------------------------------------- + + hook = get_git_hook(owner, name, "pre-receive") + + if hook is None: + print(" Hook: NOT SET") + print(" Would SET pre-receive hook") + + if args.apply: + result = apply_git_hook(owner, name, "pre-receive") + print(f" {result}") + + hook_changed += 1 + + elif hook.get("content") == GIT_HOOKS["pre-receive"]: + print(" Hook: SET (matches desired content)") + hook_unchanged += 1 + + else: + print(" Hook: SET (content differs)") + print(" Would UPDATE pre-receive hook") + + if args.apply: + result = apply_git_hook(owner, name, "pre-receive") + print(f" {result}") + + hook_changed += 1 + except requests.HTTPError: print(" FAILED") failed += 1 @@ -550,6 +730,9 @@ def main(): print(f"Tags changed: {tag_changed}") print(f"Tags unchanged: {tag_unchanged}") print() + print(f"Hooks changed: {hook_changed}") + print(f"Hooks unchanged: {hook_unchanged}") + print() print(f"Skipped: {skipped}") print(f"Failed: {failed}") -- 2.54.0 From a56d97d13d34fa3588915b748fd78f6c5c9dd852 Mon Sep 17 00:00:00 2001 From: Lyra Bot Date: Wed, 26 Aug 2026 05:18:53 -0400 Subject: [PATCH 2/2] fix(gitea): handle disabled git hooks gracefully in secure-gitea.py Previously, when Gitea is installed with DISABLE_GIT_HOOKS=true (the default), every git-hook API call returned 403 and the script: - marked every repository as FAILED, and - never counted the hook step at all. This change detects the disabled state up front with a single probe against the first editable repo: - If hooks are disabled, it prints a clear notice, skips the pre-receive step for every repo (branch/tag protection still runs normally), and exits non-zero (2) so automation notices the run is partial. - A per-repo 403 on the hook step is isolated (HookNotWritable) so it no longer clobbers the whole repo into FAILED -- branch/tag still apply, and the hook reports 'SKIPPED (git hooks not writable)'. - Adds a Hooks skipped counter and distinguishes 'disabled' vs 'not writable' in the summary. - Fixes an UnboundLocalError on the 'no editable repos' path by initializing hooks_enabled before the probe. Verified end-to-end against a mock Gitea API for both scenarios (hooks disabled -> skip + exit 2; hooks enabled -> exact/stale/missing handled correctly, exit 0). --- secure-gitea.py | 182 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 157 insertions(+), 25 deletions(-) diff --git a/secure-gitea.py b/secure-gitea.py index 2a3860d..5e7700d 100644 --- a/secure-gitea.py +++ b/secure-gitea.py @@ -94,6 +94,20 @@ TAG_DESIRED = { } +# When Gitea is installed with DISABLE_GIT_HOOKS=true (the default), the +# git-hook API returns 403 for every repository regardless of who the token +# belongs to -- even a site admin. The pre-receive guard below cannot be +# applied until an admin enables git hooks. We detect this up front (once) +# and skip all hook management so the run doesn't spam a 403 per repo. +GIT_HOOKS_ENABLED = None # True/False once probed; None = unknown + +# Sentinels the hook helpers raise (instead of requests.HTTPError) so that a +# git-hook failure is isolated from branch/tag failures and never marks a +# whole repository as FAILED. +class HookNotWritable(Exception): + """Raised when the git-hook API rejects us (usually hooks disabled).""" + + # --------------------------------------------------------------------------- # Desired Git hook configuration # @@ -436,6 +450,23 @@ def apply_tag_protection(owner, repo, existing): # Git hooks # --------------------------------------------------------------------------- +def hook_request_allowed(response): + """Return True if the response means hooks are usable, False if the + server rejected the request (403 -> DISABLE_GIT_HOOKS), and raise if + the failure is something else we should surface.""" + + if response.ok: + return True + + if response.status_code == 403: + # Gitea returns this when the authenticated user cannot manage git + # hooks, which happens whenever DISABLE_GIT_HOOKS is true (even for + # admins). Treat it as "hooks disabled / not writable". + return False + + return response.raise_for_status() + + def get_git_hook(owner, repo, hook_name): """ Return the existing {hook_name} hook, or None if it is not set. @@ -443,13 +474,21 @@ def get_git_hook(owner, repo, hook_name): Gitea returns is_active=false with empty content when the hook is not configured. Treat that as "not set" so it is reported and applied like the branch/tag protections below. + + Raises HookNotWritable when the git-hook API is not usable (e.g. git + hooks disabled), so callers can report it without failing the repo. """ - hook = api( - "GET", + url = ( f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/" - f"{quote(hook_name)}", + f"{quote(hook_name)}" ) + response = session.request("GET", f"{GITEA_URL}/api/v1{url}") + + if not hook_request_allowed(response): + raise HookNotWritable(url) + + hook = response.json() if not hook.get("is_active"): return None @@ -469,13 +508,20 @@ def describe_git_hook(hook): def apply_git_hook(owner, repo, hook_name): """Set the hook content to the desired value.""" - api( - "PATCH", + url = ( f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/" - f"{quote(hook_name)}", + f"{quote(hook_name)}" + ) + response = session.request( + "PATCH", + f"{GITEA_URL}/api/v1{url}", json={"content": GIT_HOOKS[hook_name]}, ) + if not hook_request_allowed(response): + raise HookNotWritable(url) + + response.raise_for_status() return "UPDATED" @@ -539,6 +585,55 @@ def main(): print(f"Found {len(repositories)} repositories.") print() + # ----------------------------------------------------------------------- + # Probe whether the git-hook API is usable + # ----------------------------------------------------------------------- + + # Gitea returns 403 (even for admins) when DISABLE_GIT_HOOKS is true -- + # the default. Detect that once, up front, instead of failing every + # repo's hook step. Use the first non-archived repo as the probe target. + # Initialize hooks_enabled before the probe so every code path has it. + + hooks_enabled = True + + probe_repo = next( + (r for r in repositories if not r.get("archived", False)), + None, + ) + + if probe_repo is None: + print("No editable repositories found; nothing to do.") + return + + probe_owner = probe_repo["owner"]["login"] + probe_name = probe_repo["name"] + + try: + get_git_hook(probe_owner, probe_name, "pre-receive") + print("Git hooks: enabled (git-hook API reachable)") + except HookNotWritable: + hooks_enabled = False + print( + "Git hooks: DISABLED/not writable -- pre-receive hook will be " + "skipped. Enable git hooks in Gitea (DISABLE_GIT_HOOKS=false " + "+ admin) to use the pre-receive guard." + ) + except requests.HTTPError: + # Some other API failure on the probe. Don't assume hooks are + # disabled; just note we couldn't verify and continue per-repo. + print( + "Git hooks: could not verify (API error) -- will attempt per repo" + ) + hooks_enabled = True + print() + + if not hooks_enabled: + print( + "*** Git hooks are disabled; skipping the pre-receive hook step " + "for all repositories. Branch/tag protection is unaffected. ***" + ) + print() + # ----------------------------------------------------------------------- # Counters # ----------------------------------------------------------------------- @@ -551,6 +646,8 @@ def main(): hook_changed = 0 hook_unchanged = 0 + hook_skipped = 0 + hooks_enabled = True skipped = 0 failed = 0 @@ -685,31 +782,55 @@ def main(): # Pre-receive Git hook # --------------------------------------------------------------- - hook = get_git_hook(owner, name, "pre-receive") + if hooks_enabled: + try: + hook = get_git_hook(owner, name, "pre-receive") - if hook is None: - print(" Hook: NOT SET") - print(" Would SET pre-receive hook") + if hook is None: + print(" Hook: NOT SET") + print(" Would SET pre-receive hook") - if args.apply: - result = apply_git_hook(owner, name, "pre-receive") - print(f" {result}") + if args.apply: + result = apply_git_hook( + owner, + name, + "pre-receive", + ) + print(f" {result}") - hook_changed += 1 + hook_changed += 1 - elif hook.get("content") == GIT_HOOKS["pre-receive"]: - print(" Hook: SET (matches desired content)") - hook_unchanged += 1 + elif hook.get("content") == GIT_HOOKS["pre-receive"]: + print( + " Hook: SET (matches desired content)" + ) + hook_unchanged += 1 + + else: + print(" Hook: SET (content differs)") + print(" Would UPDATE pre-receive hook") + + if args.apply: + result = apply_git_hook( + owner, + name, + "pre-receive", + ) + print(f" {result}") + + hook_changed += 1 + + except HookNotWritable: + print( + " Hook: SKIPPED (git hooks not writable)" + ) + hook_skipped += 1 else: - print(" Hook: SET (content differs)") - print(" Would UPDATE pre-receive hook") - - if args.apply: - result = apply_git_hook(owner, name, "pre-receive") - print(f" {result}") - - hook_changed += 1 + print( + " Hook: SKIPPED (git hooks disabled)" + ) + hook_skipped += 1 except requests.HTTPError: print(" FAILED") @@ -732,6 +853,12 @@ def main(): print() print(f"Hooks changed: {hook_changed}") print(f"Hooks unchanged: {hook_unchanged}") + if not hooks_enabled: + print(f"Hooks skipped: {hook_skipped} (git hooks disabled)") + elif hook_skipped: + print(f"Hooks skipped: {hook_skipped} (not writable)") + else: + print(f"Hooks skipped: {hook_skipped}") print() print(f"Skipped: {skipped}") print(f"Failed: {failed}") @@ -740,6 +867,11 @@ def main(): print() print("Dry run complete. Nothing was changed.") + # If the hook step had to be skipped (e.g. git hooks disabled), this run + # is only partially complete. Exit non-zero so automation notices. + if not hooks_enabled or hook_skipped: + sys.exit(2) + if __name__ == "__main__": main() -- 2.54.0