From 51c3f84e50f854a47d4fe790b941ab75dc9591fa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 12:13:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?refine:=206=20improvements=20=E2=80=94=20va?= =?UTF-8?q?lidators,=20confidence=20formula,=20WAF=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs fixed: - validate_xss: script context check now counts open/closed tag - EvidenceChain.confidence_pct: new tiered formula (TENTATIVE 10-39%, FIRM 50-79%, CONFIRMED 85-99%) — old flat formula showed CONFIRMED as 80% - validate_ssrf: added HTTP 307/308 as valid status codes for SSRF detection New validators: - validate_graphql: introspection detection, error verbosity analysis, and query injection detection (Did you mean / Cannot query field patterns handle raw JSON with escaped quotes via \W+ matching) - validate_race_condition: TOCTOU detection via parallel response analysis, scales weight with extra successes, detects identical bodies Integration: - scan_directories: first tool to use rate_limiter — WAF probe before fuzzing, adaptive thread count (10 → 2 on WAF, 10 → 3 on block) with ffuf -p delay flag when WAF detected Tests: +41 new tests (613 total, all passing) https://claude.ai/code/session_01QrWLmqz6sz4tMVvDWHJqrU --- src/kambo/models.py | 20 +- src/kambo/tools/scanning.py | 45 +++- src/kambo/validation.py | 227 +++++++++++++++++- tests/test_models.py | 7 +- tests/test_refine_improvements.py | 379 ++++++++++++++++++++++++++++++ 5 files changed, 667 insertions(+), 11 deletions(-) create mode 100644 tests/test_refine_improvements.py diff --git a/src/kambo/models.py b/src/kambo/models.py index 7d68b8d..506db4f 100644 --- a/src/kambo/models.py +++ b/src/kambo/models.py @@ -86,8 +86,24 @@ def confidence(self) -> Confidence: @property def confidence_pct(self) -> int: - """0-100 percentage representing confidence strength.""" - return min(100, int(self.total_weight * 40)) + """0-100 percentage representing confidence strength within tier. + + Mapping: + TENTATIVE (weight 0.0–0.99): 10–39% — single signal, high FP risk + FIRM (weight 1.0–1.99): 50–79% — multiple signals, reportable with caveats + CONFIRMED (weight 2.0+): 85–99% — exploit-grade, ready to submit + """ + w = self.total_weight + if w >= 2.0: + # Confirmed tier: starts at 85, caps at 99 + return min(99, 85 + int((w - 2.0) * 7)) + if w >= 1.0: + # Firm tier: 50–79, scales within the 1.0–2.0 range + return 50 + int((w - 1.0) * 29) + # Tentative tier: 10–39 (0.0 weight = 0% — no evidence at all) + if w == 0: + return 0 + return max(10, min(39, 10 + int(w * 29))) def add(self, signal: str, source: str, raw_data: str = "", weight: float = 1.0) -> EvidenceChain: """Return a new chain with the evidence item appended (immutable).""" diff --git a/src/kambo/tools/scanning.py b/src/kambo/tools/scanning.py index b9565b3..f941ca9 100644 --- a/src/kambo/tools/scanning.py +++ b/src/kambo/tools/scanning.py @@ -6,6 +6,7 @@ from kambo.models import Phase from kambo.parsers import parse_ffuf, parse_nmap from kambo.parsers.generic_parser import parse_json_output +from kambo.rate_limiter import get_rate_limiter from kambo.scope import validate_scope @@ -90,6 +91,10 @@ async def scan_directories( ) -> dict: """Web directory and file fuzzing with ffuf. + Integrates WAF detection and adaptive rate limiting — if a WAF is detected + from a probe response, the scan rate is automatically reduced to avoid + triggering blocks. + Args: target: Base URL to fuzz (e.g., https://example.com) wordlist: Path to wordlist inside container @@ -98,15 +103,51 @@ async def scan_directories( """ validate_scope(target) runner = get_runner() + limiter = get_rate_limiter() url = target if target.startswith("http") else f"https://{target}" + + # WAF probe: check a known-good path before fuzzing at full speed + probe_cmd = f'curl -s -D - "{url}/" 2>/dev/null | head -30' + probe_result = await runner.run(probe_cmd, "scan_directories_waf_probe", target, Phase.SCANNING, timeout=10) + + # Record the probe response through the rate limiter to detect WAF/blocking + probe_analysis = limiter.record_request( + target, + response_body=probe_result.raw_output, + headers=probe_result.raw_output[:500], # headers are in the first portion + ) + + # Adapt threading based on WAF detection + threads = 10 + extra_flags = "" + waf_name = probe_analysis.get("waf_detected") or "" + if waf_name: + threads = 2 + extra_flags = "-p 0.5" # 500ms delay between requests — be polite to WAF + elif probe_analysis.get("is_blocked"): + threads = 3 + extra_flags = "-p 0.2" + ext_flag = f"-e {extensions}" if extensions else "" - cmd = f"ffuf -u {url}/FUZZ -w {wordlist} -mc {match_codes} {ext_flag} -o /tmp/ffuf_out.json -of json -s 2>/dev/null && cat /tmp/ffuf_out.json" + cmd = ( + f"ffuf -u {url}/FUZZ -w {wordlist} -mc {match_codes} " + f"{ext_flag} -t {threads} {extra_flags} " + f"-o /tmp/ffuf_out.json -of json -s 2>/dev/null && cat /tmp/ffuf_out.json" + ) result = await runner.run(cmd, "scan_directories", target, Phase.SCANNING, timeout=120) parsed = parse_ffuf(result.raw_output) - return {"target": target, "scan_type": "directory_fuzz", **parsed} + return { + "target": target, + "scan_type": "directory_fuzz", + "waf_detected": bool(waf_name), + "waf_name": waf_name, + "rate_adapted": threads < 10, + "evasion_tips": probe_analysis.get("evasion_tips", []), + **parsed, + } async def scan_vhosts( diff --git a/src/kambo/validation.py b/src/kambo/validation.py index 06cc21f..9338607 100644 --- a/src/kambo/validation.py +++ b/src/kambo/validation.py @@ -213,8 +213,18 @@ def validate_xss( if payload_idx < 0: return chain # Payload was encoded after earlier check passed before_payload = raw_output[:payload_idx] - in_script = " tags — a closed tag means the payload is NOT inside it + script_opens = len(re.findall(r"", window, re.IGNORECASE)) + in_script = script_opens > script_closes + + # Comment detection: check if most recent + comment_window = before_payload[max(0, len(before_payload) - 200):] + last_open = comment_window.rfind("") + in_comment = last_open > last_close if in_script: chain = chain.add( @@ -342,10 +352,12 @@ def validate_ssrf( if re.search(r"(invalid url|bad request|url not allowed|blocked)", body_lower): chain = chain.add_fp_check("Server rejected the URL — input validation in place") - # Status code check (weak signal alone) - if status_code in ("200", "301", "302"): + # Status code check (weak signal alone) — 307/308 are permanent/temporary redirects + # that can indicate the server is fetching and forwarding the internal resource + if status_code in ("200", "301", "302", "307", "308"): + redirect_note = " (redirect — may indicate server-side fetch)" if status_code in ("301", "302", "307", "308") else "" chain = chain.add( - signal=f"HTTP {status_code} returned for internal payload: {payload[:100]}", + signal=f"HTTP {status_code} returned for internal payload: {payload[:100]}{redirect_note}", source="ssrf_probe", weight=0.2, ) @@ -1478,3 +1490,208 @@ def validate_deserialization( ) return chain + + +# --------------------------------------------------------------------------- +# validate_graphql — GraphQL Introspection & Injection +# --------------------------------------------------------------------------- + +_GRAPHQL_INTROSPECTION_SIGNATURES: list[str] = [ + r'"__schema"\s*:', # introspection response + r'"__types"\s*:', # type listing + r'"queryType"\s*:.*"name"', # schema root types + r'"mutationType"\s*:', # mutation support exposed + r'"directives"\s*:\s*\[', # directives listing +] + +_GRAPHQL_ERROR_SIGNATURES: list[tuple[str, str, float]] = [ + (r'"errors"\s*:.*"extensions"\s*:.*"code"', "GraphQL structured error with code — endpoint confirmed", 0.4), + # Use \W+ to handle both plain quotes and JSON-escaped \" sequences in raw HTTP bodies + (r"Cannot query field\W+(\w+)\W+on type", "Field-level error leaks schema info", 0.6), + (r"Unknown argument\W+(\w+)", "Argument error reveals accepted fields", 0.5), + (r"Variable.*must not be null", "Null variable error leaks input type", 0.3), + (r"Did you mean\W+(\w+)", "Suggestion error fully leaks field names", 0.8), +] + +_GRAPHQL_INJECTION_SIGNATURES: list[tuple[str, str, float]] = [ + (r"syntax error.*unexpected", "GraphQL syntax error from injected chars", 0.3), + (r"Expected.*got.*", "Incomplete query — injection point confirmed", 0.5), + (r"(sql|mongo|redis).*error", "Database error via GraphQL injection", 1.2), + (r"Internal\s+Server\s+Error.*graphql", "Internal error exposed via GraphQL", 0.6), +] + + +def validate_graphql( + output: str, + introspection_response: str = "", + error_response: str = "", + injection_response: str = "", + baseline_body: str = "", +) -> EvidenceChain: + """Validate GraphQL security issues: introspection enabled, info disclosure, injection. + + Three separate attack vectors: + 1. Introspection enabled — leaks full schema, used for targeted attacks + 2. Error verbosity — field names, types, and suggestions leak from errors + 3. Query injection — injected chars cause parse/DB errors + + Args: + output: General tool output (combines all responses if not split) + introspection_response: Response to an __schema introspection query + error_response: Response to an intentionally malformed query + injection_response: Response to an injection payload (e.g., ' OR 1=1) + baseline_body: Normal query response for comparison + """ + chain = EvidenceChain() + + # Combine all available response data + all_output = "\n".join(filter(None, [output, introspection_response, error_response, injection_response])) + + if not all_output.strip(): + return chain.add_fp_check("No GraphQL response data — endpoint may not exist") + + # FP check: not a GraphQL endpoint + if re.search(r"(404 not found|cannot (get|post)|method not allowed)", all_output, re.IGNORECASE): + if not re.search(r'"data"\s*:|"errors"\s*:', all_output): + return chain.add_fp_check("No GraphQL response structure — likely not a GraphQL endpoint") + + # FP check: introspection explicitly disabled + if re.search(r"(introspection.*disabled|introspection.*not.*allowed|GraphQL introspection is not allowed)", all_output, re.IGNORECASE): + chain = chain.add_fp_check("Introspection explicitly disabled — properly configured") + + # --- Introspection detection --- + if introspection_response: + for pattern in _GRAPHQL_INTROSPECTION_SIGNATURES: + if re.search(pattern, introspection_response, re.IGNORECASE): + chain = chain.add( + signal="GraphQL introspection enabled — full schema exposed", + source="graphql_introspection", + raw_data=introspection_response[:500], + weight=1.0, + ) + # Count types exposed — more types = bigger attack surface + type_count_match = re.findall(r'"name"\s*:\s*"[A-Z]\w+"', introspection_response) + if len(type_count_match) > 5: + chain = chain.add( + signal=f"Schema exposes {len(type_count_match)} named types — large attack surface", + source="graphql_introspection", + weight=0.5, + ) + break + + # --- Error verbosity detection --- + if error_response: + for pattern, description, weight in _GRAPHQL_ERROR_SIGNATURES: + if re.search(pattern, error_response, re.IGNORECASE): + chain = chain.add( + signal=description, + source="graphql_error_analysis", + raw_data=_extract_context(error_response, pattern), + weight=weight, + ) + + # --- Injection detection --- + if injection_response: + for pattern, description, weight in _GRAPHQL_INJECTION_SIGNATURES: + if re.search(pattern, injection_response, re.IGNORECASE): + chain = chain.add( + signal=description, + source="graphql_injection", + raw_data=_extract_context(injection_response, pattern), + weight=weight, + ) + break + + # Baseline comparison + if baseline_body and output and output.strip() != baseline_body.strip(): + if abs(len(output) - len(baseline_body)) > 200: + chain = chain.add( + signal="Response differs significantly from baseline", + source="graphql_baseline_comparison", + weight=0.2, + ) + + return chain + + +# --------------------------------------------------------------------------- +# validate_race_condition — TOCTOU / Race Condition +# --------------------------------------------------------------------------- + +def validate_race_condition( + responses: list[tuple[str, int, str]], + expected_unique: int = 1, + action: str = "", + baseline_count: int | None = None, +) -> EvidenceChain: + """Validate race condition / TOCTOU findings. + + A race condition is confirmed when parallel requests cause a state change + that should only happen once (e.g., coupon used twice, balance deducted once + but credit applied multiple times). + + Args: + responses: List of (status_code_str, body_hash_int, body_preview) tuples + from parallel requests sent simultaneously + expected_unique: How many unique success responses are expected (usually 1) + action: Description of the action being raced (for signal context) + baseline_count: How many times the action should succeed (usually 1) + """ + chain = EvidenceChain() + + if not responses: + return chain.add_fp_check("No responses — race condition test produced no data") + + total = len(responses) + success_responses = [r for r in responses if r[0] in ("200", "201", "204")] + error_responses = [r for r in responses if r[0] in ("409", "429", "400", "403")] + + # FP check: all requests failed (server properly serialized) + if not success_responses: + chain = chain.add_fp_check( + f"All {total} parallel requests failed — server properly handles concurrent requests" + ) + return chain + + # FP check: exactly expected successes, rest failed — proper behavior + if len(success_responses) <= expected_unique and len(error_responses) >= total - expected_unique - 1: + chain = chain.add_fp_check( + f"Only {len(success_responses)}/{total} requests succeeded — concurrency handled correctly" + ) + return chain + + # Primary signal: more successes than expected + extra_successes = len(success_responses) - expected_unique + if extra_successes > 0: + chain = chain.add( + signal=f"Race condition: {len(success_responses)}/{total} requests succeeded " + f"(expected max {expected_unique}){' for: ' + action if action else ''}", + source="race_condition_analysis", + weight=1.0 + min(1.0, extra_successes * 0.2), # more extras = stronger signal + ) + + # Check response body variance — identical success bodies = same resource hit twice + body_hashes = [r[1] for r in success_responses] + unique_hashes = set(body_hashes) + if len(unique_hashes) == 1 and len(success_responses) > 1: + chain = chain.add( + signal=f"All {len(success_responses)} success responses are identical — same state applied multiple times", + source="race_condition_identity_check", + weight=0.8, + ) + elif len(unique_hashes) > 1: + chain = chain.add( + signal=f"Varied responses across {len(success_responses)} successes — concurrent state mutation confirmed", + source="race_condition_variance_check", + weight=0.5, + ) + + # Baseline comparison: more successes than baseline implies race exploit + if baseline_count is not None and len(success_responses) > baseline_count: + chain = chain.add( + signal=f"Parallel requests produced {len(success_responses)} successes vs baseline {baseline_count}", + source="race_condition_baseline", + weight=0.5, + ) + + return chain diff --git a/tests/test_models.py b/tests/test_models.py index 5f04f23..bef9205 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -55,11 +55,14 @@ def test_confirmed_threshold(self) -> None: assert chain.confidence == Confidence.CONFIRMED assert chain.total_weight == 2.0 - def test_confidence_pct_capped_at_100(self) -> None: + def test_confidence_pct_confirmed_never_reaches_100(self) -> None: + # Certainty is never absolute — CONFIRMED caps at 99% (not 100) chain = EvidenceChain() chain = chain.add("s1", "src", weight=1.5) chain = chain.add("s2", "src", weight=1.5) - assert chain.confidence_pct == 100 # capped + pct = chain.confidence_pct + assert pct >= 85, f"CONFIRMED should be >= 85%, got {pct}%" + assert pct <= 99, f"CONFIRMED should never reach 100%, got {pct}%" def test_add_fp_check_returns_new_chain(self) -> None: chain = EvidenceChain() diff --git a/tests/test_refine_improvements.py b/tests/test_refine_improvements.py new file mode 100644 index 0000000..35a77eb --- /dev/null +++ b/tests/test_refine_improvements.py @@ -0,0 +1,379 @@ +"""Tests for the six improvements identified in the refine pass. + +1. confidence_pct formula — correct tier mapping +2. validate_xss — closed

Hello {payload}

' + chain = validate_xss(response, payload, "q") + signals = [item.signal.lower() for item in chain.items] + # The "inside " + chain = validate_xss(response, payload, "q") + has_script_signal = any( + "script" in item.signal.lower() and "inside" in item.signal.lower() + for item in chain.items + ) + assert has_script_signal, "Open " + response = f" {payload}" + chain = validate_xss(response, payload, "q") + fp_checks = " ".join(chain.false_positive_checks).lower() + assert "comment" not in fp_checks, ( + f"Closed HTML comment incorrectly blocked XSS. FP checks: {chain.false_positive_checks}" + ) + + def test_multiple_script_tags_with_one_closed(self) -> None: + payload = "alert(1)" + # Two opens, two closes before payload — balanced, payload is NOT in script + response = f"

{payload}

" + chain = validate_xss(response, payload, "q") + signals = [item.signal.lower() for item in chain.items] + assert not any("script" in s and "inside" in s for s in signals), ( + f"Balanced script tags incorrectly flagged. Signals: {chain.items}" + ) + + +# --------------------------------------------------------------------------- +# Fix 3: validate_ssrf — 307/308 status codes +# --------------------------------------------------------------------------- + +class TestSsrfRedirectCodes: + """307 and 308 must be accepted as valid SSRF indicators.""" + + def test_307_accepted(self) -> None: + chain = validate_ssrf( + payload="http://169.254.169.254/latest/meta-data/", + status_code="307", + response_body="ami-id: ami-12345678", + ) + # Should have evidence (cloud metadata matched) + assert chain.total_weight > 0 + + def test_308_accepted(self) -> None: + chain = validate_ssrf( + payload="http://metadata.google.internal/", + status_code="308", + response_body="project-id: my-project instance-id: 123", + ) + assert chain.total_weight > 0 + + def test_307_redirect_with_no_metadata_is_weak(self) -> None: + chain = validate_ssrf( + payload="http://internal-host/", + status_code="307", + response_body="redirect page", + ) + # Status alone gives 0.2 weight — tentative signal only + assert chain.total_weight < 1.0 + + def test_non_redirect_blocked(self) -> None: + chain = validate_ssrf( + payload="http://internal-host/", + status_code="403", + response_body="Access Denied", + ) + assert chain.total_weight == 0.0 + assert any("blocked" in c.lower() for c in chain.false_positive_checks) + + def test_200_still_works(self) -> None: + chain = validate_ssrf( + payload="http://169.254.169.254/", + status_code="200", + response_body="ami-id: ami-12345 iam: role-name security-credentials: key", + ) + assert chain.total_weight >= 1.5 + + +# --------------------------------------------------------------------------- +# Feature 4a: validate_graphql — introspection +# --------------------------------------------------------------------------- + +class TestGraphqlIntrospection: + """Introspection enabled = confirmed schema exposure.""" + + def test_introspection_enabled_detected(self) -> None: + introspection_resp = '{"data": {"__schema": {"queryType": {"name": "Query"}, "mutationType": {"name": "Mutation"}, "directives": []}}}' + chain = validate_graphql( + output="", + introspection_response=introspection_resp, + ) + assert chain.total_weight >= 1.0, f"Introspection should give FIRM+ confidence, got {chain.total_weight}" + + def test_disabled_introspection_is_fp(self) -> None: + chain = validate_graphql( + output='{"errors": [{"message": "GraphQL introspection is not allowed"}]}', + introspection_response='{"errors": [{"message": "GraphQL introspection is not allowed"}]}', + ) + fp_checks = " ".join(chain.false_positive_checks).lower() + assert "disabled" in fp_checks or "not allowed" in fp_checks + + def test_large_schema_gets_bonus_weight(self) -> None: + # Build a schema with >5 named types + types = [f'"name": "Type{i}"' for i in range(10)] + introspection_resp = '{' + ', '.join(types) + ', "__schema": {"queryType": {"name": "Query"}}}' + chain = validate_graphql( + output="", + introspection_response=introspection_resp, + ) + assert chain.total_weight >= 1.5 + + def test_not_graphql_endpoint_flagged(self) -> None: + chain = validate_graphql(output="404 Not Found") + assert chain.total_weight == 0 or len(chain.false_positive_checks) > 0 + + +class TestGraphqlErrors: + """Error verbosity exposes schema info.""" + + def test_suggestion_error_high_weight(self) -> None: + error_resp = '{"errors": [{"message": "Did you mean \\"userName\\"?"}]}' + chain = validate_graphql(output="", error_response=error_resp) + assert chain.total_weight >= 0.8 + + def test_field_error_medium_weight(self) -> None: + error_resp = '{"errors": [{"message": "Cannot query field \\"password\\" on type \\"User\\""}]}' + chain = validate_graphql(output="", error_response=error_resp) + assert chain.total_weight >= 0.5 + + def test_generic_error_no_schema_leak(self) -> None: + error_resp = '{"errors": [{"message": "An error occurred"}]}' + chain = validate_graphql(output="", error_response=error_resp) + # Generic error = no schema info leaked = low signal + assert chain.total_weight < 0.5 + + +class TestGraphqlInjection: + """Injection detection via parse errors.""" + + def test_db_error_via_graphql(self) -> None: + injection_resp = '{"errors": [{"message": "sql error: syntax error near \'injected\'"}]}' + chain = validate_graphql(output="", injection_response=injection_resp) + assert chain.total_weight >= 1.0 # DB error = high weight + + def test_parse_error_from_injection(self) -> None: + injection_resp = '{"errors": [{"message": "Expected Name, got "}]}' + chain = validate_graphql(output="", injection_response=injection_resp) + assert chain.total_weight >= 0.5 + + +# --------------------------------------------------------------------------- +# Feature 4b: validate_race_condition +# --------------------------------------------------------------------------- + +class TestRaceCondition: + """Race condition detection via parallel response analysis.""" + + def test_multiple_successes_detected(self) -> None: + # 5 parallel requests, all succeeded — expected only 1 + responses = [("200", hash("success_body"), "success") for _ in range(5)] + chain = validate_race_condition( + responses=responses, + expected_unique=1, + action="redeem_coupon", + ) + assert chain.total_weight >= 1.0 + assert chain.confidence in (Confidence.FIRM, Confidence.CONFIRMED) + + def test_single_success_is_not_vulnerable(self) -> None: + # Only 1 out of 10 parallel requests succeeded — proper serialization + responses = [("200", hash("success"), "ok")] + [("409", 0, "conflict") for _ in range(9)] + chain = validate_race_condition( + responses=responses, + expected_unique=1, + ) + assert chain.total_weight == 0 + assert any("correctly" in c.lower() or "properly" in c.lower() for c in chain.false_positive_checks) + + def test_all_failures_is_not_vulnerable(self) -> None: + responses = [("409", 0, "conflict") for _ in range(10)] + chain = validate_race_condition(responses=responses, expected_unique=1) + assert chain.total_weight == 0 + assert len(chain.false_positive_checks) > 0 + + def test_identical_success_bodies_strengthen_signal(self) -> None: + # Identical bodies = same resource applied multiple times + body_hash = hash("same_state") + responses = [("200", body_hash, "same") for _ in range(3)] + chain = validate_race_condition(responses=responses, expected_unique=1) + signal_texts = " ".join(item.signal.lower() for item in chain.items) + assert "identical" in signal_texts or "same" in signal_texts + + def test_varied_responses_add_variance_signal(self) -> None: + responses = [ + ("200", hash("response_a"), "state_a"), + ("200", hash("response_b"), "state_b"), + ("200", hash("response_c"), "state_c"), + ] + chain = validate_race_condition(responses=responses, expected_unique=1) + assert chain.total_weight > 0 + + def test_empty_responses_is_fp(self) -> None: + chain = validate_race_condition(responses=[], expected_unique=1) + assert chain.total_weight == 0 + assert len(chain.false_positive_checks) > 0 + + def test_baseline_count_comparison(self) -> None: + responses = [("200", hash("ok"), "ok") for _ in range(4)] + chain = validate_race_condition( + responses=responses, + expected_unique=1, + baseline_count=1, + ) + baseline_signals = [item.signal for item in chain.items if "baseline" in item.signal.lower()] + assert len(baseline_signals) > 0 + + def test_weight_scales_with_extra_successes(self) -> None: + # More extra successes = higher weight + responses_2 = [("200", hash("x"), "x") for _ in range(2)] + responses_5 = [("200", hash("x"), "x") for _ in range(5)] + chain_2 = validate_race_condition(responses=responses_2, expected_unique=1) + chain_5 = validate_race_condition(responses=responses_5, expected_unique=1) + assert chain_5.total_weight >= chain_2.total_weight + + +# --------------------------------------------------------------------------- +# Fix 6: scan_directories — rate_limiter import check +# --------------------------------------------------------------------------- + +class TestScanDirectoriesRateLimiterIntegration: + """Verify rate_limiter is imported and used in scan_directories.""" + + def test_rate_limiter_imported_in_scanning_module(self) -> None: + import kambo.tools.scanning as scanning_module + import kambo.rate_limiter as rl_module + # The module should reference the rate limiter + import inspect + source = inspect.getsource(scanning_module) + assert "get_rate_limiter" in source, "get_rate_limiter not used in scanning.py" + assert "rate_limiter" in source.lower(), "rate_limiter not imported in scanning.py" + + def test_rate_limiter_singleton_accessible(self) -> None: + from kambo.rate_limiter import get_rate_limiter + limiter = get_rate_limiter() + assert limiter is not None + # Singleton: same instance + assert get_rate_limiter() is limiter + + def test_rate_limiter_detect_waf_cloudflare(self) -> None: + from kambo.rate_limiter import detect_waf + response = "cloudflare protection cf-ray: 123" + result = detect_waf(response) + assert result is not None and "cloudflare" in result.lower() + + def test_rate_limiter_detect_waf_no_waf(self) -> None: + from kambo.rate_limiter import detect_waf + result = detect_waf("HTTP/1.1 200 OK\nContent-Type: text/html\n\nHello world") + assert result is None + + def test_rate_limiter_record_request_returns_dict(self) -> None: + from kambo.rate_limiter import get_rate_limiter + limiter = get_rate_limiter() + analysis = limiter.record_request( + target="https://example.com", + response_body="Hello world", + status_code=200, + ) + assert "waf_detected" in analysis + assert "is_blocked" in analysis + assert "recommended_delay_seconds" in analysis From 8be4d6b92c09f6022d023753a9431ddf4b2df4d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:47:14 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(review):=20address=20PR#5=20comments=20?= =?UTF-8?q?=E2=80=94=20=5F=5Ftypes=20signature=20and=20integration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment 1 — validate_graphql: fix wrong '__types' introspection signature The pattern r'"__types"\s*:' never matched real GraphQL responses. Real introspection responses expose types under __schema.types as: {"data": {"__schema": {"types": [...]}}} Fixed to r'"types"\s*:\s*\[' which reliably matches the types array present in every introspection response. Comment 2 — scan_directories: replace import-only checks with monkeypatched integration tests that exercise the actual adaptive behavior: - _fake_runner: stubs runner.run() — first call returns probe, second scan - _fake_limiter: stubs record_request() to return controlled probe_analysis - test_waf_detected_reduces_threads_to_2: Cloudflare → -t 2 -p 0.5 - test_blocked_without_waf_reduces_threads_to_3: blocked → -t 3 -p 0.2 - test_no_waf_uses_full_threads: clean → -t 10, no -p flag - test_result_always_contains_waf_metadata_keys: dict shape contract - test_probe_runs_before_ffuf: curl probe fires first, ffuf second 614 tests passing. https://claude.ai/code/session_01QrWLmqz6sz4tMVvDWHJqrU --- src/kambo/validation.py | 4 +- tests/test_refine_improvements.py | 192 ++++++++++++++++++++++++------ 2 files changed, 159 insertions(+), 37 deletions(-) diff --git a/src/kambo/validation.py b/src/kambo/validation.py index 9338607..e5e9ebc 100644 --- a/src/kambo/validation.py +++ b/src/kambo/validation.py @@ -1497,8 +1497,8 @@ def validate_deserialization( # --------------------------------------------------------------------------- _GRAPHQL_INTROSPECTION_SIGNATURES: list[str] = [ - r'"__schema"\s*:', # introspection response - r'"__types"\s*:', # type listing + r'"__schema"\s*:', # introspection response root + r'"types"\s*:\s*\[', # types array under __schema (real GQL responses) r'"queryType"\s*:.*"name"', # schema root types r'"mutationType"\s*:', # mutation support exposed r'"directives"\s*:\s*\[', # directives listing diff --git a/tests/test_refine_improvements.py b/tests/test_refine_improvements.py index 35a77eb..a02487f 100644 --- a/tests/test_refine_improvements.py +++ b/tests/test_refine_improvements.py @@ -333,47 +333,169 @@ def test_weight_scales_with_extra_successes(self) -> None: # --------------------------------------------------------------------------- -# Fix 6: scan_directories — rate_limiter import check +# Fix 6: scan_directories — integration tests with monkeypatched fakes # --------------------------------------------------------------------------- +from types import SimpleNamespace + + +def _make_tool_result(raw_output: str) -> SimpleNamespace: + """Create a minimal ToolResult-compatible stub.""" + return SimpleNamespace(raw_output=raw_output, exit_code=0) + + +def _fake_runner(probe_raw: str = "HTTP/1.1 200 OK", scan_raw: str = "") -> object: + """Return a runner stub: first run() call = probe, subsequent = scan.""" + + class _Runner: + def __init__(self) -> None: + self.commands: list[str] = [] + self._call = 0 + + async def run(self, cmd: str, *args, **kwargs) -> SimpleNamespace: + self.commands.append(cmd) + self._call += 1 + if self._call == 1: + return _make_tool_result(probe_raw) + return _make_tool_result(scan_raw or '{"results":[],"commandline":""}') + + return _Runner() + + +def _fake_limiter(probe_analysis: dict) -> object: + """Return a rate-limiter stub whose record_request returns probe_analysis.""" + + class _Limiter: + def record_request(self, target: str, **kwargs: object) -> dict: + return probe_analysis + + return _Limiter() + + class TestScanDirectoriesRateLimiterIntegration: - """Verify rate_limiter is imported and used in scan_directories.""" + """Integration tests: scan_directories WAF detection + adaptive rate limiting.""" + + # --- source-level checks (kept for fast regression) --- def test_rate_limiter_imported_in_scanning_module(self) -> None: - import kambo.tools.scanning as scanning_module - import kambo.rate_limiter as rl_module - # The module should reference the rate limiter import inspect + import kambo.tools.scanning as scanning_module + source = inspect.getsource(scanning_module) - assert "get_rate_limiter" in source, "get_rate_limiter not used in scanning.py" + assert "get_rate_limiter" in source, "get_rate_limiter not called in scanning.py" assert "rate_limiter" in source.lower(), "rate_limiter not imported in scanning.py" - def test_rate_limiter_singleton_accessible(self) -> None: - from kambo.rate_limiter import get_rate_limiter - limiter = get_rate_limiter() - assert limiter is not None - # Singleton: same instance - assert get_rate_limiter() is limiter - - def test_rate_limiter_detect_waf_cloudflare(self) -> None: - from kambo.rate_limiter import detect_waf - response = "cloudflare protection cf-ray: 123" - result = detect_waf(response) - assert result is not None and "cloudflare" in result.lower() - - def test_rate_limiter_detect_waf_no_waf(self) -> None: - from kambo.rate_limiter import detect_waf - result = detect_waf("HTTP/1.1 200 OK\nContent-Type: text/html\n\nHello world") - assert result is None - - def test_rate_limiter_record_request_returns_dict(self) -> None: - from kambo.rate_limiter import get_rate_limiter - limiter = get_rate_limiter() - analysis = limiter.record_request( - target="https://example.com", - response_body="Hello world", - status_code=200, - ) - assert "waf_detected" in analysis - assert "is_blocked" in analysis - assert "recommended_delay_seconds" in analysis + # --- WAF-detected scenario --- + + async def test_waf_detected_reduces_threads_to_2(self, monkeypatch) -> None: + import kambo.tools.scanning as sm + + runner = _fake_runner() + analysis = { + "waf_detected": "Cloudflare", + "is_blocked": False, + "evasion_tips": [{"technique": "ip_rotation", "description": "rotate IPs"}], + "recommended_delay_seconds": 0.5, + } + monkeypatch.setattr(sm, "get_runner", lambda: runner) + monkeypatch.setattr(sm, "get_rate_limiter", lambda: _fake_limiter(analysis)) + monkeypatch.setattr(sm, "validate_scope", lambda t: None) + + result = await sm.scan_directories("https://example.com") + + # ffuf command should contain -t 2 and -p 0.5 + ffuf_cmd = runner.commands[1] # second call is the real scan + assert "-t 2" in ffuf_cmd, f"Expected '-t 2' in ffuf cmd, got: {ffuf_cmd}" + assert "-p 0.5" in ffuf_cmd, f"Expected '-p 0.5' in ffuf cmd, got: {ffuf_cmd}" + + # result metadata + assert result["waf_detected"] is True + assert result["waf_name"] == "Cloudflare" + assert result["rate_adapted"] is True + assert len(result["evasion_tips"]) > 0 + + # --- blocked (no named WAF) scenario --- + + async def test_blocked_without_waf_reduces_threads_to_3(self, monkeypatch) -> None: + import kambo.tools.scanning as sm + + runner = _fake_runner() + analysis = { + "waf_detected": None, + "is_blocked": True, + "evasion_tips": [], + "recommended_delay_seconds": 0.2, + } + monkeypatch.setattr(sm, "get_runner", lambda: runner) + monkeypatch.setattr(sm, "get_rate_limiter", lambda: _fake_limiter(analysis)) + monkeypatch.setattr(sm, "validate_scope", lambda t: None) + + result = await sm.scan_directories("https://example.com") + + ffuf_cmd = runner.commands[1] + assert "-t 3" in ffuf_cmd, f"Expected '-t 3' in blocked cmd, got: {ffuf_cmd}" + assert "-p 0.2" in ffuf_cmd, f"Expected '-p 0.2' in blocked cmd, got: {ffuf_cmd}" + + assert result["waf_detected"] is False + assert result["waf_name"] == "" + assert result["rate_adapted"] is True + + # --- clean (no WAF, no block) scenario --- + + async def test_no_waf_uses_full_threads(self, monkeypatch) -> None: + import kambo.tools.scanning as sm + + runner = _fake_runner() + analysis = { + "waf_detected": None, + "is_blocked": False, + "evasion_tips": [], + "recommended_delay_seconds": 0.1, + } + monkeypatch.setattr(sm, "get_runner", lambda: runner) + monkeypatch.setattr(sm, "get_rate_limiter", lambda: _fake_limiter(analysis)) + monkeypatch.setattr(sm, "validate_scope", lambda t: None) + + result = await sm.scan_directories("https://example.com") + + ffuf_cmd = runner.commands[1] + assert "-t 10" in ffuf_cmd, f"Expected '-t 10' (full threads), got: {ffuf_cmd}" + # No delay flag expected in the clean path + assert "-p " not in ffuf_cmd, f"Unexpected delay flag in clean cmd: {ffuf_cmd}" + + assert result["waf_detected"] is False + assert result["rate_adapted"] is False + assert result["evasion_tips"] == [] + + # --- result dict structure --- + + async def test_result_always_contains_waf_metadata_keys(self, monkeypatch) -> None: + import kambo.tools.scanning as sm + + runner = _fake_runner() + analysis = {"waf_detected": None, "is_blocked": False, "evasion_tips": []} + monkeypatch.setattr(sm, "get_runner", lambda: runner) + monkeypatch.setattr(sm, "get_rate_limiter", lambda: _fake_limiter(analysis)) + monkeypatch.setattr(sm, "validate_scope", lambda t: None) + + result = await sm.scan_directories("https://example.com") + + for key in ("waf_detected", "waf_name", "rate_adapted", "evasion_tips"): + assert key in result, f"Missing key in result: {key}" + + # --- probe fires before the ffuf scan --- + + async def test_probe_runs_before_ffuf(self, monkeypatch) -> None: + import kambo.tools.scanning as sm + + runner = _fake_runner(probe_raw="HTTP/1.1 200 OK cf-ray: abc") + analysis = {"waf_detected": None, "is_blocked": False, "evasion_tips": []} + monkeypatch.setattr(sm, "get_runner", lambda: runner) + monkeypatch.setattr(sm, "get_rate_limiter", lambda: _fake_limiter(analysis)) + monkeypatch.setattr(sm, "validate_scope", lambda t: None) + + await sm.scan_directories("https://example.com") + + assert len(runner.commands) == 2, "Expected exactly 2 runner calls (probe + scan)" + assert "curl" in runner.commands[0], "First call should be the curl WAF probe" + assert "ffuf" in runner.commands[1], "Second call should be the ffuf scan"