Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs-canonical/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
4 changes: 2 additions & 2 deletions docs-canonical/TEST-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

---
Expand Down
4 changes: 2 additions & 2 deletions src/websec_validator/extractors/surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*\([^)]*"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions tests/test_pentest_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down