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
9 changes: 9 additions & 0 deletions .websec-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,12 @@ src/websec_validator/briefing.py
# The AppSync GraphQL "surface" is detected purely from our own `@aws_*` example strings — no real
# GraphQL server lives in this repo, so the synthetic graphql finding is a self-reference.
category:graphql
# Suppress correct-but-irrelevant noise for the tool's own codebase (extractors contain attack pattern definitions)
src/websec_validator/findings.py
src/websec_validator/scanners.py
src/websec_validator/extractors/llm_security.py
src/websec_validator/extractors/crypto_usage.py
src/websec_validator/extractors/transport_security.py
src/websec_validator/extractors/authz.py
category:exposure
category:transport
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ RUN pip install --no-cache-dir .
# mounted volume matches your host user.
RUN useradd --create-home --uid 1001 websec
WORKDIR /scan
HEALTHCHECK --interval=30s --timeout=3s CMD [ "websec", "--help" ]
USER websec
ENTRYPOINT ["websec"]
CMD ["--help"]
4 changes: 2 additions & 2 deletions src/websec_validator/extractors/surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
r"|\{\s*\.\.\.[\w.$]+[^{}]{0,80}?,\s*(?:role|roles|tier|plan|isAdmin|is_admin|admin|permissions?"
r"|balance|credits|isOwner|isSuperuser|superuser)\s*[,}]")),
"redos": ("ssrf-probes", None, re.compile(
r"new\s+RegExp\s*\([^)]*(?:req\.|request\.|\+)|re\.(?:compile|match|search|fullmatch)\s*\([^,)]*(?:request\.|f['\"])")),
r"new\s+RegExp\s*\([^)]*(?:req\.|request\.|\+)|re\.(?:compile|match|search|fullmatch)\s*\([^,)]*(?:request\.|\bf['\"])")),
"eval-injection": ("bola-write-verbs", None, re.compile(
r"\beval\s*\([^)]*" + _U + r"|new\s+Function\s*\([^)]*" + _U)),
# Var-arg SSRF: an http client called with a BARE identifier first-arg (not a string literal) —
Expand All @@ -98,7 +98,7 @@
"error-disclosure": ("error-disclosure-probe", None, re.compile(
r"res\.(?:json|send)\s*\([^;]{0,200}\b(?:err|error|e|ex|exc)\.(?:stack|message)\b"
r"|res\.status\(\s*\d+\s*\)\.(?:json|send)\s*\([^;]{0,200}\b(?:err|error|e)\.(?:stack|message)\b"
r"|NODE_ENV\s*[!=]==?\s*['\"]production['\"][^;{}]{0,160}\b(?:stack|message)\b")),
r"|NODE_ENV\s*[!=]==?\s*['\"]production['\"][^;{}]{0,160}\.\b(?:stack|message)\b")),
# Reflected / DOM / template XSS — a user-influenced value reaching an HTML sink with no output
# encoding. CLIENT DOM sinks (innerHTML/outerHTML/insertAdjacentHTML/document.write/jQuery .html /
# React dangerouslySetInnerHTML / Vue v-html) + SERVER template-escape-off (Jinja `|safe`,
Expand Down
2 changes: 1 addition & 1 deletion src/websec_validator/extractors/upload_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# file-bytes sinks — the old rule matched a bare `getObject` token (a local coercion helper) and a
# Prometheus `res.set('Content-Type', registry.contentType)` (the /metrics endpoint), both FPs.
SERVE_FILE = re.compile(r"res\.sendFile|\.sendFile\s*\(|\.getObject\s*\(|createReadStream|proxyMedia"
r"|streamObject|\.pipe\s*\(\s*res\b|fs\.createReadStream", re.I)
r"|\.pipe\s*\(\s*res\b|fs\.createReadStream", re.I)
NOSNIFF = re.compile(r"nosniff", re.I)
# `Content-Disposition: attachment` fully defeats the MIME-sniff→stored-XSS vector (the browser
# downloads instead of rendering), so a serve site that sets it is SAFE even without nosniff.
Expand Down
22 changes: 22 additions & 0 deletions tests/test_pentest_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ def test_appsync_and_vtl_extensions_are_walked(self):
self.assertIn(ext, CODE_EXT)



def test_redos_f_string_false_positive(self):
out = SurfaceExtractor().extract(repo({"app.py": "re.compile(r\"script-src[^;'\\\"]*'self'\")\n"}), {})
sinks = out.get("sinks", {})
self.assertNotIn("redos", sinks)



def test_error_disclosure_prose_false_positive(self):
out = SurfaceExtractor().extract(repo({"app.py": "\"NODE_ENV!=='production' branch spreads the stack\"\n"}), {})
sinks = out.get("sinks", {})
self.assertNotIn("error-disclosure", sinks)


class InsecureSecretDefaultTests(unittest.TestCase): # #8
def test_js_and_py_fallback_secret_detected_dev_ish(self):
c = repo({"config.ts": "const s = process.env.JWT_SECRET || 'dev-secret-do-not-use';\n",
Expand Down Expand Up @@ -346,6 +360,14 @@ def test_safe_upload_and_serve_clean(self):
self.assertEqual(out["findings"], [])



def test_streamobject_is_not_a_serve_file_sink(self):
out = UploadSecurityExtractor().extract(repo({"app.ts": "import { streamObject } from 'ai';\n"
"streamObject({ model, prompt });\n"}), {})
kinds = {f["kind"] for f in out["findings"]}
self.assertNotIn("serve-no-nosniff", kinds)


class PiiExposureTests(unittest.TestCase): # N4
MASKER = "export function maskContactPii(c){ return {...c, phone: mask(c.phone)}; }\n"

Expand Down