openclaw_oauth_sync/scripts/webhook-security/rotate-webhook-secret.sh
sol 2db7d7d90a feat: merge Gitea webhook security into setup wizard (issue #2)
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
2026-03-01 08:43:02 +00:00

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"