Skip to content

Commit 846e1b8

Browse files
committed
Cut external scan false positives: access controlled paths, CSP blocked XSS, htaccess
Three changes so the engines report what the baseline already does: a real finding, not a page that merely answered. An engine that calls a path reachable when it answers 401 or 403 found a working access control, or a "your session has expired" page, not a served file. Those path findings are now left out the same way a not found path is, while a finding about what the page echoes or holds is kept. A reflected cross site scripting finding is lowered to informational when the site sends a Content-Security-Policy that does not allow inline script, since an injected inline script or handler cannot run there. It is lowered with the reason rather than dropped, because a policy does not stop DOM based XSS. A weak or missing policy changes nothing, so a real finding is never hidden. The curated htaccess rule no longer matches on the word Options, which turns up in ordinary pages, and now requires a non HTML content type and a real Apache directive, so an error page can no longer look like a served config.
1 parent 06572e5 commit 846e1b8

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

‎app/core/dast_checks/curated/misconfiguration-htaccess-served.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
],
1717
"confirm": {
1818
"control": "A path that cannot exist, so the site's not found answer is known.",
19-
"differential": "status==200 and (body_contains(\"RewriteEngine\") or body_contains(\"Options \") or body_contains(\"<Directory\")) and body_differs(control)",
19+
"differential": "status==200 and \"html\" not in header(\"content-type\") and (body_contains(\"RewriteEngine\") or body_contains(\"RewriteRule\") or body_contains(\"RewriteCond\") or body_contains(\"<Directory\") or body_contains(\"<Files\") or body_contains(\"AuthType \") or body_contains(\"Require all\") or body_contains(\"Order allow\")) and body_differs(control)",
2020
"retest": 2
2121
},
2222
"reproduce": "curl -s https://host/.htaccess",

‎app/core/external.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2102,6 +2102,13 @@ def _builtin_run(scan_id, host, asset_id, profile, opts, auth, proxy) -> None:
21022102
"detail": f"left out {dropped} finding{'s' if dropped != 1 else ''} "
21032103
"for paths that answer not found, or with the same "
21042104
"page as one that cannot exist"})
2105+
csp = (base["telemetry"].get("headers") or {}).get("content-security-policy", "")
2106+
findings, lowered = soften_blocked_xss(findings, csp)
2107+
if lowered:
2108+
engines.append({"tool": "verify", "name": "CSP check", "status": "ok", "seconds": 0,
2109+
"detail": f"lowered {lowered} reflected XSS finding"
2110+
f"{'s' if lowered != 1 else ''} to informational: the "
2111+
"site's content security policy blocks inline script"})
21052112
findings = merge(roll_up(findings, url))
21062113
stopped = scan_id in _stopping
21072114
if url:
@@ -2125,6 +2132,10 @@ def _builtin_run(scan_id, host, asset_id, profile, opts, auth, proxy) -> None:
21252132
MAX_VERIFY = 400
21262133
SOFT_404_BODY = 65536
21272134
NOT_THERE = (404, 410)
2135+
# A path that answers with sign in required or forbidden is access controlled,
2136+
# not exposed. An engine that calls such a path a finding read a working
2137+
# control, or a "your session has expired" page, not a served file.
2138+
PROTECTED = (401, 403)
21282139
REFLECTED = re.compile(r"xss|cross.site.script|reflect|crlf|header injection|response split"
21292140
r"|CWE-79\b|CWE-113\b", re.I)
21302141

@@ -2209,6 +2220,16 @@ def same(a: str, a_cut: bool, b: str, b_cut: bool) -> bool:
22092220
# True, but of the page every missing address gets, so roll_up
22102221
# can say that once instead of once per address.
22112222
f = dict(f, answer="not_found")
2223+
elif status in PROTECTED:
2224+
# Sign in required or forbidden: the path is access controlled, so
2225+
# an engine calling it reachable found the control working, or a
2226+
# session expired page, not a served file. A finding about what
2227+
# the page echoes or holds is kept; a bare "this path is here" is
2228+
# dropped, the same as a not found.
2229+
if not about_content(f):
2230+
dropped += 1
2231+
continue
2232+
f = dict(f, answer="protected")
22122233
elif status:
22132234
f = dict(f, answer="found")
22142235
kept.append(f)
@@ -2222,6 +2243,64 @@ def about_content(f: dict) -> bool:
22222243
return bool(REFLECTED.search(text) or SECRET_WORDS.search(text))
22232244

22242245

2246+
# Reflected cross site scripting that works by injecting an inline script or an
2247+
# event handler. A strong Content-Security-Policy stops those from running.
2248+
XSS_WORDS = re.compile(r"\bxss\b|cross.site.script|CWE-79\b", re.I)
2249+
# Script sources broad enough that an attacker can still load a script: a bare
2250+
# scheme or a wildcard, which allow a script from anywhere on that scheme.
2251+
_BROAD_SCRIPT_SRC = {"*", "http:", "https:", "data:", "blob:", "'unsafe-inline'"}
2252+
2253+
2254+
def _csp_blocks_inline_script(csp: str) -> bool:
2255+
"""Whether a Content-Security-Policy stops an injected inline script.
2256+
2257+
True only when script is governed (by script-src, or default-src when
2258+
there is no script-src), inline script is not allowed, and the allowed
2259+
sources are not opened up to a whole scheme or a wildcard. A missing,
2260+
unsafe-inline, or wide open policy returns False, so a real reflected XSS
2261+
is never hidden by a weak policy.
2262+
"""
2263+
directives = {}
2264+
for part in (csp or "").split(";"):
2265+
part = part.strip()
2266+
if not part:
2267+
continue
2268+
name, _, rest = part.partition(" ")
2269+
directives[name.lower()] = rest.lower().split()
2270+
src = directives.get("script-src")
2271+
if src is None:
2272+
src = directives.get("default-src")
2273+
if not src:
2274+
return False
2275+
return not any(token in _BROAD_SCRIPT_SRC for token in src)
2276+
2277+
2278+
def soften_blocked_xss(findings: list, csp: str) -> tuple:
2279+
"""Lower reflected XSS findings to informational when the site's policy
2280+
blocks inline script, with the reason. -> (findings, how many lowered).
2281+
2282+
Lowered, not dropped: a policy does not stop DOM based XSS or a reflected
2283+
script that lands in an allowed source, so the finding is kept to verify
2284+
by hand, but it is no longer rated as if it ran.
2285+
"""
2286+
if not _csp_blocks_inline_script(csp):
2287+
return findings, 0
2288+
note = (" The site sends a Content-Security-Policy that does not allow "
2289+
"inline script, so an injected inline script or event handler will "
2290+
"not run. Lowered to informational: confirm by hand that a script "
2291+
"source or a DOM sink makes it exploitable before acting on it.")
2292+
lowered, out = 0, []
2293+
for f in findings:
2294+
text = f"{f.get('title') or ''} {f.get('cwe') or ''} {f.get('evidence') or ''}"
2295+
if (f.get("tool") in ("dalfox", "wapiti", "nuclei", "nikto")
2296+
and XSS_WORDS.search(text)
2297+
and RANK.get(f.get("severity"), 0) > RANK.get("info")):
2298+
f = dict(f, severity="info", evidence=((f.get("evidence") or "")[:400] + note))
2299+
lowered += 1
2300+
out.append(f)
2301+
return out, lowered
2302+
2303+
22252304
ROLL_UP_AT = 3
22262305
MAX_ROLLED_URLS = 200
22272306
_PATH_PREFIX = re.compile(r"^/\S*:\s+")

0 commit comments

Comments
 (0)