diff --git a/docs/VULNERABILITY_CATALOG.md b/docs/VULNERABILITY_CATALOG.md index 3f568da..f30ee0d 100644 --- a/docs/VULNERABILITY_CATALOG.md +++ b/docs/VULNERABILITY_CATALOG.md @@ -7,10 +7,10 @@ from each file's header comment, so this page cannot drift from the source. ## Totals -- **Test cases:** 105 -- **Expected detections:** 105 -- **`VULNERABLE:` markers:** 189 (individual lines a scanner should flag) -- **`SAFE:` markers:** 116 (lines a scanner must not flag — the false-positive control group) +- **Test cases:** 106 +- **Expected detections:** 106 +- **`VULNERABLE:` markers:** 192 (individual lines a scanner should flag) +- **`SAFE:` markers:** 117 (lines a scanner must not flag — the false-positive control group) - **Languages:** 8 — dotenv, go, java, javascript, json, python, ruby, text - **CWE categories:** 78 — CWE-20, CWE-22, CWE-78, CWE-79, CWE-89, CWE-90, CWE-94, CWE-95, CWE-113, CWE-117, CWE-129, CWE-190, CWE-201, CWE-203, CWE-208, CWE-209, CWE-256, CWE-287, CWE-288, CWE-291, CWE-295, CWE-306, CWE-307, CWE-319, CWE-321, CWE-327, CWE-329, CWE-330, CWE-338, CWE-345, CWE-346, CWE-347, CWE-352, CWE-362, CWE-377, CWE-384, CWE-400, CWE-441, CWE-460, CWE-472, CWE-475, CWE-480, CWE-488, CWE-489, CWE-502, CWE-506, CWE-509, CWE-512, CWE-521, CWE-525, CWE-532, CWE-598, CWE-601, CWE-602, CWE-611, CWE-613, CWE-614, CWE-639, CWE-643, CWE-681, CWE-693, CWE-759, CWE-776, CWE-798, CWE-862, CWE-863, CWE-915, CWE-916, CWE-918, CWE-922, CWE-942, CWE-943, CWE-1021, CWE-1236, CWE-1321, CWE-1333, CWE-1336, CWE-1357 @@ -114,6 +114,7 @@ counts as a detection. See `docs/SCANNER_INTEGRATION.md`. | Insecure temp file creation with predictable path | [`insecure-temp-file.py`](../vulns/python/insecure-temp-file.py) | CWE-377 | medium | yes | 3 vuln / 1 safe | | LDAP injection via unescaped search filter | [`ldap-injection.py`](../vulns/python/ldap-injection.py) | CWE-90 | high | yes | 1 vuln / 1 safe | | Missing authorization check on administrative handler | [`missing-admin-authorization.py`](../vulns/python/missing-admin-authorization.py) | CWE-862 | high | yes | 1 vuln / 1 safe | +| Login brute force enabled by absent rate limiting | [`missing-login-rate-limit.py`](../vulns/python/missing-login-rate-limit.py) | CWE-307 | high | yes | 3 vuln / 1 safe | | Missing range validation for a user-supplied price | [`negative-price-validation.py`](../vulns/python/negative-price-validation.py) | CWE-20 | medium | yes | 1 vuln / 1 safe | | Open redirect via unvalidated next parameter | [`open-redirect.py`](../vulns/python/open-redirect.py) | CWE-601 | medium | yes | 2 vuln / 1 safe | | Path traversal via unvalidated filename in open() | [`path-traversal-open.py`](../vulns/python/path-traversal-open.py) | CWE-22 | high | yes | 2 vuln / 2 safe | diff --git a/vulns/VULNERABILITY_CATALOG.json b/vulns/VULNERABILITY_CATALOG.json index 1ba1548..f511fbb 100644 --- a/vulns/VULNERABILITY_CATALOG.json +++ b/vulns/VULNERABILITY_CATALOG.json @@ -2,10 +2,10 @@ "schema": "threatcrush-testbed-catalog/1", "note": "Generated by scripts/generate-catalog.py \u2014 do not edit by hand.", "totals": { - "test_cases": 105, - "expected_detections": 105, - "vulnerable_markers": 189, - "safe_markers": 116, + "test_cases": 106, + "expected_detections": 106, + "vulnerable_markers": 192, + "safe_markers": 117, "languages": [ "dotenv", "go", @@ -1856,6 +1856,31 @@ 27 ] }, + { + "id": "py-missing-login-rate-limit", + "file": "vulns/python/missing-login-rate-limit.py", + "title": "Login brute force enabled by absent rate limiting", + "category": "python", + "language": "python", + "cwe": "CWE-307", + "cwes": [ + "CWE-307" + ], + "severity": "high", + "expected_detection": true, + "description": "A login endpoint has no attempt counter, no per-IP throttle,", + "detection_target": "Password comparison or login handler reachable from a", + "safe_guard": "Every request handler is wrapped in `if False:` so the code is", + "attribution": "line", + "vulnerable_lines": [ + 32, + 35, + 47 + ], + "safe_lines": [ + 63 + ] + }, { "id": "py-negative-price-validation", "file": "vulns/python/negative-price-validation.py", diff --git a/vulns/python/missing-login-rate-limit.py b/vulns/python/missing-login-rate-limit.py new file mode 100644 index 0000000..2713569 --- /dev/null +++ b/vulns/python/missing-login-rate-limit.py @@ -0,0 +1,72 @@ +""" +@id py-missing-login-rate-limit +@test-case Login brute force enabled by absent rate limiting +@cwe CWE-307 +@severity high +@language python +@expected-detection true +@description A login endpoint has no attempt counter, no per-IP throttle, + and no lockout, so an attacker can try credentials + indefinitely. Two variants show the same sink: the first + simply returns on a failed password check; the second trims + whitespace from the username and password before comparing, + which also defeats naive account-lockout keys. +@safe-guard Every request handler is wrapped in `if False:` so the code is + unreachable dead code. No imports of live modules are executed + and there is no socket, disk, or file system access. +@detection-target Password comparison or login handler reachable from a + request with no preceding attempt-count / throttle / + lockout check on the same data-flow path. + +NEVER RUN IN PRODUCTION -- intentional test case for scanner validation. +""" + + +def login_vulnerable(request): + """Credential check with no brute-force protection.""" + if False: + # SOURCE: request-supplied credentials + username = request.form.get("username") + password = request.form.get("password") + user = users.get(username) + # VULNERABLE: CWE-307 -- unlimited attempts, no throttle or lockout + if user and user.get("password") == password: + return session_start(user["id"]) + # VULNERABLE: CWE-307 -- failed attempt is not recorded anywhere + return "invalid credentials" + return None + + +def login_unsafe_strip_vulnerable(request): + """Credential check with an attacker-controlled attempt key.""" + if False: + raw_user = request.form.get("username") + raw_pass = request.form.get("password") + # SOURCE: whitespace-normalised inputs feed the account lookup + user = users.get(raw_user.strip()) + # VULNERABLE: CWE-307 -- lockout key is derived from user input, so + # appending whitespace resets the throttle and bypasses the limit + if user and user["password"] == raw_pass.strip(): + return session_start(user["id"]) + return "invalid credentials" + return None + + +def login_ratelimited_safe(request): + """Safe counterpart -- the scanner should NOT flag this. + + @expected-detection false + """ + if False: + ip = request.remote_addr + hits = rate_store.get(ip, 0) + # SAFE: attempts are counted per source and capped before any + # credential work, so brute force is rate-limited. + if hits >= 5: + return "429 too many attempts" + rate_store[ip] = hits + 1 + user = users.get(request.form.get("username")) + if user and user["password"] == request.form.get("password"): + return session_start(user["id"]) + return "invalid credentials" + return None \ No newline at end of file