websec-validator 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- websec_validator/__init__.py +14 -0
- websec_validator/briefing.py +218 -0
- websec_validator/calibration.json +75 -0
- websec_validator/calibration.py +226 -0
- websec_validator/cli.py +395 -0
- websec_validator/constitution.py +81 -0
- websec_validator/corpus.json +49 -0
- websec_validator/dynamic.py +249 -0
- websec_validator/extractors/__init__.py +56 -0
- websec_validator/extractors/auth.py +77 -0
- websec_validator/extractors/authz.py +130 -0
- websec_validator/extractors/base.py +101 -0
- websec_validator/extractors/client_exposure.py +48 -0
- websec_validator/extractors/graphql.py +71 -0
- websec_validator/extractors/iac_ci.py +65 -0
- websec_validator/extractors/integrations.py +55 -0
- websec_validator/extractors/routes.py +215 -0
- websec_validator/extractors/schemas.py +75 -0
- websec_validator/extractors/stack.py +80 -0
- websec_validator/extractors/surface.py +86 -0
- websec_validator/extractors/tenant.py +33 -0
- websec_validator/findings.py +199 -0
- websec_validator/probes.py +79 -0
- websec_validator/proof.py +96 -0
- websec_validator/recon.py +28 -0
- websec_validator/report.py +114 -0
- websec_validator/scanners.py +248 -0
- websec_validator/templates/probes/bola-cross-tenant.sh +192 -0
- websec_validator/templates/probes/bola-write-verbs.py +147 -0
- websec_validator/templates/probes/compare-roles.sh +69 -0
- websec_validator/templates/probes/dlp-bypass-offline.py +149 -0
- websec_validator/templates/probes/hs256-brute-force.py +90 -0
- websec_validator/templates/probes/jwt-attacks.sh +161 -0
- websec_validator/templates/probes/mass-assignment.py +201 -0
- websec_validator/templates/probes/race-conditions.py +144 -0
- websec_validator/templates/probes/rate-limit-burst.sh +136 -0
- websec_validator/templates/probes/s3-assess.sh +120 -0
- websec_validator/templates/probes/ssrf-probes.sh +189 -0
- websec_validator/templates/probes/webhook-forgery.py +113 -0
- websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +75 -0
- websec_validator/templates/reports/access-control-matrix.md.template +65 -0
- websec_validator/templates/reports/findings-triage.md.template +28 -0
- websec_validator/templates/reports/pentest-handover-brief.md.template +121 -0
- websec_validator/templates/reports/per-tool-FINDINGS.md.template +37 -0
- websec_validator-0.2.0.dist-info/METADATA +232 -0
- websec_validator-0.2.0.dist-info/RECORD +50 -0
- websec_validator-0.2.0.dist-info/WHEEL +5 -0
- websec_validator-0.2.0.dist-info/entry_points.txt +2 -0
- websec_validator-0.2.0.dist-info/licenses/LICENSE +21 -0
- websec_validator-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Race condition probe — fires N parallel requests at race-prone endpoints
|
|
4
|
+
and checks if the server's invariants hold.
|
|
5
|
+
|
|
6
|
+
Common race targets:
|
|
7
|
+
- "claim" / "assign" endpoints — only one parallel claim should succeed
|
|
8
|
+
- status / state toggles — multiple parallel calls should converge
|
|
9
|
+
- inventory / quota decrements — should not allow over-spend
|
|
10
|
+
- tag/label-add endpoints — should dedupe
|
|
11
|
+
|
|
12
|
+
For each target:
|
|
13
|
+
- Fire PARALLEL_REQUESTS in parallel
|
|
14
|
+
- Count successes (200/201)
|
|
15
|
+
- Compare to expected_unique (usually 1 — only one assignment should win)
|
|
16
|
+
- If success_count > expected_unique -> race condition likely
|
|
17
|
+
|
|
18
|
+
Uses async httpx for true parallelism (synchronous loops can't trigger races).
|
|
19
|
+
|
|
20
|
+
Install: pip install httpx
|
|
21
|
+
"""
|
|
22
|
+
import asyncio, httpx, json, sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from collections import Counter
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parents[2].parent
|
|
27
|
+
fixture = json.loads((ROOT / 'security/pentest-prep/fixtures/test-context.json').read_text())
|
|
28
|
+
ENV = {}
|
|
29
|
+
for line in (ROOT / 'security/zap/.env').read_text().splitlines():
|
|
30
|
+
if '=' in line and not line.lstrip().startswith('#'):
|
|
31
|
+
k, v = line.split('=', 1); ENV[k.strip()] = v.strip()
|
|
32
|
+
|
|
33
|
+
TARGET = fixture['target']
|
|
34
|
+
A_GROUP = fixture['agent_a']['group_id']
|
|
35
|
+
A_CONV = fixture['agent_a']['conversation_ids'][0]
|
|
36
|
+
A_USER_ID = fixture['agent_a'].get('user_id', '<AGENT_A_USER_ID>')
|
|
37
|
+
|
|
38
|
+
import subprocess
|
|
39
|
+
def login(u, p):
|
|
40
|
+
r = subprocess.run(['curl','-fsS','-X','POST',f"{TARGET}/api/auth/login",
|
|
41
|
+
'-H','Content-Type: application/json',
|
|
42
|
+
'-d',json.dumps({'email':u,'password':p})],
|
|
43
|
+
capture_output=True, text=True)
|
|
44
|
+
return json.loads(r.stdout)['tokens']['accessToken']
|
|
45
|
+
|
|
46
|
+
AGENT_TOK = login(ENV['ZAP_AGENT_USER'], ENV['ZAP_AGENT_PASS'])
|
|
47
|
+
|
|
48
|
+
PARALLEL = 50 # number of concurrent requests per target
|
|
49
|
+
|
|
50
|
+
# PROJECT-SPECIFIC START
|
|
51
|
+
# TODO: replace these with the race-prone endpoints from your project.
|
|
52
|
+
# Common shapes:
|
|
53
|
+
# - assign / claim: one winner expected
|
|
54
|
+
# - state toggle (snooze, archive, status flip): converges to one state
|
|
55
|
+
# - tag/label add: deduplicates
|
|
56
|
+
# - inventory decrement, points spend, quota use: must not over-spend
|
|
57
|
+
TARGETS = [
|
|
58
|
+
{
|
|
59
|
+
'name': 'assign-resource',
|
|
60
|
+
'method': 'POST',
|
|
61
|
+
'url': f"{TARGET}/api/groups/{A_GROUP}/conversations/{A_CONV}/assign",
|
|
62
|
+
'payload': {'agentId': A_USER_ID},
|
|
63
|
+
'expected_unique': 1,
|
|
64
|
+
'note': '50 parallel assigns to self -- should only one succeed (or all idempotent 200s if backend dedupes)',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
'name': 'snooze-resource',
|
|
68
|
+
'method': 'POST',
|
|
69
|
+
'url': f"{TARGET}/api/groups/{A_GROUP}/conversations/{A_CONV}/snooze",
|
|
70
|
+
'payload': {'snoozeUntil': '2027-01-01T00:00:00Z'},
|
|
71
|
+
'expected_unique': 1,
|
|
72
|
+
'note': 'Toggle endpoint -- multiple parallel calls should converge to one state',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
'name': 'status-toggle',
|
|
76
|
+
'method': 'PUT',
|
|
77
|
+
'url': f"{TARGET}/api/users/me/status",
|
|
78
|
+
'payload': {'status': 'online'},
|
|
79
|
+
'expected_unique': 1,
|
|
80
|
+
'note': 'User status update -- parallel calls should converge',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
'name': 'tag-add',
|
|
84
|
+
'method': 'POST',
|
|
85
|
+
'url': f"{TARGET}/api/groups/{A_GROUP}/conversations/{A_CONV}/tags",
|
|
86
|
+
'payload': {'tagId': 'race-test-tag-xxxxxxxx'},
|
|
87
|
+
'expected_unique': 1,
|
|
88
|
+
'note': '50 parallel adds of same tag -- should only add once if dedupe works',
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
# PROJECT-SPECIFIC END
|
|
92
|
+
|
|
93
|
+
async def fire(client, t):
|
|
94
|
+
"""Single request, return (status_code, response_body_preview)"""
|
|
95
|
+
try:
|
|
96
|
+
r = await client.request(
|
|
97
|
+
t['method'], t['url'],
|
|
98
|
+
json=t['payload'],
|
|
99
|
+
headers={'Authorization': f'Bearer {AGENT_TOK}'},
|
|
100
|
+
timeout=30.0,
|
|
101
|
+
)
|
|
102
|
+
return (r.status_code, r.text[:120])
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return (None, str(e)[:120])
|
|
105
|
+
|
|
106
|
+
async def run_target(t):
|
|
107
|
+
print(f" Firing {PARALLEL} parallel {t['method']} to {t['url'][len(TARGET):]}")
|
|
108
|
+
async with httpx.AsyncClient() as client:
|
|
109
|
+
results = await asyncio.gather(*[fire(client, t) for _ in range(PARALLEL)])
|
|
110
|
+
codes = Counter(r[0] for r in results)
|
|
111
|
+
success = sum(1 for r in results if r[0] and 200 <= r[0] < 300)
|
|
112
|
+
race_likely = success > t['expected_unique']
|
|
113
|
+
print(f" -> status counts: {dict(codes)}, successes: {success}, expected: {t['expected_unique']}")
|
|
114
|
+
if race_likely:
|
|
115
|
+
print(f" !! RACE CONDITION SUSPECTED -- {success} successes vs {t['expected_unique']} expected")
|
|
116
|
+
return {
|
|
117
|
+
'name': t['name'],
|
|
118
|
+
'parallel': PARALLEL,
|
|
119
|
+
'status_counts': dict(codes),
|
|
120
|
+
'success_count': success,
|
|
121
|
+
'expected_unique': t['expected_unique'],
|
|
122
|
+
'race_suspected': race_likely,
|
|
123
|
+
'note': t['note'],
|
|
124
|
+
'sample_responses': [r for r in results if r[0] and r[0] < 500][:3],
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async def main():
|
|
128
|
+
findings = []
|
|
129
|
+
for t in TARGETS:
|
|
130
|
+
print(f"\n=== {t['name']}: {t['note']}")
|
|
131
|
+
f = await run_target(t)
|
|
132
|
+
findings.append(f)
|
|
133
|
+
out = ROOT / 'security/pentest-prep/reports/race-conditions/findings.json'
|
|
134
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
out.write_text(json.dumps(findings, indent=2))
|
|
136
|
+
crit = sum(1 for f in findings if f['race_suspected'])
|
|
137
|
+
print(f"\n=== Summary ===")
|
|
138
|
+
print(f" race suspected on {crit}/{len(findings)} endpoints")
|
|
139
|
+
print(f" saved to {out}")
|
|
140
|
+
return crit
|
|
141
|
+
|
|
142
|
+
if __name__ == '__main__':
|
|
143
|
+
rc = asyncio.run(main())
|
|
144
|
+
sys.exit(1 if rc > 0 else 0)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# rate-limit-burst.sh — verify rate limiters actually fire under load.
|
|
4
|
+
#
|
|
5
|
+
# Three tests:
|
|
6
|
+
# 1. AUTH_RATE_LIMIT — N failed login attempts; expect a 429 by attempt K
|
|
7
|
+
# (the project's documented per-IP login throttle).
|
|
8
|
+
# 2. General apiRateLimiter — burst of GET requests against a public health
|
|
9
|
+
# endpoint; expect 429s once over the per-IP budget.
|
|
10
|
+
# 3. X-Forwarded-For bypass — repeat (1) but rotate the XFF header between
|
|
11
|
+
# requests. If the backend honors XFF for rate-limit keying WITHOUT
|
|
12
|
+
# verifying the proxy chain, attackers bypass the limiter.
|
|
13
|
+
#
|
|
14
|
+
# Usage: ./rate-limit-burst.sh
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
cd "$(dirname "$0")"
|
|
17
|
+
|
|
18
|
+
[[ -f .env ]] || { echo "No .env found" >&2; exit 1; }
|
|
19
|
+
|
|
20
|
+
read_env() {
|
|
21
|
+
local key="$1"
|
|
22
|
+
python3 -c "
|
|
23
|
+
for l in open('.env'):
|
|
24
|
+
l = l.rstrip('\n')
|
|
25
|
+
if l.startswith('#') or '=' not in l: continue
|
|
26
|
+
k, v = l.split('=', 1)
|
|
27
|
+
if k.strip() == '$key':
|
|
28
|
+
print(v); break
|
|
29
|
+
"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
TARGET="$(read_env ZAP_TARGET)"
|
|
33
|
+
[[ -n "$TARGET" ]] || { echo "ZAP_TARGET missing from .env" >&2; exit 2; }
|
|
34
|
+
|
|
35
|
+
# TODO: adjust login path and public health path to match your API.
|
|
36
|
+
LOGIN_PATH="/api/auth/login"
|
|
37
|
+
HEALTH_PATH="/api/health"
|
|
38
|
+
|
|
39
|
+
PASS_COUNT=0
|
|
40
|
+
FAIL_COUNT=0
|
|
41
|
+
FAIL_LINES=()
|
|
42
|
+
|
|
43
|
+
# === Test 1: AUTH_RATE_LIMIT ===
|
|
44
|
+
echo "=== Test 1: AUTH_RATE_LIMIT (expected ≥1 of 10 attempts to be 429) ==="
|
|
45
|
+
codes_seen=()
|
|
46
|
+
for i in $(seq 1 10); do
|
|
47
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TARGET$LOGIN_PATH" \
|
|
48
|
+
-H 'Content-Type: application/json' \
|
|
49
|
+
-d '{"email":"rl-test@example.com","password":"wrong"}')
|
|
50
|
+
codes_seen+=("$code")
|
|
51
|
+
printf ' attempt %2d → %s\n' "$i" "$code"
|
|
52
|
+
done
|
|
53
|
+
if printf '%s\n' "${codes_seen[@]}" | grep -q '^429$'; then
|
|
54
|
+
echo " PASS AUTH_RATE_LIMIT fires (saw 429)"
|
|
55
|
+
PASS_COUNT=$((PASS_COUNT+1))
|
|
56
|
+
else
|
|
57
|
+
echo " FAIL AUTH_RATE_LIMIT never fired — limiter may be misconfigured"
|
|
58
|
+
FAIL_COUNT=$((FAIL_COUNT+1))
|
|
59
|
+
FAIL_LINES+=("AUTH_RATE_LIMIT did not fire in 10 attempts")
|
|
60
|
+
fi
|
|
61
|
+
echo
|
|
62
|
+
|
|
63
|
+
# === Test 2: General health burst ===
|
|
64
|
+
echo "=== Test 2: 200 GET ${HEALTH_PATH} requests in ~10s ==="
|
|
65
|
+
codes_file=$(mktemp)
|
|
66
|
+
trap 'rm -f "$codes_file"' EXIT
|
|
67
|
+
seq 1 200 | xargs -n 1 -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' "$TARGET$HEALTH_PATH" > "$codes_file"
|
|
68
|
+
|
|
69
|
+
total=$(wc -l < "$codes_file" | tr -d ' ')
|
|
70
|
+
two_oh_oh=$(grep -c '^200$' "$codes_file" || true)
|
|
71
|
+
four_two_nine=$(grep -c '^429$' "$codes_file" || true)
|
|
72
|
+
other=$((total - two_oh_oh - four_two_nine))
|
|
73
|
+
echo " Total responses: $total"
|
|
74
|
+
echo " 200: $two_oh_oh"
|
|
75
|
+
echo " 429: $four_two_nine"
|
|
76
|
+
echo " Other: $other"
|
|
77
|
+
if [[ "$four_two_nine" -gt 0 ]]; then
|
|
78
|
+
echo " INFO apiRateLimiter fires under burst (saw 429s)"
|
|
79
|
+
else
|
|
80
|
+
echo " INFO apiRateLimiter did NOT fire — 200 reqs is below threshold."
|
|
81
|
+
echo " (general limit is per-IP; for a pentest, escalate to ~5000 reqs)"
|
|
82
|
+
fi
|
|
83
|
+
echo
|
|
84
|
+
|
|
85
|
+
# === Test 3: X-Forwarded-For bypass attempt ===
|
|
86
|
+
echo "=== Test 3: try XFF spoof to bypass AUTH_RATE_LIMIT ==="
|
|
87
|
+
echo " (If the backend respects 'trust proxy = 1' correctly, spoofed XFF"
|
|
88
|
+
echo " headers from us — a direct client — should be IGNORED for rate-limit"
|
|
89
|
+
echo " keying.)"
|
|
90
|
+
|
|
91
|
+
# First, get rate-limited so subsequent requests are blocked
|
|
92
|
+
for i in $(seq 1 7); do
|
|
93
|
+
curl -s -o /dev/null -X POST "$TARGET$LOGIN_PATH" \
|
|
94
|
+
-H 'Content-Type: application/json' \
|
|
95
|
+
-d '{"email":"xff-test@example.com","password":"wrong"}' >/dev/null
|
|
96
|
+
done
|
|
97
|
+
|
|
98
|
+
code_baseline=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TARGET$LOGIN_PATH" \
|
|
99
|
+
-H 'Content-Type: application/json' \
|
|
100
|
+
-d '{"email":"xff-test@example.com","password":"wrong"}')
|
|
101
|
+
echo " baseline (no XFF): $code_baseline"
|
|
102
|
+
|
|
103
|
+
spoofed_pass_count=0
|
|
104
|
+
for xff in "1.2.3.4" "10.0.0.1" "192.168.1.99" "127.0.0.1" "1.1.1.1, 2.2.2.2"; do
|
|
105
|
+
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TARGET$LOGIN_PATH" \
|
|
106
|
+
-H 'Content-Type: application/json' \
|
|
107
|
+
-H "X-Forwarded-For: $xff" \
|
|
108
|
+
-d '{"email":"xff-test@example.com","password":"wrong"}')
|
|
109
|
+
printf ' XFF=%-25s → %s\n' "$xff" "$code"
|
|
110
|
+
if [[ "$code_baseline" == "429" && "$code" != "429" ]]; then
|
|
111
|
+
spoofed_pass_count=$((spoofed_pass_count + 1))
|
|
112
|
+
fi
|
|
113
|
+
done
|
|
114
|
+
|
|
115
|
+
if [[ "$code_baseline" != "429" ]]; then
|
|
116
|
+
echo " SKIP AUTH limiter not in 429 state for baseline — can't test bypass"
|
|
117
|
+
elif [[ $spoofed_pass_count -gt 0 ]]; then
|
|
118
|
+
echo " FAIL XFF spoof bypassed AUTH_RATE_LIMIT ($spoofed_pass_count probes)"
|
|
119
|
+
FAIL_COUNT=$((FAIL_COUNT+1))
|
|
120
|
+
FAIL_LINES+=("XFF spoof bypasses AUTH_RATE_LIMIT — limiter may be keyed on req.ip without trust proxy validation")
|
|
121
|
+
else
|
|
122
|
+
echo " PASS XFF spoof did NOT bypass the limiter (all stayed 429)"
|
|
123
|
+
PASS_COUNT=$((PASS_COUNT+1))
|
|
124
|
+
fi
|
|
125
|
+
echo
|
|
126
|
+
|
|
127
|
+
echo "=== Summary ==="
|
|
128
|
+
echo " PASS: $PASS_COUNT"
|
|
129
|
+
echo " FAIL: $FAIL_COUNT"
|
|
130
|
+
if [[ $FAIL_COUNT -gt 0 ]]; then
|
|
131
|
+
echo
|
|
132
|
+
echo "FAILED:"
|
|
133
|
+
printf ' - %s\n' "${FAIL_LINES[@]}"
|
|
134
|
+
exit 1
|
|
135
|
+
fi
|
|
136
|
+
echo "Rate limiters behave as expected."
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# S3 bucket posture assessment.
|
|
3
|
+
# ZAP can't meaningfully test an S3 bucket, so this checks it directly with the AWS CLI:
|
|
4
|
+
# public-access posture, ACL/policy exposure, CORS, encryption, and anonymous reachability.
|
|
5
|
+
#
|
|
6
|
+
# ./s3-assess.sh # uses defaults below
|
|
7
|
+
# ./s3-assess.sh my-bucket us-east-2
|
|
8
|
+
# BUCKET=x REGION=y ./s3-assess.sh
|
|
9
|
+
#
|
|
10
|
+
# Uses your configured AWS credentials. Read-only — never writes, deletes, or changes
|
|
11
|
+
# anything. Anonymous probes use --no-sign-request / unauthenticated curl.
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
|
|
14
|
+
BUCKET="${1:-${BUCKET:-<UPLOADS_BUCKET>}}"
|
|
15
|
+
REGION="${2:-${REGION:-us-east-1}}"
|
|
16
|
+
|
|
17
|
+
pass=0; warn=0; fail=0
|
|
18
|
+
ok() { echo " [ OK ] $*"; pass=$((pass+1)); }
|
|
19
|
+
note() { echo " [INFO] $*"; }
|
|
20
|
+
wn() { echo " [WARN] $*"; warn=$((warn+1)); }
|
|
21
|
+
bad() { echo " [FAIL] $*"; fail=$((fail+1)); }
|
|
22
|
+
hdr() { echo; echo "== $* =="; }
|
|
23
|
+
|
|
24
|
+
command -v aws >/dev/null || { echo "aws CLI not found. Install it or run from a box that has it."; exit 1; }
|
|
25
|
+
echo "Assessing s3://$BUCKET (region $REGION) as: $(aws sts get-caller-identity --query Arn --output text 2>/dev/null || echo 'UNKNOWN')"
|
|
26
|
+
|
|
27
|
+
hdr "Bucket exists / reachable"
|
|
28
|
+
if aws s3api head-bucket --bucket "$BUCKET" --region "$REGION" 2>/tmp/_s3err; then
|
|
29
|
+
ok "head-bucket succeeded"
|
|
30
|
+
else
|
|
31
|
+
bad "head-bucket failed: $(tr -d '\n' </tmp/_s3err). Check bucket name/region/credentials."
|
|
32
|
+
echo; echo "Cannot continue without bucket access."; exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
hdr "Public Access Block (want all four = true)"
|
|
36
|
+
PAB=$(aws s3api get-public-access-block --bucket "$BUCKET" --region "$REGION" \
|
|
37
|
+
--query 'PublicAccessBlockConfiguration' --output text 2>/dev/null)
|
|
38
|
+
if [[ -z "$PAB" ]]; then
|
|
39
|
+
bad "No Public Access Block configured — bucket can be made public via ACL/policy."
|
|
40
|
+
else
|
|
41
|
+
echo " BlockPublicAcls/IgnorePublicAcls/BlockPublicPolicy/RestrictPublicBuckets = $PAB"
|
|
42
|
+
if [[ "$PAB" == "True True True True" || "$PAB" == "true true true true" ]]; then
|
|
43
|
+
ok "All public access blocked at the bucket level."
|
|
44
|
+
else
|
|
45
|
+
wn "One or more public-access-block settings are OFF — review above."
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
hdr "Bucket policy status (IsPublic should be False)"
|
|
50
|
+
PS=$(aws s3api get-bucket-policy-status --bucket "$BUCKET" --region "$REGION" \
|
|
51
|
+
--query 'PolicyStatus.IsPublic' --output text 2>/dev/null)
|
|
52
|
+
case "$PS" in
|
|
53
|
+
False|false) ok "Policy status: not public." ;;
|
|
54
|
+
True|true) bad "Bucket policy is PUBLIC. Inspect the policy below." ;;
|
|
55
|
+
*) note "No bucket policy (or none readable) — policy status N/A." ;;
|
|
56
|
+
esac
|
|
57
|
+
|
|
58
|
+
hdr "Bucket policy document"
|
|
59
|
+
if aws s3api get-bucket-policy --bucket "$BUCKET" --region "$REGION" --query Policy --output text 2>/dev/null > /tmp/_s3pol && [[ -s /tmp/_s3pol ]]; then
|
|
60
|
+
python3 -m json.tool < /tmp/_s3pol 2>/dev/null | sed 's/^/ /' || sed 's/^/ /' /tmp/_s3pol
|
|
61
|
+
if grep -qE '"Principal"[[:space:]]*:[[:space:]]*"\*"|"AWS"[[:space:]]*:[[:space:]]*"\*"' /tmp/_s3pol; then
|
|
62
|
+
wn 'Policy contains a wildcard Principal ("*") — confirm it is paired with a tight Condition, not an open Allow.'
|
|
63
|
+
else
|
|
64
|
+
ok "No wildcard Principal in policy."
|
|
65
|
+
fi
|
|
66
|
+
else
|
|
67
|
+
note "No bucket policy set."
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
hdr "Bucket ACL (look for AllUsers / AuthenticatedUsers grants)"
|
|
71
|
+
ACL=$(aws s3api get-bucket-acl --bucket "$BUCKET" --region "$REGION" \
|
|
72
|
+
--query "Grants[?contains(Grantee.URI || 'x','AllUsers') || contains(Grantee.URI || 'x','AuthenticatedUsers')].[Grantee.URI,Permission]" \
|
|
73
|
+
--output text 2>/dev/null)
|
|
74
|
+
if [[ -z "$ACL" ]]; then
|
|
75
|
+
ok "No AllUsers/AuthenticatedUsers ACL grants."
|
|
76
|
+
else
|
|
77
|
+
bad "Public/cross-account ACL grants found: $ACL"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
hdr "Default encryption (want AES256 or aws:kms)"
|
|
81
|
+
ENC=$(aws s3api get-bucket-encryption --bucket "$BUCKET" --region "$REGION" \
|
|
82
|
+
--query 'ServerSideEncryptionConfiguration.Rules[0].ApplyServerSideEncryptionByDefault.SSEAlgorithm' \
|
|
83
|
+
--output text 2>/dev/null)
|
|
84
|
+
[[ -n "$ENC" && "$ENC" != "None" ]] && ok "Default encryption: $ENC" || wn "No default encryption configured."
|
|
85
|
+
|
|
86
|
+
hdr "CORS configuration (watch for AllowedOrigins '*')"
|
|
87
|
+
CORS=$(aws s3api get-bucket-cors --bucket "$BUCKET" --region "$REGION" --output json 2>/dev/null)
|
|
88
|
+
if [[ -z "$CORS" ]]; then
|
|
89
|
+
ok "No CORS configuration (or none readable)."
|
|
90
|
+
else
|
|
91
|
+
echo "$CORS" | sed 's/^/ /'
|
|
92
|
+
echo "$CORS" | grep -q '"\*"' && wn "CORS allows '*' somewhere — confirm that's intended for an uploads bucket." || ok "No wildcard in CORS."
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
hdr "Versioning"
|
|
96
|
+
VER=$(aws s3api get-bucket-versioning --bucket "$BUCKET" --region "$REGION" --query Status --output text 2>/dev/null)
|
|
97
|
+
[[ "$VER" == "Enabled" ]] && ok "Versioning enabled." || note "Versioning: ${VER:-not enabled}."
|
|
98
|
+
|
|
99
|
+
hdr "Anonymous access probes (should all be DENIED)"
|
|
100
|
+
if aws s3 ls "s3://$BUCKET" --no-sign-request --region "$REGION" >/dev/null 2>&1; then
|
|
101
|
+
bad "Anonymous ListBucket SUCCEEDED — bucket lists objects without credentials!"
|
|
102
|
+
else
|
|
103
|
+
ok "Anonymous ListBucket denied."
|
|
104
|
+
fi
|
|
105
|
+
CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://${BUCKET}.s3.${REGION}.amazonaws.com/" 2>/dev/null)
|
|
106
|
+
case "$CODE" in
|
|
107
|
+
403) ok "Anonymous HTTP GET on bucket root → 403 (denied)." ;;
|
|
108
|
+
200) bad "Anonymous HTTP GET on bucket root → 200 (bucket index is PUBLIC)!" ;;
|
|
109
|
+
*) note "Anonymous HTTP GET on bucket root → HTTP $CODE." ;;
|
|
110
|
+
esac
|
|
111
|
+
|
|
112
|
+
echo; echo "=================================================="
|
|
113
|
+
echo "Summary for s3://$BUCKET : ${pass} OK, ${warn} WARN, ${fail} FAIL"
|
|
114
|
+
echo "=================================================="
|
|
115
|
+
echo "Reminder — also test the UPLOAD PATH (these are app-layer, not bucket-layer):"
|
|
116
|
+
echo " - content-type bypass on the upload endpoint (mime sniffing vs claimed type)"
|
|
117
|
+
echo " - path traversal in uploaded filenames (../ in the key)"
|
|
118
|
+
echo " - whether media signed-URL / media-token can be forged or replayed"
|
|
119
|
+
rm -f /tmp/_s3err /tmp/_s3pol
|
|
120
|
+
[[ $fail -gt 0 ]] && exit 1 || exit 0
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# ssrf-probes.sh — manual SSRF probe.
|
|
4
|
+
#
|
|
5
|
+
# Admin endpoints often accept URL-shaped fields (SSO domain, integration base
|
|
6
|
+
# URLs, etc.). If a handler fetches those URLs server-side without validating
|
|
7
|
+
# the host, an attacker who controls an admin account (or finds an admin auth
|
|
8
|
+
# bypass) can force the backend to fetch AWS IMDS credentials, internal
|
|
9
|
+
# services, or arbitrary intranet hosts.
|
|
10
|
+
#
|
|
11
|
+
# This probe attempts each known URL-accepting admin endpoint with classic
|
|
12
|
+
# SSRF targets:
|
|
13
|
+
# - 169.254.169.254 — AWS EC2 IMDSv1 metadata endpoint
|
|
14
|
+
# - 169.254.170.2 — ECS task metadata
|
|
15
|
+
# - 127.0.0.1:3000 — localhost
|
|
16
|
+
# - 10.0.0.1 — RFC1918 internal
|
|
17
|
+
# - file:// — local file scheme (some HTTP libraries support this)
|
|
18
|
+
#
|
|
19
|
+
# Expected: each request returns 400/403 (input validation refuses the host),
|
|
20
|
+
# or 200 with an error body that does NOT contain credential material / IMDS
|
|
21
|
+
# response shapes. A response time > 5s on an IMDS URL (without immediate
|
|
22
|
+
# rejection) is a strong signal the backend actually fetched it.
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# 1. Set ZAP_ADMIN_USER / ZAP_ADMIN_PASS in .env.
|
|
26
|
+
# 2. ./ssrf-probes.sh
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
cd "$(dirname "$0")"
|
|
29
|
+
|
|
30
|
+
[[ -f .env ]] || { echo "No .env found" >&2; exit 1; }
|
|
31
|
+
|
|
32
|
+
read_env() {
|
|
33
|
+
local key="$1"
|
|
34
|
+
python3 -c "
|
|
35
|
+
for l in open('.env'):
|
|
36
|
+
l = l.rstrip('\n')
|
|
37
|
+
if l.startswith('#') or '=' not in l: continue
|
|
38
|
+
k, v = l.split('=', 1)
|
|
39
|
+
if k.strip() == '$key':
|
|
40
|
+
print(v); break
|
|
41
|
+
"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
TARGET="$(read_env ZAP_TARGET)"
|
|
45
|
+
USER="$(read_env ZAP_ADMIN_USER)"
|
|
46
|
+
PASS="$(read_env ZAP_ADMIN_PASS)"
|
|
47
|
+
|
|
48
|
+
[[ -n "$TARGET" && -n "$USER" && -n "$PASS" ]] || {
|
|
49
|
+
echo "ERROR: ZAP_TARGET / ZAP_ADMIN_USER / ZAP_ADMIN_PASS required in .env" >&2
|
|
50
|
+
echo "SSRF probes need an admin account (system/integration permissions)" >&2
|
|
51
|
+
exit 2
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
echo "==> minting admin token..."
|
|
55
|
+
TOKEN=$(curl -fsS -X POST "$TARGET/api/auth/login" \
|
|
56
|
+
-H 'Content-Type: application/json' \
|
|
57
|
+
-d "$(jq -nc --arg e "$USER" --arg p "$PASS" '{email:$e,password:$p}')" \
|
|
58
|
+
| jq -r '.tokens.accessToken')
|
|
59
|
+
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || { echo "admin login failed" >&2; exit 3; }
|
|
60
|
+
|
|
61
|
+
SSRF_TARGETS=(
|
|
62
|
+
"http://169.254.169.254/latest/meta-data/"
|
|
63
|
+
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"
|
|
64
|
+
"http://169.254.170.2/v2/credentials/"
|
|
65
|
+
"http://127.0.0.1:3000/api/admin/users"
|
|
66
|
+
"http://10.0.0.1/"
|
|
67
|
+
"http://[::1]:3000/"
|
|
68
|
+
"file:///etc/passwd"
|
|
69
|
+
"gopher://127.0.0.1:6379/_INFO"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
FAIL_COUNT=0
|
|
73
|
+
WARN_COUNT=0
|
|
74
|
+
FAIL_LINES=()
|
|
75
|
+
|
|
76
|
+
PROBE_PUT() {
|
|
77
|
+
local label="$1" endpoint="$2" body_template="$3"
|
|
78
|
+
for url in "${SSRF_TARGETS[@]}"; do
|
|
79
|
+
local body
|
|
80
|
+
body=$(echo "$body_template" | sed "s|{SSRF}|$url|g")
|
|
81
|
+
local start end duration code body_resp
|
|
82
|
+
start=$(date +%s)
|
|
83
|
+
body_resp=$(curl -s -m 8 -w '\nHTTP_CODE:%{http_code}' -X PUT "$TARGET$endpoint" \
|
|
84
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
85
|
+
-H 'Content-Type: application/json' \
|
|
86
|
+
-d "$body" 2>&1 || true)
|
|
87
|
+
end=$(date +%s)
|
|
88
|
+
duration=$((end - start))
|
|
89
|
+
code=$(echo "$body_resp" | grep -oE 'HTTP_CODE:[0-9]+' | cut -d: -f2)
|
|
90
|
+
body_clean=$(echo "$body_resp" | grep -v 'HTTP_CODE:' | head -c 200)
|
|
91
|
+
evaluate_response "$label" "PUT $endpoint url=$url" "$code" "$duration" "$body_clean"
|
|
92
|
+
done
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
PROBE_POST() {
|
|
96
|
+
local label="$1" endpoint="$2" body_template="$3"
|
|
97
|
+
for url in "${SSRF_TARGETS[@]}"; do
|
|
98
|
+
local body
|
|
99
|
+
body=$(echo "$body_template" | sed "s|{SSRF}|$url|g")
|
|
100
|
+
local start end duration code body_resp
|
|
101
|
+
start=$(date +%s)
|
|
102
|
+
body_resp=$(curl -s -m 8 -w '\nHTTP_CODE:%{http_code}' -X POST "$TARGET$endpoint" \
|
|
103
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
104
|
+
-H 'Content-Type: application/json' \
|
|
105
|
+
-d "$body" 2>&1 || true)
|
|
106
|
+
end=$(date +%s)
|
|
107
|
+
duration=$((end - start))
|
|
108
|
+
code=$(echo "$body_resp" | grep -oE 'HTTP_CODE:[0-9]+' | cut -d: -f2)
|
|
109
|
+
body_clean=$(echo "$body_resp" | grep -v 'HTTP_CODE:' | head -c 200)
|
|
110
|
+
evaluate_response "$label" "POST $endpoint url=$url" "$code" "$duration" "$body_clean"
|
|
111
|
+
done
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
evaluate_response() {
|
|
115
|
+
local label="$1" probe="$2" code="$3" duration="$4" body="$5"
|
|
116
|
+
if echo "$body" | grep -qE 'AccessKeyId|SecretAccessKey|InstanceId|root:x:0:0|redis_version'; then
|
|
117
|
+
printf ' %-4s %s [code=%s, %ds] EVIDENCE OF SSRF in body!\n' FAIL "$probe" "$code" "$duration"
|
|
118
|
+
FAIL_COUNT=$((FAIL_COUNT+1))
|
|
119
|
+
FAIL_LINES+=("$label $probe — IMDS/file/redis content leaked")
|
|
120
|
+
return
|
|
121
|
+
fi
|
|
122
|
+
if [[ "$probe" == *"169.254.169.254"* || "$probe" == *"169.254.170.2"* ]]; then
|
|
123
|
+
if [[ "$duration" -gt 5 ]]; then
|
|
124
|
+
printf ' %-4s %s [code=%s, %ds] slow response — backend may have fetched IMDS\n' WARN "$probe" "$code" "$duration"
|
|
125
|
+
WARN_COUNT=$((WARN_COUNT+1))
|
|
126
|
+
return
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
if [[ "$code" == "400" || "$code" == "403" || "$code" == "422" ]]; then
|
|
130
|
+
printf ' %-4s %s [code=%s, %ds] validation rejected\n' PASS "$probe" "$code" "$duration"
|
|
131
|
+
return
|
|
132
|
+
fi
|
|
133
|
+
if [[ "$code" == "500" ]]; then
|
|
134
|
+
printf ' %-4s %s [code=%s, %ds] backend errored — verify it did not attempt the fetch\n' WARN "$probe" "$code" "$duration"
|
|
135
|
+
WARN_COUNT=$((WARN_COUNT+1))
|
|
136
|
+
return
|
|
137
|
+
fi
|
|
138
|
+
if [[ "$code" == "200" ]]; then
|
|
139
|
+
printf ' %-4s %s [code=%s, %ds] 200 OK no IMDS evidence (handled gracefully)\n' PASS "$probe" "$code" "$duration"
|
|
140
|
+
return
|
|
141
|
+
fi
|
|
142
|
+
printf ' %-4s %s [code=%s, %ds]\n' PASS "$probe" "$code" "$duration"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# PROJECT-SPECIFIC START
|
|
146
|
+
# These probes target the URL-accepting admin endpoints in your application.
|
|
147
|
+
# REPLACE them with your project's endpoints. Look for any admin handler that
|
|
148
|
+
# takes a URL/host/endpoint/domain field in its request body. Common shapes:
|
|
149
|
+
# - SSO settings (issuer URL, metadata URL, callback)
|
|
150
|
+
# - Integration config (webhook target, S3 endpoint, GraphQL URL)
|
|
151
|
+
# - "Test connection" endpoints
|
|
152
|
+
|
|
153
|
+
echo "=== SSO settings — typically accepts SSO domain / issuer URLs ==="
|
|
154
|
+
PROBE_PUT "sso-settings" "/api/auth/sso/settings" \
|
|
155
|
+
'{"enabled":true,"issuer":"{SSRF}","clientId":"x","clientSecret":"y","metadataUrl":"{SSRF}"}'
|
|
156
|
+
|
|
157
|
+
echo
|
|
158
|
+
echo "=== SSO test endpoint ==="
|
|
159
|
+
PROBE_POST "sso-test" "/api/auth/sso/test" '{"domain":"{SSRF}"}'
|
|
160
|
+
|
|
161
|
+
echo
|
|
162
|
+
echo "=== Integration settings — third-party base URL etc. ==="
|
|
163
|
+
PROBE_PUT "integrations" "/api/admin/integrations" \
|
|
164
|
+
'{"providerBaseUrl":"{SSRF}","providerApiKey":"x"}'
|
|
165
|
+
|
|
166
|
+
echo
|
|
167
|
+
echo "=== Integration test endpoints ==="
|
|
168
|
+
PROBE_POST "test-s3" "/api/admin/integrations/test/s3" \
|
|
169
|
+
'{"awsS3Endpoint":"{SSRF}","awsS3Bucket":"test","awsS3Region":"us-east-1","awsS3AccessKeyId":"AKIA","awsS3SecretAccessKey":"x"}'
|
|
170
|
+
PROBE_POST "test-graphql" "/api/admin/integrations/test/graphql" \
|
|
171
|
+
'{"graphqlUrl":"{SSRF}","apiKey":"x"}'
|
|
172
|
+
# PROJECT-SPECIFIC END
|
|
173
|
+
|
|
174
|
+
echo
|
|
175
|
+
echo "=== Summary ==="
|
|
176
|
+
echo " FAIL (definitive SSRF evidence): $FAIL_COUNT"
|
|
177
|
+
echo " WARN (suspicious — manual review): $WARN_COUNT"
|
|
178
|
+
if [[ $FAIL_COUNT -gt 0 ]]; then
|
|
179
|
+
echo
|
|
180
|
+
echo "REAL SSRF FINDINGS:"
|
|
181
|
+
printf ' - %s\n' "${FAIL_LINES[@]}"
|
|
182
|
+
exit 1
|
|
183
|
+
fi
|
|
184
|
+
if [[ $WARN_COUNT -gt 0 ]]; then
|
|
185
|
+
echo
|
|
186
|
+
echo "Review the WARN lines manually — they may indicate the backend"
|
|
187
|
+
echo "is fetching the URL even though no credential content leaked back."
|
|
188
|
+
fi
|
|
189
|
+
echo "No SSRF evidence found."
|