Skip to content

Commit b970253

Browse files
committed
Keep both accounts signed in for the cross account checks
The cross account checks run last, after the engines, which on a deep scan can take hours. By then the sign in from the start of the scan has often run out, and the site answers an authenticated request with a "your session has expired" page. That page is HTTP 200, so the old check read it as the account's real page and could report access that was not there. Three changes fix it. Each account signs in fresh at the start of this stage, so the sessions are seconds old, not hours. A "session expired" or "sign in" page is now recognised and never treated as the account's content, so a check never fires against one. And if a session still runs out while the checks are going, that account signs in again once and the request is retried, so a slow stage does not lose the session.
1 parent 5780f6e commit b970253

2 files changed

Lines changed: 67 additions & 15 deletions

File tree

‎app/core/dast_checks/engine.py‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ def _normalize(body: str) -> str:
192192
return re.sub(r"\s+", " ", body).strip()
193193

194194

195+
# A page that answers HTTP 200 but is really a sign in wall or a session that
196+
# has run out. A long scan can outlive the session it signed in with, and the
197+
# site then answers these instead of the real page; a status check alone is
198+
# fooled, so a compare against one would be meaningless.
199+
_AUTHLESS = re.compile(
200+
r"session (has )?(expired|timed ?out)|your session (has )?(expired|timed)|"
201+
r"(log|sign) ?in again|authenticate again|please (authenticate|log ?in|sign ?in)|"
202+
r"you need to (log|sign) ?in|(log|sign) ?in to continue", re.I)
203+
204+
205+
def _looks_authless(resp) -> bool:
206+
"""True when a response is a sign in or session expired wall, not content."""
207+
return bool(_AUTHLESS.search(((resp or {}).get("body") or "")[:8000]))
208+
209+
195210
def _env_for(response: dict, control: dict, oob_hit: bool, saved: dict = None) -> dict:
196211
"""The names and helpers a differential may use.
197212
@@ -524,6 +539,23 @@ def _run_two_account(spec: dict, ctx: dict, domains, budget: Budget) -> list:
524539
a = Client(base_url, domains, "read", budget, allow_private, ctx.get("headers"))
525540
b = Client(base_url, domains, "read", budget, allow_private, ctx.get("second_headers"))
526541
anon = Client(base_url, domains, "read", budget, allow_private, None)
542+
# A long scan can outlive the sign in, so each account can sign in again once
543+
# if its session has run out while the checks run. Kept to one per account
544+
# so a site that will not keep a session is not hammered.
545+
reauth = {"a": ctx.get("reauth_a"), "b": ctx.get("reauth_b")}
546+
may_reauth = {"a": bool(reauth["a"]), "b": bool(reauth["b"])}
547+
548+
def live(client, who, url):
549+
"""Fetch as one account; if the session has run out, sign in again once
550+
and retry, so no check ever runs against a session expired page."""
551+
resp = client.request("GET", url)
552+
if _looks_authless(resp) and may_reauth[who]:
553+
may_reauth[who] = False
554+
fresh = reauth[who]()
555+
if fresh:
556+
client.headers = dict(fresh)
557+
resp = client.request("GET", url)
558+
return resp
527559

528560
confirm = spec.get("confirm") or {}
529561
differential = confirm.get("differential") or "False"
@@ -533,18 +565,25 @@ def _run_two_account(spec: dict, ctx: dict, domains, budget: Budget) -> list:
533565
for url in _b_objects(b, base_url, ctx.get("code_routes")):
534566
confirmations, resp_a = 0, None
535567
for _attempt in range(retest):
536-
resp_b = b.request("GET", url)
537-
if resp_b.get("status") != 200:
568+
resp_b = live(b, "b", url)
569+
# It must be the second account's real object: a 200 that is not a
570+
# sign in or session expired wall.
571+
if resp_b.get("status") != 200 or _looks_authless(resp_b):
538572
break
539-
resp_a = a.request("GET", url)
573+
resp_a = live(a, "a", url)
540574
resp_anon = anon.request("GET", url)
575+
# The first account must have reached the real object too; a sign in
576+
# wall is not access and there would be nothing to compare.
577+
if _looks_authless(resp_a):
578+
resp_a = None
579+
break
541580
env = _env_for(resp_a, {}, False,
542581
saved={"b": resp_b, "anon": resp_anon})
543582
if evaluate(differential, env):
544583
confirmations += 1
545584
else:
546585
break
547-
if confirmations == retest and confirmations > 0:
586+
if confirmations == retest and confirmations > 0 and resp_a is not None:
548587
f = _finding(spec, a, resp_a, url)
549588
f["evidence"] = _two_account_evidence(url, resp_a, resp_b, resp_anon)
550589
f["reproduce"] = _two_account_reproduce(url)

‎app/core/external.py‎

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,7 +2189,7 @@ def _run_curated_rules(scan_id, asset_id, url, wb, session, opts, rulesets) -> t
21892189

21902190
specs, domains = [], []
21912191
excluded = invalid = 0
2192-
second_headers = None
2192+
auth_a = auth_b = None
21932193
with session_scope() as db:
21942194
for row in db.query(DastRule).filter(DastRule.enabled.is_(True)).all():
21952195
if not profiles.selected(row.rule_class, row.rulesets, rulesets):
@@ -2203,16 +2203,28 @@ def _run_curated_rules(scan_id, asset_id, url, wb, session, opts, rulesets) -> t
22032203
domains = scope.company_domains(
22042204
settings_mod.get(db, "web.company_domains") or [])
22052205
oob = callback.for_scan(db)
2206-
# The second account, for the compare checks. Built only when one is
2207-
# set and at least one enabled rule needs it.
2208-
if any("two_accounts" in (s.get("needs") or []) for s in specs):
2209-
asset = db.get(ExternalAsset, asset_id)
2210-
auth_b = external_auth.load_b(asset) if asset else {}
2211-
if auth_b:
2212-
sess_b = external_auth.session_for(auth_b, opts["allow_private"])
2213-
if sess_b.get("ok"):
2214-
second_headers = _session_headers(sess_b)
2215-
headers = _session_headers(session)
2206+
asset = db.get(ExternalAsset, asset_id)
2207+
auth_a = (external_auth.load(asset) or None) if asset else None
2208+
# The second account, for the compare checks. Loaded only when at least
2209+
# one enabled rule needs it.
2210+
if asset and any("two_accounts" in (s.get("needs") or []) for s in specs):
2211+
auth_b = external_auth.load_b(asset) or None
2212+
2213+
# Sign in FRESH for this stage. The curated rules run last, after the engines
2214+
# (which on premium can take hours), by when the sign in from the start of the
2215+
# scan has usually run out and the site answers "session expired". Each
2216+
# account is signed in again now, and the engine can sign in again once more
2217+
# if a session still lapses while the checks run.
2218+
def _fresh_headers(auth):
2219+
if not auth:
2220+
return None
2221+
s = external_auth.session_for(auth, opts["allow_private"])
2222+
return _session_headers(s) if s.get("ok") else None
2223+
2224+
headers = _fresh_headers(auth_a) or _session_headers(session)
2225+
second_headers = _fresh_headers(auth_b)
2226+
reauth_a = (lambda: _fresh_headers(auth_a)) if auth_a else None
2227+
reauth_b = (lambda: _fresh_headers(auth_b)) if auth_b else None
22162228

22172229
host = urlparse(url).hostname or ""
22182230
row = {"tool": "curated", "name": "Premium curated rules", "status": "ok",
@@ -2231,6 +2243,7 @@ def _run_curated_rules(scan_id, asset_id, url, wb, session, opts, rulesets) -> t
22312243
"allow_private": opts["allow_private"],
22322244
"code_routes": list(wb.get("routes") or []),
22332245
"headers": headers or None, "second_headers": second_headers,
2246+
"reauth_a": reauth_a, "reauth_b": reauth_b,
22342247
"budget": 60, "oob": oob}
22352248
from collections import Counter
22362249

0 commit comments

Comments
 (0)