From c011317da0b7975516b8b92b04b1784c2c2c8ec4 Mon Sep 17 00:00:00 2001 From: Venkata Satish Date: Fri, 10 Jul 2026 14:11:46 +0530 Subject: [PATCH 1/6] fix(reporter/scanner): stop MFA/SAML markers shipping as CRITICAL auth-bypass fabrications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends full-tool review (Group A). Three scanner markers landed in mfa/ and saml/ findings.txt, which map to the (critical/9.8) auth_bypass template, and no prefix suppressed them — so each shipped as a fabricated CRITICAL Broken-Auth finding in client reports: - [MFA-RESPONSE-MANIP] fires when an OTP endpoint returns {"success":false} for a WRONG code — i.e. SECURE behaviour. scanner.sh's own comment called it 'indicator only'. Pure false positive. - [MFA-NO-RATE-LIMIT] a missing-429 rate-limit gap (at most MEDIUM), not an auth bypass. - [SAML-METADATA-EXPOSED] a public SP/IdP metadata document (public by design); a LEAD for XSW, not a bypass. Its extracted saml/certs.txt cert blobs were ALSO each ingested as their own CRITICAL. Fixes: - reporter.py: suppress the three prefixes + add certs.txt to NON_FINDING_FILES. - reporter.py: remove the dead shadowed high/8.1 'Authentication Bypass' template literal (silently overwritten by the critical/9.8 'Broken Authentication' reassignment) — the footgun that made these resolve to 9.8. - scanner.sh: route all three to manual_review/ as leads (root cause), matching the existing -CANDIDATE siblings. The CONFIRMED [MFA-WORKFLOW-SKIP] and [SAML-SIG-STRIP] markers stay in findings.txt. TDD: 3 new/extended tests (fabrication-leaks SUPPRESS+KEEP, template-not-shadowed, scanner marker routing). Reporter/mfa/saml/scanner regression set: 366 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- reporter.py | 41 +++++++----- scanner.sh | 24 +++++-- tests/test_reporter_fabrication_leaks.py | 40 ++++++++++++ tests/test_scanner_mfa_saml_marker_routing.py | 64 +++++++++++++++++++ 4 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 tests/test_scanner_mfa_saml_marker_routing.py diff --git a/reporter.py b/reporter.py index 67fd32d..d8978d4 100644 --- a/reporter.py +++ b/reporter.py @@ -408,21 +408,11 @@ ("OWASP CSRF", "https://owasp.org/www-community/attacks/csrf"), ], }, - "auth_bypass": { - "title": "Authentication Bypass on {host}", - "severity": "high", "cvss": "8.1", "cwe": "CWE-287", - "impact": ( - "An attacker can access protected resources or administrative interfaces " - "without valid credentials, potentially leading to account takeover or data exposure." - ), - "remediation": ( - "Enforce authentication checks server-side on every protected route, " - "remove default credentials, and verify SPA route guards are not relied on alone." - ), - "references": [ - ("OWASP Forced Browsing", "https://owasp.org/www-community/attacks/Forced_browsing"), - ], - }, + # NOTE: "auth_bypass" is deliberately NOT defined here. It is set once, below, + # via `VULN_TEMPLATES["auth_bypass"] = {...}` (critical/9.8, "Broken + # Authentication"). A previous stale high/8.1 literal at this position was + # silently overwritten by that reassignment — editing it had no runtime effect + # (the footgun behind the MFA/SAML markers shipping at 9.8). Keep exactly one. "open_redirect": { "title": "Open Redirect on {host}", "severity": "medium", "cvss": "6.1", "cwe": "CWE-601", @@ -922,6 +912,11 @@ def load_findings(findings_dir: str) -> list: # generation chatter, not a confirmed reflection). Verified XSS comes from dalfox; these # raw lines were promoted to MEDIUM XSS findings. "xsstrike_results.txt", + # saml/ — record_saml_metadata() writes the extracted evidence blobs + # here, one per line. saml/ maps to the (critical) auth_bypass template, so each raw + # cert line was ingested as its OWN CRITICAL "Authentication Bypass". This is evidence + # for the [SAML-METADATA-EXPOSED] note (itself suppressed below), not a finding. + "certs.txt", } # Line-prefix markers used by scanner.sh to record state, not findings. NON_FINDING_PREFIXES = ( @@ -982,6 +977,22 @@ def load_findings(findings_dir: str) -> list: # the strong-proof tier (session-cookie issuance + verified follow- # up request) did not confirm. [LDAP-BYPASS-CONFIRMED] is the # proven variant. + # ── friends full-tool review (Group A): mfa/ and saml/ map to the + # (critical) auth_bypass template. Three markers there are NOT a + # confirmed bypass and were shipping as CRITICAL 9.8 fabrications. + # The genuinely-confirmed siblings ([MFA-WORKFLOW-SKIP], + # [SAML-SIG-STRIP]) use different prefixes and are NOT suppressed. + "[MFA-RESPONSE-MANIP]", # mfa/ — server returns a JSON {"success":false} for a WRONG OTP, + # i.e. SECURE behaviour. scanner.sh's own comment calls it an + # "indicator only" / "candidate"; nothing was manipulated or + # bypassed. Pure false positive. (Now also routed to + # manual_review/ by scanner.sh.) + "[MFA-NO-RATE-LIMIT]", # mfa/ — no 429 seen in a burst of OTP POSTs. A real but at-most + # MEDIUM rate-limiting gap, NOT a CRITICAL authentication bypass. + # Re-surfaced as a manual-review lead rather than a fabricated crit. + "[SAML-METADATA-EXPOSED]", # saml/ — a public SP/IdP SAML metadata document (EntityDescriptor + # / X509Certificate) is public BY DESIGN; it aids XSW/cert + # extraction as a LEAD but is not itself an auth bypass. ) for fn in sorted(os.listdir(path)): if not fn.endswith(".txt"): diff --git a/scanner.sh b/scanner.sh index 4ce6717..ed47e90 100755 --- a/scanner.sh +++ b/scanner.sh @@ -820,8 +820,12 @@ if ! skip_has mfa; then # all-ERR/all-000 (curl failures), which carries no rate-limit signal. if echo "$STATUS_CODES" | grep -qE '[1-5][0-9]{2}' \ && ! echo "$STATUS_CODES" | grep -q "429"; then - log_vuln "[MFA] No rate limit detected on OTP endpoint: $BASE" - echo "[MFA-NO-RATE-LIMIT] $BASE | codes: $STATUS_CODES" >> "$FINDINGS_DIR/mfa/findings.txt" + # A missing 429 is (at most) a MEDIUM rate-limiting gap, NOT a CONFIRMED + # authentication bypass. mfa/ maps to the critical auth_bypass template, so + # writing it there shipped it as a fabricated CRITICAL 9.8. Route to + # manual_review as a lead (reporter also suppresses the prefix, belt+braces). + log_info "[MFA] No rate limit on OTP endpoint (manual-review lead): $BASE" + echo "[MFA-NO-RATE-LIMIT] $BASE | codes: $STATUS_CODES | missing 429 (medium rate-limit gap — manual review)" >> "$FINDINGS_DIR/manual_review/mfa_candidates.txt" fi # --- Test 2: MFA workflow skip (pre-MFA session to protected page) --- @@ -865,8 +869,12 @@ if ! skip_has mfa; then -H "Content-Type: application/json" \ -d '{"otp":"999999"}' 2>/dev/null || true) if echo "$RESP" | grep -qi '"success"\s*:\s*false\|"verified"\s*:\s*false\|"status"\s*:\s*"fail"'; then - log_vuln "[MFA] Response manipulation candidate (server sends JSON success flag): $BASE" - echo "[MFA-RESPONSE-MANIP] $BASE | change false->true in response" >> "$FINDINGS_DIR/mfa/findings.txt" + # INDICATOR ONLY: the server merely REPORTS failure as a JSON flag for a WRONG + # OTP — that is SECURE behaviour, nothing was manipulated or bypassed. Writing + # it to mfa/findings.txt shipped a fabricated CRITICAL 9.8. It is a manual-review + # lead (try actually flipping the flag), not a finding. + log_info "[MFA] Response-flag present (manual-review lead — NOT a confirmed bypass): $BASE" + echo "[MFA-RESPONSE-MANIP] $BASE | server emits a JSON success flag — manually verify whether flipping false->true bypasses MFA" >> "$FINDINGS_DIR/manual_review/mfa_candidates.txt" fi done <<< "$MFA_ENDPOINTS" @@ -911,8 +919,12 @@ if ! skip_has saml; then [ -z "$url" ] && continue RESP=$(curl -sk --max-time 8 "$url" 2>/dev/null || true) if echo "$RESP" | grep -qi "EntityDescriptor\|IDPSSODescriptor\|X509Certificate"; then - log_vuln "[SAML] Metadata exposed (aids XSW/cert extraction): $url" - echo "[SAML-METADATA-EXPOSED] $url" >> "$FINDINGS_DIR/saml/findings.txt" + # A public SP/IdP SAML metadata document is public BY DESIGN (that is how + # federation works). It is a LEAD for XSW/cert extraction, NOT an auth bypass. + # saml/ maps to the critical auth_bypass template, so writing it to + # saml/findings.txt shipped a fabricated CRITICAL 9.8. Route to manual_review. + log_info "[SAML] Metadata exposed (manual-review lead, aids XSW/cert extraction): $url" + echo "[SAML-METADATA-EXPOSED] $url | public metadata (aids XSW) — manual review, not a confirmed bypass" >> "$FINDINGS_DIR/manual_review/saml_candidates.txt" # Extract cert if present echo "$RESP" | grep -o '[^<]*' | head -3 >> "$FINDINGS_DIR/saml/certs.txt" 2>/dev/null || true fi diff --git a/tests/test_reporter_fabrication_leaks.py b/tests/test_reporter_fabrication_leaks.py index be05caa..fa998fb 100644 --- a/tests/test_reporter_fabrication_leaks.py +++ b/tests/test_reporter_fabrication_leaks.py @@ -66,6 +66,23 @@ def _worst(tmp_path, relpath, line): ("jwt/jwt_1.txt", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiJ9.s5c8Rt0kA1b2C3d4E5f6G7h8I9j0K"), ("jwt/jwt_1_results.txt", "Original JWT: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.sig | alg=HS256 sub=admin"), ("jwt/jwt_2_results.txt", "[+] Testing potential signing keys... jwttool_forged=eyJ0eXAiOi...tampered token"), + # ── friends full-tool review (Group A): MFA/SAML markers that are NOT a + # confirmed auth bypass were shipping as CRITICAL 9.8 auth_bypass because + # mfa/ and saml/ map to the (critical) auth_bypass template and no prefix + # suppressed them. + # [MFA-RESPONSE-MANIP]: server merely returns a JSON {"success":false} + # for a WRONG OTP — i.e. SECURE behaviour. scanner.sh's own comment + # calls it "indicator only". Pure false positive. + ("mfa/findings.txt", "[MFA-RESPONSE-MANIP] https://t.example.invalid/otp | change false->true in response"), + # [MFA-NO-RATE-LIMIT]: missing 429 on an OTP endpoint is (at most) a MEDIUM + # rate-limiting gap, NOT a CRITICAL authentication bypass. + ("mfa/findings.txt", "[MFA-NO-RATE-LIMIT] https://t.example.invalid/otp | codes: 12 200"), + # [SAML-METADATA-EXPOSED]: a public SP/IdP SAML metadata document is public + # BY DESIGN (that is how federation works). Not an auth bypass. + ("saml/findings.txt", "[SAML-METADATA-EXPOSED] https://t.example.invalid/saml/metadata"), + # saml/certs.txt: raw evidence lines were each ingested as + # their own CRITICAL auth_bypass finding. Evidence file, not a finding file. + ("saml/certs.txt", "MIIDazCCAlOgAwIBAgIUABCDEF0123456789fakecertdata"), ] KEEP = [ @@ -79,6 +96,15 @@ def _worst(tmp_path, relpath, line): # jwt_*.txt narrative file), so the numbered-file exemption must not # swallow it. ("jwt/jwt_confirmed.txt", "[JWT-WEAK-SECRET-CONFIRMED] https://t.example.invalid/api :: jwt_tool -C cracked weak signing secret 'secret123'"), + # ── friends full-tool review (Group A): the CONFIRMED auth-bypass markers in + # the SAME mfa/ and saml/ dirs must still surface — suppression of the + # unverified siblings above must not over-suppress these. + # [MFA-WORKFLOW-SKIP]: protected page reached pre-MFA with an authenticated + # marker AND differing from the unauth baseline (scanner.sh gates all three). + ("mfa/findings.txt", "[MFA-WORKFLOW-SKIP] https://t.example.invalid/dashboard accessible (HTTP 200, authenticated content, differs from unauth baseline)"), + # [SAML-SIG-STRIP]: an UNSIGNED assertion established a real session — a + # genuine signature-bypass. + ("saml/findings.txt", "[SAML-SIG-STRIP] https://t.example.invalid/saml/acs | HTTP 200 | unsigned assertion established a session"), ] @@ -94,3 +120,17 @@ def test_verified_markers_are_still_reported(tmp_path): worst = _worst(tmp_path, relpath, line) assert worst is not None, ( f"a VERIFIED marker was over-suppressed (dropped from report): {relpath} :: {line[:50]}") + + +def test_auth_bypass_template_not_shadowed(): + """The auth_bypass template was defined TWICE: a dead ``high/8.1`` + "Authentication Bypass on {host}" literal, silently overwritten by a + ``critical/9.8`` "Broken Authentication on {host}" reassignment. A + maintainer editing the dead literal would see no effect (the footgun behind + F1-F3 shipping at 9.8). There must be exactly one live definition.""" + src = open(reporter.__file__).read() + assert "Authentication Bypass on {host}" not in src, ( + "the dead/shadowed auth_bypass template literal is still present — " + "editing it has no runtime effect; remove it and keep the single " + "'Broken Authentication on {host}' definition.") + assert reporter.VULN_TEMPLATES["auth_bypass"]["severity"] == "critical" diff --git a/tests/test_scanner_mfa_saml_marker_routing.py b/tests/test_scanner_mfa_saml_marker_routing.py new file mode 100644 index 0000000..b2b6419 --- /dev/null +++ b/tests/test_scanner_mfa_saml_marker_routing.py @@ -0,0 +1,64 @@ +"""scanner.sh must NOT write non-confirmed MFA/SAML markers into the CONFIRMED +findings.txt files (which map to the critical auth_bypass template). + +friends full-tool review (Group A): three markers were written to +``mfa/findings.txt`` / ``saml/findings.txt`` and shipped as CRITICAL 9.8 +"Broken Authentication" even though none is a confirmed auth bypass: + + - [MFA-RESPONSE-MANIP] — server returns JSON {"success":false} for a WRONG + OTP, i.e. SECURE behaviour (pure false positive). + - [MFA-NO-RATE-LIMIT] — missing 429 burst; at most a MEDIUM rate-limit gap. + - [SAML-METADATA-EXPOSED] — a public SP/IdP metadata document (public by + design); a LEAD for XSW, not an auth bypass. + +They must be routed to manual_review/ as leads (like the existing +[MFA-WORKFLOW-SKIP-CANDIDATE]/[SAML-SIG-STRIP-CANDIDATE] siblings). The +genuinely-confirmed markers [MFA-WORKFLOW-SKIP] and [SAML-SIG-STRIP] must stay +in their findings.txt. +""" +import os +import subprocess + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCANNER_SH = os.path.join(HERE, "scanner.sh") + +UNCONFIRMED = ["[MFA-RESPONSE-MANIP]", "[MFA-NO-RATE-LIMIT]", "[SAML-METADATA-EXPOSED]"] +CONFIRMED = ["[MFA-WORKFLOW-SKIP]", "[SAML-SIG-STRIP]"] + + +def _emit_lines(marker): + src = open(SCANNER_SH).read() + # scanner.sh writes markers via `echo "[MARKER] ..." >> "$FINDINGS_DIR/..."`. + # Match the exact marker token (bracketed) to avoid catching the -CANDIDATE + # variants that already live in manual_review. + out = [] + for ln in src.splitlines(): + if "echo" not in ln: + continue + # exact marker followed by a space (not the -CANDIDATE suffix) + if (marker + " ") in ln: + out.append(ln.strip()) + return out + + +def test_scanner_still_parses(): + subprocess.run(["bash", "-n", SCANNER_SH], check=True) + + +def test_unconfirmed_markers_routed_to_manual_review(): + for m in UNCONFIRMED: + emits = _emit_lines(m) + assert emits, f"expected scanner.sh to still emit {m} somewhere" + for ln in emits: + assert "manual_review" in ln, ( + f"{m} must be routed to manual_review/, not a confirmed findings file: {ln}") + assert "findings.txt" not in ln, ( + f"{m} still written to a confirmed findings.txt: {ln}") + + +def test_confirmed_markers_stay_in_findings(): + for m in CONFIRMED: + emits = _emit_lines(m) + assert emits, f"expected scanner.sh to still emit {m}" + assert any("findings.txt" in ln for ln in emits), ( + f"{m} is a CONFIRMED auth bypass and must remain in a findings.txt: {emits}") From eaa59e466a5f11c7aa0a94881dae922f9b4f9405 Mon Sep 17 00:00:00 2001 From: Venkata Satish Date: Fri, 10 Jul 2026 14:17:58 +0530 Subject: [PATCH 2/6] fix(reporter/vikramaditya): stop confirmed findings vanishing from client reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends full-tool review (Group B) — three engines wrote CONFIRMED CRITICALs into finding dirs the reporter never ingested (only a WARNING), so real vulnerabilities the tool had already proven were silently dropped from the client report: F5 nextjs_bypass/ — whitebox/nextjs_bypass.py writes a CONFIRMED CVE-2025-29927 middleware auth bypass as '[CRITICAL] ... url'. Dir was unmapped -> dropped. Fix: map SUBDIR_VTYPE['nextjs_bypass'] = 'auth_bypass'. F6 sqlmap_reqfile/ + sqlmap_post/ — run_sqlmap_request_file (--request-file) and run_sqlmap_targeted's POST pass write confirmed sqlmap results CSVs into these sibling dirs; reporter Method 1f read ONLY sqlmap/. Fix: Method 1f now reads all three (and treats results.txt as a primary CSV name); both added to meta_dirs. F13 HAR report — main() passed reporter.py the result JSON *file*; reporter __main__ requires a DIRECTORY (Method 1c reads har_vapt_*.json inside it) and exits 1 'Not a directory', so EVERY HAR report produced nothing while 'Done' printed. Fix: extract _dispatch_har_report(output_dir) — passes the dir, surfaces a non-zero reporter exit instead of swallowing it. Verified end-to-end: a real reporter.py run over a dir with nextjs_bypass/ + sqlmap_reqfile/ now emits VN-001 (Broken Auth 9.8) and VN-002 (sqlmap-confirmed SQLi 9.8). TDD: 6 new tests. Reporter/sqlmap/har/vikramaditya set: 377 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- reporter.py | 30 ++++++-- tests/test_reporter_silent_dir_drops.py | 74 +++++++++++++++++++ .../test_vikramaditya_har_report_dispatch.py | 42 +++++++++++ vikramaditya.py | 30 ++++++-- 4 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 tests/test_reporter_silent_dir_drops.py create mode 100644 tests/test_vikramaditya_har_report_dispatch.py diff --git a/reporter.py b/reporter.py index d8978d4..888acce 100644 --- a/reporter.py +++ b/reporter.py @@ -737,6 +737,9 @@ def _severity_counts(findings: list) -> dict: # CONFIRMED]/[SPEL-CONFIRMED] "ldap": "auth_bypass", # ldap_injection_tester phase — [LDAP-INJECTION-CONFIRMED]/ # [LDAP-BYPASS-CONFIRMED] + "nextjs_bypass": "auth_bypass", # whitebox/nextjs_bypass phase — CONFIRMED CVE-2025-29927 + # middleware auth bypass, written as `[CRITICAL] ... url`. + # Was unmapped → every confirmed bypass silently dropped. # NOTE: saml_xsw's [SAML-XSW-CONFIRMED] findings land in findings/saml/ — the SAME dir the # "saml" entry above already covers. No separate saml_xsw key is needed. # NOTE: email_auth/ is intentionally NOT mapped here. Its findings.json is parsed by @@ -829,6 +832,8 @@ def load_findings(findings_dir: str) -> list: "cves_custom", # cves_custom/ is handled by Method 1c below "brain_active", # brain_active/ is handled by Method 1e below "sqlmap", # sqlmap/ is handled by Method 1f below + "sqlmap_reqfile", # --request-file confirmed results — Method 1f below + "sqlmap_post", # POST-path confirmed results — Method 1f below "email_auth", # email_auth/findings.json handled by Method 1d below "exposed_credentials", # handled by Method 1h below "burp", # burp/findings.json handled by Method 1g below @@ -1354,18 +1359,31 @@ def load_findings(findings_dir: str) -> list: # Safety contract: a header-only file (sqlmap found nothing) yields zero findings, and a # row sqlmap itself tagged "false positive or unexploitable" is skipped (mirrors the # brain.py candidate filter) so a scanner-rejected row never becomes a CRITICAL finding. - sqlmap_dir = os.path.join(findings_dir, "sqlmap") - if os.path.isdir(sqlmap_dir): + # friends full-tool review: run_sqlmap_request_file (the --request-file path) + # writes its confirmed results CSV to sqlmap_reqfile/results.txt, and + # run_sqlmap_targeted's POST pass writes to sqlmap_post/ — SIBLING dirs Method + # 1f never read, so sqlmap-CONFIRMED SQLi from those paths was silently dropped + # from the client report. Read all three dirs (dedup shared across them). + _sqlmap_dirs = ("sqlmap", "sqlmap_reqfile", "sqlmap_post") + if any(os.path.isdir(os.path.join(findings_dir, _d)) for _d in _sqlmap_dirs): import csv as _csv import glob as _glob from urllib.parse import urlparse as _urlparse, parse_qsl as _parse_qsl sqlmap_tmpl = VULN_TEMPLATES.get("sqli_sqlmap_confirmed", {}) seen_sqlmap = set() sqlmap_csvs = [] - primary = os.path.join(sqlmap_dir, "sqlmap_results.txt") - if os.path.isfile(primary): - sqlmap_csvs.append(primary) - sqlmap_csvs.extend(sorted(_glob.glob(os.path.join(sqlmap_dir, "results-*.csv")))) + for _sd in _sqlmap_dirs: + sqlmap_dir = os.path.join(findings_dir, _sd) + if not os.path.isdir(sqlmap_dir): + continue + # sqlmap/ uses --results-file=sqlmap_results.txt; the reqfile path uses + # results.txt. Both are the same CSV schema; non-CSV files are skipped + # by the fieldnames guard below. + for _primary_name in ("sqlmap_results.txt", "results.txt"): + primary = os.path.join(sqlmap_dir, _primary_name) + if os.path.isfile(primary): + sqlmap_csvs.append(primary) + sqlmap_csvs.extend(sorted(_glob.glob(os.path.join(sqlmap_dir, "results-*.csv")))) for csv_path in sqlmap_csvs: try: # utf-8-sig strips a BOM if present (sqlmap-on-Windows / concatenated CSVs) diff --git a/tests/test_reporter_silent_dir_drops.py b/tests/test_reporter_silent_dir_drops.py new file mode 100644 index 0000000..8f04a68 --- /dev/null +++ b/tests/test_reporter_silent_dir_drops.py @@ -0,0 +1,74 @@ +"""reporter.load_findings must ingest CONFIRMED findings that engines write into +finding subdirs the reporter previously did not map — the friends full-tool +review found real CRITICALs vanishing from the client report because their dir +was neither in SUBDIR_VTYPE nor read by a dedicated loader (only a WARNING). + + - nextjs_bypass/ : whitebox/nextjs_bypass.py writes a CONFIRMED CVE-2025-29927 + middleware auth bypass as `[CRITICAL] ... url`. + - sqlmap_reqfile/ : run_sqlmap_request_file (--request-file path) writes a + confirmed sqlmap results CSV (--results-file=results.txt). + - sqlmap_post/ : run_sqlmap_targeted's POST pass writes a confirmed sqlmap + results CSV. + +Reporter Method 1f previously read only findings/sqlmap/, so the reqfile/post +CSVs were dropped. All three must now surface at their real (critical) severity. +All test data is SYNTHETIC (example.invalid). +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import reporter # noqa: E402 + + +def _sevs(tmp_path): + return [str(f.get("severity", "")).lower() + for f in reporter.load_findings(str(tmp_path)) if isinstance(f, dict)] + + +def test_nextjs_bypass_confirmed_is_ingested(tmp_path): + d = tmp_path / "nextjs_bypass" + d.mkdir() + (d / "findings.txt").write_text( + "[CRITICAL] CVE-2025-29927: middleware bypass on /admin " + "https://t.example.invalid/admin\n") + assert "critical" in _sevs(tmp_path), ( + "confirmed Next.js middleware auth bypass (CVE-2025-29927) was dropped " + "from the report") + + +# A minimal, valid sqlmap results-file CSV (the exact columns Method 1f keys on). +_SQLMAP_CSV = ( + "Target URL,Place,Parameter,Technique(s),Note(s)\n" + "https://t.example.invalid/p?id=1,GET,id,\"boolean-based blind, UNION query\",\n" +) + + +def test_sqlmap_reqfile_confirmed_is_ingested(tmp_path): + d = tmp_path / "sqlmap_reqfile" + d.mkdir() + # run_sqlmap_request_file writes --results-file=/results.txt (a CSV). + (d / "results.txt").write_text(_SQLMAP_CSV) + assert "critical" in _sevs(tmp_path), ( + "sqlmap-confirmed SQLi from the --request-file path was dropped") + + +def test_sqlmap_post_confirmed_is_ingested(tmp_path): + d = tmp_path / "sqlmap_post" + d.mkdir() + (d / "results-t.example.invalid.csv").write_text(_SQLMAP_CSV) + assert "critical" in _sevs(tmp_path), ( + "sqlmap-confirmed SQLi from the POST path was dropped") + + +def test_unmapped_reqfile_post_do_not_warn(tmp_path, capsys): + """Once handled by Method 1f, sqlmap_reqfile/ and sqlmap_post/ must not trip + the 'subdir not in SUBDIR_VTYPE — contents IGNORED' warning.""" + for sub in ("sqlmap_reqfile", "sqlmap_post"): + (tmp_path / sub).mkdir() + (tmp_path / sub / "results.txt").write_text(_SQLMAP_CSV) + reporter.load_findings(str(tmp_path)) + warned = capsys.readouterr().out + assert "is not " not in warned or "sqlmap_reqfile" not in warned, warned + assert "sqlmap_post" not in warned or "IGNORED" not in warned, warned diff --git a/tests/test_vikramaditya_har_report_dispatch.py b/tests/test_vikramaditya_har_report_dispatch.py new file mode 100644 index 0000000..10f51ac --- /dev/null +++ b/tests/test_vikramaditya_har_report_dispatch.py @@ -0,0 +1,42 @@ +"""The HAR-VAPT report dispatch must hand reporter.py the OUTPUT DIRECTORY, not +the result JSON file. + +friends full-tool review (F13): main() called +``reporter.py /har_vapt_*.json``. reporter.py's __main__ requires a +DIRECTORY (``os.path.isdir``) and exits 1 ("Not a directory: ...") on a file — +so EVERY HAR report silently produced nothing while vikramaditya still printed +"Done". reporter Method 1c reads ``har_vapt_*.json`` from INSIDE the dir, so the +directory is the correct argument. The dispatch must also surface a non-zero +reporter exit instead of ignoring it. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import vikramaditya # noqa: E402 + + +def test_har_report_dispatch_passes_output_dir_not_result_file(monkeypatch, tmp_path): + cap = {} + + def _fake_stream(cmd, **kw): + cap["cmd"] = cmd + return 0 + + monkeypatch.setattr(vikramaditya, "_run_streaming", _fake_stream) + out_dir = str(tmp_path) + vikramaditya._dispatch_har_report(out_dir) + + assert cap["cmd"][-1] == out_dir, ( + "reporter.py must be handed the output DIRECTORY (Method 1c reads " + f"har_vapt_*.json inside it), got: {cap['cmd'][-1]}") + assert not cap["cmd"][-1].endswith(".json"), ( + "a .json FILE was passed — reporter exits 1 'Not a directory'") + assert os.path.basename(cap["cmd"][-2]) == "reporter.py" + + +def test_har_report_dispatch_returns_reporter_exit_code(monkeypatch, tmp_path): + monkeypatch.setattr(vikramaditya, "_run_streaming", lambda cmd, **kw: 1) + rc = vikramaditya._dispatch_har_report(str(tmp_path)) + assert rc == 1, "a non-zero reporter exit must be surfaced, not swallowed" diff --git a/vikramaditya.py b/vikramaditya.py index 682a522..af57331 100755 --- a/vikramaditya.py +++ b/vikramaditya.py @@ -54,6 +54,26 @@ from procutil import _fork_safe_spawn, run_capture # noqa: E402 +def _dispatch_har_report(output_dir: str) -> int: + """Generate the HTML/MD report for a HAR-VAPT run. + + reporter.py's __main__ requires a DIRECTORY and ingests the run's + ``har_vapt_*.json`` from INSIDE it via Method 1c. It must therefore be handed + ``output_dir`` — NOT the result JSON file. Passing the file made reporter exit + 1 ("Not a directory: ...") so every HAR report silently produced nothing while + the caller still printed "Done" (friends full-tool review F13). A non-zero + reporter exit is surfaced, not swallowed. Returns the reporter exit code.""" + cmd = [sys.executable, os.path.join(SCRIPT_DIR, "reporter.py"), output_dir] + # Fork-safe launch: HAR VAPT just did in-process requests I/O, so a bare + # subprocess.run() fork() here is the macOS atfork SIGSEGV class. Route through + # procutil like every other post-network dispatch (run_hunt / run_report). + rc = _run_streaming(cmd, cwd=SCRIPT_DIR) + if rc != 0: + log("warn", f"HAR report generation failed (reporter exit {rc}) — " + f"no HTML/Markdown report was written for {output_dir}") + return rc + + def _run_streaming(cmd, cwd=None, env=None) -> int: """Fork-safe replacement for ``subprocess.run(cmd)`` when output must STREAM to the parent console (no capture) — e.g. long hunt.py phases whose markers @@ -2125,12 +2145,10 @@ def _resolve_assess_creds_once() -> bool: want_report = autonomous or confirm("Generate HTML report?", default_yes=False) if want_report: try: - cmd = [sys.executable, os.path.join(SCRIPT_DIR, "reporter.py"), result_file] - # Fork-safe launch: HAR VAPT just did in-process requests I/O, - # so a bare subprocess.run() fork() here is the macOS atfork - # SIGSEGV class. Route through procutil like every other - # post-network dispatch (run_hunt / run_report). - _run_streaming(cmd, cwd=SCRIPT_DIR) + # Hand reporter.py the output DIRECTORY (it reads har_vapt_*.json + # inside it via Method 1c). Passing the result FILE made reporter + # exit 1 and produce no report — see _dispatch_har_report / F13. + _dispatch_har_report(output_dir) except Exception as e: log("warn", f"Report generation failed: {e}") print(f"\n {D}Done.{N}\n") From 99ccb1ec5289a0be6acf41494896d8b1797d35c5 Mon Sep 17 00:00:00 2001 From: Venkata Satish Date: Fri, 10 Jul 2026 14:53:39 +0530 Subject: [PATCH 3/6] fix(autopilot/reporter): kill IDOR/credential/PoC fabrications (Group C part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends full-tool review — autopilot + reporter fabrications: F12 autopilot_api_hunt.py — the AWS Access Key ID in a SigV4 presigned URL (X-Amz-Credential=AKIA...) is PUBLIC/non-secret by design (the secret signs the URL but never appears in it). Reporting it as HIGH aws_key_exposed was a false positive on any working S3 presigned-upload API. Removed the emission. F11 autopilot_api_hunt.py IDORScanner — flagged HIGH idor whenever a 200 body held any PII-named field, with NO owner baseline: an endpoint that ignores the id and returns the CALLER's own record was flagged. Now collects the PII value-set per (request shape, id) and only confirms when >=2 ids return >=2 DISTINCT records; emits once per endpoint. (A hardened cross-user tester already existed in api_idor_scanner.py but was orphaned.) F10 reporter.py — the IDOR / chained-attack / timing PoCs hard-coded fake victim PII ('Alice' / victim@example.com / 9000000000) and fake latencies (6.6s,6.1s,6.4s) under 'WHAT THE SERVER RETURNS (actual response)' / 'ACTUAL DATA LEAKED' / 'ACTUAL TIMING DATA' headers — invented data shown as real evidence in a client report. All four blocks now render the finding's REAL captured evidence (or neutral language when absent). TDD: 5 new tests (SigV4 FP, IDOR owner-baseline benign+positive, IDOR PoC uses real evidence, no fabricated victim constants in source). RED confirmed for F10 via stash. reporter/autopilot/idor set: 238 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- autopilot_api_hunt.py | 73 ++++++++++------ reporter.py | 64 ++++++++------ tests/test_autopilot_fabrication_fixes.py | 101 ++++++++++++++++++++++ tests/test_reporter_fabrication_leaks.py | 30 +++++++ 4 files changed, 215 insertions(+), 53 deletions(-) create mode 100644 tests/test_autopilot_fabrication_fixes.py diff --git a/autopilot_api_hunt.py b/autopilot_api_hunt.py index 4146793..38160a5 100644 --- a/autopilot_api_hunt.py +++ b/autopilot_api_hunt.py @@ -770,6 +770,13 @@ def run(self, session: AuthSession, endpoints: list[dict], f"were not tested for IDOR.")) for ep in idor_targets[:_IDOR_CAP]: path = ep["path"] + # F11 owner-baseline: a single 200-with-PII is NOT proof of IDOR — the + # endpoint may ignore the id and return the CALLER's OWN record for every + # value (correct behaviour). Real IDOR requires that DIFFERENT ids return + # DIFFERENT records. Collect the PII value-set per (request shape, id) and + # only confirm when >=2 distinct non-empty PII sets appear across >=2 ids + # for the SAME shape. Emit once per endpoint, not once per id. + shape_hits: dict = {} # shape_key -> {test_id: (exposed, sample, url, signature)} for test_id in [1, 2, 3, 100]: # Try both FormData and JSON (Django APIs typically use FormData) for payload in [ @@ -778,6 +785,8 @@ def run(self, session: AuthSession, endpoints: list[dict], {"data": {"id": str(test_id), "course_id": str(test_id)}}, {"data": {"learner_id": str(test_id)}}, ]: + shape_key = tuple(sorted(payload)) + tuple( + sorted(k for v in payload.values() if isinstance(v, dict) for k in v)) resp = session.request("POST", path, **payload) if resp["status"] in (200, 201) and isinstance(resp["body"], dict): body = resp["body"] @@ -790,21 +799,35 @@ def run(self, session: AuthSession, endpoints: list[dict], if not isinstance(data, dict): continue exposed = {k for k in data.keys() if k.lower() in self.PII_KEYS} - if exposed: - sample = ', '.join(f'{k}={data[k]}' for k in list(exposed)[:3] if data.get(k)) - f = {"type": "idor", "severity": HIGH, - "detail": f"IDOR: id={test_id} exposes PII ({', '.join(exposed)}) on {path}", - "url": resp["url"], - "evidence": f"id={test_id} → {sample}"} - findings.append(f) - if saver: - saver.save(f) - saver.save_txt(f) - break - if findings and findings[-1]["detail"].startswith(f"IDOR: id={test_id}"): - break # Found IDOR with this payload format - if any(f["detail"].startswith(f"IDOR: id={test_id}") for f in findings): - break # One confirmed IDOR per endpoint + if not exposed: + continue + signature = frozenset((k, str(data.get(k))) for k in exposed) + sample = ', '.join(f'{k}={data[k]}' for k in list(exposed)[:3] if data.get(k)) + shape_hits.setdefault(shape_key, {})[test_id] = ( + exposed, sample, resp["url"], signature) + # Decide per request shape: IDOR only when >=2 ids returned >=2 DISTINCT + # PII records. Identical PII across ids == the caller's own record echoed + # (benign) -> no finding. Emit at most ONE finding per endpoint (the first + # confirming shape) rather than one per payload variant. + for hits in shape_hits.values(): + distinct_sigs = {h[3] for h in hits.values() if h[3]} + if len(hits) < 2 or len(distinct_sigs) < 2: + continue + reps = sorted(hits.items()) + (id1, h1), (id2, h2) = reps[0], reps[1] + all_exposed = sorted(set().union(*[h[0] for h in hits.values()])) + f = {"type": "idor", "severity": HIGH, + "detail": (f"IDOR on {path}: different ids return different records " + f"(PII: {', '.join(all_exposed)})"), + "url": h1[2], + "evidence": (f"id={id1} → {h1[1]} | id={id2} → {h2[1]} " + f"— distinct records confirm the id selects another " + f"user's data")} + findings.append(f) + if saver: + saver.save(f) + saver.save_txt(f) + break # one confirmed IDOR per endpoint is enough log("ok", f" {len(findings)} IDOR findings") return findings @@ -942,18 +965,14 @@ def run(self, session: AuthSession, saver: FindingSaver = None) -> list[dict]: if saver: saver.save(f) saver.save_txt(f) - # Extract AWS key - url = resp["body"]["uploadUrl"] - key_match = re.search(r"Credential=([A-Z0-9]+)%2F", url) - bucket_match = re.search(r"https://([^/]+)\.s3\.", url) - if key_match: - f2 = {"type": "aws_key_exposed", "severity": HIGH, - "detail": f"AWS Access Key ID exposed: {key_match.group(1)}", - "url": url[:80], "evidence": f"Key in presigned URL"} - findings.append(f2) - if saver: - saver.save(f2) - saver.save_txt(f2) + # NOTE (friends full-tool review F12): do NOT report the + # ``X-Amz-Credential=AKIA...`` value from the presigned URL as a + # credential leak. The AWS Access Key *ID* is a PUBLIC identifier + # present in EVERY SigV4 signature by design; the secret key signs + # the URL but never appears in it. Flagging the key ID produced a + # HIGH ``aws_key_exposed`` false positive on any working S3 + # presigned-upload API. The real signal (a presigned URL minted for + # a dangerous filename/type) is already captured as ``f`` above. break # One confirmed is enough for S3 # Test profile image upload bypass diff --git a/reporter.py b/reporter.py index 888acce..3d87dcc 100644 --- a/reporter.py +++ b/reporter.py @@ -1811,25 +1811,24 @@ def _claim_unproven(_line, _out): poc_lines.append("1. Login to the application with any valid learner account") poc_lines.append(f"2. Open browser developer tools (F12) → Network tab") poc_lines.append(f"3. Send a POST request to: {url}") - poc_lines.append(f" with body: id=1 (or id=2, id=3, etc.)") + poc_lines.append(f" varying the object id (id=1, id=2, id=3, …)") poc_lines.append("") - poc_lines.append("WHAT THE SERVER RETURNS (actual response):") - poc_lines.append(' {') - poc_lines.append(' "status": true,') - poc_lines.append(' "data": {') - poc_lines.append(' "id": 2,') - poc_lines.append(' "first_name": "Alice", ← OTHER user\'s name') - poc_lines.append(' "email": "victim@example.com", ← OTHER user\'s email') - poc_lines.append(' "contact_no": "9000000000", ← OTHER user\'s phone') - poc_lines.append(' "address_line_1": "",') - poc_lines.append(' "pin_code": ""') - poc_lines.append(' }') - poc_lines.append(' }') + # friends full-tool review F10: use the REAL evidence captured by + # this scan — NEVER invent specific victim PII. The old block hard- + # coded a fake name/email/phone under a "WHAT THE SERVER RETURNS + # (actual response)" header, i.e. fabricated data presented as the + # real response in a client report. + poc_lines.append("OBSERVED (from this scan):") + if evidence: + poc_lines.append(f" {evidence}") + else: + poc_lines.append(" Different id values returned different users' " + "records (see the finding evidence).") poc_lines.append("") poc_lines.append("EXPECTED BEHAVIOR: Server should return 403 Forbidden when") poc_lines.append(" a user tries to access another user's profile.") - poc_lines.append("ACTUAL BEHAVIOR: Server returns the full profile of ANY user") - poc_lines.append(" by simply changing the 'id' parameter.") + poc_lines.append("ACTUAL BEHAVIOR: Server returns another user's record") + poc_lines.append(" by simply changing the object id parameter.") elif vtype == "score_manipulation" and url != "N/A": poc_lines.append("HOW TO REPRODUCE:") poc_lines.append("1. Login to the application as any learner") @@ -1890,10 +1889,14 @@ def _claim_unproven(_line, _out): poc_lines.append(f"4. Repeat with: email=nonexistent_fake_user@fake.com") poc_lines.append(f"5. Compare response times") poc_lines.append("") - poc_lines.append("ACTUAL RESULTS:") - poc_lines.append(" Valid email (exists): 6.6s, 6.1s, 6.4s (average ~6.4 seconds)") - poc_lines.append(" Invalid email (fake): 0.1s, 0.1s, 0.1s (average ~0.1 seconds)") - poc_lines.append(" Difference: 64x slower for valid emails!") + # friends full-tool review F10: use the real measured evidence, + # never invent specific latencies. + poc_lines.append("MEASURED RESULTS (from this scan):") + if evidence: + poc_lines.append(f" {evidence}") + else: + poc_lines.append(" Valid emails responded measurably slower than invalid " + "ones — a consistent, statistically significant gap.") poc_lines.append("") poc_lines.append("WHY THIS IS A PROBLEM:") poc_lines.append(" An attacker can check thousands of email addresses against your system.") @@ -1965,10 +1968,14 @@ def _claim_unproven(_line, _out): poc_lines.append(" - Full names, email addresses, phone numbers, addresses") poc_lines.append(" of every learner on the platform") poc_lines.append("") - poc_lines.append("ACTUAL DATA LEAKED (example for id=2):") - poc_lines.append(' "first_name": "Alice"') - poc_lines.append(' "email": "victim@example.com"') - poc_lines.append(' "contact_no": "9000000000"') + # friends full-tool review F10: use the real finding evidence, + # never invent specific victim PII. + poc_lines.append("DATA AT RISK (per the IDOR finding evidence):") + if evidence: + poc_lines.append(f" {evidence}") + else: + poc_lines.append(" Each learner's full name, email, phone and address " + "(see the IDOR finding).") poc_lines.append("") poc_lines.append("IMPACT: Complete PII breach of all users with persistent access.") poc_lines.append("") @@ -1998,9 +2005,14 @@ def _claim_unproven(_line, _out): poc_lines.append(" 3. Attacker then brute-forces the login for each valid account") poc_lines.append(" 4. No rate limit means thousands of passwords can be tried") poc_lines.append("") - poc_lines.append("ACTUAL TIMING DATA:") - poc_lines.append(" victim@example.com → 6.6s, 6.1s, 6.4s (VALID)") - poc_lines.append(" nonexistent@fake.com → 0.1s, 0.1s, 0.1s (INVALID)") + # friends full-tool review F10: use the real measured timing + # evidence, never invent specific addresses/latencies. + poc_lines.append("TIMING SIGNAL (per the timing-oracle finding evidence):") + if evidence: + poc_lines.append(f" {evidence}") + else: + poc_lines.append(" Valid emails respond measurably slower than invalid " + "ones (see the timing-oracle finding).") poc_lines.append("") poc_lines.append("IMPACT: Attacker discovers valid accounts, then brute-forces") poc_lines.append(" passwords with no resistance. Full account takeover.") diff --git a/tests/test_autopilot_fabrication_fixes.py b/tests/test_autopilot_fabrication_fixes.py new file mode 100644 index 0000000..5a23bc0 --- /dev/null +++ b/tests/test_autopilot_fabrication_fixes.py @@ -0,0 +1,101 @@ +"""autopilot_api_hunt must not fabricate credential-leak / IDOR findings. + +friends full-tool review (Group C): + F12 — the AWS Access Key ID embedded in every SigV4 presigned URL + (``X-Amz-Credential=AKIA...``) is PUBLIC and non-secret by design; the + secret key signs the URL but never appears in it. Reporting the key ID as + HIGH ``aws_key_exposed`` is a pure false positive on any working S3 + presigned-upload API. + F11 — IDOR was declared HIGH whenever a 200 body contained a PII-named field, + with NO owner baseline: an endpoint that ignores the supplied id and + always returns the CALLER's own record (correct behaviour) was flagged. + +All test data is SYNTHETIC (example.invalid / fake AKIA id). +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import autopilot_api_hunt as ah # noqa: E402 + + +class _FakeSession: + """Minimal AuthSession stand-in for FileUploadTester.""" + base_url = "https://t.example.invalid" + + def __init__(self, presigned=True): + self._presigned = presigned + + def request(self, method, path, data=None, **kw): + if "get-aws-sign-upload-video" in path and self._presigned: + return {"status": 200, "body": { + "uploadUrl": ("https://bkt.s3.amazonaws.com/test.html?" + "X-Amz-Credential=AKIAFAKE000000000000%2F20260101%2F" + "us-east-1%2Fs3%2Faws4_request&" + "X-Amz-Signature=deadbeefdeadbeef")}} + return {"status": 404, "body": {}} + + +@pytest.fixture(autouse=True) +def _no_network(monkeypatch): + # FileUploadTester's profile-image bypass calls requests.post directly. + import requests + + def _boom(*a, **k): + raise RuntimeError("network disabled in test") + + monkeypatch.setattr(requests, "post", _boom) + + +def test_sigv4_presigned_access_key_id_not_reported_as_credential(): + findings = ah.FileUploadTester().run(_FakeSession(presigned=True)) + types = [f.get("type") for f in findings] + assert "aws_key_exposed" not in types, ( + "the AWS Access Key ID in a SigV4 presigned URL is public/non-secret — " + "reporting it as a credential leak is a false positive") + + +class _ScriptedIdSession: + """AuthSession stand-in for IDORScanner: maps the requested id to a record.""" + base_url = "https://t.example.invalid" + + def __init__(self, record_fn): + self._record_fn = record_fn + + def request(self, method, path, data=None, json_body=None, **kw): + payload = data or json_body or {} + rid = str(payload.get("id") or payload.get("learner_id") or "") + body = self._record_fn(rid) + if body is None: + return {"status": 404, "body": {}, "url": path, "method": method} + return {"status": 200, "body": body, + "url": f"{self.base_url}/{path}?id={rid}", "method": method} + + +_IDOR_EPS = [{"path": "view-profile", "method": "POST"}] + + +def test_idor_endpoint_ignoring_id_returns_own_record_is_benign(): + # Server ignores the id and always returns the CALLER's own record. PII is + # present and 200 OK, but it is NOT IDOR — must not fire (F11 owner-baseline). + sess = _ScriptedIdSession(lambda rid: { + "data": {"id": "self-101", "email": "caller@example.invalid", + "first_name": "Caller"}}) + findings = ah.IDORScanner().run(sess, _IDOR_EPS) + assert not any(f.get("type") == "idor" for f in findings), ( + "an endpoint that echoes the caller's own record for every id is not IDOR") + + +def test_idor_distinct_record_per_id_fires(): + # Server honors the id and returns a DIFFERENT user's record per id → genuine + # IDOR. Must fire (exactly once for the endpoint, not once per id). + sess = _ScriptedIdSession(lambda rid: { + "data": {"id": rid, "email": f"user{rid}@example.invalid", + "first_name": f"User{rid}"}} if rid else None) + findings = ah.IDORScanner().run(sess, _IDOR_EPS) + idor = [f for f in findings if f.get("type") == "idor"] + assert idor, "distinct PII records across ids is a genuine IDOR and must fire" + assert len(idor) == 1, f"IDOR must be reported once per endpoint, got {len(idor)}" diff --git a/tests/test_reporter_fabrication_leaks.py b/tests/test_reporter_fabrication_leaks.py index fa998fb..7b6e08a 100644 --- a/tests/test_reporter_fabrication_leaks.py +++ b/tests/test_reporter_fabrication_leaks.py @@ -122,6 +122,36 @@ def test_verified_markers_are_still_reported(tmp_path): f"a VERIFIED marker was over-suppressed (dropped from report): {relpath} :: {line[:50]}") +def test_idor_poc_uses_real_evidence_not_invented_pii(tmp_path): + """friends full-tool review F10: the autopilot IDOR PoC hard-coded + "Alice"/"victim@example.com"/"9000000000" under a "WHAT THE SERVER RETURNS + (actual response)" header — invented PII presented as the real server + response in a client report. The PoC must render the finding's REAL captured + evidence instead.""" + import json as _json + (tmp_path / "finding_001.json").write_text(_json.dumps({ + "type": "idor", "severity": "high", + "url": "https://t.example.invalid/view-profile", + "detail": "IDOR on view-profile", + "evidence": "id=1 -> email=real1@corp.invalid | id=2 -> email=real2@corp.invalid", + })) + pocs = " ".join(str(f.get("poc", "")) for f in reporter.load_findings(str(tmp_path))) + for invented in ("victim@example.com", "Alice", "9000000000"): + assert invented not in pocs, f"IDOR PoC fabricates victim PII: {invented!r}" + assert "real1@corp.invalid" in pocs or "id=1" in pocs, ( + "the PoC must use the finding's real captured evidence") + + +def test_reporter_ships_no_fabricated_victim_constants(): + """No PoC/narrative block may hard-code specific victim PII or timing values + (F10). These were presented as 'ACTUAL DATA LEAKED' / 'ACTUAL TIMING DATA'.""" + src = open(reporter.__file__).read() + for bad in ('victim@example.com', '"first_name": "Alice"', '"email": "victim', + '9000000000', '6.6s, 6.1s, 6.4s'): + assert bad not in src, ( + f"fabricated victim constant still hard-coded in reporter.py: {bad!r}") + + def test_auth_bypass_template_not_shadowed(): """The auth_bypass template was defined TWICE: a dead ``high/8.1`` "Authentication Bypass on {host}" literal, silently overwritten by a From 6653d651a7082617cd3cff61640ce99159b6ebf9 Mon Sep 17 00:00:00 2001 From: Venkata Satish Date: Fri, 10 Jul 2026 14:58:29 +0530 Subject: [PATCH 4/6] fix(har): confirm SSTI by evaluation, not the '49 in body' substring (F4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends full-tool review F4 — har_vapt_engine declared CRITICAL SSTI whenever the response contained the substring '49' after injecting {{7*7}}, with no baseline, no distinctive canary and no reflection guard. Any page containing 49 (a price, an id, '49 results') became a fabricated CRITICAL. New _probe_ssti: injects a DISTINCTIVE arithmetic canary (91193*90007), confirms the product is ABSENT from a baseline (un-injected) response, and requires the raw expression NOT to be reflected verbatim (reflection != evaluation). Fires once per param for both {{...}} and ${...} wrappers. TDD: 2 tests (reflection+incidental-49 is benign; real evaluation fires). HAR set: 124 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- har_vapt_engine.py | 72 +++++++++++++++++++++++++------ tests/test_har_ssti_baseline.py | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 14 deletions(-) create mode 100644 tests/test_har_ssti_baseline.py diff --git a/har_vapt_engine.py b/har_vapt_engine.py index 3c3fe9d..a0c4f62 100644 --- a/har_vapt_engine.py +++ b/har_vapt_engine.py @@ -396,26 +396,70 @@ def test_xss(self) -> Dict: evidence=ctx, param=param, payload=payload) results['vulnerable'].append(url) break - # SSTI - if payload == '{{7*7}}' and '49' in body: - self._log('critical', 'SSTI', url, - f"Template expression evaluated in param '{param}'", - param=param, payload=payload) - results['vulnerable'].append(url) - break - if payload == '${7*7}' and '49' in body: - self._log('critical', 'SSTI (EL)', url, - f"EL expression evaluated in param '{param}'", - param=param, payload=payload) - results['vulnerable'].append(url) - break except Exception: pass + # SSTI is confirmed by a dedicated evaluation probe (NOT the '49 in + # body' substring heuristic, which fired on any page containing 49 — + # friends full-tool review F4). Run once per param. + if self._probe_ssti(url, method, base_params, param): + results['vulnerable'].append(url) results['tested'] += 1 - self.test_results['xss'] = results return results + def _probe_ssti(self, url: str, method: str, base_params: dict, param: str) -> bool: + """Confirm server-side template / EL injection by EVALUATION, not substring. + + friends full-tool review F4: the old check flagged CRITICAL SSTI whenever + ``49`` appeared in the body after injecting ``{{7*7}}`` — so any page with + a price/id/"49 results" was a fabricated CRITICAL. This probe instead: + 1. uses a DISTINCTIVE arithmetic canary whose product is improbable in + normal content (coincidental substring match is negligible); + 2. confirms the product is ABSENT from a baseline (un-injected) response, + so dynamic content that already contains the number can't false-fire; + 3. requires the raw expression NOT to be reflected verbatim — a template + that is echoed unevaluated is reflection, not SSTI. + Returns True and logs a CRITICAL finding on confirmation. + """ + base = url.split('?')[0] + # Distinctive operands (computed, so no manual-arithmetic risk). + a, b = 91193, 90007 + product = str(a * b) + + def _fetch(value): + params = {**base_params, param: value} + try: + if method == 'POST': + r = self.session.post(base, data=params, timeout=15) + else: + r = self.session.get(base, params=params, timeout=15) + return r.text or "" + except Exception: + return None + + baseline = _fetch("vapt_ssti_baseline") + if baseline is None or product in baseline: + # Request failed, or the number already appears un-injected — cannot + # attribute a later match to evaluation. Do NOT fire (anti-fabrication). + return False + + for expr_tmpl, label in (("{{%d*%d}}", "SSTI"), ("${%d*%d}", "SSTI (EL)")): + expr = expr_tmpl % (a, b) + body = _fetch(expr) + if body is None: + continue + # Evaluated: product present, AND neither the wrapped expression nor the + # bare "a*b" echoed back (those would be reflection, not evaluation). + if product in body and expr not in body and f"{a}*{b}" not in body: + idx = body.find(product) + self._log('critical', label, url, + f"Template expression evaluated in param '{param}' " + f"({a}*{b} rendered as {product})", + evidence=body[max(0, idx - 40):idx + len(product) + 40], + param=param, payload=expr) + return True + return False + # ── Command Injection ───────────────────────────────────────────────── def test_command_injection(self) -> Dict: diff --git a/tests/test_har_ssti_baseline.py b/tests/test_har_ssti_baseline.py new file mode 100644 index 0000000..a4175b2 --- /dev/null +++ b/tests/test_har_ssti_baseline.py @@ -0,0 +1,76 @@ +"""har_vapt_engine SSTI detection must require EVALUATION, not a bare substring. + +friends full-tool review F4: SSTI was declared CRITICAL whenever the response +contained the substring ``49`` after injecting ``{{7*7}}`` — no baseline, no +distinctive canary, no reflection guard. Any page with ``49`` in it (a price, an +id, "49 results") was a fabricated CRITICAL. The probe must use a distinctive +arithmetic canary, confirm the product is ABSENT from a baseline response, and +require the raw expression NOT to be reflected verbatim. + +All test data is SYNTHETIC (example.invalid). +""" +import os +import re +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import har_vapt_engine as hve # noqa: E402 + + +class _Resp: + def __init__(self, text): + self.text = text + self.status_code = 200 + + +class _ReflectSession: + """Echoes the injected value verbatim (reflection) and always contains '49' + naturally. A correct probe must NOT flag this as SSTI.""" + def _render(self, params): + return "search: 49 results found — " + " ".join(str(v) for v in (params or {}).values()) + + def post(self, url, data=None, timeout=None, **k): + return _Resp(self._render(data)) + + def get(self, url, params=None, timeout=None, **k): + return _Resp(self._render(params)) + + +class _EvalSession: + """Actually evaluates a ``{{a*b}}`` / ``${a*b}`` template to its product.""" + _rx = re.compile(r'^\$?\{\{?(\d+)\s*\*\s*(\d+)\}?\}$') + + def _render(self, params): + out = ["page"] + for v in (params or {}).values(): + m = self._rx.match(str(v)) + out.append(str(int(m.group(1)) * int(m.group(2))) if m else str(v)) + return " ".join(out) + + def post(self, url, data=None, timeout=None, **k): + return _Resp(self._render(data)) + + def get(self, url, params=None, timeout=None, **k): + return _Resp(self._render(params)) + + +def _engine(session): + eng = hve.HARVAPTEngine({"config": {"target_domain": "t.example.invalid"}}) + eng.session = session + return eng + + +def test_ssti_reflection_with_incidental_49_not_flagged(): + eng = _engine(_ReflectSession()) + eng._probe_ssti("https://t.example.invalid/x", "GET", {}, "q") + assert not any(str(v["type"]).startswith("SSTI") for v in eng.vulnerabilities), ( + "a reflected (un-evaluated) template on a page that merely contains '49' " + "must not be flagged as SSTI") + + +def test_ssti_real_evaluation_is_flagged(): + eng = _engine(_EvalSession()) + eng._probe_ssti("https://t.example.invalid/x", "GET", {}, "q") + assert any(str(v["type"]).startswith("SSTI") for v in eng.vulnerabilities), ( + "a genuinely evaluated template expression must be flagged as SSTI") From 05ba1ba090a6b5689609d28c3c666f8b43cd7010 Mon Sep 17 00:00:00 2001 From: Venkata Satish Date: Fri, 10 Jul 2026 15:04:34 +0530 Subject: [PATCH 5/6] fix(auth_utils/ldap): close two silent coverage gaps (F14, F8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends full-tool review — coverage gaps: F14 auth_utils.AuthSession.request — collapsed EVERY exception (TLS-verify failure, timeout, conn reset) to {'status': 0}. Downstream 'status in (200,201)' checks treat that like a benign non-hit, so a systematic transport failure (bad/expired bearer, proxy outage) completed the scan with 0 findings and NO coverage-loss signal. Now returns transport_error= True + error, counts self.transport_errors/total_requests, and logs a stderr WARNING at the 1st/5th/25th/every-100th failure. F8 ldap_injection_tester.looks_like_ldap_backed_auth — intersected cve.detect_technologies names (php/iis/tomcat) with a marker set (active-directory/adfs/spring-security/...) those names never contain, so the LDAP phase ALWAYS skipped — an AD/ADFS login on IIS read clean. The gate now also fires on enterprise SSO/ADFS/CAS login URL paths and on NTLM/Negotiate/Kerberos WWW-Authenticate challenges; hunt.py feeds it the crawled recon URLs. TDD: 6 tests (transport-error marked+counted vs real 404; gate fires on tech tag / ADFS-SSO URL / NTLM, still skips a plain PHP app). ldap+auth_utils sets green. Co-Authored-By: Claude Opus 4.8 (1M context) --- auth_utils.py | 21 +++++++- hunt.py | 25 ++++++++- ldap_injection_tester.py | 37 +++++++++++-- tests/test_auth_utils_transport_error.py | 66 ++++++++++++++++++++++++ tests/test_ldap_gate_broadened.py | 40 ++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 tests/test_auth_utils_transport_error.py create mode 100644 tests/test_ldap_gate_broadened.py diff --git a/auth_utils.py b/auth_utils.py index de7f779..d55aefb 100644 --- a/auth_utils.py +++ b/auth_utils.py @@ -13,6 +13,7 @@ import json import os import struct +import sys import tempfile import threading import time @@ -258,6 +259,13 @@ def __init__(self, base_url: str, rate_limiter: RateLimiter = None): self._creds = None self._login_url = None self.token = None + # friends full-tool review F14: count transport failures (TLS/timeout/conn + # reset) separately from real HTTP responses. request() used to collapse + # every exception to status:0, which downstream 'status in (200,201)' checks + # treat as a benign non-hit — so a systematic transport failure produced a + # clean-looking 0-finding run with no coverage-loss signal. + self.transport_errors = 0 + self.total_requests = 0 # Cookie name carrying the auth token for explicit-token requests. Defaults to # 'cf_at' but is overwritten in auto_login() with whichever cookie the server # actually set the JWT in (cf_at/access_token/jwt/token/session), so cookie-only @@ -568,6 +576,7 @@ def request(self, method: str, path: str, token: str = None, body = resp.json() except Exception: body = resp.text[:2000] + self.total_requests += 1 return { "status": resp.status_code, "headers": dict(resp.headers), @@ -576,7 +585,17 @@ def request(self, method: str, path: str, token: str = None, "method": method, } except Exception as e: - return {"status": 0, "headers": {}, "body": str(e), "url": url, "method": method} + # F14: a transport failure is NOT an absent endpoint. Mark it distinctly + # and count it so a systematic failure (bad token, TLS, proxy outage) + # surfaces as lost coverage instead of a benign 0-finding run. + self.total_requests += 1 + self.transport_errors += 1 + if self.transport_errors in (1, 5, 25) or self.transport_errors % 100 == 0: + print(f"[auth_utils] WARNING: transport failure #{self.transport_errors} " + f"({type(e).__name__}) on {method} {url} — these are COVERAGE LOSS, " + f"not clean non-hits: {str(e)[:120]}", file=sys.stderr) + return {"status": 0, "headers": {}, "body": str(e), "url": url, + "method": method, "transport_error": True, "error": str(e)} class FindingSaver: diff --git a/hunt.py b/hunt.py index 52dd844..db8206d 100644 --- a/hunt.py +++ b/hunt.py @@ -8408,8 +8408,29 @@ def run_ldap_injection(domain: str) -> bool: recon_dir = _resolve_recon_dir(domain) techs = cve_module.detect_technologies(domain, recon_dir=recon_dir) fingerprint_tags = {name.lower() for name in techs.keys()} - if not ldap_injection_tester.looks_like_ldap_backed_auth(fingerprint_tags): - log("info", "LDAP injection: stack fingerprint does not suggest LDAP-backed auth — skipping") + # friends full-tool review F8: tech tags alone never carry the LDAP markers, so + # also feed the gate the crawled URLs — an ADFS/SSO/CAS login path is the signal + # a blackbox scan actually observes for AD/LDAP-backed auth. + ldap_urls = [] + try: + urls_dir = os.path.join(recon_dir, "urls") + if os.path.isdir(urls_dir): + for _fn in os.listdir(urls_dir): + if not _fn.endswith(".txt"): + continue + with open(os.path.join(urls_dir, _fn), errors="replace") as _uf: + for _line in _uf: + _u = _line.strip() + if _u.startswith("http"): + ldap_urls.append(_u) + if len(ldap_urls) >= 5000: + break + if len(ldap_urls) >= 5000: + break + except OSError: + pass + if not ldap_injection_tester.looks_like_ldap_backed_auth(fingerprint_tags, urls=ldap_urls): + log("info", "LDAP injection: no LDAP/AD signal (stack fingerprint, SSO/ADFS URL, or NTLM) — skipping") _brain_phase_complete("LDAP INJECTION", True, detail=f"target={domain} skipped: stack fingerprint does not suggest LDAP-backed auth") return True diff --git a/ldap_injection_tester.py b/ldap_injection_tester.py index 0e50bd3..eb86a7a 100644 --- a/ldap_injection_tester.py +++ b/ldap_injection_tester.py @@ -19,9 +19,40 @@ "samba-ad", "389-ds", } - -def looks_like_ldap_backed_auth(fingerprint_tags: set[str]) -> bool: - return bool(fingerprint_tags & _LDAP_STACK_MARKERS) +# friends full-tool review F8: tech-fingerprint names alone never carry the +# markers above, so the gate always skipped. These are the LDAP/AD signals a +# blackbox scan actually observes — enterprise SSO/ADFS/CAS login URL paths, and +# NTLM/Negotiate/Kerberos WWW-Authenticate challenges (the classic AD tell). +_LDAP_URL_PATTERNS = ( + "/adfs", "/sso", "/cas/login", "/cas/", "/simplesaml", "/openam", "/nidp", + "/oam/", "/siteminder", "/ldap", "/openidm", "/nds", +) +_NTLM_AUTH_MARKERS = ("negotiate", "ntlm", "kerberos") + + +def looks_like_ldap_backed_auth(fingerprint_tags: set[str], urls=None, + www_authenticate=None) -> bool: + """True when the target's auth is plausibly LDAP/AD-backed. + + Signals (any one suffices): + - a tech tag in _LDAP_STACK_MARKERS; + - an enterprise SSO/ADFS/CAS login URL path (F8 — the common blackbox tell); + - an NTLM/Negotiate/Kerberos WWW-Authenticate challenge (AD-integrated auth). + """ + if fingerprint_tags & _LDAP_STACK_MARKERS: + return True + if urls: + blob = " ".join(str(u).lower() for u in urls) + if any(p in blob for p in _LDAP_URL_PATTERNS): + return True + if www_authenticate: + if isinstance(www_authenticate, (list, set, tuple)): + vals = " ".join(str(v).lower() for v in www_authenticate) + else: + vals = str(www_authenticate).lower() + if any(m in vals for m in _NTLM_AUTH_MARKERS): + return True + return False def build_rfc4515_fuzz_payloads() -> list[str]: diff --git a/tests/test_auth_utils_transport_error.py b/tests/test_auth_utils_transport_error.py new file mode 100644 index 0000000..156a646 --- /dev/null +++ b/tests/test_auth_utils_transport_error.py @@ -0,0 +1,66 @@ +"""AuthSession.request must distinguish a TRANSPORT failure from an absent +endpoint. + +friends full-tool review F14: request() collapsed every exception (TLS-verify +failure, timeout, connection reset) to ``{"status": 0}``. Consumers test +``status in (200, 201)``, so a systematic transport failure (e.g. a bad/expired +bearer, a proxy outage) made every request look like a benign non-hit and the +scan completed with 0 findings and NO signal that coverage was lost. A transport +error must be marked distinctly and counted so the lost coverage is visible. +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import auth_utils # noqa: E402 + + +class _Boom: + headers: dict = {} + cookies: dict = {} + + def request(self, *a, **k): + raise RuntimeError("TLSError: certificate verify failed") + + +class _Resp404: + status_code = 404 + headers: dict = {} + text = "not found" + + def json(self): + raise ValueError("no json") + + +class _NotFound: + headers: dict = {} + cookies: dict = {} + + def request(self, *a, **k): + return _Resp404() + + +def _session(fake): + s = auth_utils.AuthSession("https://t.example.invalid") + s._session = fake + s._limiter.wait = lambda: None # no rate-limit sleep in tests + return s + + +def test_transport_error_is_marked_and_counted(): + s = _session(_Boom()) + r = s.request("GET", "/x") + assert r["status"] == 0 + assert r.get("transport_error") is True, ( + "a transport failure must be distinguishable from an absent endpoint") + assert s.transport_errors >= 1, "transport failures must be counted on the session" + + +def test_real_404_is_not_a_transport_error(): + s = _session(_NotFound()) + r = s.request("GET", "/x") + assert r["status"] == 404 + assert not r.get("transport_error"), ( + "a real HTTP 404 is an absent endpoint, NOT a transport/coverage failure") + assert s.transport_errors == 0 diff --git a/tests/test_ldap_gate_broadened.py b/tests/test_ldap_gate_broadened.py new file mode 100644 index 0000000..ce5f2e8 --- /dev/null +++ b/tests/test_ldap_gate_broadened.py @@ -0,0 +1,40 @@ +"""The LDAP-injection gate must fire on real blackbox AD/LDAP signals, not only +on tech-fingerprint tags that are never actually emitted. + +friends full-tool review F8: looks_like_ldap_backed_auth intersected the tech +names from cve.detect_technologies (php/iis/tomcat/...) with a marker set +(active-directory/adfs/spring-security/...) that detect_technologies NEVER +produces — so the phase always skipped, even on an obvious AD/ADFS login. The +gate must also recognise ADFS/SSO login URL paths and NTLM/Negotiate/Kerberos +WWW-Authenticate challenges (the signals a blackbox scan actually observes). +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ldap_injection_tester import looks_like_ldap_backed_auth # noqa: E402 + + +def test_fires_on_explicit_tech_marker(): + assert looks_like_ldap_backed_auth({"active-directory"}) + assert looks_like_ldap_backed_auth({"spring-security"}) + + +def test_fires_on_adfs_or_sso_login_url(): + assert looks_like_ldap_backed_auth( + {"iis", "asp.net"}, urls=["https://t.example.invalid/adfs/ls/?wa=wsignin1.0"]) + assert looks_like_ldap_backed_auth( + {"iis"}, urls=["https://t.example.invalid/simplesaml/module.php"]) + + +def test_fires_on_ntlm_or_negotiate_challenge(): + assert looks_like_ldap_backed_auth({"iis"}, www_authenticate=["Negotiate", "NTLM"]) + assert looks_like_ldap_backed_auth({"tomcat"}, www_authenticate="Kerberos") + + +def test_still_skips_plain_php_app(): + assert not looks_like_ldap_backed_auth( + {"php", "nginx"}, + urls=["https://t.example.invalid/login", "https://t.example.invalid/api/users"], + www_authenticate=["Basic realm=api"]) From 74ebb72d8c0b54ef6473e2696010a48260c5458e Mon Sep 17 00:00:00 2001 From: Venkata Satish Date: Fri, 10 Jul 2026 15:10:44 +0530 Subject: [PATCH 6/6] fix(brain/jwt): claim-relevant grounding + wire JWT replay confirmation (F9, F7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Friends full-tool review — last two Group C findings: F9 brain_scanner._verdict_findings — a verdict was tagged [VERIFIED — grounded run] whenever ANY script produced output (grounded = successful_runs > 0), so a script printing only 'Server: nginx/1.24' grounded an unrelated 'SQL injection CONFIRMED' verdict, which the reporter KEPT as a verified critical. Now accumulates the grounded stdout and DOWNGRADES to [MODEL CLAIM] when that output is purely passive fingerprint/banner data (no exploitation evidence). Conservative: substantive-but-unclassified output is never downgraded (no regression), and omitting grounded_output preserves legacy behaviour for existing callers. F7 jwt_kid_injection.confirm_replay — implemented + tested but NEVER called, so a genuinely accepted RS256->HS256 / kid forgery produced no finding. New confirm_replay_any bounds the replay over candidate endpoints and fails closed; run_jwt_audit now replays the forged token against recon API endpoints and writes [JWT-KEY-CONFUSION-CONFIRMED] (a real critical) ONLY on confirmed acceptance — otherwise it stays a manual-review lead. TDD: 8 tests (grounding passive-banner downgrade / real-evidence keep / legacy / ungrounded; confirm_replay_any first-confirmed / none / skips-error / cap-bounded). brain_scanner + jwt sets green. Co-Authored-By: Claude Opus 4.8 (1M context) --- brain_scanner.py | 68 ++++++++++++++++++++++--- hunt.py | 30 ++++++++++- jwt_kid_injection.py | 23 +++++++++ tests/test_brain_grounding_relevance.py | 45 ++++++++++++++++ tests/test_jwt_confirm_replay_wiring.py | 57 +++++++++++++++++++++ 5 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 tests/test_brain_grounding_relevance.py create mode 100644 tests/test_jwt_confirm_replay_wiring.py diff --git a/brain_scanner.py b/brain_scanner.py index fe13868..89de59f 100644 --- a/brain_scanner.py +++ b/brain_scanner.py @@ -441,14 +441,59 @@ def _is_usage_banner(stdout: str) -> bool: return True -def _verdict_findings(response: str, grounded: bool) -> list: +# friends full-tool review F9: signals that a grounded stdout actually PROVES +# exploitation (command output, extracted data, SQL/EL errors, file content, +# reflected markers) — not merely a passive server banner/version string. +_EXPLOIT_EVIDENCE_RE = re.compile( + r"uid=\d|gid=\d|groups=\d|root:.{0,3}:0:0:|/bin/(ba|z)?sh\b|" # command exec / /etc/passwd + r"SQL syntax|ORA-\d{4,5}|SQLSTATE|PSQLException|" # SQL errors + r"syntax error at or near|Unclosed quotation|mysql_fetch|" # SQL errors + r"-----BEGIN [A-Z]|<\?php|BEGIN RSA|ssh-rsa AAAA|" # file/key content + r"HTTP/\d\.\d 500|Traceback \(most recent call last\)|" # server error / stack + r"onerror=|