Integrates the 5-layer Gitea webhook security system from sol/clawgravity-hook-security (v2.0) into the setup wizard. ## What's added ### New files (from clawgravity-hook-security v2.0) - scripts/webhook-security/gitea-hmac-verify.js -- njs HMAC-SHA256 module - scripts/webhook-security/gitea-approve-repo -- allowlist helper - scripts/webhook-security/rotate-webhook-secret.sh -- monthly secret rotation (templated) - scripts/webhook-security/webhook-audit-alert.sh -- daily audit summaries (templated) - scripts/webhook-security/ntfy-blocked-pickup.sh -- blocked webhook alerts (templated) - templates/webhook-security/nginx-site.conf.example - templates/webhook-security/nginx.conf.example - templates/webhook-security/gitea-repo-allowlist.json.example - docs/WEBHOOK-SECURITY.md -- full documentation - docs/SECURITY-AUDIT.md -- 35-case test matrix - tests/test-webhook-security.sh -- 48 offline tests ### Modified files - setup.sh: Step 11 (webhook security wizard with 6 sub-sections) - scripts/uninstall.sh: webhook security cleanup section - README.md: Webhook Security section after Quick Start - Makefile: test target now runs test-webhook-security.sh - .secret-scan-allowlist: allowlist docs/SECURITY-AUDIT.md (test fixture) ## Security layers 1. IP allowlisting (nginx) 2. Rate limiting 10 req/s burst 20 (nginx) 3. Payload size 1MB (nginx) 4. HMAC-SHA256 signature verification (njs) 5. Per-repository allowlist (njs) ## make check - prettier: PASS - secret-scan: PASS - tests: 48/48 PASS Closes #2
255 lines
9.2 KiB
Bash
Executable File
255 lines
9.2 KiB
Bash
Executable File
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# =============================================================================
|
|
# Webhook Secret Rotation Script (v3)
|
|
# Rotates the HMAC-SHA256 secret used by nginx njs to verify Gitea webhooks.
|
|
# Runs monthly via cron: 0 3 1 * * (1st of month, 3am UTC)
|
|
#
|
|
# Flow:
|
|
# 1. Generate new secret
|
|
# 2. Update ALL Gitea webhooks with new secret (before nginx)
|
|
# 3. Verify at least one webhook succeeds
|
|
# 4. Write new secret to nginx file + container copy
|
|
# 5. Reload nginx
|
|
# 6. If any step fails, rollback everything
|
|
# =============================================================================
|
|
|
|
SECRET_FILE="/etc/nginx/gitea-webhook-secret"
|
|
GITEA_API="@@GITEA_API@@/api/v1"
|
|
GITEA_TOKEN_FILE="/etc/nginx/gitea-admin-token"
|
|
BACKUP_DIR="/var/lib/webhook-security/secret-backups"
|
|
LOG_TAG="webhook-secret-rotation"
|
|
# Container path on the HOST filesystem (not inside the container)
|
|
CONTAINER_SECRET_FILE=""
|
|
WEBHOOK_URL_MATCH="@@WEBHOOK_URL_MATCH@@"
|
|
NTFY_TOPIC=""
|
|
# Only scan repos from these owners (optimization: skip 100+ sneak/* repos)
|
|
SCAN_OWNERS="@@SCAN_OWNERS@@"
|
|
|
|
DRY_RUN=false
|
|
if [ "${1:-}" = "--dry-run" ]; then
|
|
DRY_RUN=true
|
|
echo "[DRY RUN] No changes will be made"
|
|
fi
|
|
|
|
log() { echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $1"; logger -t "$LOG_TAG" "$1" 2>/dev/null || true; }
|
|
|
|
notify_failure() {
|
|
curl -sf -H "Title: Webhook Secret Rotation FAILED" -H "Priority: urgent" \
|
|
-d "$1" "$NTFY_TOPIC" > /dev/null 2>&1 || true
|
|
}
|
|
|
|
die() {
|
|
log "FATAL: $1"
|
|
notify_failure "$1"
|
|
exit 1
|
|
}
|
|
|
|
# --- Preflight checks ---
|
|
[ -f "$GITEA_TOKEN_FILE" ] || die "Gitea admin token file not found: $GITEA_TOKEN_FILE"
|
|
GITEA_TOKEN=$(tr -d '[:space:]' < "$GITEA_TOKEN_FILE")
|
|
[ -n "$GITEA_TOKEN" ] || die "Gitea admin token file is empty"
|
|
|
|
[ -f "$SECRET_FILE" ] || die "Current secret file not found: $SECRET_FILE"
|
|
OLD_SECRET=$(tr -d '[:space:]' < "$SECRET_FILE")
|
|
[ -n "$OLD_SECRET" ] || die "Current secret file is empty"
|
|
|
|
command -v jq >/dev/null 2>&1 || die "jq not found - required for JSON processing"
|
|
command -v openssl >/dev/null 2>&1 || die "openssl not found - required for secret generation"
|
|
|
|
log "Starting webhook secret rotation"
|
|
|
|
# --- Dynamic repo discovery (filtered by owner) ---
|
|
log "Discovering repos with our webhooks..."
|
|
declare -A REPO_HOOKS # repo -> space-separated hook IDs
|
|
|
|
for OWNER in $SCAN_OWNERS; do
|
|
page=1
|
|
while true; do
|
|
REPOS_JSON=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/search?limit=50&page=$page&owner=$OWNER" 2>/dev/null || echo '{"data":[]}')
|
|
PAGE_REPOS=$(echo "$REPOS_JSON" | jq -r '.data[]?.full_name // empty' 2>/dev/null || true)
|
|
[ -n "$PAGE_REPOS" ] || break
|
|
|
|
while IFS= read -r REPO; do
|
|
[ -n "$REPO" ] || continue
|
|
HOOK_IDS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPO/hooks" 2>/dev/null | \
|
|
jq -r ".[] | select(.config.url | contains(\"$WEBHOOK_URL_MATCH\")) | .id" 2>/dev/null || true)
|
|
if [ -n "$HOOK_IDS" ]; then
|
|
REPO_HOOKS["$REPO"]="$HOOK_IDS"
|
|
log " Found: $REPO (hooks: $(echo $HOOK_IDS | tr '\n' ' '))"
|
|
fi
|
|
done <<< "$PAGE_REPOS"
|
|
|
|
page=$((page + 1))
|
|
done
|
|
done
|
|
|
|
REPO_COUNT=${#REPO_HOOKS[@]}
|
|
log "Found $REPO_COUNT repos with our webhooks"
|
|
|
|
if [ "$REPO_COUNT" -eq 0 ]; then
|
|
die "No repos found with our webhooks - something is wrong with discovery"
|
|
fi
|
|
|
|
# --- Generate new secret ---
|
|
NEW_SECRET=$(openssl rand -hex 32)
|
|
|
|
if $DRY_RUN; then
|
|
log "[DRY RUN] Would update $REPO_COUNT repos"
|
|
log "[DRY RUN] Would write new secret to $SECRET_FILE"
|
|
log "[DRY RUN] Would update container secret at $CONTAINER_SECRET_FILE"
|
|
log "[DRY RUN] Would reload nginx"
|
|
for REPO in "${!REPO_HOOKS[@]}"; do
|
|
log "[DRY RUN] $REPO: hooks ${REPO_HOOKS[$REPO]}"
|
|
done
|
|
exit 0
|
|
fi
|
|
|
|
# --- Backup old secret ---
|
|
mkdir -p "$BACKUP_DIR"
|
|
chmod 700 "$BACKUP_DIR"
|
|
BACKUP_FILE="$BACKUP_DIR/secret-$(date -u '+%Y%m%d-%H%M%S').bak"
|
|
cp "$SECRET_FILE" "$BACKUP_FILE"
|
|
chmod 600 "$BACKUP_FILE"
|
|
log "Backed up old secret to $BACKUP_FILE"
|
|
ls -t "$BACKUP_DIR"/secret-*.bak 2>/dev/null | tail -n +7 | xargs -r rm -f
|
|
|
|
# --- Phase 1: Update ALL Gitea webhooks with new secret FIRST ---
|
|
# This is done BEFORE writing the new secret to nginx. If this phase fails,
|
|
# we rollback the webhooks to the old secret. nginx never sees the new secret.
|
|
log "Phase 1: Updating Gitea webhooks with new secret..."
|
|
|
|
UPDATED=0
|
|
FAILED=0
|
|
UPDATED_REPOS=() # Track which repos were updated (for rollback)
|
|
FAILED_REPOS=()
|
|
|
|
for REPO in "${!REPO_HOOKS[@]}"; do
|
|
HOOK_IDS="${REPO_HOOKS[$REPO]}"
|
|
log " Updating $REPO..."
|
|
|
|
REPO_OK=true
|
|
while IFS= read -r HOOK_ID; do
|
|
[ -n "$HOOK_ID" ] || continue
|
|
|
|
# Get current hook events
|
|
EVENTS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPO/hooks/$HOOK_ID" 2>/dev/null | \
|
|
jq -c '.events' 2>/dev/null || echo '["issues","issue_comment"]')
|
|
|
|
# Delete old hook
|
|
DEL_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPO/hooks/$HOOK_ID")
|
|
|
|
if [ "$DEL_CODE" != "204" ]; then
|
|
log " WARNING: Delete hook #$HOOK_ID returned HTTP $DEL_CODE"
|
|
REPO_OK=false
|
|
continue
|
|
fi
|
|
|
|
# Create new hook with new secret
|
|
CREATE_BODY=$(jq -n \
|
|
--arg secret "$NEW_SECRET" \
|
|
--arg url "https://$WEBHOOK_URL_MATCH" \
|
|
--argjson events "$EVENTS" \
|
|
'{type: "gitea", active: true, config: {url: $url, content_type: "json", secret: $secret}, events: $events}')
|
|
|
|
CREATE_RESULT=$(curl -sf -X POST \
|
|
-H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$CREATE_BODY" \
|
|
"$GITEA_API/repos/$REPO/hooks" 2>/dev/null || echo "FAIL")
|
|
|
|
NEW_ID=$(echo "$CREATE_RESULT" | jq -r '.id // empty' 2>/dev/null || true)
|
|
if [ -n "$NEW_ID" ]; then
|
|
log " Created hook #$NEW_ID (replaced #$HOOK_ID)"
|
|
UPDATED=$((UPDATED + 1))
|
|
else
|
|
log " FAILED to create replacement for #$HOOK_ID"
|
|
REPO_OK=false
|
|
fi
|
|
done <<< "$HOOK_IDS"
|
|
|
|
if $REPO_OK; then
|
|
UPDATED_REPOS+=("$REPO")
|
|
else
|
|
FAILED_REPOS+=("$REPO")
|
|
FAILED=$((FAILED + 1))
|
|
fi
|
|
done
|
|
|
|
log "Phase 1 complete: $UPDATED hooks updated, $FAILED repos with failures"
|
|
|
|
# --- Rollback if any failures ---
|
|
if [ "$FAILED" -gt 0 ]; then
|
|
log "ROLLING BACK: $FAILED repo(s) failed webhook update"
|
|
log "Failed repos: ${FAILED_REPOS[*]}"
|
|
|
|
# Rollback successful repos back to old secret
|
|
for REPO in "${UPDATED_REPOS[@]}"; do
|
|
log " Rolling back $REPO to old secret..."
|
|
HOOK_IDS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPO/hooks" 2>/dev/null | \
|
|
jq -r ".[] | select(.config.url | contains(\"$WEBHOOK_URL_MATCH\")) | .id" 2>/dev/null || true)
|
|
|
|
while IFS= read -r HID; do
|
|
[ -n "$HID" ] || continue
|
|
EVENTS=$(curl -sf -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPO/hooks/$HID" 2>/dev/null | \
|
|
jq -c '.events' 2>/dev/null || echo '["issues","issue_comment"]')
|
|
|
|
curl -s -o /dev/null -X DELETE -H "Authorization: token $GITEA_TOKEN" \
|
|
"$GITEA_API/repos/$REPO/hooks/$HID"
|
|
|
|
ROLLBACK_BODY=$(jq -n \
|
|
--arg secret "$OLD_SECRET" \
|
|
--arg url "https://$WEBHOOK_URL_MATCH" \
|
|
--argjson events "$EVENTS" \
|
|
'{type: "gitea", active: true, config: {url: $url, content_type: "json", secret: $secret}, events: $events}')
|
|
|
|
curl -sf -X POST -H "Authorization: token $GITEA_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$ROLLBACK_BODY" \
|
|
"$GITEA_API/repos/$REPO/hooks" > /dev/null 2>&1 || true
|
|
done <<< "$HOOK_IDS"
|
|
done
|
|
|
|
die "Rotation aborted: $FAILED repo(s) failed. All webhooks rolled back to old secret. Failed: ${FAILED_REPOS[*]}"
|
|
fi
|
|
|
|
# --- Phase 2: Write new secret to nginx + container ---
|
|
log "Phase 2: Writing new secret to nginx..."
|
|
printf '%s' "$NEW_SECRET" > "$SECRET_FILE"
|
|
chown root:www-data "$SECRET_FILE"
|
|
chmod 640 "$SECRET_FILE"
|
|
log "Wrote new secret to $SECRET_FILE"
|
|
|
|
# Update container-local copy
|
|
if [ -d "$(dirname "$CONTAINER_SECRET_FILE")" ]; then
|
|
printf '%s' "$NEW_SECRET" > "$CONTAINER_SECRET_FILE"
|
|
chmod 600 "$CONTAINER_SECRET_FILE"
|
|
log "Updated container secret at $CONTAINER_SECRET_FILE"
|
|
else
|
|
log "WARNING: Container secret directory not found at $(dirname "$CONTAINER_SECRET_FILE")"
|
|
fi
|
|
|
|
# --- Phase 3: Reload nginx ---
|
|
log "Phase 3: Reloading nginx..."
|
|
if ! nginx -t 2>/dev/null; then
|
|
# Rollback nginx secret
|
|
cp "$BACKUP_FILE" "$SECRET_FILE"
|
|
chown root:www-data "$SECRET_FILE"
|
|
chmod 640 "$SECRET_FILE"
|
|
die "nginx -t failed after writing new secret. Rolled back secret file. Webhooks still have NEW secret — manual intervention needed!"
|
|
fi
|
|
|
|
nginx -s reload
|
|
log "nginx reloaded successfully"
|
|
|
|
# --- Done ---
|
|
log "Rotation complete. Updated: $UPDATED hooks across ${#UPDATED_REPOS[@]} repos. Failed: 0"
|