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:
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