Skip to content

Commit 9f468bb

Browse files
committed
External sites: signed in scans, one user agent, a default scan per site
A site can carry a scan sign in: a header such as Authorization: Bearer, a session cookie copied from a signed in browser, or a form login. It is saved encrypted and never shown again. A form login is signed in once before the engines start, keeping hidden fields such as a CSRF token, and its session is handed on. nuclei, ffuf, dalfox, sqlmap, nikto and wapiti sign in; the baseline checks and testssl stay signed out. Anything an engine echoed back of the secret is taken out of the findings, the engine notes and errors before they are stored. A form login or HTTP Basic goes to nextgen-dast in its own fields; a token or cookie, which its API has no field for, is refused rather than run signed out. A signed in scan past baseline needs the confirmation, and the scan page and the report say whether it signed in. Every scan, screenshot, telemetry read and login now sends one current desktop Chrome user agent, which Settings can replace with another browser's. sqlmap no longer picks a random one and testssl no longer uses its own. The scan dialog started on baseline, a check of a few seconds that runs no engines, so a real scan looked like it had finished too early. It now starts on quick, says what each profile does and how long it takes, and uses the site's own default profile, which Add and Edit can set. Bulk scans can use each site's default. A baseline result offers a deeper scan. A Cloudflare 502 for a site whose origin is down was taken for a firewall block page and its screenshot hidden. It is now shown as the site being down behind its CDN. Words that also appear on ordinary pages, such as please enable cookies, only count as a block on a refusal status.
1 parent 4f2b74a commit 9f468bb

21 files changed

Lines changed: 831 additions & 76 deletions

‎app/core/api_reference.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,11 @@ def _f(name, kind, required, description):
459459
_f("external_auth_needed", "boolean", False, "Signing in goes through something outside the site."),
460460
_f("whitelist_needed", "boolean", False, "The scanner has to be let through a firewall."),
461461
_f("change_frequency_days", "integer", False, "How often the site changes, in days."),
462+
_f("default_profile", "string", False, "The scan profile used when none is given: "
463+
"baseline, quick, standard, thorough or premium."),
464+
_f("scan_auth", "object", False, "How a scan signs in: kind header (header_name, "
465+
"header_value), cookie (cookie) or form (login_url, "
466+
"username, password). null removes it. Never returned."),
462467
_f("repositories", "array", False, "Objects with repository (org/name) and an optional "
463468
"branch. Replaces what was picked before."),
464469
_f("telemetry_fields", "object", False, "Telemetry filled in by hand, by field name: "
@@ -549,7 +554,11 @@ def _external_endpoints() -> list:
549554
"and up, and any DAST server, send attack payloads and need confirm.",
550555
[asset_id],
551556
body=[_f("profile", "string", False, "baseline, quick, standard, "
552-
"thorough or premium."),
557+
"thorough or premium. The site's default "
558+
"when left out."),
559+
_f("authenticated", "boolean", False, "Sign in with the site's scan "
560+
"sign in. On by default when it "
561+
"has one."),
553562
_f("server", "string", False, "builtin, or a nextgen-dast server's name."),
554563
_f("confirm", "boolean", False, "True to say you may scan it."),
555564
_f("ai", "boolean", False, "True to have the model triage the "

‎app/core/dast_engines.py‎

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,19 @@
3838
log = logging.getLogger("gcr.dast")
3939

4040
PROFILES = {
41-
"baseline": "Read only checks: headers, cookies, TLS, exposed files, open "
42-
"ports. A few dozen requests, about a minute.",
43-
"quick": "Baseline, then testssl and nuclei for exposures, "
44-
"misconfigurations, known CVEs and default logins.",
45-
"standard": "Quick, then nikto and wapiti. Sends attack payloads. "
46-
"Can take an hour or more.",
47-
"thorough": "Standard, with ffuf content discovery first and a wider "
48-
"nuclei pass. Sends attack payloads.",
49-
"premium": "Thorough, then sqlmap and dalfox. The most invasive. Can run "
50-
"for hours.",
41+
"baseline": "A light, read only check: headers, cookies, TLS, open ports and "
42+
"files that should never be served. About 20 requests, done in "
43+
"seconds. Runs none of the scanning engines.",
44+
"quick": "Baseline, then testssl and nuclei for exposures, misconfigurations, "
45+
"known CVEs and default logins. Usually 10 to 30 minutes.",
46+
"standard": "Quick, then nikto and wapiti, which crawl the site and try attack "
47+
"payloads. An hour or more.",
48+
"thorough": "Standard, with ffuf content discovery first and a wider nuclei "
49+
"pass. Sends attack payloads. Several hours on a large site.",
50+
"premium": "Thorough, then sqlmap and dalfox. The most invasive. Can run for "
51+
"many hours.",
5152
}
53+
DEFAULT_PROFILE = "quick"
5254
ACTIVE_PROFILES = {"standard", "thorough", "premium"}
5355

5456
PLANS = {
@@ -78,8 +80,11 @@
7880
TEMPLATES = Path(os.environ.get("GCR_NUCLEI_TEMPLATES",
7981
str(Path.home() / ".cache" / "nuclei-templates")))
8082

81-
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
82-
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
83+
def _ua() -> str:
84+
"""The one user agent every engine sends, from Settings."""
85+
from app.core.external_probe import user_agent
86+
87+
return user_agent()
8388

8489

8590
def binary(tool: str) -> str:
@@ -176,7 +181,7 @@ def command(tool: str, url: str, host: str, profile: str, workdir: Path,
176181
if tool == "testssl":
177182
report = workdir / "testssl.json"
178183
return ([exe, "--jsonfile", str(report), "--quiet", "--color", "0",
179-
"--warnings", "off", "--sneaky", host], report)
184+
"--warnings", "off", "--user-agent", _ua(), host], report)
180185
if tool == "nuclei":
181186
tags = {"quick": "exposure,misconfig,cve,default-login",
182187
"thorough": "exposure,misconfig,cve,default-login,token-spray,"
@@ -191,7 +196,7 @@ def command(tool: str, url: str, host: str, profile: str, workdir: Path,
191196
"-disable-update-check", "-no-color", "-silent",
192197
"-severity", "info,low,medium,high,critical", "-tags", tags,
193198
"-rl", "50", "-c", "25", "-timeout", "10", "-retries", "1",
194-
"-H", f"User-Agent: {UA}"]
199+
"-H", f"User-Agent: {_ua()}"]
195200
templates = _nuclei_templates(workdir, -1)
196201
if templates:
197202
argv += ["-t", templates]
@@ -201,7 +206,7 @@ def command(tool: str, url: str, host: str, profile: str, workdir: Path,
201206
flags = "x6" if profile == "quick" else ("x6789ab" if profile in ("thorough", "premium") else "x69")
202207
return ([exe, "-h", url, "-output", str(report), "-Format", "json",
203208
"-ask", "no", "-nointeractive", "-Tuning", flags,
204-
"-maxtime", "3000s", "-useragent", UA], report)
209+
"-maxtime", "3000s", "-useragent", _ua()], report)
205210
if tool == "wapiti":
206211
report = workdir / "wapiti.json"
207212
modules = ("sql,timesql,xss,permanentxss,ssrf,xxe,exec,file,csrf,"
@@ -211,7 +216,7 @@ def command(tool: str, url: str, host: str, profile: str, workdir: Path,
211216
argv = [exe, "-u", url, "-f", "json", "-o", str(report),
212217
"--flush-session", "--verbose", "0", "--scope", "subdomain",
213218
"-m", modules, "--max-attack-time", "1800",
214-
"--max-scan-time", str(TIMEOUTS["wapiti"] - 300), "-A", UA,
219+
"--max-scan-time", str(TIMEOUTS["wapiti"] - 300), "-A", _ua(),
215220
"--store-session", str(workdir / "wapiti-session"),
216221
"--store-config", str(workdir / "wapiti-config")]
217222
# Routes from the code and paths found by ffuf, as extra places to
@@ -221,7 +226,7 @@ def command(tool: str, url: str, host: str, profile: str, workdir: Path,
221226
return argv, report
222227
if tool == "sqlmap":
223228
out = workdir / "sqlmap"
224-
return ([exe, "-u", url, "--batch", "--random-agent", "--crawl", "2",
229+
return ([exe, "-u", url, "--batch", "-A", _ua(), "--crawl", "2",
225230
"--forms", "--smart", "--level", "2", "--risk", "2",
226231
"--output-dir", str(out), "--disable-coloring"], out)
227232
if tool == "dalfox":
@@ -233,13 +238,13 @@ def command(tool: str, url: str, host: str, profile: str, workdir: Path,
233238
targets.write_text("\n".join([url] + with_params[:200]) + "\n")
234239
return ([exe, "scan", str(targets), "-f", "json", "-o", str(report), "-S",
235240
"--workers", "10", "--timeout", "10", "--scan-timeout", "1500",
236-
"--skip-mining-dom", "--user-agent", UA], report)
241+
"--skip-mining-dom", "--user-agent", _ua()], report)
237242
if tool == "ffuf":
238243
report = workdir / "ffuf.json"
239244
return ([exe, "-u", url.rstrip("/") + "/FUZZ", "-w", str(WORDLIST),
240245
"-of", "json", "-o", str(report), "-mc", "200,301,302,401,403",
241246
"-fs", "0", "-ac", "-t", "20", "-rate", "40", "-timeout", "10",
242-
"-maxtime", "240", "-s", "-H", f"User-Agent: {UA}"], report)
247+
"-maxtime", "240", "-s", "-H", f"User-Agent: {_ua()}"], report)
243248
raise ValueError(f"unknown engine {tool}")
244249

245250

@@ -270,7 +275,7 @@ def links_in(url: str, body: str) -> list:
270275

271276

272277
def run_engine(tool: str, url: str, host: str, profile: str, workdir: Path,
273-
scan_id: int, paths: list = None) -> dict:
278+
scan_id: int, paths: list = None, auth_args: list = None) -> dict:
274279
"""Run one engine and read its report.
275280
276281
-> {tool, status, seconds, findings, detail, paths}
@@ -289,6 +294,7 @@ def run_engine(tool: str, url: str, host: str, profile: str, workdir: Path,
289294
engine_dir.mkdir(parents=True, exist_ok=True)
290295
try:
291296
argv, report = command(tool, url, host, profile, engine_dir, paths)
297+
argv = list(argv) + list(auth_args or [])
292298
except Exception as exc: # noqa: BLE001
293299
out.update(status="failed", detail=f"{type(exc).__name__}: {exc}"[:300])
294300
return out

‎app/core/dast_report.py‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,26 @@ def build(db, asset, scan, user=None, organization: str = "") -> dict:
282282
for e in skipped:
283283
limitations.append(f"{e.get('name') or e.get('tool')} did not complete: "
284284
f"{e.get('detail') or e.get('status')}.")
285-
if asset.external_auth_needed:
286-
limitations.append("Signing in to this site goes through an outside service, so the "
287-
"scan saw only what is public.")
285+
if scan.authenticated:
286+
kinds = {"header": "a header", "cookie": "a session cookie", "form": "a form login"}
287+
signin = next((e for e in engines if e.get("tool") == "signin"), None)
288+
limitations.append(f"The engines signed in with {kinds.get(scan.auth_kind, 'a sign in')}, "
289+
"so they saw what that user sees. The baseline checks and testssl "
290+
"ran signed out.")
291+
if signin and signin.get("status") != "ok":
292+
limitations.append(f"Signing in did not work: {signin.get('detail')}.")
293+
unsigned = [e.get("name") for e in engines
294+
if e.get("signed_in") is False and e.get("status") == "ok"
295+
and e.get("tool") not in ("baseline", "testssl")]
296+
if unsigned:
297+
limitations.append("These ran signed out: " + ", ".join(unsigned) + ".")
298+
else:
299+
limitations.append("The scan did not sign in, so it saw only the public parts of "
300+
"the site.")
301+
if asset.external_auth_needed and not scan.authenticated:
302+
limitations.append("Signing in to this site goes through an outside service. A "
303+
"session cookie set in the site's sign in settings lets a scan "
304+
"get past it.")
288305
if asset.whitelist_needed:
289306
limitations.append("The site needs the scanner's address let through a firewall. If "
290307
"it was not, parts of the site were blocked from the scan.")

0 commit comments

Comments
 (0)