diff --git a/CHANGELOG.md b/CHANGELOG.md index db53af9..b8555c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- **SSRF outbound HTTP detection**: updated `needle` regex in `surface.py` to allow methods such as `needle.get(url)`, catching previously missed NodeGoat SSRF vulnerability. + ### Added - **No-Row-Level-Security detection** (`missing-rls` class, in `schemas.py` + the ledger) — committed diff --git a/README.md b/README.md index 87b6666..9329e53 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ upload, cross-tenant BOLA, role/authz gaps). ## Tests ```bash -python3 -m unittest discover -s tests # stdlib only, no Noir/network — 324 tests +python3 -m unittest discover -s tests # stdlib only, no Noir/network — 342 tests ``` ## Releasing (maintainer) diff --git a/docs-canonical/ENVIRONMENT.md b/docs-canonical/ENVIRONMENT.md index 857b672..66224cd 100644 --- a/docs-canonical/ENVIRONMENT.md +++ b/docs-canonical/ENVIRONMENT.md @@ -96,6 +96,6 @@ Never point the dynamic phase at production. ```bash git clone https://github.com/raccioly/websec-validator && cd websec-validator pipx install --editable . # or: pip install -e . in a 3.11+ venv -python3 -m unittest discover -s tests # 324 tests, stdlib only +python3 -m unittest discover -s tests # 342 tests, stdlib only docguard guard # validate the documentation (CDD) ``` diff --git a/docs-canonical/TEST-SPEC.md b/docs-canonical/TEST-SPEC.md index 2a7ae37..4e6cbb5 100644 --- a/docs-canonical/TEST-SPEC.md +++ b/docs-canonical/TEST-SPEC.md @@ -10,11 +10,11 @@ > Last updated: 2026-06-22 The suite is **stdlib `unittest` only** — no third-party test runner, no network, no Noir, no running -app. **324 tests** across five files run in ~1s and gate every release (the `publish.yml` workflow +app. **342 tests** across five files run in ~1s and gate every release (the `publish.yml` workflow also installs the built wheel and smoke-runs `websec run`). ```bash -python3 -m unittest discover -s tests # 324 tests, stdlib only +python3 -m unittest discover -s tests # 342 tests, stdlib only ``` --- diff --git a/src/websec_validator/extractors/surface.py b/src/websec_validator/extractors/surface.py index c83c4e6..7f0227b 100644 --- a/src/websec_validator/extractors/surface.py +++ b/src/websec_validator/extractors/surface.py @@ -43,7 +43,7 @@ # gating: None | "sql" | "nosql" (datastore-dependent classes) SINKS = { "ssrf": ("ssrf-probes", None, re.compile( - r"(?:\bfetch|axios(?:\.\w+)?|got|node-fetch|superagent|needle|undici|requests\.\w+|httpx\.\w+|urllib\.request\.\w+)" + r"(?:\bfetch|axios(?:\.\w+)?|got|node-fetch|superagent|needle(?:\.\w+)?|undici|requests\.\w+|httpx\.\w+|urllib\.request\.\w+)" r"\s*\(\s*[^)\n;]{0,160}?" + _REQ_SRC)), "command-injection": ("ssrf-probes", None, re.compile( r"(?:child_process\.exec|\bexecSync|\bexec|\bspawn|os\.system|subprocess\.(?:run|call|check_output|Popen))\s*\([^)]*" @@ -89,7 +89,7 @@ # MED-FP by design (axios.get(someVar) is common) → kept LOW-confidence; promote when reachable # from a controller that reads req.query. "ssrf-outbound-http": ("ssrf-probes", None, re.compile( - r"(?:axios(?:\.(?:get|post|put|delete|patch|request|head))?|got|node-fetch|needle|superagent|undici" + r"(?:axios(?:\.(?:get|post|put|delete|patch|request|head))?|got|node-fetch|needle(?:\.\w+)?|superagent|undici" r"|https?\.request|requests\.(?:get|post|put|patch|request)|httpx\.(?:get|post|request|AsyncClient))" r"\s*\(\s*[A-Za-z_$][\w$.]*\s*[,)]")), # OUTPUT-side disclosure — a DOCUMENTED EXCEPTION to the user-input-marker rule (this is a diff --git a/tests/test_pentest_regressions.py b/tests/test_pentest_regressions.py index 734194f..885a925 100644 --- a/tests/test_pentest_regressions.py +++ b/tests/test_pentest_regressions.py @@ -141,6 +141,13 @@ def test_ssrf_outbound_var_arg_fires_not_literal(self): self.assertIn("proxy.ts", out["sinks"]["ssrf-outbound-http"]["files"]) self.assertNotIn("safe.ts", out["sinks"]["ssrf-outbound-http"]["files"]) + def test_ssrf_outbound_needle_get(self): + out = SurfaceExtractor().extract(repo({ + "research.js": "const url = req.query.url; return needle.get(url, (err, resp, body) => {});\n" + }), {"stack": {"datastores": []}}) + self.assertIn("ssrf-outbound-http", out["sinks"]) + self.assertIn("research.js", out["sinks"]["ssrf-outbound-http"]["files"]) + def test_error_disclosure_fires_not_on_generic_message(self): out = SurfaceExtractor().extract(repo({ "errh.ts": "export const h=(err,req,res,next)=>res.status(500).json({ error: err.stack });\n",