diff --git a/loop/plan.md b/loop/plan.md index 06fae30d..e886b772 100644 --- a/loop/plan.md +++ b/loop/plan.md @@ -25,3 +25,25 @@ I now have complete visibility into all affected code. Here is the remediation p - [x] **FIX-021** — fix(INJ-007): sanitize log output in `core/archipelago/src/api/handler.rs` `handle_node_message` (line 136) — replace newlines (`\n`, `\r`) and ANSI escape sequences in `from` and `msg` with safe representations before passing to `tracing::info!`; use `.replace('\n', "\\n").replace('\r', "\\r")` - [x] **FIX-022** — fix: harden `image-recipe/configs/archipelago.service` — change `User=root` to `User=archipelago` (dedicated non-root service account); set `Environment="ARCHIPELAGO_DEV_MODE=false"`; add `NoNewPrivileges=true`, `ProtectSystem=strict`, `ReadWritePaths=/var/lib/archipelago`; this reduces blast radius for all findings - [x] **VERIFY** — test: re-run pentest curl probes from the exploitation report against all 21 finding endpoints to confirm: unauthenticated requests return 401, path traversal payloads are rejected, CORS headers are restrictive, security headers are present, WebSocket requires auth, and the service runs as non-root with dev mode disabled + +--- + +## Post-Fix Verification (ALWAYS run as final step) + +After all FIX tasks are complete and deployed, run the automated verification script: + +```bash +./scripts/verify-pentest-fixes.sh +``` + +This script tests every pentest finding against the live server: +- Login returns HttpOnly/SameSite session cookie +- All sensitive RPC methods return 401 without auth +- WebSocket, container logs, LND proxy require auth +- Rate limiting triggers on 6th failed login +- Path traversal, untrusted registries, spoofed pubkeys are rejected +- CORS blocks evil origins +- Nginx security headers are present +- Logout invalidates the session + +If verification fails (exit code 1), DO NOT mark VERIFY as done. Fix the failing checks and redeploy first. diff --git a/scripts/overnight-loop.sh b/scripts/overnight-loop.sh index e491eae0..533c9306 100755 --- a/scripts/overnight-loop.sh +++ b/scripts/overnight-loop.sh @@ -1,8 +1,11 @@ #!/bin/bash cd /Users/dorian/Projects/archy +# Default to the pentest fix plan; override with $1 +PLAN="${1:-.claude/plans/reflective-meandering-castle.md}" + while true; do - claude -p "Read .claude/plans/reflective-meandering-castle.md — execute the next task not marked [DONE]. After completing, deploy with ./scripts/deploy-to-target.sh --live, mark it [DONE] in the plan file, commit and push. If all tasks are [DONE], write a summary report and exit." \ + claude -p "Read $PLAN — execute the next task not marked [DONE]. After completing, deploy with ./scripts/deploy-to-target.sh --live, mark it [DONE] in the plan file, commit and push. If all tasks are [DONE], run ./scripts/verify-pentest-fixes.sh to validate, write a summary report and exit." \ --max-turns 50 \ --allowedTools "Edit,Write,Bash,Read,Glob,Grep,Agent,WebFetch,WebSearch" diff --git a/scripts/verify-pentest-fixes.sh b/scripts/verify-pentest-fixes.sh new file mode 100755 index 00000000..b45e4757 --- /dev/null +++ b/scripts/verify-pentest-fixes.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# Verify pentest remediation fixes on the live server. +# Exit 0 = all checks pass, Exit 1 = one or more failures. +# Usage: ./scripts/verify-pentest-fixes.sh [host] [password] + +set -euo pipefail + +HOST="${1:-192.168.1.228}" +PASSWORD="${2:-EwPDR8q45l0Upx@}" +BACKEND="http://$HOST:5678" +NGINX="http://$HOST" +PASS=0 +FAIL=0 + +green() { printf "\033[32m PASS\033[0m %s\n" "$1"; PASS=$((PASS+1)); } +red() { printf "\033[31m FAIL\033[0m %s\n" "$1"; FAIL=$((FAIL+1)); } +check() { if [ "$1" = "true" ]; then green "$2"; else red "$2"; fi; } + +echo "============================================" +echo " Pentest Fix Verification — $HOST" +echo "============================================" +echo "" + +# --- Login and get session cookie --- +echo "--- Authentication ---" +LOGIN_RESP=$(curl -sv -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -d "{\"method\":\"auth.login\",\"params\":{\"password\":\"$PASSWORD\"}}" 2>&1) + +COOKIE=$(echo "$LOGIN_RESP" | grep -i "set-cookie" | sed 's/.*session=//;s/;.*//' | head -1) +LOGIN_OK=$(echo "$LOGIN_RESP" | tail -1 | grep -q '"error":null' && echo true || echo false) +COOKIE_SET=$([ ${#COOKIE} -gt 10 ] && echo true || echo false) + +check "$LOGIN_OK" "AUTH-001: Login returns success" +check "$COOKIE_SET" "AUTH-001: Login sets HttpOnly session cookie (len=${#COOKIE})" + +HTTPONLY=$(echo "$LOGIN_RESP" | grep -i "set-cookie" | grep -qi "httponly" && echo true || echo false) +SAMESITE=$(echo "$LOGIN_RESP" | grep -i "set-cookie" | grep -qi "samesite" && echo true || echo false) +check "$HTTPONLY" "AUTH-001: Cookie has HttpOnly flag" +check "$SAMESITE" "AUTH-001: Cookie has SameSite flag" + +# --- Unauthenticated access should be blocked --- +echo "" +echo "--- Unauthenticated Access (should all be 401) ---" + +for METHOD in "node.did" "node.signChallenge" "node-list-peers" "package.install" "container-list" "auth.resetOnboarding" "bitcoin.getinfo" "lnd.getinfo"; do + CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -d "{\"method\":\"$METHOD\",\"params\":{}}") + check "$([ "$CODE" = "401" ] && echo true || echo false)" "AUTH-002: $METHOD without auth → $CODE" +done + +# --- WebSocket without auth --- +WS_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Upgrade: websocket" -H "Connection: Upgrade" \ + -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ + -H "Sec-WebSocket-Version: 13" "$BACKEND/ws/db") +check "$([ "$WS_CODE" = "401" ] && echo true || echo false)" "AUTH-007: WebSocket without auth → $WS_CODE" + +# --- Container logs & LND proxy without auth --- +LOGS_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BACKEND/api/container/logs?app_id=bitcoin&lines=10") +check "$([ "$LOGS_CODE" = "401" ] && echo true || echo false)" "AUTH-012: Container logs without auth → $LOGS_CODE" + +LND_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BACKEND/proxy/lnd/v1/getinfo") +check "$([ "$LND_CODE" = "401" ] && echo true || echo false)" "AUTH-011: LND proxy without auth → $LND_CODE" + +# --- Authenticated access should work --- +echo "" +echo "--- Authenticated Access (should work) ---" + +DID_RESP=$(curl -s -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -H "Cookie: session=$COOKIE" \ + -d '{"method":"node.did","params":{}}') +DID_OK=$(echo "$DID_RESP" | grep -q '"did":' && echo true || echo false) +check "$DID_OK" "AUTH-002: node.did with valid session returns data" + +# --- Rate limiting --- +echo "" +echo "--- Rate Limiting ---" + +# Burn through rate limit window +for i in $(seq 1 5); do + curl -s -o /dev/null -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -d "{\"method\":\"auth.login\",\"params\":{\"password\":\"wrong$i\"}}" +done +RATE_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -d '{"method":"auth.login","params":{"password":"wrong6"}}') +check "$([ "$RATE_CODE" = "429" ] && echo true || echo false)" "AUTH-003: 6th login attempt → $RATE_CODE (expect 429)" + +# --- Input validation --- +echo "" +echo "--- Input Validation ---" + +TRAVERSAL=$(curl -s -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -H "Cookie: session=$COOKIE" \ + -d '{"method":"package.uninstall","params":{"id":"../../tmp/evil"}}') +TRAVERSAL_BLOCKED=$(echo "$TRAVERSAL" | grep -qi "invalid" && echo true || echo false) +check "$TRAVERSAL_BLOCKED" "INJ-002: Path traversal rejected" + +REGISTRY=$(curl -s -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -H "Cookie: session=$COOKIE" \ + -d '{"method":"package.install","params":{"id":"test","dockerImage":"evil.com/rootkit:latest"}}') +REGISTRY_BLOCKED=$(echo "$REGISTRY" | grep -qi "invalid" && echo true || echo false) +check "$REGISTRY_BLOCKED" "SSRF-004: Untrusted registry rejected" + +PUBKEY=$(curl -s -X POST "$BACKEND/archipelago/node-message" \ + -H 'Content-Type: application/json' \ + -d '{"from_pubkey":"SPOOFED","message":"injected"}') +PUBKEY_BLOCKED=$(echo "$PUBKEY" | grep -qi "invalid" && echo true || echo false) +check "$PUBKEY_BLOCKED" "AUTH-008: Spoofed pubkey rejected" + +# --- CORS --- +echo "" +echo "--- CORS ---" + +CORS_HEADER=$(curl -s -D- -X POST "$BACKEND/archipelago/node-message" \ + -H 'Content-Type: application/json' \ + -H 'Origin: http://evil.com' \ + -d '{"from_pubkey":"aaaa","message":"test"}' 2>&1 | grep -i "access-control-allow-origin" || true) +CORS_OK=$([ -z "$CORS_HEADER" ] && echo true || echo false) +check "$CORS_OK" "AUTH-009: No CORS header for evil.com origin" + +# --- Nginx Security Headers --- +echo "" +echo "--- Nginx Security Headers ---" + +HEADERS=$(curl -sI "$NGINX/") +for H in "X-Content-Type-Options" "X-Frame-Options" "Referrer-Policy" "Content-Security-Policy"; do + FOUND=$(echo "$HEADERS" | grep -qi "$H" && echo true || echo false) + check "$FOUND" "XSS-004: $H header present" +done + +# --- Logout invalidation --- +echo "" +echo "--- Session Lifecycle ---" + +curl -s -o /dev/null -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -H "Cookie: session=$COOKIE" \ + -d '{"method":"auth.logout","params":{}}' + +POST_LOGOUT=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BACKEND/rpc/v1" \ + -H 'Content-Type: application/json' \ + -H "Cookie: session=$COOKIE" \ + -d '{"method":"node.did","params":{}}') +check "$([ "$POST_LOGOUT" = "401" ] && echo true || echo false)" "AUTH-006: Session invalid after logout → $POST_LOGOUT" + +# --- Summary --- +echo "" +echo "============================================" +TOTAL=$((PASS+FAIL)) +echo " Results: $PASS/$TOTAL passed, $FAIL failed" +echo "============================================" + +if [ "$FAIL" -gt 0 ]; then + echo "VERIFICATION FAILED" + exit 1 +else + echo "ALL CHECKS PASSED" + exit 0 +fi