Skip to content
Merged
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
7 changes: 6 additions & 1 deletion src/websec_validator/dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,14 @@ def write_auth_enforcement(target: str, facts: dict, max_endpoints: int = 80) ->
verdict = "auth-enforced"
elif code in (200, 201, 204):
verdict = "EXECUTED-UNAUTH"
elif code in (400, 422, 404, 405, 409, 415, 500):
elif code in (400, 422, 404, 405, 409, 415):
verdict = "no-auth-gate (reached handler/validation)"
else:
# 500 (and any other code) is INCONCLUSIVE: a 500 may be the auth layer itself throwing,
# not the handler running unauthenticated — so it must NOT become a no-auth-gate verdict
# (which would escalate to a HIGH missing-auth finding AND poison the calibration oracle
# with a confirmed-real sample). Matches the forged-token engine, which also excludes 500
# from "reached handler".
verdict = f"http-{code}"
results.append({"method": method, "path": path, "status": code, "verdict": verdict})

Expand Down
18 changes: 17 additions & 1 deletion src/websec_validator/findings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"ssrf": (["CWE-918 SSRF"], "ASVS V12.6", ["API7:2023 SSRF"]),
"secret": (["CWE-798 Hard-coded Credentials"], "ASVS V2.10", ["API8:2023 Misconfiguration"]),
"sqli": (["CWE-89 SQL Injection"], "ASVS V5.3.4", ["API8:2023"]),
"nosql-injection": (["CWE-943 Improper Neutralization of Data within a Query"], "ASVS V5.3.4", ["API8:2023"]),
"redos": (["CWE-1333 Inefficient Regular Expression Complexity (ReDoS)"], "ASVS V5.2.4", []),
"eval-injection": (["CWE-95 Eval Injection", "CWE-94 Code Injection"], "ASVS V5.2.4", []),
"command-injection": (["CWE-78 OS Command Injection"], "ASVS V5.3.8", []),
"path-traversal": (["CWE-22 Path Traversal"], "ASVS V12.3", []),
"ssti": (["CWE-1336 SSTI"], "ASVS V5.2.5", []),
Expand Down Expand Up @@ -72,6 +75,12 @@
"verifying decode (e.g. jwt.verify with the key / a checked session), never an *Unsafe* "
"or decode-only path whose output then feeds requireAuth/requireAdmin.",
"ssrf": "Validate + allowlist outbound URLs; block RFC1918/IMDS/file://; never fetch a raw user-supplied URL.",
"nosql-injection": "Never pass raw req.body into a query/operator position; reject $-prefixed keys, use a typed "
"query builder or schema validation, and cast expected types before querying.",
"redos": "Bound the regex (no nested/ambiguous quantifiers), cap input length, or use a linear-time engine "
"(RE2) — and never build a pattern from unsanitized user input.",
"eval-injection": "Remove eval()/new Function()/exec on user input; use a safe parser, a typed dispatch table, "
"or an explicit allowlist of operations instead.",
"secret": "Rotate the credential, remove from code/history, load from a secrets manager.",
"cve": "Upgrade the dependency to the fixed version.",
"iac": "Apply the hardening (non-root user, pin actions to a SHA, enforce TLS, etc.).",
Expand All @@ -95,6 +104,12 @@
CONF_RANK = {"HIGH": 2, "MEDIUM": 1, "LOW": 0}
WRITE_VERBS = {"POST", "PUT", "PATCH", "DELETE"}

# surface.py sink keys → STANDARDS/attack-class keys where they differ, so a sink cites its SPECIFIC
# CWE instead of falling back to the generic "sast" (CWE-710). sql-injection is the high-value case
# (surface.py emits `sql-injection`; STANDARDS keys it `sqli`). nosql-injection/redos/eval-injection
# now have their own STANDARDS entries, so they resolve directly.
_SINK_ATTACK = {"sql-injection": "sqli"}


def _cite(cls):
cwe, asvs, api = STANDARDS.get(cls, ([], "", []))
Expand Down Expand Up @@ -273,7 +288,8 @@ def build_ledger(facts: dict, unified: dict | None, dynamic: dict | None = None,
if cls == "ssrf-outbound-http":
sev = "LOW" # var-arg only — weaker than the user-gated `ssrf` class
else:
attack = cls if cls in STANDARDS else "sast"
_acls = _SINK_ATTACK.get(cls, cls)
attack = _acls if _acls in STANDARDS else "sast"
ev = [{"layer": "recon", "detail": f"user-input-gated {cls} in {info.get('count')} file(s)"}]
if cls in ("sqli", "sql-injection") and is_nosql_only:
sev = "LOW"
Expand Down
27 changes: 26 additions & 1 deletion tests/test_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

from websec_validator import dynamic, findings, probes, scanners # noqa: E402
from websec_validator import calibration, dynamic, findings, probes, scanners # noqa: E402
from websec_validator.extractors.auth import AuthExtractor # noqa: E402
from websec_validator.extractors.authz import AuthzExtractor # noqa: E402
from websec_validator.extractors.base import RepoContext # noqa: E402
Expand Down Expand Up @@ -183,6 +183,31 @@ def fake_request(method, url, token=None, timeout=20, data=None, cookie=None):
self.assertTrue(any(u.endswith("/api/groups/2/items") for u in captured)) # int coerced into the path


class WriteAuthEnforcement500Tests(unittest.TestCase):
def test_500_is_inconclusive_not_no_auth_gate(self):
# a 500 may be the AUTH layer throwing, not the handler running unauth — must NOT become a
# no-auth-gate verdict (would escalate to a HIGH missing-auth finding AND poison the
# calibration oracle with a confirmed-real sample). Matches the forged-token engine.
facts = {"routes": {"endpoints": [{"method": "POST", "path": "/api/x"}]}}

def fake(method, url, token=None, timeout=20, data=None, cookie=None):
return 500, "err"
with mock.patch.object(dynamic, "_request", fake):
r = dynamic.write_auth_enforcement("http://t", facts)
self.assertEqual(r["results"][0]["verdict"], "http-500") # inconclusive, not no-auth-gate
self.assertEqual(r["no_auth_gate"], []) # so it feeds no missing-auth finding
self.assertEqual(calibration.samples_from_dynamic({"write_auth_enforcement": r}), []) # oracle clean

def test_400_still_no_auth_gate(self): # regression guard: real reached-handler codes unaffected
facts = {"routes": {"endpoints": [{"method": "POST", "path": "/api/y"}]}}

def fake(method, url, token=None, timeout=20, data=None, cookie=None):
return 400, "bad"
with mock.patch.object(dynamic, "_request", fake):
r = dynamic.write_auth_enforcement("http://t", facts)
self.assertTrue(r["results"][0]["verdict"].startswith("no-auth-gate"))


class ProbeRegistrationTests(unittest.TestCase):
def test_forged_token_always_staged(self):
self.assertIn("forged-token", probes.ALWAYS)
Expand Down
18 changes: 18 additions & 0 deletions tests/test_recon.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,24 @@ def test_webhook_without_sig_enters_ledger(self):
self.assertIn("CWE-345 Insufficient Verification of Data Authenticity", hit[0]["standards"]["cwe"])
self.assertTrue(hit[0]["remediation"])

def test_sink_attack_class_maps_to_specific_cwe(self):
# surface.py emits `sql-injection`/`nosql-injection`/`redos`/`eval-injection`; each must cite
# its SPECIFIC CWE, not fall back to the generic "sast" (CWE-710).
facts = {"surface": {"sinks": {
"sql-injection": {"count": 1, "files": ["a.ts"]},
"nosql-injection": {"count": 1, "files": ["b.ts"]},
"redos": {"count": 1, "files": ["c.ts"]},
"eval-injection": {"count": 1, "files": ["d.ts"]}}},
"stack": {"datastores": ["postgres"]}}
by = {f["title"]: f for f in findings.build_ledger(facts, None, None, [])["findings"]}
self.assertEqual(by["sql-injection sink (1 site(s))"]["attack_class"], "sqli")
self.assertIn("CWE-89 SQL Injection", by["sql-injection sink (1 site(s))"]["standards"]["cwe"])
self.assertEqual(by["nosql-injection sink (1 site(s))"]["attack_class"], "nosql-injection")
self.assertTrue(by["nosql-injection sink (1 site(s))"]["standards"]["cwe"][0].startswith("CWE-943"))
self.assertEqual(by["eval-injection sink (1 site(s))"]["attack_class"], "eval-injection")
# a specific remediation, not the generic default
self.assertNotEqual(by["redos sink (1 site(s))"]["remediation"], "Review and remediate per the cited standard.")

def test_sqli_not_downranked_when_sql_orm_present(self):
# fix #9: stack.py emits `sql-orm`/`prisma(sql)` labels; findings._sql must count them as SQL
# so a SQL-ORM + Mongo app isn't misread as nosql-only and its SQLi wrongly cut to LOW.
Expand Down