feat(gitea): enforce pre-receive hook in secure-gitea.py #1

Merged
andrew merged 2 commits from feat/pre-receive-hook into main 2026-08-26 10:02:05 -04:00
Member

Summary

Extends secure-gitea.py (the "harden each repo" script) so it also manages the pre-receive git hook on every repo, alongside the existing branch/tag protection.

What it does

  • Embeds the canonical pre-receive guard content (the same "no workflow changes from untrusted branches" script in pre-receive-guard.sh) as GIT_HOOKS["pre-receive"].
  • For each repo, reads the current hook via GET /repos/{owner}/{repo}/hooks/git/pre-receive.
  • If the hook is not set (Gitea returns is_active: false) → reports "NOT SET / Would SET".
  • If the content differs → reports "SET (content differs) / Would UPDATE".
  • If it already matches exactly → reports "SET (matches desired content)" and leaves it alone.
  • Applies via PATCH .../hooks/git/pre-receive with {"content": ...} only under --apply, mirroring the branch/tag behavior (dry-run by default).
  • Adds Hooks changed / Hooks unchanged to the summary, skips archived repos, and inherits the existing failure handling.

Why exact-match

Verified against Gitea 1.27.2 source:

  • modules/git/hook.goGetHook reads hooks/pre-receive.d/pre-receive and returns that content verbatim (via is_active + content); Update() stores it verbatim (only strips \r).
  • The generated hooks/pre-receive wrapper runs every executable file in pre-receive.d/, so setting this content is all that's required for the guard to take effect.
  • EditGitHookOption is just {"content": "..."}. So "set properly" == byte-exact match.

Validation

  • py_compile clean.
  • Embedded GIT_HOOKS["pre-receive"] is byte-identical to pre-receive-guard.sh (3236 bytes).
  • Verified end-to-end against a mock Gitea API: correctly detected exact match (unchanged), stale content (UPDATE), missing (SET), and skipped archived repos. The mock asserted the PATCH body equals the canonical guard exactly.

Notes

  • Runs off the same GITEA_DOMAIN / GITEA_TOKEN / GITEA_ADMIN env vars as the rest of the script.
  • The API requires the authenticated user to be repo admin and have git-hook privileges (DISABLE_GIT_HOOKS=false + admin or AllowGitHook) — i.e., the boss-tier account the script already uses.
## Summary Extends `secure-gitea.py` (the "harden each repo" script) so it also manages the **pre-receive git hook** on every repo, alongside the existing branch/tag protection. ## What it does - Embeds the canonical pre-receive guard content (the same "no workflow changes from untrusted branches" script in `pre-receive-guard.sh`) as `GIT_HOOKS["pre-receive"]`. - For each repo, reads the current hook via `GET /repos/{owner}/{repo}/hooks/git/pre-receive`. - If the hook is **not set** (Gitea returns `is_active: false`) → reports "NOT SET / Would SET". - If the content **differs** → reports "SET (content differs) / Would UPDATE". - If it **already matches exactly** → reports "SET (matches desired content)" and leaves it alone. - Applies via `PATCH .../hooks/git/pre-receive` with `{"content": ...}` only under `--apply`, mirroring the branch/tag behavior (dry-run by default). - Adds **Hooks changed / Hooks unchanged** to the summary, skips archived repos, and inherits the existing failure handling. ## Why exact-match Verified against Gitea **1.27.2** source: - `modules/git/hook.go` — `GetHook` reads `hooks/pre-receive.d/pre-receive` and returns that content verbatim (via `is_active` + `content`); `Update()` stores it verbatim (only strips `\r`). - The generated `hooks/pre-receive` wrapper runs every executable file in `pre-receive.d/`, so setting this content is all that's required for the guard to take effect. - `EditGitHookOption` is just `{"content": "..."}`. So "set properly" == byte-exact match. ## Validation - `py_compile` clean. - Embedded `GIT_HOOKS["pre-receive"]` is **byte-identical** to `pre-receive-guard.sh` (3236 bytes). - Verified end-to-end against a mock Gitea API: correctly detected *exact match* (unchanged), *stale content* (UPDATE), *missing* (SET), and skipped archived repos. The mock asserted the PATCH body equals the canonical guard exactly. ## Notes - Runs off the same `GITEA_DOMAIN` / `GITEA_TOKEN` / `GITEA_ADMIN` env vars as the rest of the script. - The API requires the authenticated user to be repo admin **and** have git-hook privileges (`DISABLE_GIT_HOOKS=false` + admin or `AllowGitHook`) — i.e., the boss-tier account the script already uses.
lyra added 1 commit 2026-08-26 04:13:16 -04:00
feat(gitea): enforce pre-receive hook in secure-gitea.py
Sync GitHub / sync (push) Successful in 7s
9415ded220
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.
lyra added 1 commit 2026-08-26 05:18:55 -04:00
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).
Author
Member

Follow-up fix pushed (a56d97d): handles the DISABLE_GIT_HOOKS=true (default) case gracefully.

Verified against Gitea 1.27.2 source that the git-hook API returns 403 for every repo — even for admins — when git hooks are disabled (CanEditGitHook returns false). The previous revision would have marked every repo FAILED and never counted the hook step.

Now the script:

  • Probes the git-hook API once, up front (first editable repo).
  • If disabled → prints a clear notice, skips the pre-receive step for all repos (branch/tag protection still runs), reports Hooks skipped, and exits 2 so automation notices the partial run.
  • Isolates per-repo hook 403s (HookNotWritable) so a single hook failure no longer clobbers the whole repo into FAILED.
  • Fixes an UnboundLocalError on the "no editable repos" path.

Validated end-to-end against a mock Gitea API for both scenarios (disabled → skip + exit 2; enabled → exact/stale/missing all handled correctly, exit 0). 19/19 assertions passed.

Follow-up fix pushed (a56d97d): **handles the `DISABLE_GIT_HOOKS=true` (default) case gracefully.** Verified against Gitea 1.27.2 source that the git-hook API returns **403 for every repo** — even for admins — when git hooks are disabled (`CanEditGitHook` returns false). The previous revision would have marked every repo `FAILED` and never counted the hook step. Now the script: - Probes the git-hook API **once, up front** (first editable repo). - If disabled → prints a clear notice, **skips** the pre-receive step for all repos (branch/tag protection still runs), reports `Hooks skipped`, and **exits 2** so automation notices the partial run. - Isolates per-repo hook 403s (`HookNotWritable`) so a single hook failure no longer clobbers the whole repo into `FAILED`. - Fixes an `UnboundLocalError` on the "no editable repos" path. Validated end-to-end against a mock Gitea API for both scenarios (disabled → skip + exit 2; enabled → exact/stale/missing all handled correctly, exit 0). **19/19 assertions passed.**
andrew approved these changes 2026-08-26 10:01:52 -04:00
andrew merged commit 8b9f4819d6 into main 2026-08-26 10:02:05 -04:00
andrew deleted branch feat/pre-receive-hook 2026-08-26 10:02:05 -04:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: public/shell-scripts#1