This post presents a bash script that audits a VPS from an external perspective: port scanning, TLS configuration, security headers, path traversal, API authentication, and information leakage. The script runs from an external machine to observe the same attack surface visible to malicious actors.
Problem Statement
After hardening a server (see previous post), verification from an external perspective is necessary to catch:
- Ports accidentally exposed through firewall misconfigurations
- TLS accepting deprecated protocols
- Security headers missing in production
- Path traversal bypasses in nginx
- Information leaking in error responses
An external audit provides the same perspective an attacker would have.
Proposed Solution
A single bash script executed from any external machine (laptop, different VPS, CI/CD) that:
- Scans ports and flags unexpected ones
- Tests TLS protocol versions and certificate expiry
- Checks HTTP security headers
- Verifies decoy responses work (for rathole setups)
- Attempts path traversal and injection
- Tests API authentication
- Detects information leakage
- Fuzzes HTTP methods and malformed requests
Implementation
Create vps-pentest.sh:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| #!/bin/bash
# External security audit script
# Run from an off-network machine: ./vps-pentest.sh <target> [domain]
set -uo pipefail
RED='\033[0;31m'
GRN='\033[0;32m'
YLW='\033[0;33m'
RST='\033[0m'
PASS=0
FAIL=0
WARN=0
TARGET="${1:-}"
DOMAIN="${2:-$TARGET}"
if [[ -z "$TARGET" ]]; then
echo "Usage: vps-pentest.sh <ip-or-hostname> [domain]"
echo " Run from an external machine to audit the target."
exit 1
fi
pass() { ((PASS++)); printf "${GRN}[PASS]${RST} %s\n" "$1"; }
fail() { ((FAIL++)); printf "${RED}[FAIL]${RST} %s\n" "$1"; }
warn() { ((WARN++)); printf "${YLW}[WARN]${RST} %s\n" "$1"; }
info() { printf " %s\n" "$1"; }
section() { echo ""; echo "======================================"; echo " $1"; echo "======================================"; }
# Check dependencies
for cmd in curl openssl nmap timeout nc; do
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: $cmd is required but not installed"
exit 1
fi
done
echo "Starting security audit of $TARGET"
echo "Domain: $DOMAIN"
echo "Time: $(date)"
|
Section 1: Port Scanning
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| section "PORT SCAN"
# Define expected open ports - customize for specific setup
EXPECTED_OPEN="22 80 443"
# Ports that should be localhost-only (rathole, internal services)
LOCALHOST_ONLY="2222 2223 8443 11434"
# Common ports to scan
SCAN_PORTS="21,22,23,25,53,80,111,135,139,443,445,993,995,1433,1521,2222,2223,3306,3389,5432,5900,6379,8080,8443,8888,9090,9200,11434,27017"
OPEN_PORTS=$(nmap -Pn -p "$SCAN_PORTS" --open -T4 "$TARGET" 2>/dev/null | grep '^[0-9]' | cut -d'/' -f1)
for port in $OPEN_PORTS; do
if echo "$EXPECTED_OPEN" | grep -qw "$port"; then
pass "Port $port open (expected)"
else
fail "Unexpected port $port is open"
fi
done
# Verify internal ports are NOT reachable
for port in $LOCALHOST_ONLY; do
if echo "$OPEN_PORTS" | grep -qw "$port"; then
fail "Port $port should be localhost-only but is reachable!"
else
pass "Port $port not reachable externally (good)"
fi
done
|
Section 2: TLS Configuration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| section "TLS CONFIGURATION"
# Check deprecated TLS versions are rejected
for ver in ssl3 tls1 tls1_1; do
if timeout 5 openssl s_client -connect "$TARGET:443" -"$ver" </dev/null 2>&1 | grep -q "Cipher is"; then
fail "Deprecated $ver is accepted"
else
pass "$ver rejected"
fi
done
# Check modern TLS versions work
for ver in tls1_2 tls1_3; do
if timeout 5 openssl s_client -connect "$TARGET:443" -"$ver" </dev/null 2>&1 | grep -q "Cipher is"; then
pass "$ver supported"
else
warn "$ver not supported"
fi
done
# Check certificate
CERT_INFO=$(timeout 5 openssl s_client -connect "$TARGET:443" -servername "$DOMAIN" </dev/null 2>/dev/null)
CERT_CN=$(echo "$CERT_INFO" | openssl x509 -noout -subject 2>/dev/null | grep -oP 'CN\s*=\s*\K.*')
CERT_EXPIRY=$(echo "$CERT_INFO" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [[ -n "$CERT_CN" ]]; then
pass "TLS certificate present (CN=$CERT_CN)"
else
fail "Could not retrieve TLS certificate"
fi
if [[ -n "$CERT_EXPIRY" ]]; then
# Calculate days until expiry
EXPIRY_EPOCH=$(date -d "$CERT_EXPIRY" +%s 2>/dev/null || \
date -j -f "%b %d %T %Y %Z" "$CERT_EXPIRY" +%s 2>/dev/null)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [[ "$DAYS_LEFT" -lt 7 ]]; then
fail "Certificate expires in $DAYS_LEFT days!"
elif [[ "$DAYS_LEFT" -lt 30 ]]; then
warn "Certificate expires in $DAYS_LEFT days"
else
pass "Certificate valid for $DAYS_LEFT more days"
fi
fi
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| section "HTTP SECURITY HEADERS"
HEADERS=$(curl -sk -I "https://$TARGET/" 2>/dev/null)
check_header() {
local name="$1"
local expected="$2"
local val
val=$(echo "$HEADERS" | grep -i "^$name:" | head -1)
if [[ -z "$val" ]]; then
fail "Missing header: $name"
elif [[ -n "$expected" ]] && ! echo "$val" | grep -qi "$expected"; then
warn "$name present but unexpected value"
info "Got: $(echo "$val" | tr -d '\r')"
else
pass "$name present"
fi
}
check_header "Strict-Transport-Security" "max-age"
check_header "X-Frame-Options" ""
check_header "X-Content-Type-Options" "nosniff"
# Server header should not reveal version
SERVER_HDR=$(echo "$HEADERS" | grep -i "^server:" | tr -d '\r')
if echo "$SERVER_HDR" | grep -qiE "nginx/|apache/|iis/"; then
fail "Server header leaks version: $SERVER_HDR"
elif [[ -n "$SERVER_HDR" ]]; then
warn "Server header present: $SERVER_HDR"
else
pass "No server version disclosed"
fi
|
Section 4: Decoy Responses (for Rathole/Tunnel Setups)
For rathole configurations with nginx decoys (see rathole post), verify proper operation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| section "DECOY / PROXY SHIELDING"
# Regular GET should return decoy, not internal service
DECOY=$(curl -sk "https://$TARGET/" 2>/dev/null)
if echo "$DECOY" | grep -qE '"status":\s*"(ok|healthy)"'; then
pass "Root path returns decoy JSON"
else
warn "Root path response may not be decoy"
info "Got: $(echo "$DECOY" | head -c 100)"
fi
# WebSocket upgrade should reach proxy (not decoy)
WS_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
-H "Upgrade: websocket" -H "Connection: upgrade" \
"https://$TARGET/" 2>/dev/null)
if [[ "$WS_CODE" == "502" || "$WS_CODE" == "101" ]]; then
pass "WebSocket upgrade proxied correctly (HTTP $WS_CODE)"
else
warn "WebSocket upgrade returned HTTP $WS_CODE"
fi
# Non-websocket Upgrade header should NOT be proxied
BOGUS=$(curl -sk -H "Upgrade: bogus" -H "Connection: upgrade" \
"https://$TARGET/" 2>/dev/null)
if echo "$BOGUS" | grep -qE '"status":\s*"(ok|healthy)"'; then
pass "Non-websocket Upgrade gets decoy (not proxied)"
else
fail "Non-websocket Upgrade may have bypassed filter"
fi
|
Section 5: Path Traversal Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| section "PATH TRAVERSAL & INJECTION"
TRAVERSAL_PATHS=(
"/../../../etc/passwd"
"/..%2f..%2f..%2fetc%2fpasswd"
"/%2e%2e/%2e%2e/etc/passwd"
"/api/../../../etc/passwd"
"/api%2F..%2F..%2Fetc%2Fpasswd"
"/%00"
"/api/v1/config%00"
)
for path in "${TRAVERSAL_PATHS[@]}"; do
RESP=$(curl -sk "https://$TARGET$path" 2>/dev/null)
if echo "$RESP" | grep -q "root:"; then
fail "Path traversal succeeded: $path"
else
pass "Traversal blocked: $path"
fi
done
|
Section 6: API Authentication
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| section "API ENDPOINT SECURITY"
# Test an authenticated endpoint (customize path for specific setup)
API_PATH="/api/v1/config"
# Without API key
RESP_NOKEY=$(curl -sk "https://$TARGET$API_PATH" 2>/dev/null)
if echo "$RESP_NOKEY" | grep -qiE '"(forbidden|unauthorized|error)"'; then
pass "$API_PATH rejects missing API key"
elif [[ $(curl -sk -o /dev/null -w "%{http_code}" "https://$TARGET$API_PATH") == "403" ]]; then
pass "$API_PATH returns 403 without key"
else
fail "$API_PATH accessible without authentication"
info "Got: $(echo "$RESP_NOKEY" | head -c 100)"
fi
# With wrong API key
RESP_BADKEY=$(curl -sk -H "X-Api-Key: invalid-key-here" \
"https://$TARGET$API_PATH" 2>/dev/null)
if echo "$RESP_BADKEY" | grep -qiE '"(forbidden|unauthorized|error)"'; then
pass "$API_PATH rejects invalid API key"
else
fail "$API_PATH may accept invalid key"
fi
# Key in query string (should NOT work - keys belong in headers)
RESP_QSKEY=$(curl -sk "https://$TARGET$API_PATH?api_key=test" 2>/dev/null)
if echo "$RESP_QSKEY" | grep -qiE '"(forbidden|unauthorized|error)"'; then
pass "$API_PATH rejects key in query string"
else
warn "$API_PATH may accept key via query string"
fi
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| section "INFORMATION LEAKAGE"
# Check responses do not contain HTML with server info
for path in "/" "/health" "/nonexistent" "/api/v1/status"; do
RESP=$(curl -sk "https://$TARGET$path" 2>/dev/null)
if echo "$RESP" | grep -qiE '<html|<head|powered.by|nginx|apache'; then
fail "$path leaks HTML/server info"
info "Sample: $(echo "$RESP" | head -c 100)"
else
pass "$path returns clean response"
fi
done
# Check error responses do not leak stack traces
for method in DELETE PUT PATCH; do
RESP=$(curl -sk -X "$method" "https://$TARGET/" 2>/dev/null)
if echo "$RESP" | grep -qiE 'traceback|exception|stack|at line|error in'; then
fail "$method request leaks stack trace"
else
pass "$method request: no stack trace"
fi
done
|
Section 8: SSH Hardening Check
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| section "SSH HARDENING"
# Check if SSH prompts for password (should be key-only)
SSH_OUTPUT=$(timeout 5 ssh -o BatchMode=yes \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=5 \
"$TARGET" exit 2>&1 || true)
if echo "$SSH_OUTPUT" | grep -qi "password"; then
warn "SSH may accept password authentication"
else
pass "SSH does not prompt for password (key-only)"
fi
# Get SSH banner
SSH_BANNER=$(timeout 3 bash -c "echo '' | nc -w1 $TARGET 22 2>/dev/null" | head -1)
if [[ -n "$SSH_BANNER" ]]; then
info "SSH banner: $SSH_BANNER"
fi
|
Section 9: HTTP Method Fuzzing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| section "HTTP METHOD FUZZING"
for method in TRACE OPTIONS CONNECT PROPFIND MKCOL; do
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
-X "$method" "https://$TARGET/" 2>/dev/null)
case "$CODE" in
405|404|403|400|200)
pass "$method returns HTTP $CODE (handled)"
;;
500|502|503)
warn "$method returns HTTP $CODE (server error)"
;;
*)
warn "$method returned unexpected HTTP $CODE"
;;
esac
done
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| section "MALFORMED REQUESTS"
# Oversized header (should be rejected)
JUNK=$(head -c 16000 /dev/urandom | base64 | tr -d '\n')
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
-H "X-Junk: $JUNK" "https://$TARGET/" 2>/dev/null)
if [[ "$CODE" == "494" || "$CODE" == "431" || "$CODE" == "400" ]]; then
pass "Oversized header rejected (HTTP $CODE)"
elif [[ "$CODE" == "200" ]]; then
pass "Oversized header handled gracefully"
else
warn "Oversized header returned HTTP $CODE"
fi
# Null byte in URL
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
"https://$TARGET/%00" 2>/dev/null)
if [[ "$CODE" == "400" || "$CODE" == "404" ]]; then
pass "Null byte in URL rejected (HTTP $CODE)"
else
warn "Null byte in URL returned HTTP $CODE"
fi
# Very long URL
LONG_PATH=$(python3 -c "print('a'*8000)" 2>/dev/null || printf 'a%.0s' {1..8000})
CODE=$(curl -sk -o /dev/null -w "%{http_code}" \
"https://$TARGET/$LONG_PATH" 2>/dev/null)
if [[ "$CODE" == "414" || "$CODE" == "400" || "$CODE" == "404" ]]; then
pass "Long URL handled (HTTP $CODE)"
else
warn "Long URL returned HTTP $CODE"
fi
|
Results Summary
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| section "RESULTS"
echo ""
printf "${GRN}PASS: %d${RST} ${RED}FAIL: %d${RST} ${YLW}WARN: %d${RST}\n" "$PASS" "$FAIL" "$WARN"
echo ""
if [[ "$FAIL" -gt 0 ]]; then
echo "!! Failures detected - review above !!"
exit 1
elif [[ "$WARN" -gt 0 ]]; then
echo "Warnings present - review recommended."
exit 0
else
echo "All checks passed."
exit 0
fi
|
Usage
Run from any machine that is NOT the target server:
1
2
3
4
5
| # Basic usage
./vps-pentest.sh your-server.com
# With explicit domain for TLS SNI
./vps-pentest.sh 123.45.67.89 your-server.com
|
Sample Output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| Starting security audit of your-server.com
Domain: your-server.com
Time: Sat Feb 28 15:00:00 MST 2026
======================================
PORT SCAN
======================================
[PASS] Port 22 open (expected)
[PASS] Port 80 open (expected)
[PASS] Port 443 open (expected)
[PASS] Port 2222 not reachable externally (good)
[PASS] Port 8443 not reachable externally (good)
[PASS] Port 11434 not reachable externally (good)
======================================
TLS CONFIGURATION
======================================
[PASS] ssl3 rejected
[PASS] tls1 rejected
[PASS] tls1_1 rejected
[PASS] tls1_2 supported
[PASS] tls1_3 supported
[PASS] TLS certificate present (CN=your-server.com)
[PASS] Certificate valid for 72 more days
======================================
HTTP SECURITY HEADERS
======================================
[PASS] Strict-Transport-Security present
[PASS] X-Frame-Options present
[PASS] X-Content-Type-Options present
[PASS] No server version disclosed
...
======================================
RESULTS
======================================
PASS: 42 FAIL: 0 WARN: 2
Warnings present - review recommended.
|
Customization
Adding Expected Ports
Edit the EXPECTED_OPEN variable:
1
2
| # Web server + game server + custom app
EXPECTED_OPEN="22 80 443 27015 8080"
|
Adding Protected Endpoints
Add more API paths to test:
1
2
3
| for path in "/api/v1/config" "/admin" "/metrics" "/.env"; do
# ... authentication tests
done
|
Custom Decoy Detection
If the decoy returns different JSON:
1
2
3
| if echo "$DECOY" | grep -q '"your-decoy-field"'; then
pass "Decoy working"
fi
|
CI/CD Integration
Run as a scheduled job after deployments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| # .github/workflows/security-audit.yml
name: Security Audit
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM
workflow_dispatch:
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt-get install -y nmap netcat-openbsd
- name: Run security audit
run: ./vps-pentest.sh $
env:
VPS_HOST: $
- name: Notify on failure
if: failure()
run: |
curl -X POST "$" \
-d '{"text":"Security audit failed for VPS!"}'
|
Test Coverage
| Test | Vulnerabilities Detected |
|---|
| Port scan | Firewall misconfigs, accidental exposures |
| TLS versions | Outdated protocol support |
| Certificate expiry | Let’s Encrypt renewal failures |
| Security headers | Missing HSTS, clickjacking protection |
| Decoy responses | Rathole/tunnel bypass |
| Path traversal | nginx alias vulnerabilities |
| API auth | Missing authentication checks |
| Info leakage | Error pages revealing internals |
| Method fuzzing | Unexpected method handling |
| Malformed requests | Input validation gaps |
Limitations
This script provides a quick sanity check, not a replacement for professional penetration testing. It does not cover:
- Application-layer vulnerabilities (XSS, SQL injection)
- Business logic flaws
- Authenticated attack surfaces
- Rate limiting / DoS resilience
- DNS configuration
For comprehensive testing, dedicated tools (Burp Suite, OWASP ZAP) or professional security assessments are recommended.
Conclusion
Regular external audits detect configuration drift before attackers do. This script provides:
- Rapid feedback: Execution completes in under a minute
- Actionable results: PASS/FAIL/WARN with clear messages
- CI/CD ready: Exit codes for automation
- Customizable: Additional checks are easily added
The script should run after every deployment, weekly via cron, or as part of regular maintenance. The most effective security audit is one that executes consistently.