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
This commit is contained in:
86
scripts/webhook-security/gitea-approve-repo
Executable file
86
scripts/webhook-security/gitea-approve-repo
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# gitea-approve-repo - Add a Gitea repo to the webhook allowlist
|
||||
# Usage: gitea-approve-repo owner/repo
|
||||
# After adding, validates nginx config. You must manually reload nginx.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ALLOWLIST="/etc/nginx/gitea-repo-allowlist.json"
|
||||
REPO="${1:-}"
|
||||
|
||||
if [ -z "$REPO" ]; then
|
||||
echo "Usage: gitea-approve-repo owner/repo"
|
||||
echo ""
|
||||
echo "Adds a repository to the Gitea webhook allowlist."
|
||||
echo "After adding, validates with nginx -t."
|
||||
echo "You must manually run: sudo nginx -s reload"
|
||||
echo ""
|
||||
echo "Current allowlist:"
|
||||
if [ -f "$ALLOWLIST" ]; then
|
||||
python3 -c "import json; d=json.load(open('$ALLOWLIST')); [print(' - ' + r) for r in d.get('repos', [])]"
|
||||
echo ""
|
||||
echo "Trusted owners:"
|
||||
python3 -c "import json; d=json.load(open('$ALLOWLIST')); [print(' - ' + o) for o in d.get('trusted_owners', [])]"
|
||||
else
|
||||
echo " (file not found: $ALLOWLIST)"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate format: must contain exactly one /
|
||||
if ! echo "$REPO" | grep -qP '^[^/]+/[^/]+$'; then
|
||||
echo "ERROR: Invalid repo format. Must be: owner/repo (e.g. myorg/my-project)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if already approved
|
||||
if [ -f "$ALLOWLIST" ]; then
|
||||
EXISTING=$(python3 -c "import json; d=json.load(open('$ALLOWLIST')); print('yes' if '$REPO' in d.get('repos', []) else 'no')")
|
||||
if [ "$EXISTING" = "yes" ]; then
|
||||
echo "Repo '$REPO' is already in the allowlist."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if owner is trusted (auto-allowed)
|
||||
OWNER_TRUSTED=$(python3 -c "import json; d=json.load(open('$ALLOWLIST')); owner='$REPO'.split('/')[0]; print('yes' if owner in d.get('trusted_owners', []) else 'no')")
|
||||
if [ "$OWNER_TRUSTED" = "yes" ]; then
|
||||
echo "Repo '$REPO' is already allowed via trusted owner."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add to allowlist
|
||||
if [ ! -f "$ALLOWLIST" ]; then
|
||||
echo "ERROR: Allowlist file not found: $ALLOWLIST"
|
||||
echo "Create it first with: echo '{\"repos\": [], \"trusted_owners\": []}' > $ALLOWLIST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Use python3 to safely modify JSON
|
||||
python3 -c "
|
||||
import json
|
||||
with open('$ALLOWLIST', 'r') as f:
|
||||
data = json.load(f)
|
||||
data.setdefault('repos', []).append('$REPO')
|
||||
with open('$ALLOWLIST', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\n')
|
||||
print('Added: $REPO')
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "Updated allowlist:"
|
||||
python3 -c "import json; d=json.load(open('$ALLOWLIST')); [print(' - ' + r) for r in d.get('repos', [])]"
|
||||
echo ""
|
||||
|
||||
# Validate nginx config
|
||||
echo "Validating nginx configuration..."
|
||||
if nginx -t 2>&1; then
|
||||
echo ""
|
||||
echo "Config is valid. To activate, run:"
|
||||
echo " sudo nginx -s reload"
|
||||
else
|
||||
echo ""
|
||||
echo "WARNING: nginx -t failed! Check the configuration before reloading."
|
||||
exit 1
|
||||
fi
|
||||
64
scripts/webhook-security/gitea-hmac-verify.js
Normal file
64
scripts/webhook-security/gitea-hmac-verify.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
var ALLOWLIST_PATH = '/etc/nginx/gitea-repo-allowlist.json';
|
||||
var SECRET_PATH = '/etc/nginx/gitea-webhook-secret';
|
||||
|
||||
function getSecret() {
|
||||
try { return fs.readFileSync(SECRET_PATH).toString().trim(); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function loadAllowlist() {
|
||||
try { return JSON.parse(fs.readFileSync(ALLOWLIST_PATH).toString()); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function isRepoAllowed(repoFullName, allowlist) {
|
||||
if (!allowlist || !repoFullName) return false;
|
||||
if ((allowlist.repos || []).indexOf(repoFullName) !== -1) return true;
|
||||
var owners = allowlist.trusted_owners || [];
|
||||
for (var i = 0; i < owners.length; i++) {
|
||||
if (repoFullName.startsWith(owners[i] + '/')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function constantTimeEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
async function verifyAndProxy(r) {
|
||||
var secret = getSecret();
|
||||
if (!secret) { r.error('gitea-hmac: failed to read secret'); r.return(500, 'Config error'); return; }
|
||||
|
||||
var giteaSig = r.headersIn['X-Gitea-Signature'];
|
||||
if (!giteaSig) { r.error('gitea-hmac: missing X-Gitea-Signature'); r.return(403, 'Missing signature'); return; }
|
||||
|
||||
var body = r.requestText || '';
|
||||
var hmac = crypto.createHmac('sha256', secret); hmac.update(body);
|
||||
if (!constantTimeEqual(hmac.digest('hex'), giteaSig)) {
|
||||
r.error('gitea-hmac: signature mismatch'); r.return(403, 'Invalid signature'); return;
|
||||
}
|
||||
|
||||
var allowlist = loadAllowlist();
|
||||
if (!allowlist) { r.error('gitea-hmac: cannot read allowlist'); r.return(403, 'Authorization unavailable'); return; }
|
||||
|
||||
var repoFullName = '';
|
||||
try { repoFullName = (JSON.parse(body).repository || {}).full_name || ''; }
|
||||
catch (e) { r.error('gitea-hmac: invalid JSON'); r.return(403, 'Invalid body'); return; }
|
||||
|
||||
if (!repoFullName) { r.error('gitea-hmac: no repo name'); r.return(403, 'Missing repo'); return; }
|
||||
if (!isRepoAllowed(repoFullName, allowlist)) {
|
||||
r.error('gitea-hmac: BLOCKED ' + repoFullName); r.return(403, 'Not authorized'); return;
|
||||
}
|
||||
|
||||
var res = await r.subrequest('/hooks/gitea-upstream', { method: r.method, body: body });
|
||||
var ct = res.headersOut['Content-Type']; if (ct) r.headersOut['Content-Type'] = ct;
|
||||
r.return(res.status, res.responseBody);
|
||||
}
|
||||
|
||||
export default { verifyAndProxy };
|
||||
77
scripts/webhook-security/ntfy-blocked-pickup.sh
Executable file
77
scripts/webhook-security/ntfy-blocked-pickup.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# ntfy-blocked-pickup.sh - Scans nginx error log for blocked webhook attempts
|
||||
# and sends ntfy.sh notifications for each new occurrence.
|
||||
#
|
||||
# Designed to run as a cron job every 60 seconds:
|
||||
# * * * * * /opt/webhook-security/scripts/ntfy-blocked-pickup.sh
|
||||
#
|
||||
# State file tracks the last-seen log position to avoid duplicate alerts.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ========================= CONFIGURATION =========================
|
||||
# Customize these values for your environment
|
||||
|
||||
ERROR_LOG="/var/log/nginx/error.log"
|
||||
STATE_FILE="/var/lib/webhook-security/ntfy-pickup-state"
|
||||
NTFY_TOPIC="@@NTFY_TOPIC@@"
|
||||
PATTERN="gitea-hmac: BLOCKED webhook from unauthorized repo:"
|
||||
|
||||
# =================================================================
|
||||
|
||||
# Ensure state directory exists
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
|
||||
# Read last processed byte offset (0 if first run)
|
||||
if [ -f "$STATE_FILE" ]; then
|
||||
LAST_OFFSET=$(cat "$STATE_FILE")
|
||||
else
|
||||
LAST_OFFSET=0
|
||||
fi
|
||||
|
||||
# Get current file size
|
||||
if [ ! -f "$ERROR_LOG" ]; then
|
||||
echo "No error log found at $ERROR_LOG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CURRENT_SIZE=$(stat -c%s "$ERROR_LOG" 2>/dev/null || stat -f%z "$ERROR_LOG" 2>/dev/null)
|
||||
|
||||
# Handle log rotation (file shrank)
|
||||
if [ "$CURRENT_SIZE" -lt "$LAST_OFFSET" ]; then
|
||||
LAST_OFFSET=0
|
||||
fi
|
||||
|
||||
# No new data
|
||||
if [ "$CURRENT_SIZE" -eq "$LAST_OFFSET" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract new lines and filter for blocked webhook entries
|
||||
NEW_BLOCKS=$(tail -c +"$((LAST_OFFSET + 1))" "$ERROR_LOG" | grep -F "$PATTERN" || true)
|
||||
|
||||
if [ -n "$NEW_BLOCKS" ]; then
|
||||
# Count blocked attempts
|
||||
COUNT=$(echo "$NEW_BLOCKS" | wc -l)
|
||||
|
||||
# Extract unique repo names
|
||||
REPOS=$(echo "$NEW_BLOCKS" | grep -oP 'unauthorized repo: \K\S+' | sort -u | tr '\n' ', ' | sed 's/,$//')
|
||||
|
||||
# Build notification message
|
||||
MSG="BLOCKED: ${COUNT} webhook(s) from unauthorized repo(s): ${REPOS}"
|
||||
|
||||
# Send ntfy notification
|
||||
curl -sf \
|
||||
-H "Title: Gitea Webhook Blocked" \
|
||||
-H "Priority: urgent" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "$MSG" \
|
||||
"$NTFY_TOPIC" > /dev/null 2>&1 || true
|
||||
|
||||
# Also log to syslog
|
||||
logger -t webhook-security "ntfy-blocked-pickup: $MSG"
|
||||
fi
|
||||
|
||||
# Update state file with current position
|
||||
echo "$CURRENT_SIZE" > "$STATE_FILE"
|
||||
254
scripts/webhook-security/rotate-webhook-secret.sh
Executable file
254
scripts/webhook-security/rotate-webhook-secret.sh
Executable file
@@ -0,0 +1,254 @@
|
||||
#!/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"
|
||||
167
scripts/webhook-security/webhook-audit-alert.sh
Executable file
167
scripts/webhook-security/webhook-audit-alert.sh
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
# webhook-audit-alert.sh - Daily webhook audit summary
|
||||
#
|
||||
# Analyzes the OpenClaw webhook event logs (JSONL) for the previous day:
|
||||
# - Total events, by result type
|
||||
# - Blocked/rejected events (anomalies)
|
||||
# - Signature failures
|
||||
# - Untrusted sender attempts
|
||||
#
|
||||
# Posts summary to a Mattermost channel and creates Gitea issues for anomalies.
|
||||
#
|
||||
# Daily cron:
|
||||
# 5 0 * * * /opt/webhook-security/scripts/webhook-audit-alert.sh >> /var/log/webhook-audit.log 2>&1
|
||||
#
|
||||
# Prerequisites:
|
||||
# - jq installed
|
||||
# - MATTERMOST_BOT_TOKEN env var set
|
||||
# - Gitea admin token file at GITEA_TOKEN_FILE path
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ========================= CONFIGURATION =========================
|
||||
# Customize these values for your environment
|
||||
|
||||
LOGS_DIR="/var/lib/openclaw/hooks/logs" # OpenClaw webhook event log directory
|
||||
MATTERMOST_URL="@@MATTERMOST_URL@@" # e.g., https://mattermost.example.com/api/v4
|
||||
MATTERMOST_TOKEN="${MATTERMOST_BOT_TOKEN:?Set MATTERMOST_BOT_TOKEN env var}"
|
||||
MATTERMOST_CHANNEL="@@MATTERMOST_CHANNEL_ID@@" # Target channel for audit summaries
|
||||
GITEA_API="@@GITEA_API_BASE@@/api/v1" # e.g., https://git.example.com/api/v1
|
||||
GITEA_TOKEN_FILE="/etc/nginx/gitea-admin-token"
|
||||
GITEA_REPO="@@GITEA_REPO@@" # e.g., myorg/webhook-security
|
||||
LOG_TAG="webhook-audit"
|
||||
|
||||
# =================================================================
|
||||
|
||||
# Date for analysis (yesterday)
|
||||
AUDIT_DATE=$(date -u -d "yesterday" '+%Y-%m-%d' 2>/dev/null || date -u -v-1d '+%Y-%m-%d')
|
||||
MONTH=$(echo "$AUDIT_DATE" | cut -d- -f1-2)
|
||||
LOG_FILE="$LOGS_DIR/webhook-events-${MONTH}.jsonl"
|
||||
|
||||
log() {
|
||||
echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $1"
|
||||
}
|
||||
|
||||
post_mattermost() {
|
||||
local MSG="$1"
|
||||
local ROOT_ID="${2:-}"
|
||||
local PAYLOAD
|
||||
|
||||
if [ -n "$ROOT_ID" ]; then
|
||||
PAYLOAD=$(jq -n --arg ch "$MATTERMOST_CHANNEL" --arg msg "$MSG" --arg rid "$ROOT_ID" \
|
||||
'{channel_id: $ch, message: $msg, root_id: $rid}')
|
||||
else
|
||||
PAYLOAD=$(jq -n --arg ch "$MATTERMOST_CHANNEL" --arg msg "$MSG" \
|
||||
'{channel_id: $ch, message: $msg}')
|
||||
fi
|
||||
|
||||
RESPONSE=$(curl -sf -X POST "$MATTERMOST_URL/posts" \
|
||||
-H "Authorization: Bearer $MATTERMOST_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" 2>/dev/null || echo '{"id":""}')
|
||||
|
||||
echo "$RESPONSE" | jq -r '.id' 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
create_gitea_issue() {
|
||||
local TITLE="$1"
|
||||
local BODY="$2"
|
||||
|
||||
if [ ! -f "$GITEA_TOKEN_FILE" ]; then
|
||||
log "WARNING: No Gitea token file, skipping issue creation"
|
||||
return
|
||||
fi
|
||||
|
||||
local TOKEN=$(cat "$GITEA_TOKEN_FILE" | tr -d '[:space:]')
|
||||
|
||||
curl -sf -X POST "$GITEA_API/repos/$GITEA_REPO/issues" \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b, labels: []}' )" \
|
||||
> /dev/null 2>&1 || log "WARNING: Failed to create Gitea issue"
|
||||
}
|
||||
|
||||
log "Starting audit for $AUDIT_DATE"
|
||||
|
||||
# Check if log file exists
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
log "No log file found: $LOG_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Filter to yesterday's entries
|
||||
YESTERDAY_ENTRIES=$(grep "\"$AUDIT_DATE" "$LOG_FILE" || true)
|
||||
|
||||
if [ -z "$YESTERDAY_ENTRIES" ]; then
|
||||
log "No webhook events on $AUDIT_DATE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count totals
|
||||
TOTAL=$(echo "$YESTERDAY_ENTRIES" | wc -l)
|
||||
ROUTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"routed"' || echo 0)
|
||||
SKIPPED_LOOP=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"skipped_loop"' || echo 0)
|
||||
SKIPPED_DUP=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"skipped_dup"' || echo 0)
|
||||
SKIPPED_UNSUPPORTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"skipped_unsupported"' || echo 0)
|
||||
REJECTED_NO_SIG=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"rejected_no_sig"' || echo 0)
|
||||
REJECTED_NO_SENDER=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"rejected_no_sender"' || echo 0)
|
||||
PERM_TRUSTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"perm_trusted"' || echo 0)
|
||||
PERM_UNTRUSTED=$(echo "$YESTERDAY_ENTRIES" | grep -c '"result":"perm_untrusted"' || echo 0)
|
||||
|
||||
# Check for anomalies
|
||||
ANOMALIES=0
|
||||
ANOMALY_DETAILS=""
|
||||
|
||||
if [ "$REJECTED_NO_SIG" -gt 0 ]; then
|
||||
ANOMALIES=$((ANOMALIES + REJECTED_NO_SIG))
|
||||
ANOMALY_DETAILS="${ANOMALY_DETAILS}\n- $REJECTED_NO_SIG events with missing signature"
|
||||
fi
|
||||
|
||||
if [ "$REJECTED_NO_SENDER" -gt 0 ]; then
|
||||
ANOMALIES=$((ANOMALIES + REJECTED_NO_SENDER))
|
||||
ANOMALY_DETAILS="${ANOMALY_DETAILS}\n- $REJECTED_NO_SENDER events with missing sender"
|
||||
fi
|
||||
|
||||
if [ "$PERM_UNTRUSTED" -gt 0 ]; then
|
||||
# Get unique untrusted senders
|
||||
UNTRUSTED_SENDERS=$(echo "$YESTERDAY_ENTRIES" | grep '"result":"perm_untrusted"' | jq -r '.sender' 2>/dev/null | sort -u | tr '\n' ', ' | sed 's/,$//')
|
||||
ANOMALY_DETAILS="${ANOMALY_DETAILS}\n- $PERM_UNTRUSTED events from untrusted senders: $UNTRUSTED_SENDERS"
|
||||
fi
|
||||
|
||||
# Build summary message
|
||||
SUMMARY="### Webhook Audit Summary: $AUDIT_DATE\n\n"
|
||||
SUMMARY="${SUMMARY}| Metric | Count |\n|---|---|\n"
|
||||
SUMMARY="${SUMMARY}| Total events | $TOTAL |\n"
|
||||
SUMMARY="${SUMMARY}| Routed (delivered) | $ROUTED |\n"
|
||||
SUMMARY="${SUMMARY}| Skipped (loop prevention) | $SKIPPED_LOOP |\n"
|
||||
SUMMARY="${SUMMARY}| Skipped (duplicate) | $SKIPPED_DUP |\n"
|
||||
SUMMARY="${SUMMARY}| Skipped (unsupported event) | $SKIPPED_UNSUPPORTED |\n"
|
||||
SUMMARY="${SUMMARY}| Rejected (no signature) | $REJECTED_NO_SIG |\n"
|
||||
SUMMARY="${SUMMARY}| Rejected (no sender) | $REJECTED_NO_SENDER |\n"
|
||||
SUMMARY="${SUMMARY}| Permission: trusted | $PERM_TRUSTED |\n"
|
||||
SUMMARY="${SUMMARY}| Permission: untrusted | $PERM_UNTRUSTED |\n"
|
||||
|
||||
if [ "$ANOMALIES" -gt 0 ]; then
|
||||
SUMMARY="${SUMMARY}\n**ANOMALIES DETECTED ($ANOMALIES):**$(echo -e "$ANOMALY_DETAILS")\n"
|
||||
fi
|
||||
|
||||
# Post to Mattermost
|
||||
log "Posting summary to Mattermost"
|
||||
ROOT_POST_ID=$(post_mattermost "$(echo -e "$SUMMARY")")
|
||||
|
||||
if [ -n "$ROOT_POST_ID" ] && [ "$ROOT_POST_ID" != "null" ] && [ "$ROOT_POST_ID" != "" ]; then
|
||||
log "Posted to Mattermost, post ID: $ROOT_POST_ID"
|
||||
else
|
||||
log "WARNING: Failed to post to Mattermost"
|
||||
fi
|
||||
|
||||
# Create Gitea issue for anomalies
|
||||
if [ "$ANOMALIES" -gt 0 ]; then
|
||||
log "Creating Gitea issue for $ANOMALIES anomalies"
|
||||
ISSUE_TITLE="[AUDIT] $ANOMALIES anomalies detected on $AUDIT_DATE"
|
||||
ISSUE_BODY="## Webhook Audit Anomalies: $AUDIT_DATE\n\n**$ANOMALIES anomalous events detected:**\n$(echo -e "$ANOMALY_DETAILS")\n\n### Action Required\nReview the webhook event log at \`$LOG_FILE\` for entries from $AUDIT_DATE.\n\nFilter command:\n\`\`\`bash\ngrep \"$AUDIT_DATE\" $LOG_FILE | jq 'select(.result | test(\"rejected|untrusted\"))'\n\`\`\`"
|
||||
create_gitea_issue "$ISSUE_TITLE" "$(echo -e "$ISSUE_BODY")"
|
||||
fi
|
||||
|
||||
log "Audit complete. Total: $TOTAL, Anomalies: $ANOMALIES"
|
||||
Reference in New Issue
Block a user