#!/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"