diff --git a/.secret-scan-allowlist b/.secret-scan-allowlist index d17cc97..e80f898 100644 --- a/.secret-scan-allowlist +++ b/.secret-scan-allowlist @@ -6,3 +6,5 @@ isOAuthToken # Domain references - public/documented www.rooh.red git.eeqj.de +# Webhook security audit - all-zeros test signature (not a real secret) +docs/SECURITY-AUDIT.md diff --git a/Makefile b/Makefile index 2d3b52b..635bb77 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ install: npm install test: - @echo "[SKIP] Tests require installed system services (not available in CI)" + bash tests/test-webhook-security.sh fmt: npx prettier --write . diff --git a/README.md b/README.md index d04dedd..da85941 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,38 @@ The interactive wizard will: Every install step asks for your confirmation first — you have full control over what gets installed. +## Webhook Security (Optional) + +The setup wizard includes an optional Step 11 that installs 5-layer webhook +security for the `/hooks/gitea` endpoint. This protects your OpenClaw instance +from unauthorized webhook requests. + +**Security layers:** + +1. IP allowlisting (only your Gitea server can send webhooks) +2. Rate limiting (10 req/s, burst 20) +3. Payload size limit (1MB) +4. HMAC-SHA256 signature verification (njs module) +5. Per-repository allowlist (only approved repos can trigger agents) + +**What gets installed (when you choose to enable it during setup):** + +- nginx njs HMAC module (`/etc/nginx/njs/gitea-hmac-verify.js`) +- Webhook secret file (`/etc/nginx/gitea-webhook-secret`) +- Repository allowlist (`/etc/nginx/gitea-repo-allowlist.json`) +- Helper scripts (`/opt/webhook-security/scripts/`) +- `gitea-approve-repo` command (add repos to allowlist) + +After installation, manage the allowlist with: + +```bash +gitea-approve-repo owner/repo # Allow a specific repo +cat /etc/nginx/gitea-repo-allowlist.json # View current allowlist +``` + +Full documentation: [docs/WEBHOOK-SECURITY.md](docs/WEBHOOK-SECURITY.md) +Security audit: [docs/SECURITY-AUDIT.md](docs/SECURITY-AUDIT.md) + ## Prerequisites - Linux server with **systemd** diff --git a/docs/SECURITY-AUDIT.md b/docs/SECURITY-AUDIT.md new file mode 100644 index 0000000..56315e1 --- /dev/null +++ b/docs/SECURITY-AUDIT.md @@ -0,0 +1,156 @@ +# Security Audit Report + +Full test matrix for the Gitea webhook security layers. Each test was verified +against a live deployment. + +## Test Matrix + +### HMAC-SHA256 Verification (Layer 4) + +| # | Test Case | Method | Expected | Verified | +| --- | ---------------------------------- | ----------------------------------- | -------------------------- | -------- | +| 1 | Valid signature | `openssl dgst -sha256 -hmac SECRET` | 200 (proxied) | Yes | +| 2 | Invalid signature | Random hex string as signature | 403 "Invalid signature" | Yes | +| 3 | Missing `X-Gitea-Signature` header | No signature header sent | 403 "Missing signature" | Yes | +| 4 | Empty body with valid signature | HMAC of empty string | 200 (proxied, body parsed) | Yes | +| 5 | Timing attack resistance | Constant-time XOR comparison | No early-exit on mismatch | Yes | + +### IP Allowlisting (Layer 1) + +| # | Test Case | Method | Expected | Verified | +| --- | ----------------------- | -------------------- | ------------------------ | -------- | +| 6 | Request from allowed IP | From Gitea server IP | Passes to HMAC check | Yes | +| 7 | Request from denied IP | From any other IP | 403 (nginx default page) | Yes | +| 8 | Request from localhost | From 127.0.0.1 | Passes to HMAC check | Yes | + +### Rate Limiting (Layer 2) + +| # | Test Case | Method | Expected | Verified | +| --- | -------------- | ------------------------------- | ------------------ | -------- | +| 9 | Under limit | < 10 req/s | Normal processing | Yes | +| 10 | At burst limit | 20 concurrent requests | All processed | Yes | +| 11 | Over limit | > 10 req/s sustained past burst | 429/503 for excess | Yes | + +### Payload Size (Layer 3) + +| # | Test Case | Method | Expected | Verified | +| --- | ----------------- | --------------- | ---------------------------- | -------- | +| 12 | Normal payload | < 1MB | Normal processing | Yes | +| 13 | Oversized payload | > 1MB POST body | 413 Request Entity Too Large | Yes | + +### Repository Allowlist (Layer 5) + +| # | Test Case | Method | Expected | Verified | +| --- | ------------------------------ | ------------------------------- | ------------------------ | -------- | +| 14 | Exact match in `repos` | `owner/repo` in repos array | Allowed | Yes | +| 15 | Trusted owner prefix | `owner/*` via trusted_owners | Allowed | Yes | +| 16 | Unknown repo | Not in repos or trusted_owners | 403 "Not authorized" | Yes | +| 17 | Missing `repository.full_name` | Payload without repo field | 403 "Missing repo" | Yes | +| 18 | Malformed JSON body | Non-JSON payload with valid sig | 403 "Invalid body" | Yes | +| 19 | Case sensitivity | `OWNER/repo` vs `owner/repo` | Blocked (case-sensitive) | Yes | + +### Secret Rotation + +| # | Test Case | Method | Expected | Verified | +| --- | --------------------------- | --------------------------- | ------------------------------------ | -------- | +| 20 | Full successful cycle | `rotate-webhook-secret.sh` | New secret active, all hooks updated | Yes | +| 21 | Dry run mode | `--dry-run` flag | No changes made, plan displayed | Yes | +| 22 | Partial failure (Gitea API) | Simulate API error | Rollback all webhooks to old secret | Yes | +| 23 | nginx reload failure | Simulate bad config | Restore old secret file, alert sent | Yes | +| 24 | Missing admin token | Remove token file | Fails safely with error message | Yes | +| 25 | Missing current secret | Remove secret file | Fails safely with error message | Yes | +| 26 | Dynamic discovery | No hardcoded repo list | Finds repos via Gitea API | Yes | +| 27 | Owner filtering | Only trusted owners scanned | Skips unrelated repos | Yes | + +### Fail-Closed Behavior + +| # | Test Case | Method | Expected | Verified | +| --- | ---------------------- | --------------------------------------------- | ------------------------------- | -------- | +| 28 | Missing secret file | Delete `/etc/nginx/gitea-webhook-secret` | 500 "Config error" | Yes | +| 29 | Missing allowlist file | Delete `/etc/nginx/gitea-repo-allowlist.json` | 403 "Authorization unavailable" | Yes | +| 30 | Corrupt allowlist | Invalid JSON in allowlist file | 403 "Authorization unavailable" | Yes | +| 31 | Empty allowlist | `{"repos":[],"trusted_owners":[]}` | All repos blocked | Yes | + +### Monitoring & Alerting + +| # | Test Case | Method | Expected | Verified | +| --- | ------------------------- | ------------------------------- | --------------------------- | -------- | +| 32 | Blocked webhook detection | Trigger blocked repo webhook | ntfy alert within 60s | Yes | +| 33 | Log rotation handling | Simulate rotated error log | State resets, no duplicates | Yes | +| 34 | Daily audit summary | Run audit script | Summary with correct counts | Yes | +| 35 | Anomaly detection | Include untrusted sender events | Gitea issue created | Yes | + +## Design Principles + +### Fail-Closed + +Every error path denies the request. There is no scenario where a misconfiguration +or missing file results in an open endpoint: + +- Missing secret → 500 (request rejected before HMAC check) +- Missing allowlist → 403 (all repos blocked) +- Corrupt allowlist → 403 (JSON parse failure = null = blocked) +- Missing repo field → 403 + +### No Caching + +The njs module reads the secret and allowlist from disk on every request. This +means: + +- Secret rotation takes effect immediately (no nginx reload needed for the secret) +- Allowlist changes take effect immediately +- Trade-off: minor disk I/O per request (acceptable for webhook volume) + +### Constant-Time Comparison + +HMAC signatures are compared using XOR-based constant-time comparison to prevent +timing attacks. The comparison always processes every character regardless of +where the first difference occurs. + +### Defense in Depth + +Six independent security layers mean that a bypass of any single layer does not +compromise the endpoint. Each layer operates independently: + +1. IP allowlist (network layer) +2. Rate limiting (traffic layer) +3. Payload size (resource layer) +4. HMAC verification (authentication layer) +5. Repository allowlist (authorization layer) +6. Permission check (identity layer, in OpenClaw transform) + +## How to Run Verification Tests + +See the **Verification Tests** section in [README.md](README.md) for commands +to manually test each layer. + +### Automated Testing + +```bash +# Generate a test signature +SECRET=$(cat /etc/nginx/gitea-webhook-secret) +BODY='{"repository":{"full_name":"owner/repo"}}' +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') + +# Test valid request (from allowed IP / localhost) +curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea \ + -H "X-Gitea-Signature: $SIG" -d "$BODY" +# Expected: 200 (if owner/repo is in allowlist) or 403 (if not) + +# Test invalid signature +curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea \ + -H "X-Gitea-Signature: 0000000000000000000000000000000000000000000000000000000000000000" \ + -d "$BODY" +# Expected: 403 + +# Test missing signature +curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea -d "$BODY" +# Expected: 403 + +# Test unknown repo +BODY2='{"repository":{"full_name":"unknown/repo"}}' +SIG2=$(echo -n "$BODY2" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') +curl -s -o /dev/null -w "%{http_code}" http://localhost/hooks/gitea \ + -H "X-Gitea-Signature: $SIG2" -d "$BODY2" +# Expected: 403 +``` diff --git a/docs/WEBHOOK-SECURITY.md b/docs/WEBHOOK-SECURITY.md new file mode 100644 index 0000000..e1abc89 --- /dev/null +++ b/docs/WEBHOOK-SECURITY.md @@ -0,0 +1,309 @@ +# Webhook Security for OpenClaw + +This document covers the 5-layer Gitea webhook security system that can be +installed alongside the OAuth token sync. The security system protects your +OpenClaw webhook endpoint from unauthorized requests. + +Source: [sol/clawgravity-hook-security](https://git.eeqj.de/sol/clawgravity-hook-security) (v2.0) + +## Overview + +When you install OpenClaw, it exposes a `/hooks/gitea` endpoint that receives +Gitea webhooks and routes them to your agents. Without protection, anyone who +can reach this endpoint can send arbitrary webhook payloads to your agents. + +This security system adds five independent layers of defense: + +| Layer | Mechanism | Where | +| ----- | ---------------------------------- | --------- | +| 1 | IP allowlisting | nginx | +| 2 | Rate limiting (10 req/s, burst 20) | nginx | +| 3 | Payload size limit (1MB) | nginx | +| 4 | HMAC-SHA256 signature verification | nginx njs | +| 5 | Per-repository allowlist | nginx njs | + +Every layer operates independently. A bypass of any single layer does not +compromise the endpoint — all layers must pass for a request to be proxied. + +## Architecture + +``` +Gitea Server + | + v (HTTPS) +nginx + | + +-- Layer 1: IP allowlist (allow Gitea IP, deny all) + +-- Layer 2: Rate limit (10 req/s, burst 20) + +-- Layer 3: Payload size (1MB max) + +-- Layer 4+5: njs HMAC verify + repo allowlist + | + v (internal proxy, only if all layers pass) +OpenClaw Gateway (:3000/hooks/gitea) +``` + +## Installed Files + +After running `./setup.sh` with webhook security enabled: + +| File | Purpose | +| -------------------------------------------------------- | -------------------------------------- | +| `/etc/nginx/njs/gitea-hmac-verify.js` | njs module (Layers 4 + 5) | +| `/etc/nginx/gitea-webhook-secret` | HMAC shared secret (root:www-data 640) | +| `/etc/nginx/gitea-repo-allowlist.json` | Repository allowlist | +| `/opt/webhook-security/scripts/rotate-webhook-secret.sh` | Monthly secret rotation | +| `/opt/webhook-security/scripts/webhook-audit-alert.sh` | Daily audit summaries | +| `/opt/webhook-security/scripts/ntfy-blocked-pickup.sh` | Real-time blocked alerts | +| `/usr/local/bin/gitea-approve-repo` | Add repos to allowlist | + +Template files (reference only, not deployed): + +| File | Purpose | +| -------------------------------------------------------------- | -------------------------------- | +| `templates/webhook-security/nginx.conf.example` | http block additions | +| `templates/webhook-security/nginx-site.conf.example` | Site config with location blocks | +| `templates/webhook-security/gitea-repo-allowlist.json.example` | Allowlist format reference | + +## nginx Configuration + +Two changes are required in your nginx configuration. The setup wizard displays +these during installation — this section documents them for reference. + +### 1. nginx.conf (http block) + +Add to the `http {}` block in `/etc/nginx/nginx.conf`: + +```nginx +http { + js_path "/etc/nginx/njs/"; + js_import gitea_hmac from gitea-hmac-verify.js; + limit_req_zone $binary_remote_addr zone=gitea_webhook:1m rate=10r/s; +} +``` + +### 2. Site config (location blocks) + +Add to your site configuration (e.g. `/etc/nginx/sites-enabled/openclaw`): + +```nginx +# Internal upstream for Gitea webhook (post-HMAC verification) +location /hooks/gitea-upstream { + internal; + proxy_pass http://127.0.0.1:YOUR_OPENCLAW_PORT/hooks/gitea; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization "Bearer YOUR_OPENCLAW_GATEWAY_TOKEN"; + proxy_set_header X-Gitea-Event $http_x_gitea_event; + proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery; + proxy_set_header X-Gitea-Signature $http_x_gitea_signature; + proxy_buffering off; +} + +# Gitea webhook — HMAC-SHA256 verified by njs before proxying +location = /hooks/gitea { + # Layer 1: IP allowlisting + allow YOUR_GITEA_SERVER_IP; + allow 127.0.0.1; + deny all; + + # Layer 2: Rate limiting + limit_req zone=gitea_webhook burst=20 nodelay; + + # Layer 3: Payload size limit + client_body_buffer_size 1m; + client_max_body_size 1m; + + # Layer 4+5: HMAC verification + repo allowlist (njs) + js_content gitea_hmac.verifyAndProxy; +} +``` + +After editing, validate and reload: + +```bash +nginx -t && nginx -s reload +``` + +## Managing the Repository Allowlist + +The allowlist at `/etc/nginx/gitea-repo-allowlist.json` controls which +repositories are allowed to trigger your webhook. Changes take effect +immediately (no nginx reload needed). + +### Add a specific repository + +```bash +gitea-approve-repo owner/repo-name +``` + +### Add all repos from a trusted owner + +Edit `/etc/nginx/gitea-repo-allowlist.json` directly: + +```json +{ + "repos": ["owner/specific-repo"], + "trusted_owners": ["trusted-org"] +} +``` + +### View current allowlist + +```bash +cat /etc/nginx/gitea-repo-allowlist.json +``` + +### Remove a repository + +Edit `/etc/nginx/gitea-repo-allowlist.json` and remove the entry from the +`repos` array. + +## Secret Rotation + +The HMAC secret should be rotated periodically. A rotation script is included +that handles the full rotation cycle atomically: + +1. Generates a new secret +2. Updates all Gitea webhooks with the new secret (via API) +3. Verifies the updates succeeded +4. Writes the new secret to nginx +5. Reloads nginx +6. Rolls back everything if any step fails + +### Manual rotation + +```bash +# Dry run first (no changes) +/opt/webhook-security/scripts/rotate-webhook-secret.sh --dry-run + +# Actual rotation +sudo /opt/webhook-security/scripts/rotate-webhook-secret.sh +``` + +### Prerequisites for rotation + +- `jq` installed +- A Gitea admin token file at `/etc/nginx/gitea-admin-token` + (create with: `echo "YOUR_GITEA_ADMIN_TOKEN" > /etc/nginx/gitea-admin-token && chmod 600 /etc/nginx/gitea-admin-token`) + +### Automated rotation (cron) + +The setup wizard offers to install a monthly cron job: + +``` +0 3 1 * * /opt/webhook-security/scripts/rotate-webhook-secret.sh >> /var/log/webhook-secret-rotation.log 2>&1 +``` + +After rotation, you do NOT need to update your Gitea webhook settings — +the rotation script updates Gitea automatically. + +## Monitoring + +### Blocked webhook alerts (ntfy) + +The `ntfy-blocked-pickup.sh` script scans nginx's error log for blocked +webhook attempts and sends ntfy.sh push notifications. + +When run every minute via cron, you get near-real-time alerts when someone +attempts to trigger your webhook from an unauthorized repository. + +To view blocked attempts manually: + +```bash +grep "gitea-hmac: BLOCKED" /var/log/nginx/error.log +``` + +### Daily audit summary (Mattermost) + +The `webhook-audit-alert.sh` script analyzes OpenClaw's webhook event logs +for the previous day and posts a summary to a Mattermost channel. It also +creates Gitea issues for anomalies (rejected/untrusted events). + +Log format expected: JSONL at `/var/lib/openclaw/hooks/logs/webhook-events-YYYY-MM.jsonl` + +## Security Properties + +### Fail-Closed + +Every error path denies the request: + +- Missing secret file → 500 (request rejected before HMAC check) +- Missing allowlist file → 403 (all repos blocked) +- Corrupt allowlist JSON → 403 (parse failure = blocked) +- Missing repo field in payload → 403 +- Signature mismatch → 403 + +There is no misconfiguration that results in an open endpoint. + +### No Caching + +The njs module reads the secret and allowlist from disk on every request. +This means: + +- Secret rotation takes effect immediately (no nginx reload needed) +- Allowlist changes take effect immediately +- Trade-off: minor disk I/O per request (negligible for webhook volume) + +### Constant-Time Comparison + +HMAC signatures are compared using XOR-based constant-time comparison to +prevent timing attacks. See `scripts/webhook-security/gitea-hmac-verify.js`. + +## Troubleshooting + +### 403 on all webhooks + +1. Check nginx error log: `tail -f /var/log/nginx/error.log` +2. Verify secret matches Gitea: `cat /etc/nginx/gitea-webhook-secret` +3. Check allowlist: `cat /etc/nginx/gitea-repo-allowlist.json` +4. Verify njs module loaded: `nginx -T | grep js_` + +### 403 "Missing signature" + +The webhook in Gitea is not configured with a secret. Edit the webhook in +Gitea Settings and set the secret to match `/etc/nginx/gitea-webhook-secret`. + +### 403 "Invalid signature" + +The HMAC secret in nginx does not match the one configured in Gitea. +Verify they match or run the rotation script. + +### 403 "Not authorized" + +The repository is not in the allowlist. Add it: + +```bash +gitea-approve-repo owner/repo-name +``` + +### nginx -t fails after installation + +The njs module may not be loaded. Check: + +```bash +# Verify njs module is installed +dpkg -l libnginx-mod-http-js + +# Install if missing +apt-get install -y libnginx-mod-http-js + +# Verify module file exists +ls /usr/lib/nginx/modules/ngx_http_js_module.so +``` + +### Permission denied on secret file + +The secret file must be readable by nginx (www-data group): + +```bash +chown root:www-data /etc/nginx/gitea-webhook-secret +chmod 640 /etc/nginx/gitea-webhook-secret +``` + +## Reference + +- Full test matrix: [docs/SECURITY-AUDIT.md](SECURITY-AUDIT.md) +- Template examples: `templates/webhook-security/` +- Source code: [sol/clawgravity-hook-security](https://git.eeqj.de/sol/clawgravity-hook-security) diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh index 5d05de1..04b659a 100755 --- a/scripts/uninstall.sh +++ b/scripts/uninstall.sh @@ -37,6 +37,74 @@ for script in /usr/local/bin/sync-oauth-token.sh /usr/local/bin/refresh-claude-t fi done +# ============================================================================ +# Webhook Security Cleanup (if installed) +# ============================================================================ +WH_INSTALLED=false + +for wh_file in /etc/nginx/njs/gitea-hmac-verify.js /etc/nginx/gitea-webhook-secret /etc/nginx/gitea-repo-allowlist.json /opt/webhook-security /usr/local/bin/gitea-approve-repo; do + if [ -e "$wh_file" ]; then + WH_INSTALLED=true + break + fi +done + +if $WH_INSTALLED; then + echo "" + echo "Webhook security files detected." + read -rp "[uninstall] Remove webhook security files? [y/N]: " WH_CONFIRM + WH_CONFIRM="${WH_CONFIRM:-N}" + if [[ "$WH_CONFIRM" =~ ^[Yy] ]]; then + # Remove njs module + if [ -f /etc/nginx/njs/gitea-hmac-verify.js ]; then + log "Removing /etc/nginx/njs/gitea-hmac-verify.js" + rm -f /etc/nginx/njs/gitea-hmac-verify.js + fi + + # Remove secret file + if [ -f /etc/nginx/gitea-webhook-secret ]; then + log "Removing /etc/nginx/gitea-webhook-secret" + rm -f /etc/nginx/gitea-webhook-secret + fi + + # Remove allowlist + if [ -f /etc/nginx/gitea-repo-allowlist.json ]; then + log "Removing /etc/nginx/gitea-repo-allowlist.json" + rm -f /etc/nginx/gitea-repo-allowlist.json + fi + + # Remove scripts directory + if [ -d /opt/webhook-security ]; then + log "Removing /opt/webhook-security/" + rm -rf /opt/webhook-security + fi + + # Remove gitea-approve-repo helper + if [ -f /usr/local/bin/gitea-approve-repo ]; then + log "Removing /usr/local/bin/gitea-approve-repo" + rm -f /usr/local/bin/gitea-approve-repo + fi + + # Remove webhook-security cron entries + if crontab -l 2>/dev/null | grep -q 'webhook-security\|ntfy-blocked-pickup\|rotate-webhook-secret\|webhook-audit-alert'; then + log "Removing webhook-security cron entries..." + crontab -l 2>/dev/null | grep -v 'webhook-security\|ntfy-blocked-pickup\|rotate-webhook-secret\|webhook-audit-alert' | crontab - + log "Cron entries removed" + fi + + log "Webhook security files removed." + echo "" + log "IMPORTANT: You must also remove the webhook security configuration" + log "from your nginx config manually:" + log " - Remove js_path, js_import, and limit_req_zone from nginx.conf http block" + log " - Remove the 'location = /hooks/gitea' block from your site config" + log " - Remove the 'location /hooks/gitea-upstream' block from your site config" + log " - Run: nginx -t && nginx -s reload" + else + log "Skipping webhook security cleanup." + fi +fi + echo "" log "Done. The following files were NOT removed (contain your credentials):" log " - /root/.openclaw/credentials/oauth.json" diff --git a/scripts/webhook-security/gitea-approve-repo b/scripts/webhook-security/gitea-approve-repo new file mode 100755 index 0000000..dece2d2 --- /dev/null +++ b/scripts/webhook-security/gitea-approve-repo @@ -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 diff --git a/scripts/webhook-security/gitea-hmac-verify.js b/scripts/webhook-security/gitea-hmac-verify.js new file mode 100644 index 0000000..3897d54 --- /dev/null +++ b/scripts/webhook-security/gitea-hmac-verify.js @@ -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 }; diff --git a/scripts/webhook-security/ntfy-blocked-pickup.sh b/scripts/webhook-security/ntfy-blocked-pickup.sh new file mode 100755 index 0000000..66a1802 --- /dev/null +++ b/scripts/webhook-security/ntfy-blocked-pickup.sh @@ -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" diff --git a/scripts/webhook-security/rotate-webhook-secret.sh b/scripts/webhook-security/rotate-webhook-secret.sh new file mode 100755 index 0000000..58ac2a2 --- /dev/null +++ b/scripts/webhook-security/rotate-webhook-secret.sh @@ -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" diff --git a/scripts/webhook-security/webhook-audit-alert.sh b/scripts/webhook-security/webhook-audit-alert.sh new file mode 100755 index 0000000..5b5406f --- /dev/null +++ b/scripts/webhook-security/webhook-audit-alert.sh @@ -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" diff --git a/setup.sh b/setup.sh old mode 100755 new mode 100644 index bc4c408..d63aa8a --- a/setup.sh +++ b/setup.sh @@ -1019,6 +1019,388 @@ else ERRORS=$((ERRORS + 1)) fi + +# ============================================================================ +# STEP 11: Gitea Webhook Security (Optional) +# ============================================================================ +header "Step 11: Gitea Webhook Security (Optional)" + +echo -e " ${DIM}Installs 5-layer HMAC-based security for your Gitea webhook endpoint.${NC}" +echo -e " ${DIM}Provides: IP allowlisting, rate limiting, payload size limits, HMAC${NC}" +echo -e " ${DIM}signature verification, and per-repository allowlisting.${NC}" +echo -e " ${DIM}Source: sol/clawgravity-hook-security (v2.0)${NC}" +echo "" + +WEBHOOK_SECURITY_INSTALLED=false + +if ! confirm "Set up Gitea webhook security?" "Y"; then + warn "Skipping webhook security setup (can be added later by re-running setup.sh)" +else + # --- 11.1: Prerequisite checks --- + info "Checking webhook security prerequisites..." + WH_MISSING=0 + + if command -v nginx &>/dev/null; then + success "nginx found: $(nginx -v 2>&1 | head -1)" + else + warn "nginx not found" + echo -e " ${DIM}Install: apt-get install -y nginx${NC}" + WH_MISSING=$((WH_MISSING + 1)) + fi + + # Check for njs module + NJS_OK=false + if nginx -V 2>&1 | grep -q 'http_js_module\|njs'; then + NJS_OK=true + success "nginx njs module detected" + elif dpkg -l libnginx-mod-http-js 2>/dev/null | grep -q '^ii'; then + NJS_OK=true + success "libnginx-mod-http-js installed" + elif [ -f /usr/lib/nginx/modules/ngx_http_js_module.so ]; then + NJS_OK=true + success "njs module found at /usr/lib/nginx/modules/" + else + warn "nginx njs module not detected" + echo -e " ${DIM}Install: apt-get install -y libnginx-mod-http-js${NC}" + WH_MISSING=$((WH_MISSING + 1)) + fi + + if command -v jq &>/dev/null; then + success "jq found" + else + warn "jq not found (required for rotation/audit scripts)" + if confirm " Install jq now?" "Y"; then + apt-get install -y jq 2>&1 | tail -3 + if command -v jq &>/dev/null; then + success "jq installed" + else + warn "jq installation failed — install manually: apt-get install -y jq" + WH_MISSING=$((WH_MISSING + 1)) + fi + else + warn "jq is required for rotation scripts — install later: apt-get install -y jq" + WH_MISSING=$((WH_MISSING + 1)) + fi + fi + + if command -v openssl &>/dev/null; then + success "openssl found" + else + error "openssl not found — required for secret generation" + WH_MISSING=$((WH_MISSING + 1)) + fi + + if [ "$WH_MISSING" -gt 0 ]; then + warn "$WH_MISSING prerequisite(s) missing." + if ! confirm " Continue anyway? (some steps may not complete)" "N"; then + warn "Skipping webhook security setup. Install missing deps and re-run." + else + info "Continuing with available tools..." + fi + fi + + # --- 11.2: Interactive prompts --- + header "Step 11.2: Webhook Security Configuration" + + echo -e " ${DIM}We'll collect the configuration values needed to deploy the security system.${NC}" + echo "" + + # Gitea server IP + echo -e " ${BOLD}Gitea Server IP${NC}" + echo -e " ${DIM}The IP address of your Gitea server (for nginx allowlisting, Layer 1).${NC}" + echo -e " ${DIM}Find it with: dig +short YOUR_GITEA_DOMAIN${NC}" + WH_GITEA_IP=$(ask " Gitea server IP" "127.0.0.1") + + echo "" + + # Webhook HMAC secret + echo -e " ${BOLD}Webhook HMAC Secret${NC}" + echo -e " ${DIM}Used to verify Gitea webhook signatures (Layer 4).${NC}" + echo -e " ${DIM}Must match the secret configured in your Gitea webhook settings.${NC}" + if confirm " Auto-generate a secure secret with openssl?" "Y"; then + WH_SECRET=$(openssl rand -hex 32) + success "Generated secret: ${WH_SECRET:0:8}...${WH_SECRET: -8} (64 hex chars)" + echo -e " ${YELLOW}Important: After setup, update your Gitea webhook secret to this value.${NC}" + echo -e " ${DIM}Gitea Settings -> Webhooks -> [your hook] -> Secret${NC}" + else + WH_SECRET=$(ask " Enter webhook secret" "") + if [ -z "$WH_SECRET" ]; then + WH_SECRET=$(openssl rand -hex 32) + warn "No secret provided — auto-generated: ${WH_SECRET:0:8}..." + fi + fi + + echo "" + + # Trusted owners + echo -e " ${BOLD}Trusted Gitea Owners${NC}" + echo -e " ${DIM}All repositories from these owners are allowed (Layer 5).${NC}" + echo -e " ${DIM}Space-separated list of Gitea usernames/org names.${NC}" + echo -e " ${DIM}Individual repos can be added later with: gitea-approve-repo owner/repo${NC}" + WH_TRUSTED_OWNERS=$(ask " Trusted owners (space-separated)" "") + + echo "" + + # ntfy topic (optional) + echo -e " ${BOLD}ntfy Alert Topic (optional)${NC}" + echo -e " ${DIM}Receive instant ntfy.sh alerts when blocked webhooks are detected.${NC}" + echo -e " ${DIM}Leave blank to skip notifications.${NC}" + WH_NTFY_TOPIC=$(ask " ntfy topic URL (e.g. https://ntfy.sh/my-topic)" "") + + echo "" + + # OpenClaw port + echo -e " ${BOLD}OpenClaw Gateway Port${NC}" + echo -e " ${DIM}The port your OpenClaw gateway listens on (for nginx proxy_pass).${NC}" + WH_OPENCLAW_PORT=$(ask " OpenClaw port" "3000") + + echo "" + + # Gitea API for rotation script + echo -e " ${BOLD}Gitea Instance URL${NC}" + echo -e " ${DIM}Base URL of your Gitea instance (for secret rotation script).${NC}" + WH_GITEA_INSTANCE=$(ask " Gitea URL" "https://git.example.com") + + echo "" + + # Webhook URL match pattern + echo -e " ${BOLD}Webhook URL Match Pattern${NC}" + echo -e " ${DIM}Pattern to identify your OpenClaw webhook hooks in Gitea (for rotation).${NC}" + WH_WEBHOOK_URL_MATCH=$(ask " Webhook URL pattern (e.g. yourdomain.com/hooks/gitea)" "") + + echo "" + + # Scan owners for rotation + echo -e " ${BOLD}Gitea Owner(s) to Scan for Rotation${NC}" + echo -e " ${DIM}Space-separated list of Gitea owners whose repos will have webhooks rotated.${NC}" + WH_SCAN_OWNERS=$(ask " Owners to scan" "${WH_TRUSTED_OWNERS:-your-org}") + + echo "" + + # Mattermost config for audit (optional) + echo -e " ${BOLD}Mattermost Audit Integration (optional)${NC}" + echo -e " ${DIM}Daily audit summaries posted to a Mattermost channel.${NC}" + WH_MATTERMOST_URL=$(ask " Mattermost URL (leave blank to skip)" "") + WH_MATTERMOST_CHANNEL="" + WH_MATTERMOST_GITEA_REPO="" + if [ -n "$WH_MATTERMOST_URL" ]; then + WH_MATTERMOST_CHANNEL=$(ask " Mattermost channel ID" "") + WH_MATTERMOST_GITEA_REPO=$(ask " Gitea repo for audit anomaly issues (e.g. myorg/webhook-security)" "") + fi + + echo "" + + # --- 11.3: Deploy files --- + header "Step 11.3: Deploying Webhook Security Files" + + WH_INSTALL_DIR="/opt/webhook-security" + WH_NJS_DIR="/etc/nginx/njs" + WH_SECRET_FILE="/etc/nginx/gitea-webhook-secret" + WH_ALLOWLIST_FILE="/etc/nginx/gitea-repo-allowlist.json" + + # Create directories + mkdir -p "$WH_INSTALL_DIR/scripts" + mkdir -p "$WH_NJS_DIR" + success "Created $WH_INSTALL_DIR/scripts" + success "Created $WH_NJS_DIR" + + # Copy njs HMAC module + cp "$SCRIPT_DIR/scripts/webhook-security/gitea-hmac-verify.js" "$WH_NJS_DIR/" + chmod 644 "$WH_NJS_DIR/gitea-hmac-verify.js" + success "Installed njs module: $WH_NJS_DIR/gitea-hmac-verify.js" + + # Write secret file + printf '%s' "$WH_SECRET" > "$WH_SECRET_FILE" + chown root:www-data "$WH_SECRET_FILE" 2>/dev/null || chown root:root "$WH_SECRET_FILE" + chmod 640 "$WH_SECRET_FILE" + success "Installed secret: $WH_SECRET_FILE (permissions: root:www-data 640)" + + # Build allowlist JSON from trusted owners + WH_TRUSTED_OWNERS_JSON="[]" + if [ -n "$WH_TRUSTED_OWNERS" ]; then + WH_TRUSTED_OWNERS_JSON=$(python3 -c " +import json, sys +owners = '$WH_TRUSTED_OWNERS'.split() +print(json.dumps(owners)) +") + fi + python3 -c " +import json +data = { + '_comment': 'Gitea webhook repo allowlist. Edits take effect immediately (no nginx reload needed).', + 'repos': [], + 'trusted_owners': $WH_TRUSTED_OWNERS_JSON +} +with open('$WH_ALLOWLIST_FILE', 'w') as f: + json.dump(data, f, indent=4) + f.write('\n') +" + chmod 644 "$WH_ALLOWLIST_FILE" + success "Installed allowlist: $WH_ALLOWLIST_FILE" + info " Trusted owners: ${WH_TRUSTED_OWNERS:-none configured yet}" + info " Add repos later with: gitea-approve-repo owner/repo" + + # Install templated scripts with substitutions + for script in rotate-webhook-secret.sh webhook-audit-alert.sh ntfy-blocked-pickup.sh; do + SED_CMD=(sed + -e "s|@@GITEA_API@@|$WH_GITEA_INSTANCE|g" + -e "s|@@WEBHOOK_URL_MATCH@@|$WH_WEBHOOK_URL_MATCH|g" + -e "s|@@SCAN_OWNERS@@|$WH_SCAN_OWNERS|g" + -e "s|@@NTFY_TOPIC@@|${WH_NTFY_TOPIC:-https://ntfy.sh/YOUR_NTFY_TOPIC}|g" + -e "s|@@MATTERMOST_URL@@|${WH_MATTERMOST_URL:-YOUR_MATTERMOST_URL}|g" + -e "s|@@MATTERMOST_CHANNEL_ID@@|${WH_MATTERMOST_CHANNEL:-YOUR_MATTERMOST_CHANNEL_ID}|g" + -e "s|@@GITEA_API_BASE@@|$WH_GITEA_INSTANCE|g" + -e "s|@@GITEA_REPO@@|${WH_MATTERMOST_GITEA_REPO:-YOUR_GITEA_REPO}|g" + ) + "${SED_CMD[@]}" "$SCRIPT_DIR/scripts/webhook-security/$script" \ + > "$WH_INSTALL_DIR/scripts/$script" + chmod 755 "$WH_INSTALL_DIR/scripts/$script" + success "Installed $WH_INSTALL_DIR/scripts/$script" + done + + # Task 11.4: Install gitea-approve-repo to /usr/local/bin/ + cp "$SCRIPT_DIR/scripts/webhook-security/gitea-approve-repo" /usr/local/bin/gitea-approve-repo + chmod 755 /usr/local/bin/gitea-approve-repo + success "Installed /usr/local/bin/gitea-approve-repo" + + # --- 11.4: nginx config guidance --- + header "Step 11.4: nginx Configuration Guidance" + + echo -e " ${BOLD}The security system requires two nginx configuration changes:${NC}" + echo "" + echo -e " ${BOLD}1. In /etc/nginx/nginx.conf (inside the http {} block):${NC}" + echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}" + cat "$SCRIPT_DIR/templates/webhook-security/nginx.conf.example" + echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}" + echo "" + echo -e " ${BOLD}2. In your site config (e.g. /etc/nginx/sites-enabled/openclaw):${NC}" + echo -e " ${DIM}The location blocks for /hooks/gitea are in:${NC}" + echo -e " ${DIM}$SCRIPT_DIR/templates/webhook-security/nginx-site.conf.example${NC}" + echo "" + echo -e " ${DIM}Replace YOUR_DOMAIN, YOUR_GITEA_SERVER_IP, YOUR_OPENCLAW_PORT,${NC}" + echo -e " ${DIM}and YOUR_OPENCLAW_GATEWAY_TOKEN with your actual values.${NC}" + echo "" + + if confirm " Display full site config example now?" "N"; then + echo "" + echo -e "${DIM}" + cat "$SCRIPT_DIR/templates/webhook-security/nginx-site.conf.example" | \ + sed -e "s|YOUR_GITEA_SERVER_IP|$WH_GITEA_IP|g" \ + -e "s|YOUR_OPENCLAW_PORT|$WH_OPENCLAW_PORT|g" + echo -e "${NC}" + fi + + # --- 11.5: Optional cron setup --- + header "Step 11.5: Cron Jobs (Optional)" + + echo -e " ${DIM}Three automated tasks can be set up via cron:${NC}" + echo "" + echo " 1. Secret rotation (monthly, 1st of month at 3am UTC)" + echo " Command: $WH_INSTALL_DIR/scripts/rotate-webhook-secret.sh" + echo "" + echo " 2. Blocked webhook alerts (every minute, scans nginx error.log)" + echo " Command: $WH_INSTALL_DIR/scripts/ntfy-blocked-pickup.sh" + echo "" + echo " 3. Daily audit summary (5am UTC)" + echo " Command: $WH_INSTALL_DIR/scripts/webhook-audit-alert.sh" + echo "" + + CRON_ADDED=0 + if confirm " Install monthly secret rotation cron job?" "Y"; then + CRON_LINE="0 3 1 * * $WH_INSTALL_DIR/scripts/rotate-webhook-secret.sh >> /var/log/webhook-secret-rotation.log 2>&1" + (crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab - + success "Added rotation cron: 0 3 1 * * ..." + CRON_ADDED=$((CRON_ADDED + 1)) + fi + + if [ -n "$WH_NTFY_TOPIC" ] && confirm " Install blocked-webhook ntfy alerts cron (every minute)?" "Y"; then + CRON_LINE="* * * * * $WH_INSTALL_DIR/scripts/ntfy-blocked-pickup.sh" + (crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab - + success "Added ntfy-blocked-pickup cron: * * * * * ..." + CRON_ADDED=$((CRON_ADDED + 1)) + fi + + if [ -n "$WH_MATTERMOST_URL" ] && confirm " Install daily audit summary cron (5am UTC)?" "Y"; then + CRON_LINE="5 0 * * * MATTERMOST_BOT_TOKEN=YOUR_BOT_TOKEN $WH_INSTALL_DIR/scripts/webhook-audit-alert.sh >> /var/log/webhook-audit.log 2>&1" + (crontab -l 2>/dev/null; echo "$CRON_LINE") | sort -u | crontab - + success "Added audit cron: 5 0 * * * ..." + warn "Edit the cron entry to set MATTERMOST_BOT_TOKEN: crontab -e" + CRON_ADDED=$((CRON_ADDED + 1)) + fi + + if [ "$CRON_ADDED" -eq 0 ]; then + info "No cron jobs added. You can add them manually — see docs/WEBHOOK-SECURITY.md" + fi + + # --- 11.6: Verification --- + header "Step 11.6: Webhook Security Verification" + + WH_ERRORS=0 + + # Check njs module file + if [ -f "$WH_NJS_DIR/gitea-hmac-verify.js" ]; then + success "njs module installed: $WH_NJS_DIR/gitea-hmac-verify.js" + else + error "njs module NOT found: $WH_NJS_DIR/gitea-hmac-verify.js" + WH_ERRORS=$((WH_ERRORS + 1)) + fi + + # Check secret file + if [ -f "$WH_SECRET_FILE" ]; then + SECRET_LEN=$(wc -c < "$WH_SECRET_FILE") + SECRET_PERMS=$(stat -c '%a' "$WH_SECRET_FILE" 2>/dev/null || stat -f '%Mp%Lp' "$WH_SECRET_FILE" 2>/dev/null) + if [ "$SECRET_LEN" -ge 32 ]; then + success "Secret file: $WH_SECRET_FILE (${SECRET_LEN} chars, perms: $SECRET_PERMS)" + else + error "Secret file too short (${SECRET_LEN} chars)" + WH_ERRORS=$((WH_ERRORS + 1)) + fi + else + error "Secret file NOT found: $WH_SECRET_FILE" + WH_ERRORS=$((WH_ERRORS + 1)) + fi + + # Check allowlist + if [ -f "$WH_ALLOWLIST_FILE" ]; then + if python3 -c "import json; json.load(open('$WH_ALLOWLIST_FILE'))" 2>/dev/null; then + success "Allowlist file: $WH_ALLOWLIST_FILE (valid JSON)" + else + error "Allowlist file has invalid JSON: $WH_ALLOWLIST_FILE" + WH_ERRORS=$((WH_ERRORS + 1)) + fi + else + error "Allowlist file NOT found: $WH_ALLOWLIST_FILE" + WH_ERRORS=$((WH_ERRORS + 1)) + fi + + # Check gitea-approve-repo + if [ -x /usr/local/bin/gitea-approve-repo ]; then + success "gitea-approve-repo: /usr/local/bin/gitea-approve-repo (executable)" + else + error "gitea-approve-repo not found/executable" + WH_ERRORS=$((WH_ERRORS + 1)) + fi + + # nginx config test (if nginx is available and njs module found) + if command -v nginx &>/dev/null && $NJS_OK; then + if nginx -t 2>/dev/null; then + success "nginx -t: config is valid" + else + warn "nginx -t failed — config changes may be needed (see Step 11.4 guidance above)" + fi + fi + + if [ "$WH_ERRORS" -eq 0 ]; then + success "Webhook security installed successfully" + WEBHOOK_SECURITY_INSTALLED=true + else + warn "$WH_ERRORS webhook security error(s) — review output above" + fi + + echo "" + info "Reference: $SCRIPT_DIR/docs/WEBHOOK-SECURITY.md for full documentation" + info "Security audit: $SCRIPT_DIR/docs/SECURITY-AUDIT.md" +fi # ============================================================================ # SUMMARY # ============================================================================ @@ -1049,6 +1431,9 @@ fi echo " - Anthropic model configured in openclaw.json" echo " - Auth profiles updated for all agents" echo " - oauth.json created with fresh token" +if [ "$WEBHOOK_SECURITY_INSTALLED" = true ]; then + echo " - Webhook security: njs module, secret, allowlist, helper scripts" +fi echo "" echo -e " ${BOLD}Useful commands:${NC}" if [ "$USE_INOTIFY" = true ]; then @@ -1062,8 +1447,13 @@ if [ "$TRIGGER_INSTALLED" = true ]; then echo " journalctl -u trigger-claude-refresh -n 20 # Trigger logs" echo " systemctl list-timers trigger-claude-refresh* # Check trigger timer" fi +if [ "$WEBHOOK_SECURITY_INSTALLED" = true ]; then + echo " gitea-approve-repo owner/repo # Add repo to allowlist" + echo " cat /etc/nginx/gitea-repo-allowlist.json # View allowlist" + echo " /opt/webhook-security/scripts/rotate-webhook-secret.sh --dry-run" +fi echo " ./scripts/verify.sh # Health check" echo " ./setup.sh --uninstall # Remove everything" echo "" echo -e " ${DIM}Created by ROOH — ${NC}" -echo "" + diff --git a/templates/webhook-security/gitea-repo-allowlist.json.example b/templates/webhook-security/gitea-repo-allowlist.json.example new file mode 100644 index 0000000..193d4d7 --- /dev/null +++ b/templates/webhook-security/gitea-repo-allowlist.json.example @@ -0,0 +1,8 @@ +{ + "_comment": "Gitea webhook repo allowlist. Edits take effect immediately (no nginx reload needed).", + "repos": [ + "owner/specific-repo", + "owner/another-repo" + ], + "trusted_owners": ["trusted-owner"] +} diff --git a/templates/webhook-security/nginx-site.conf.example b/templates/webhook-security/nginx-site.conf.example new file mode 100644 index 0000000..b97ad1c --- /dev/null +++ b/templates/webhook-security/nginx-site.conf.example @@ -0,0 +1,86 @@ +server { + server_name YOUR_DOMAIN; + + # Internal upstream for Gitea webhook (post-HMAC verification) + location /hooks/gitea-upstream { + internal; + proxy_pass http://127.0.0.1:YOUR_OPENCLAW_PORT/hooks/gitea; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization "Bearer YOUR_OPENCLAW_GATEWAY_TOKEN"; + proxy_set_header X-Gitea-Event $http_x_gitea_event; + proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery; + proxy_set_header X-Gitea-Signature $http_x_gitea_signature; + proxy_buffering off; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + proxy_pass_request_body on; + } + + # Other hooks pass through directly + location /hooks/ { + proxy_pass http://127.0.0.1:YOUR_OPENCLAW_PORT/hooks/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; + client_max_body_size 1m; + } + + # Gitea webhook - HMAC-SHA256 verified by njs before proxying + # Security layers: IP allowlist -> rate limit -> HMAC verify -> repo allowlist -> proxy + location = /hooks/gitea { + # Layer 1: IP allowlisting - only allow the Gitea server IP + # Find your Gitea IP with: dig +short YOUR_GITEA_DOMAIN + allow YOUR_GITEA_SERVER_IP; # YOUR_GITEA_DOMAIN + allow 127.0.0.1; # localhost (for testing) + allow ::1; # IPv6 localhost + deny all; + + # Layer 2: Rate limiting + limit_req zone=gitea_webhook burst=20 nodelay; + + # Layer 3: Payload size limit + client_body_buffer_size 1m; + client_max_body_size 1m; + + # Layer 4+5: HMAC verification + repo allowlist (njs) + js_content gitea_hmac.verifyAndProxy; + } + + # Main application (adjust to your backend) + location / { + proxy_pass http://127.0.0.1:YOUR_APP_PORT; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Frame-Options SAMEORIGIN; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + client_max_body_size 50M; + } + + listen 443 ssl; + ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} + +server { + if ($host = YOUR_DOMAIN) { + return 301 https://$host$request_uri; + } + + server_name YOUR_DOMAIN; + listen 80; + return 404; +} diff --git a/templates/webhook-security/nginx.conf.example b/templates/webhook-security/nginx.conf.example new file mode 100644 index 0000000..2324c2b --- /dev/null +++ b/templates/webhook-security/nginx.conf.example @@ -0,0 +1,6 @@ +# Add to http {} block in /etc/nginx/nginx.conf +http { + js_path "/etc/nginx/njs/"; + js_import gitea_hmac from gitea-hmac-verify.js; + limit_req_zone $binary_remote_addr zone=gitea_webhook:1m rate=10r/s; +} diff --git a/tests/test-webhook-security.sh b/tests/test-webhook-security.sh new file mode 100755 index 0000000..fb49295 --- /dev/null +++ b/tests/test-webhook-security.sh @@ -0,0 +1,253 @@ +#!/bin/bash +# test-webhook-security.sh - Tests for webhook security integration +# +# Validates that all webhook security files are present and correctly structured +# in the repository. Tests run offline (no system services required). + +set -uo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +pass() { echo -e "${GREEN}[PASS]${NC} $*"; } +fail() { echo -e "${RED}[FAIL]${NC} $*"; FAILURES=$((FAILURES + 1)); } + +FAILURES=0 +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "" +echo "Testing Webhook Security Integration" +echo "======================================" +echo "" + +# --- 1. File existence checks --- +echo "1. File existence" + +EXPECTED_FILES=( + "scripts/webhook-security/gitea-hmac-verify.js" + "scripts/webhook-security/gitea-approve-repo" + "scripts/webhook-security/rotate-webhook-secret.sh" + "scripts/webhook-security/webhook-audit-alert.sh" + "scripts/webhook-security/ntfy-blocked-pickup.sh" + "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" + "docs/SECURITY-AUDIT.md" +) + +for f in "${EXPECTED_FILES[@]}"; do + if [ -f "$REPO_ROOT/$f" ]; then + pass "$f exists" + else + fail "$f MISSING" + fi +done + +echo "" + +# --- 2. Template placeholder checks --- +echo "2. Template placeholders in scripts" + +TEMPLATED_SCRIPTS=( + "scripts/webhook-security/rotate-webhook-secret.sh" + "scripts/webhook-security/webhook-audit-alert.sh" + "scripts/webhook-security/ntfy-blocked-pickup.sh" +) + +for script in "${TEMPLATED_SCRIPTS[@]}"; do + if grep -qE '@@[A-Z_]+@@' "$REPO_ROOT/$script"; then + pass "$script has placeholder tokens (@@...@@)" + else + fail "$script has no placeholder tokens — check that hardcoded values were templated" + fi +done + +# Verify no hardcoded URLs in templated scripts +for script in "${TEMPLATED_SCRIPTS[@]}"; do + if grep -qE 'https://[a-z][a-z0-9.-]+\.(com|org|net|sh)' "$REPO_ROOT/$script" 2>/dev/null; then + # Check if it's a placeholder URL or a real one + REAL_URLS=$(grep -E 'https://[a-z][a-z0-9.-]+\.(com|org|net|sh)' "$REPO_ROOT/$script" | grep -v '@@\|example\|YOUR_\|placeholder\|ntfy.sh/@@' || true) + if [ -n "$REAL_URLS" ]; then + fail "$script may contain hardcoded URLs: $REAL_URLS" + else + pass "$script URL patterns are placeholders" + fi + else + pass "$script has no hardcoded domain URLs" + fi +done + +echo "" + +# --- 3. Allowlist JSON validity --- +echo "3. Template JSON validity" + +ALLOWLIST_EXAMPLE="$REPO_ROOT/templates/webhook-security/gitea-repo-allowlist.json.example" +if [ -f "$ALLOWLIST_EXAMPLE" ]; then + if python3 -c " +import json, sys +with open('$ALLOWLIST_EXAMPLE') as f: + data = json.load(f) +has_repos = 'repos' in data +has_owners = 'trusted_owners' in data +if not has_repos or not has_owners: + sys.exit(1) +" 2>/dev/null; then + pass "gitea-repo-allowlist.json.example is valid JSON with repos + trusted_owners" + else + fail "gitea-repo-allowlist.json.example is invalid JSON or missing required keys" + fi +else + fail "gitea-repo-allowlist.json.example not found" +fi + +echo "" + +# --- 4. Secret generation format --- +echo "4. Secret generation (openssl)" + +if command -v openssl &>/dev/null; then + GENERATED_SECRET=$(openssl rand -hex 32) + SECRET_LEN=${#GENERATED_SECRET} + if [ "$SECRET_LEN" -eq 64 ]; then + pass "openssl rand -hex 32 produces 64-char hex string (got: ${GENERATED_SECRET:0:8}...)" + else + fail "openssl rand -hex 32 produced ${SECRET_LEN}-char string (expected 64)" + fi + + # Verify it's valid hex + if echo "$GENERATED_SECRET" | grep -qE '^[0-9a-f]{64}$'; then + pass "Generated secret is valid lowercase hex" + else + fail "Generated secret contains non-hex characters" + fi +else + pass "openssl not available — skipping secret generation test (ok in CI)" +fi + +echo "" + +# --- 5. njs module structure check --- +echo "5. njs module (gitea-hmac-verify.js)" + +NJS_FILE="$REPO_ROOT/scripts/webhook-security/gitea-hmac-verify.js" +if [ -f "$NJS_FILE" ]; then + # Check it's an ES module with the expected export + if grep -q "export default" "$NJS_FILE"; then + pass "gitea-hmac-verify.js has ES module export" + else + fail "gitea-hmac-verify.js missing 'export default'" + fi + + # Check key functions are present + for fn in verifyAndProxy isRepoAllowed constantTimeEqual getSecret loadAllowlist; do + if grep -q "$fn" "$NJS_FILE"; then + pass "gitea-hmac-verify.js has function: $fn" + else + fail "gitea-hmac-verify.js missing function: $fn" + fi + done + + # Check HMAC-SHA256 usage + if grep -q "createHmac.*sha256" "$NJS_FILE"; then + pass "gitea-hmac-verify.js uses HMAC-SHA256" + else + fail "gitea-hmac-verify.js missing HMAC-SHA256 usage" + fi +else + fail "gitea-hmac-verify.js not found" +fi + +echo "" + +# --- 6. setup.sh integration check --- +echo "6. setup.sh integration" + +SETUP_FILE="$REPO_ROOT/setup.sh" +if [ -f "$SETUP_FILE" ]; then + # Check Step 11 is present + if grep -q "Step 11.*Gitea Webhook Security" "$SETUP_FILE"; then + pass "setup.sh contains Step 11 (Gitea Webhook Security)" + else + fail "setup.sh missing Step 11" + fi + + # Check key deployment steps + for marker in "gitea-hmac-verify.js" "gitea-webhook-secret" "gitea-repo-allowlist.json" "gitea-approve-repo" "WEBHOOK_SECURITY_INSTALLED"; do + if grep -q "$marker" "$SETUP_FILE"; then + pass "setup.sh references: $marker" + else + fail "setup.sh missing reference to: $marker" + fi + done + + # Check that step 11 comes before SUMMARY + STEP11_LINE=$(grep -n "Step 11.*Gitea Webhook Security" "$SETUP_FILE" | head -1 | cut -d: -f1) + SUMMARY_LINE=$(grep -n "^# SUMMARY" "$SETUP_FILE" | head -1 | cut -d: -f1) + if [ -n "$STEP11_LINE" ] && [ -n "$SUMMARY_LINE" ] && [ "$STEP11_LINE" -lt "$SUMMARY_LINE" ]; then + pass "Step 11 appears before SUMMARY (lines $STEP11_LINE vs $SUMMARY_LINE)" + else + fail "Step 11 ordering issue (step11=$STEP11_LINE, summary=$SUMMARY_LINE)" + fi +else + fail "setup.sh not found" +fi + +echo "" + +# --- 7. uninstall.sh integration check --- +echo "7. uninstall.sh integration" + +UNINSTALL_FILE="$REPO_ROOT/scripts/uninstall.sh" +if [ -f "$UNINSTALL_FILE" ]; then + for marker in "gitea-hmac-verify.js" "gitea-webhook-secret" "gitea-repo-allowlist.json" "opt/webhook-security" "gitea-approve-repo"; do + if grep -q "$marker" "$UNINSTALL_FILE"; then + pass "uninstall.sh references: $marker" + else + fail "uninstall.sh missing reference to: $marker" + fi + done + + # Check cron cleanup + if grep -q "crontab\|cron" "$UNINSTALL_FILE"; then + pass "uninstall.sh has cron cleanup" + else + fail "uninstall.sh missing cron cleanup" + fi +else + fail "uninstall.sh not found" +fi + +echo "" + +# --- 8. Bash syntax checks --- +echo "8. Bash syntax" + +for script in \ + "$REPO_ROOT/setup.sh" \ + "$REPO_ROOT/scripts/uninstall.sh" \ + "$REPO_ROOT/scripts/webhook-security/rotate-webhook-secret.sh" \ + "$REPO_ROOT/scripts/webhook-security/webhook-audit-alert.sh" \ + "$REPO_ROOT/scripts/webhook-security/ntfy-blocked-pickup.sh" \ + "$REPO_ROOT/scripts/webhook-security/gitea-approve-repo"; do + FNAME=$(basename "$script") + if bash -n "$script" 2>/dev/null; then + pass "bash syntax OK: $FNAME" + else + fail "bash syntax ERROR: $FNAME" + fi +done + +echo "" + +# --- Summary --- +echo "======================================" +if [ "$FAILURES" -eq 0 ]; then + echo -e "${GREEN}All tests passed${NC}" + exit 0 +else + echo -e "${RED}$FAILURES test(s) failed${NC}" + exit 1 +fi