openclaw_oauth_sync/docs/SECURITY-AUDIT.md
sol 2db7d7d90a feat: merge Gitea webhook security into setup wizard (issue #2)
Integrates the 5-layer Gitea webhook security system from
sol/clawgravity-hook-security (v2.0) into the setup wizard.

## What's added

### New files (from clawgravity-hook-security v2.0)
- scripts/webhook-security/gitea-hmac-verify.js    -- njs HMAC-SHA256 module
- scripts/webhook-security/gitea-approve-repo       -- allowlist helper
- scripts/webhook-security/rotate-webhook-secret.sh -- monthly secret rotation (templated)
- scripts/webhook-security/webhook-audit-alert.sh   -- daily audit summaries (templated)
- scripts/webhook-security/ntfy-blocked-pickup.sh   -- blocked webhook alerts (templated)
- templates/webhook-security/nginx-site.conf.example
- templates/webhook-security/nginx.conf.example
- templates/webhook-security/gitea-repo-allowlist.json.example
- docs/WEBHOOK-SECURITY.md   -- full documentation
- docs/SECURITY-AUDIT.md     -- 35-case test matrix
- tests/test-webhook-security.sh  -- 48 offline tests

### Modified files
- setup.sh: Step 11 (webhook security wizard with 6 sub-sections)
- scripts/uninstall.sh: webhook security cleanup section
- README.md: Webhook Security section after Quick Start
- Makefile: test target now runs test-webhook-security.sh
- .secret-scan-allowlist: allowlist docs/SECURITY-AUDIT.md (test fixture)

## Security layers
1. IP allowlisting (nginx)
2. Rate limiting 10 req/s burst 20 (nginx)
3. Payload size 1MB (nginx)
4. HMAC-SHA256 signature verification (njs)
5. Per-repository allowlist (njs)

## make check
- prettier: PASS
- secret-scan: PASS
- tests: 48/48 PASS

Closes #2
2026-03-01 08:43:02 +00:00

157 lines
8.4 KiB
Markdown

# 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
```