diff --git a/docs/VULNERABILITY_CATALOG.md b/docs/VULNERABILITY_CATALOG.md index 6c43af5..9e7d46a 100644 --- a/docs/VULNERABILITY_CATALOG.md +++ b/docs/VULNERABILITY_CATALOG.md @@ -7,12 +7,12 @@ from each file's header comment, so this page cannot drift from the source. ## Totals -- **Test cases:** 122 -- **Expected detections:** 122 -- **`VULNERABLE:` markers:** 227 (individual lines a scanner should flag) -- **`SAFE:` markers:** 138 (lines a scanner must not flag — the false-positive control group) +- **Test cases:** 124 +- **Expected detections:** 124 +- **`VULNERABLE:` markers:** 231 (individual lines a scanner should flag) +- **`SAFE:` markers:** 141 (lines a scanner must not flag — the false-positive control group) - **Languages:** 8 — dotenv, go, java, javascript, json, python, ruby, text -- **CWE categories:** 86 — 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-311, CWE-319, CWE-321, CWE-326, 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-434, 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-522, CWE-525, CWE-532, CWE-598, CWE-601, CWE-602, CWE-611, CWE-613, CWE-614, CWE-620, CWE-639, CWE-640, CWE-643, CWE-681, CWE-693, CWE-732, CWE-759, CWE-776, CWE-798, CWE-835, 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 +- **CWE categories:** 87 — 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-311, CWE-319, CWE-321, CWE-326, 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-409, CWE-434, 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-522, CWE-525, CWE-532, CWE-598, CWE-601, CWE-602, CWE-611, CWE-613, CWE-614, CWE-620, CWE-639, CWE-640, CWE-643, CWE-681, CWE-693, CWE-732, CWE-759, CWE-776, CWE-798, CWE-835, 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 ## How coverage is scored @@ -152,6 +152,7 @@ counts as a detection. See `docs/SCANNER_INTEGRATION.md`. | XPath injection via string interpolation | [`xpath-injection.py`](../vulns/python/xpath-injection.py) | CWE-643 | high | yes | 1 vuln / 1 safe | | XSS via Jinja2 autoescape disabled and server-side template injection | [`xss-jinja2-autoescape-off.py`](../vulns/python/xss-jinja2-autoescape-off.py) | CWE-79 | high | yes | 3 vuln / 1 safe | | XML external entity expansion in an insecure parser | [`xxe-lxml-resolve.py`](../vulns/python/xxe-lxml-resolve.py) | CWE-611 | high | yes | 2 vuln / 1 safe | +| Path traversal through zip archive entry names (Zip Slip) | [`zip-slip-traversal.py`](../vulns/python/zip-slip-traversal.py) | CWE-409 | high | yes | 2 vuln / 1 safe | ## Ruby diff --git a/vulns/VULNERABILITY_CATALOG.json b/vulns/VULNERABILITY_CATALOG.json index 5ddddbc..8659b89 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": 122, - "expected_detections": 122, - "vulnerable_markers": 227, - "safe_markers": 138, + "test_cases": 124, + "expected_detections": 124, + "vulnerable_markers": 231, + "safe_markers": 141, "languages": [ "dotenv", "go", @@ -65,6 +65,7 @@ "CWE-377", "CWE-384", "CWE-400", + "CWE-409", "CWE-434", "CWE-441", "CWE-460", @@ -2772,6 +2773,30 @@ 42 ] }, + { + "id": "py-zip-slip-traversal", + "file": "vulns/python/zip-slip-traversal.py", + "title": "Path traversal through zip archive entry names (Zip Slip)", + "category": "python", + "language": "python", + "cwe": "CWE-409", + "cwes": [ + "CWE-409" + ], + "severity": "high", + "expected_detection": true, + "description": "A zip archive from an untrusted source is extracted without", + "detection_target": "zipfile.ZipFile.extractall / extract / namelist-derived", + "safe_guard": "All extraction wrapped in `if False:` (unreachable dead code).", + "attribution": "line", + "vulnerable_lines": [ + 32, + 40 + ], + "safe_lines": [ + 52 + ] + }, { "id": "rb-cwe480-credential-comparison", "file": "vulns/ruby/cwe-480-ruby.rb", diff --git a/vulns/python/zip-slip-traversal.py b/vulns/python/zip-slip-traversal.py new file mode 100644 index 0000000..f75a44e --- /dev/null +++ b/vulns/python/zip-slip-traversal.py @@ -0,0 +1,60 @@ +""" +@id py-zip-slip-traversal +@test-case Path traversal through zip archive entry names (Zip Slip) +@cwe CWE-409 +@severity high +@language python +@expected-detection true +@description A zip archive from an untrusted source is extracted without + checking that each member name stays inside the destination + directory. An entry named `../../etc/cron.d/backdoor` escapes the + target dir, letting the attacker write arbitrary files on the + host. The safe counterpart validates every member against the + resolved destination root. Detection target is an extract call + where entry names are used for write paths with no containment + check. +@safe-guard All extraction wrapped in `if False:` (unreachable dead code). + No archive is read from disk; the ZipFile is built from a + synthetic bytes literal. +@detection-target zipfile.ZipFile.extractall / extract / namelist-derived + writes where ZipInfo.filename is not checked for + ../ or absolute-path traversal before being resolved. + +NEVER RUN IN PRODUCTION - intentional test case for scanner validation. +""" + +import zipfile +import io + + +def extract_zip_slip(zip_bytes: bytes, dest: str) -> None: + if False: + # VULNERABLE: CWE-409 - entry names trusted, no containment check + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + zf.extractall(dest) + return None + + +def extract_zip_slip_single(zip_bytes: bytes, dest: str, member: str) -> None: + if False: + # VULNERABLE: CWE-409 - single member, still no traversal check + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + zf.extract(member, dest) + return None + + +def extract_zip_safe(zip_bytes: bytes, dest: str) -> None: + """Safe counterpart - the scanner should NOT flag this. + + @expected-detection false + """ + if False: + # SAFE: validate each member stays within the destination root + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: + for info in zf.infolist(): + import os + target = os.path.realpath(os.path.join(dest, info.filename)) + if not target.startswith(os.path.realpath(dest) + os.sep): + raise ValueError("member escapes destination: " + info.filename) + zf.extractall(dest) + return None \ No newline at end of file