d0be2fdcf1
使 deploy-production.sh 可读本机 secrets 并在非 docker 组用户下用 sudo 识别 1Panel 栈;修复 security_probe_external.sh 的 curl shift 误用与 WebSocket 自动 venv。
227 lines
8.5 KiB
Bash
227 lines
8.5 KiB
Bash
#!/usr/bin/env bash
|
|
# security_probe_external.sh — Read-only external attack-surface probes (no credentials).
|
|
# Usage: NEXUS_PROBE_BASE=https://api.synaglobal.vip bash scripts/security_probe_external.sh
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
BASE="${NEXUS_PROBE_BASE:-https://api.synaglobal.vip}"
|
|
REPORT_DIR="${NEXUS_PROBE_REPORT_DIR:-/tmp/nexus-external-probe}"
|
|
PROBE_VENV="${NEXUS_PROBE_VENV_DIR:-/tmp/nexus-probe-venv}"
|
|
mkdir -p "$REPORT_DIR"
|
|
OUT="$REPORT_DIR/probe-$(date -u +%Y%m%dT%H%M%SZ).txt"
|
|
|
|
log() { echo "$@" | tee -a "$OUT"; }
|
|
plog() { echo "$@" | tee -a "$OUT" >&2; }
|
|
pass() { log " [PASS] $*"; }
|
|
fail() { log " [FAIL] $*"; }
|
|
info() { log " [INFO] $*"; }
|
|
warn() { log " [WARN] $*"; }
|
|
|
|
# Resolve a Python with `websockets` for BL-07 probes (project venv → system → bootstrap venv).
|
|
resolve_ws_python() {
|
|
local py cand
|
|
if [[ -n "${NEXUS_PROBE_PYTHON:-}" && -x "${NEXUS_PROBE_PYTHON}" ]]; then
|
|
if "${NEXUS_PROBE_PYTHON}" -c 'import websockets' 2>/dev/null; then
|
|
echo "${NEXUS_PROBE_PYTHON}"
|
|
return 0
|
|
fi
|
|
fi
|
|
for cand in "${ROOT}/venv/bin/python" "${ROOT}/.venv/bin/python"; do
|
|
[[ -x "$cand" ]] || continue
|
|
if "$cand" -c 'import websockets' 2>/dev/null; then
|
|
echo "$cand"
|
|
return 0
|
|
fi
|
|
done
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
if python3 -c 'import websockets' 2>/dev/null; then
|
|
echo python3
|
|
return 0
|
|
fi
|
|
fi
|
|
if ! command -v python3 >/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
if [[ ! -x "${PROBE_VENV}/bin/python" ]]; then
|
|
python3 -m venv "${PROBE_VENV}" 2>>"$OUT" || return 1
|
|
fi
|
|
if ! "${PROBE_VENV}/bin/python" -c 'import websockets' 2>/dev/null; then
|
|
"${PROBE_VENV}/bin/pip" install -q "websockets==14.1" 2>>"$OUT" || return 1
|
|
fi
|
|
echo "${PROBE_VENV}/bin/python"
|
|
}
|
|
|
|
# probe METHOD PATH [JSON_BODY] [extra curl args...]
|
|
# Prints HTTP status code to stdout only.
|
|
probe() {
|
|
local method="$1" path="$2" body="${3:-}"
|
|
# shift 3 is wrong for GET (only 2 args): bash leaves $@ untouched when n > $#.
|
|
shift 2
|
|
if [[ "$method" != "GET" ]]; then
|
|
shift 1
|
|
fi
|
|
local url="${BASE}${path}"
|
|
local tmp code_file
|
|
tmp="$(mktemp)"
|
|
code_file="$(mktemp)"
|
|
local -a curl_args=(-sS -o "$tmp" -w "%{http_code}" --max-time 20 -X "$method")
|
|
if [[ "$method" != "GET" ]]; then
|
|
curl_args+=(-H "Content-Type: application/json")
|
|
[[ -n "$body" ]] && curl_args+=(-d "$body")
|
|
fi
|
|
local code
|
|
if ! curl "${curl_args[@]}" "$@" "$url" >"$code_file" 2>>"$OUT"; then
|
|
code="000"
|
|
else
|
|
code="$(tr -d '\r\n' <"$code_file")"
|
|
fi
|
|
rm -f "$code_file"
|
|
local snippet
|
|
snippet="$(head -c 400 "$tmp" | tr '\n' ' ')"
|
|
plog ""
|
|
plog "── $method $path → HTTP $code"
|
|
plog " body: ${snippet:-<empty>}"
|
|
rm -f "$tmp"
|
|
echo "$code"
|
|
}
|
|
|
|
log "═══ Nexus external attack-surface probe ═══"
|
|
log "BASE=$BASE"
|
|
log "OUT=$OUT"
|
|
log "time=$(date -u -Iseconds)"
|
|
|
|
# ── Health ──
|
|
c=$(probe GET /health)
|
|
[[ "$c" == "200" ]] && pass "GET /health → 200" || fail "GET /health code=$c"
|
|
|
|
c=$(probe GET /health/detail)
|
|
[[ "$c" == "401" ]] && pass "GET /health/detail requires JWT" || fail "GET /health/detail code=$c (expected 401) — component leak if 200"
|
|
|
|
# ── OpenAPI / docs ──
|
|
c=$(probe GET /openapi.json)
|
|
[[ "$c" == "200" ]] && warn "GET /openapi.json accessible (schema disclosure)" || info "GET /openapi.json code=$c"
|
|
|
|
c=$(probe GET /docs)
|
|
[[ "$c" == "200" ]] && warn "GET /docs accessible" || info "GET /docs code=$c"
|
|
|
|
c=$(probe GET /redoc)
|
|
[[ "$c" == "200" ]] && warn "GET /redoc accessible" || info "GET /redoc code=$c"
|
|
|
|
# ── Install API ──
|
|
c=$(probe GET /api/install/status)
|
|
if [[ "$c" == "404" ]]; then
|
|
pass "GET /api/install/status hidden when locked (404)"
|
|
elif [[ "$c" == "200" ]]; then
|
|
warn "GET /api/install/status returns 200 (install state leak)"
|
|
else
|
|
info "GET /api/install/status code=$c"
|
|
fi
|
|
|
|
for ep in env-check connection-check state; do
|
|
c=$(probe GET "/api/install/$ep")
|
|
[[ "$c" == "404" ]] && pass "GET /api/install/$ep hidden when locked (404)" || fail "GET /api/install/$ep code=$c (expected 404)"
|
|
done
|
|
|
|
c=$(probe POST /api/install/init-db '{"db_host":"x"}')
|
|
[[ "$c" == "404" ]] && pass "POST /api/install/init-db hidden when locked (404)" || fail "POST /api/install/init-db code=$c (expected 404)"
|
|
|
|
c=$(probe POST /api/install/create-admin '{"username":"x","password":"x","install_token":"x"}')
|
|
[[ "$c" == "404" ]] && pass "POST /api/install/create-admin hidden when locked (404)" || fail "POST /api/install/create-admin code=$c (expected 404)"
|
|
|
|
c=$(probe POST /api/install/test-credentials '{}')
|
|
[[ "$c" == "404" ]] && pass "POST /api/install/test-credentials hidden when locked (404)" || fail "POST /api/install/test-credentials code=$c (expected 404)"
|
|
|
|
c=$(probe POST /api/install/lock '{}')
|
|
[[ "$c" == "404" ]] && pass "POST /api/install/lock hidden when locked (404)" || fail "POST /api/install/lock code=$c (expected 404)"
|
|
|
|
# ── Public settings ──
|
|
c=$(probe GET /api/settings/bing-wallpapers)
|
|
[[ "$c" == "200" ]] && pass "GET /api/settings/bing-wallpapers public read-only" || fail "code=$c"
|
|
|
|
c=$(probe POST /api/settings/bing-wallpapers/sync '{}')
|
|
[[ "$c" == "401" ]] && pass "POST bing-wallpapers/sync requires JWT" || fail "POST bing-wallpapers/sync code=$c (expected 401)"
|
|
|
|
# ── Protected admin API ──
|
|
c=$(probe GET /api/servers/)
|
|
[[ "$c" == "401" ]] && pass "GET /api/servers/ requires JWT" || fail "code=$c"
|
|
|
|
c=$(probe POST /api/servers/ '{"name":"x","domain":"1.2.3.4"}')
|
|
[[ "$c" == "401" ]] && pass "POST /api/servers/ requires JWT" || fail "code=$c"
|
|
|
|
c=$(probe GET /api/audit/)
|
|
[[ "$c" == "401" ]] && pass "GET /api/audit/ requires JWT" || fail "code=$c"
|
|
|
|
# ── Agent ──
|
|
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}')
|
|
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat no key rejected ($c)" || fail "code=$c"
|
|
|
|
c=$(probe POST /api/agent/heartbeat '{"server_id":1,"is_online":true}' -H "X-API-Key: invalid-probe-key-000")
|
|
[[ "$c" == "401" || "$c" == "422" ]] && pass "POST /api/agent/heartbeat bad key rejected ($c)" || fail "code=$c"
|
|
|
|
c=$(probe POST /api/agent/script-callback \
|
|
'{"job_id":"probe0001","server_id":1,"secret":"invalid-probe-secret","exit_code":0}' \
|
|
-H "X-API-Key: invalid-probe-key-000")
|
|
[[ "$c" == "401" || "$c" == "404" || "$c" == "429" ]] && pass "POST script-callback bad key ($c)" || fail "code=$c"
|
|
|
|
# ── Auth login (single probe) ──
|
|
login_tmp="$(mktemp)"
|
|
curl -sS -o "$login_tmp" --max-time 15 -X POST \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username":"nonexistent_probe_user","password":"wrong"}' \
|
|
"${BASE}/api/auth/login" 2>>"$OUT" || true
|
|
login_body="$(cat "$login_tmp")"
|
|
rm -f "$login_tmp"
|
|
info "login wrong user: $login_body"
|
|
[[ "$login_body" == *"用户名或密码错误"* ]] && pass "login message unified (no user enumeration)" || warn "login message may leak enumeration"
|
|
|
|
# ── Static agent ──
|
|
c=$(probe GET /agent/install.sh)
|
|
[[ "$c" == "200" ]] && info "GET /agent/install.sh public (expected)" || info "code=$c"
|
|
|
|
# ── WebSocket ──
|
|
WS_PY="$(resolve_ws_python || true)"
|
|
if [[ -n "${WS_PY}" ]]; then
|
|
log ""
|
|
log "── WebSocket probes (python=${WS_PY})"
|
|
"${WS_PY}" - "$BASE" 2>>"$OUT" <<'PY' | tee -a "$OUT" || true
|
|
import asyncio, sys
|
|
import websockets
|
|
|
|
base = sys.argv[1].rstrip("/")
|
|
ws_base = base.replace("https://", "wss://").replace("http://", "ws://")
|
|
|
|
async def probe_ws(path, token=None):
|
|
url = f"{ws_base}{path}"
|
|
if token is not None:
|
|
url += f"?token={token}"
|
|
try:
|
|
async with websockets.connect(url, open_timeout=10, close_timeout=5) as ws:
|
|
print(f" [FAIL] {path} connected without expected close")
|
|
except Exception as e:
|
|
msg = str(e)
|
|
if "4001" in msg or "Missing JWT" in msg:
|
|
print(f" [PASS] {path} no token → rejected ({msg[:80]})")
|
|
elif "4401" in msg or "Invalid" in msg or "expired" in msg.lower():
|
|
print(f" [PASS] {path} bad token → rejected ({msg[:80]})")
|
|
elif "403" in msg:
|
|
# Starlette: close() before accept() → HTTP 403 (same direct and via proxy)
|
|
print(f" [PASS] {path} unauthenticated → rejected (HTTP 403)")
|
|
else:
|
|
print(f" [INFO] {path} → {msg[:120]}")
|
|
|
|
async def main():
|
|
await probe_ws("/ws/alerts")
|
|
await probe_ws("/ws/alerts", "invalid.jwt.token")
|
|
await probe_ws("/ws/sync")
|
|
await probe_ws("/ws/terminal/1", "invalid.jwt.token")
|
|
|
|
asyncio.run(main())
|
|
PY
|
|
else
|
|
warn "WebSocket probes skipped: no python with websockets (set NEXUS_PROBE_PYTHON or install venv)"
|
|
fi
|
|
|
|
log ""
|
|
log "═══ Probe complete ═══"
|
|
log "Full log: $OUT"
|