Compare commits
No commits in common. "main" and "policies/add-standard-files" have entirely different histories.
main
...
policies/a
@ -6,5 +6,3 @@ 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
|
||||
|
||||
2
Makefile
2
Makefile
@ -8,7 +8,7 @@ install:
|
||||
npm install
|
||||
|
||||
test:
|
||||
bash tests/test-webhook-security.sh
|
||||
@echo "[SKIP] Tests require installed system services (not available in CI)"
|
||||
|
||||
fmt:
|
||||
npx prettier --write .
|
||||
|
||||
32
README.md
32
README.md
@ -46,38 +46,6 @@ 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**
|
||||
|
||||
@ -1,156 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,309 +0,0 @@
|
||||
# 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)
|
||||
@ -37,74 +37,6 @@ 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"
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
#!/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
|
||||
@ -1,64 +0,0 @@
|
||||
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 };
|
||||
@ -1,77 +0,0 @@
|
||||
#!/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"
|
||||
@ -1,254 +0,0 @@
|
||||
#!/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"
|
||||
@ -1,167 +0,0 @@
|
||||
#!/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"
|
||||
392
setup.sh
Normal file → Executable file
392
setup.sh
Normal file → Executable file
@ -1019,388 +1019,6 @@ 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
|
||||
# ============================================================================
|
||||
@ -1431,9 +1049,6 @@ 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
|
||||
@ -1447,13 +1062,8 @@ 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 — <project-url>${NC}"
|
||||
|
||||
echo ""
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"_comment": "Gitea webhook repo allowlist. Edits take effect immediately (no nginx reload needed).",
|
||||
"repos": [
|
||||
"owner/specific-repo",
|
||||
"owner/another-repo"
|
||||
],
|
||||
"trusted_owners": ["trusted-owner"]
|
||||
}
|
||||
@ -1,86 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
# 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;
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
#!/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
|
||||
Loading…
Reference in New Issue
Block a user