From 3c0f782b6598e46e2df0a626f3a7e3d3c2affbff Mon Sep 17 00:00:00 2001 From: Remigiusz Antczak Date: Wed, 28 Oct 2026 21:33:06 +0100 Subject: [PATCH] Add opt-in dynamic_probe.py step to the audit flow Static scanners missed an unhandled URIError returning 500, an unrated error endpoint and a DB TLS verify-off setting, and the audit had no dynamic step. dynamic_probe.py probes a local app (localhost or 127.0.0.1 only, redirects never followed) for 5xx on malformed percent-encoding and oversized inputs, missing rate limiting on a named endpoint, missing security headers and open redirect, and prints review_checks-shaped rows (finding, clean or not-checked). Any dropped, reset or timed-out request makes the affected probe not-checked, never clean. Routed from full-audit.md via references/dynamic-probe.md. Tests: ran 32/32 (scripts/test_dynamic_probe_mock.py, mocked connection layer, no socket). Needs CI or an unsandboxed run: 16 checks in scripts/test_dynamic_probe.py (stub http.server on 127.0.0.1); the authoring sandbox blocks bind. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/INDEX.md | 1 + .../skills/deep-code-review/INDEX-scripts.md | 1 + .claude/skills/deep-code-review/INDEX.md | 7 +- .../references/dynamic-probe.md | 19 +++ .../deep-code-review/references/full-audit.md | 2 + .../deep-code-review/references/full-map.md | 2 +- .../deep-code-review/scripts/dynamic_probe.py | 134 ++++++++++++++++++ changelog.d/wave-dynamic-probe.md | 7 + scripts/ci-tests.d/dynamic-probe.sh | 3 + scripts/mustload-budgets.tsv | 5 +- scripts/size-budgets.tsv | 11 +- scripts/test_dynamic_probe.py | 67 +++++++++ scripts/test_dynamic_probe_mock.py | 130 +++++++++++++++++ 13 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 .claude/skills/deep-code-review/references/dynamic-probe.md create mode 100644 .claude/skills/deep-code-review/scripts/dynamic_probe.py create mode 100644 changelog.d/wave-dynamic-probe.md create mode 100644 scripts/ci-tests.d/dynamic-probe.sh create mode 100644 scripts/test_dynamic_probe.py create mode 100644 scripts/test_dynamic_probe_mock.py diff --git a/.claude/skills/INDEX.md b/.claude/skills/INDEX.md index e239241d9..1ae042868 100644 --- a/.claude/skills/INDEX.md +++ b/.claude/skills/INDEX.md @@ -122,6 +122,7 @@ Run `python3 /scripts/` (`bash` for `.sh`). `name: purpose — argu - coverage_ledger.py: completeness check for a machine-report coverage map. — [--domains SPEC] - derived_numbers.py: bind a doc number to the counter that derives it. — [--fix] [--selftest] [ROOT] - design_drift.py: zero-token check that UI code still uses the project's design… — [--strict] [--json] [--tokens FILE]… +- dynamic_probe.py: opt-in dynamic probes of a LOCAL running app. — [--rate-endpoint PATH] [--burst N]… - finding_ground_check.py: mark each gap finding grounded or not. — [--root DIR] [--ref… - finding_verdict.py: strict verdict gate for machine-report gap rows that carry a… — ... - fix_class_gate.py: every fix commit must touch a pinned test, or say why not. — --base --head [--test-glob… diff --git a/.claude/skills/deep-code-review/INDEX-scripts.md b/.claude/skills/deep-code-review/INDEX-scripts.md index d33f9c47c..b3159f4e4 100644 --- a/.claude/skills/deep-code-review/INDEX-scripts.md +++ b/.claude/skills/deep-code-review/INDEX-scripts.md @@ -13,6 +13,7 @@ Generated by `scripts/skill_index.py` (do not hand-edit). The script half of `IN | `scripts/coverage_ledger.py` | completeness check for a machine-report coverage map. | coverage_ledger.py [--domains SPEC] | | `scripts/derived_numbers.py` | bind a doc number to the counter that derives it. | python3 derived_numbers.py [--fix] [--selftest] [ROOT] Exit codes: 0 ok (or fixed); 1 drift or bad marker; 2 usage or s… | | `scripts/design_drift.py` | zero-token check that UI code still uses the project's design tokens. | design_drift.py [--strict] [--json] [--tokens FILE] [--base REF] [PATH...] | +| `scripts/dynamic_probe.py` | opt-in dynamic probes of a LOCAL running app. | dynamic_probe.py [--rate-endpoint PATH] [--burst N] BASE_URL | | `scripts/finding_ground_check.py` | mark each gap finding grounded or not. | finding_ground_check.py [--root DIR] [--ref GITREF] | | `scripts/finding_verdict.py` | strict verdict gate for machine-report gap rows that carry a verdict. | finding_verdict.py ... | | `scripts/fix_class_gate.py` | every fix commit must touch a pinned test, or say why not. | fix_class_gate.py --base --head [--test-glob GLOB ...] [--trigger-glob GLOB ...] fix_class_gate.py --selfte… | diff --git a/.claude/skills/deep-code-review/INDEX.md b/.claude/skills/deep-code-review/INDEX.md index 6f3975913..f2496627f 100644 --- a/.claude/skills/deep-code-review/INDEX.md +++ b/.claude/skills/deep-code-review/INDEX.md @@ -72,14 +72,15 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing | `references/domain-s.md` | domain S (Branches, merges & open-work triage) is applicable in Phase 2 — the coverage ledger marks… | 1179 | | `references/domain-t.md` | domain T (Multi-tenancy & isolation) is applicable in Phase 2 — the coverage ledger marks it, or a… | 915 | | `references/domain-w.md` | domain W (Workflows, jobs & scheduling) is applicable in Phase 2 — the coverage ledger marks it, or… | 2053 | +| `references/dynamic-probe.md` | static scanners came back clean (or are unavailable) and the target app runs locally: it probes for… | 472 | | `references/energy-efficiency.md` | the target runs on a battery or radio (mobile, embedded, wearable, laptop client, browser tab), run… | 1167 | | `references/example-review-report.md` | you want a finished FULL deliverable shape after install. | 98 | | `references/frontend-a11y.md` | the target renders UI (web, mobile web, component library, or a server-rendered view). | 4976 | | `references/frontend-security.md` | the diff sanitizes or renders user-controlled HTML, sets or edits a Content-Security-Policy, adds a… | 1564 | -| `references/full-audit.md` | the scope is FULL (never for DIFF or FILE). | 1646 | +| `references/full-audit.md` | the scope is FULL (never for DIFF or FILE). | 1689 | | `references/full-map-principles.md` | a finding turns on principle 4-6, 10 or 11 (1-3 and 7-9 stay in full-map.md), or when setting or re… | 765 | | `references/full-map-report.md` | writing the Phase 5 report or closing a review (Definition of done (a)). | 542 | -| `references/full-map.md` | the review is FILE/FULL scope, a DIFF over 300 lines or with a blast-radius flag, or anything past… | 4746 | +| `references/full-map.md` | the review is FILE/FULL scope, a DIFF over 300 lines or with a blast-radius flag, or anything past… | 4755 | | `references/i18n-l10n.md` | the target renders user-facing text in more than one language or locale, accepts user-supplied Unic… | 2540 | | `references/infra-evolution-by-stage.md` | the going-forward roadmap (report-format.md) must advise how a project's infrastructure and archite… | 1971 | | `references/infra-iac-containers.md` | the target ships Dockerfiles, Kubernetes manifests/Helm, Terraform/Pulumi/CloudFormation, CI/CD wor… | 4342 | @@ -151,4 +152,4 @@ Generated by `scripts/skill_index.py` (do not hand-edit; rerun it after changing ## Scripts -30 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### deep-code-review`, has only each one-line purpose and arguments. +31 scripts. Read `INDEX-scripts.md` when you need a script (a trigger and CLI per script). `../INDEX.md`, `### deep-code-review`, has only each one-line purpose and arguments. diff --git a/.claude/skills/deep-code-review/references/dynamic-probe.md b/.claude/skills/deep-code-review/references/dynamic-probe.md new file mode 100644 index 000000000..c69de0984 --- /dev/null +++ b/.claude/skills/deep-code-review/references/dynamic-probe.md @@ -0,0 +1,19 @@ +Read this when static scanners came back clean (or are unavailable) and the target app runs locally: it probes for crash-on-bad-input, missing rate limiting, missing security headers and open redirects that source scanners miss. Opt-in; sibling of `app-pentest.md`. + +## Run it + +`python3 scripts/dynamic_probe.py [--rate-endpoint PATH] [--burst N] http://127.0.0.1:PORT` (beside the skill's other scripts). Only `localhost` and `127.0.0.1` are accepted (exit 2 otherwise; no allowlist override, unlike `app_pentest.sh`). Redirects are never followed. Use a disposable instance: the burst sends up to `--burst` (default 30, max 500) GETs to the named endpoint. + +## Output + +A JSON array in the `review_checks.sh` row shape (`file`, `line`, `rule`, `message`, `text`, `tool`). `rule` is a finding id, `clean` or `not-checked`: + +- `server-error`: any 5xx on malformed percent-encoding (`/%`, `/%zz`, truncated UTF-8) or an oversized path or query. Typical root cause: an unhandled `URIError` / decode exception returning 500 instead of 400. +- `no-rate-limit`: no 429 within the burst on `--rate-endpoint`. Name the error or auth endpoint you care about; without the flag the row is `not-checked`. +- `missing-header`: CSP, `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` (unless CSP `frame-ancestors`), and HSTS on https. +- `open-redirect`: a 3xx whose `Location` echoes an attacker URL passed via a common redirect parameter, on `/` only. +- `not-checked`: the endpoint was unreachable or not named. Never read it as clean. + +## After the run + +Findings are leads: reproduce by hand and apply `appsec-verify.md` section 1. `clean` covers only the inputs probed; a limiter keyed per IP or a redirect on another route is not seen. Say so; do not write "no vulnerabilities found". A burst can trip a limiter that exists but sits above `--burst`: raise it before filing `no-rate-limit`. diff --git a/.claude/skills/deep-code-review/references/full-audit.md b/.claude/skills/deep-code-review/references/full-audit.md index 5446fa343..94644f068 100644 --- a/.claude/skills/deep-code-review/references/full-audit.md +++ b/.claude/skills/deep-code-review/references/full-audit.md @@ -78,3 +78,5 @@ Run a lens only when its trigger exists; record the grep and hit count either wa id, status and evidence line in the `machine-report.md` ledger so the next run re-checks it automatically. On a FULL re-run, load the prior machine report in `code-review/` for the same target by default (`machine-report.md` section 3, no `PRIOR` flag needed) and emit `fixed` or `still-open` for every prior id before new hunting. + +Opt-in dynamic step: read `dynamic-probe.md` when the app runs locally and static scanners found nothing (malformed-input 5xx, missing rate limit, headers, open redirect). diff --git a/.claude/skills/deep-code-review/references/full-map.md b/.claude/skills/deep-code-review/references/full-map.md index b7edd002d..632b3c17f 100644 --- a/.claude/skills/deep-code-review/references/full-map.md +++ b/.claude/skills/deep-code-review/references/full-map.md @@ -189,7 +189,7 @@ applicable domains. Language footguns: | T | Multi-tenancy & isolation | `domain-t.md` (when one deployment serves multiple tenants) | | W | Workflows, jobs & scheduling | `domain-w.md` (when the target runs cron, queues, or multi-step workflows) | -Also: `app-pentest.md` (scan running app), `supply-chain-check.md` (advisories, Actions). +Also: `app-pentest.md` (scan running app), `supply-chain-check.md` (advisories, Actions), `dynamic-probe.md` (local probes). --- diff --git a/.claude/skills/deep-code-review/scripts/dynamic_probe.py b/.claude/skills/deep-code-review/scripts/dynamic_probe.py new file mode 100644 index 000000000..0a157d66c --- /dev/null +++ b/.claude/skills/deep-code-review/scripts/dynamic_probe.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""dynamic_probe.py [--rate-endpoint PATH] [--burst N] BASE_URL — opt-in dynamic probes of a LOCAL running app. + +Refuses (exit 2) any host but localhost / 127.0.0.1, userinfo, or a non-http(s) scheme. Never follows redirects. +Probes: malformed percent-encoding and oversized inputs (any 5xx is a finding), missing rate limiting (burst on +--rate-endpoint; without it that probe is not-checked), security headers, open redirect. +Prints a JSON array in the review_checks.sh row shape: {file,line,rule,message,text,tool}; rule is a finding id, +`clean` or `not-checked`. A clean row proves only what was probed. Read references/dynamic-probe.md before use. +""" +import http.client, json, ssl, sys +from urllib.parse import urlsplit + +TOOL = "dynamic_probe" +rows = [] + + +def row(path, rule, msg): + rows.append({"file": path, "line": 0, "rule": rule, "message": msg, "text": f"{rule}: {msg}", "tool": TOOL}) + + +def req(base, path): + """GET raw path (no normalisation, no redirects) -> (status, headers) or None when the connection fails.""" + https = base.scheme == "https" + cls = http.client.HTTPSConnection if https else http.client.HTTPConnection + kw = {"context": ssl._create_unverified_context()} if https else {} # localhost self-signed certs + try: + c = cls(base.hostname, base.port, timeout=5, **kw) + c.request("GET", path) + r = c.getresponse() + r.read(1024) + return r.status, {k.lower(): v for k, v in r.getheaders()} + except (OSError, http.client.HTTPException): + return None + + +def probe_inputs(b): + cases = ["/%", "/%zz", "/%E0%A4%A", "/?q=%", "/?q=%zz", "/?q=%C0%AF", "/" + "a" * 10000, "/?q=" + "a" * 100000] + bad = dropped = 0 + for p in cases: + r = req(b, p) + if r is None: + dropped += 1 + elif r[0] >= 500: + bad += 1 + row(p[:60], "server-error", f"GET returned {r[0]} for malformed or oversized input") + if dropped: + row("/", "not-checked", f"malformed/oversized inputs: {dropped} of {len(cases)} requests dropped, reset or timed out") + elif bad == 0: + row("/", "clean", f"no 5xx across {len(cases)} malformed/oversized inputs") + + +def probe_rate(b, ep, n): + if not ep: + return row("", "not-checked", "rate limit: pass --rate-endpoint PATH") + res = [req(b, ep) for _ in range(n)] + codes = [r[0] for r in res if r] + dropped = res.count(None) + if 429 in codes: + row(ep, "clean", f"429 seen within {n} requests") + elif dropped: + row(ep, "not-checked", f"rate limit: {dropped} of {n} requests dropped, reset or timed out, no 429") + else: + row(ep, "no-rate-limit", f"{n} rapid requests, no 429 (statuses seen: {sorted(set(codes))})") + + +def probe_headers(b): + r = req(b, "/") + if not r: + return row("/", "not-checked", "security headers: / unreachable") + h = r[1] + want = ["content-security-policy", "x-content-type-options", "referrer-policy"] + if "frame-ancestors" not in h.get("content-security-policy", ""): + want.append("x-frame-options") + if b.scheme == "https": + want.append("strict-transport-security") + miss = [w for w in want if w not in h] + for m in miss: + row("/", "missing-header", f"{m} absent") + if not miss: + row("/", "clean", "expected security headers present") + + +def probe_redirect(b): + evil, hit, dropped = "https://evil.example.com", 0, 0 + for k in ("next", "url", "redirect", "redirect_uri", "return", "returnTo", "dest", "continue"): + r = req(b, f"/?{k}={evil}") + dropped += r is None + loc = r[1].get("location", "") if r and 300 <= r[0] < 400 else "" + if loc.startswith((evil, "//evil.example.com")): + hit += 1 + row(f"/?{k}=", "open-redirect", f"{r[0]} Location echoes attacker URL") + if dropped: + row("/", "not-checked", f"open redirect: {dropped} requests dropped, reset or timed out") + elif not hit: + row("/", "clean", "no redirect to an off-site URL via common params (only / probed)") + + +def main(argv): + ep, n, args = "", 30, list(argv) + while len(args) > 1 and args[0] in ("--rate-endpoint", "--burst"): + flag, val = args[0], args[1] + args = args[2:] + if flag == "--rate-endpoint": + ep = val + else: + try: + n = int(val) + assert n > 0 and val.isascii() + except (ValueError, AssertionError): + print("usage: --burst needs a positive ASCII integer", file=sys.stderr) + return 2 + n = min(n, 500) + if len(args) != 1: + print("usage: dynamic_probe.py [--rate-endpoint PATH] [--burst N] BASE_URL", file=sys.stderr) + return 2 + try: + b = urlsplit(args[0]) + b.port + except ValueError: + print("REFUSED: bad URL", file=sys.stderr) + return 2 + if b.scheme not in ("http", "https") or b.username or b.password or b.hostname not in ("localhost", "127.0.0.1"): + print("REFUSED: base URL must be http(s)://localhost or 127.0.0.1, no userinfo", file=sys.stderr) + return 2 + if ep and not ep.startswith("/"): + print("REFUSED: --rate-endpoint must start with /", file=sys.stderr) + return 2 + probe_inputs(b), probe_rate(b, ep, n), probe_headers(b), probe_redirect(b) + print(json.dumps(rows, indent=1)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/changelog.d/wave-dynamic-probe.md b/changelog.d/wave-dynamic-probe.md new file mode 100644 index 000000000..3d37b3dba --- /dev/null +++ b/changelog.d/wave-dynamic-probe.md @@ -0,0 +1,7 @@ +### Added +- deep-code-review `scripts/dynamic_probe.py`: opt-in dynamic step for a locally running app (localhost or 127.0.0.1 only, never follows redirects). Probes malformed percent-encoding and oversized inputs (any 5xx is a finding), a burst on a named endpoint for missing rate limiting, security headers, and open redirect, and prints review_checks-shaped rows (finding, clean or not-checked). Routed from `full-audit.md` via the new `references/dynamic-probe.md`. Tests: `scripts/test_dynamic_probe.py`. +size-budget-raise: .claude/skills/deep-code-review/references/full-audit.md 6584→6757 route line to the dynamic probe step +size-budget-raise: .claude/skills/INDEX.md 14885→14993 dynamic_probe.py script row +size-budget-raise: .claude/skills/deep-code-review/INDEX.md 20406→20551 dynamic-probe.md reference row and script count +size-budget-raise: .claude/skills/deep-code-review/INDEX-scripts.md 5356→5498 dynamic_probe.py script row +size-budget-raise: .claude/skills/deep-code-review/references/full-map.md 18985→19020 route to dynamic-probe.md diff --git a/scripts/ci-tests.d/dynamic-probe.sh b/scripts/ci-tests.d/dynamic-probe.sh new file mode 100644 index 000000000..5c6e52487 --- /dev/null +++ b/scripts/ci-tests.d/dynamic-probe.sh @@ -0,0 +1,3 @@ +# Registers the dynamic_probe.py stub-server test (localhost http.server only, no real host) with CI. +python3 scripts/test_dynamic_probe.py +python3 scripts/test_dynamic_probe_mock.py diff --git a/scripts/mustload-budgets.tsv b/scripts/mustload-budgets.tsv index 6178cc057..b825023fd 100644 --- a/scripts/mustload-budgets.tsv +++ b/scripts/mustload-budgets.tsv @@ -313,8 +313,8 @@ lib / SDK 9368 # Routing dedupe: domain-checklists.md drops its A-W table (SKILL.md Domain map is the home), language-stack-redflags.md trims a pattern list, branch-and-merge-hygiene.md drops Cross-references and the standards-name list (full only). Floors: phase-floor-light 23115->22810 (-305), phase-floor-full 31334->30786 (-548) (net of +51 bytes in SKILL.md: the M and O Domain-map names echo their domain-m.md / domain-o.md triggers for trigger_lint); no archetype row changed. # Slim SKILL.md split: the tables moved verbatim to references/full-map.md, which the gate now counts instead of SKILL.md (SKILL.md is the slim small-DIFF fast path, bounded by routing --max-bytes and scripts/test_slim_skill_fast_path.py). Floors: phase-floor-light 22810->22796, phase-floor-full 30786->30772 (-14 each); no archetype row changed. # full-map.md core split: principles 4-6, 10 and 11 plus the Project stage model moved (principles 7 and 8, the read-only default and fetched-content-is-data guardrails, stay in the core because no finding trigger fires before a write or a fetch) verbatim to references/full-map-principles.md (read only when a finding turns on one of them); the Findings report and Definition of done (a) to references/full-map-report.md (read at Phase 5 / when closing); the Severity rubric stays in the core because every review ranks findings. Honest bytes per review, before->after: 22122 -> 20456 typical (core 18438 + report file 2018, -1666, -7.5%); 18438 core-only (-3684); worst case 23516 when the principles file also loads (+1394). Floors: phase-floor-light 22796->21877, phase-floor-full 30772->29856 (-919 and -916; the core counts, the split files are not Phase 0-2 mandatory loads); no archetype row changed. -phase-floor-light 18617 -phase-floor-full 26596 +phase-floor-light 18626 +phase-floor-full 26605 # Train 2026-10-12: union of merged PRs nets the floors down 1 token each (23359->23358, 31578->31577). phase-conditional method-situational.md phase-conditional redflags-situational.md @@ -322,3 +322,4 @@ phase-conditional redflags-situational.md # Security verify route: full-map.md's domain-B row names appsec-verify.md with its trigger (+60 bytes) and security-appsec.md's index gains a one-line row for it (+42 bytes, 27998 B under the 28000-byte cap); the reference itself is conditional, off every must-load set. Archetypes web 18841->18851, mobile 13467->13477, api / service 16714->16724, agent / LLM / MCP 18402->18412 (+10 each). Floors: phase-floor-light 18578->18593, phase-floor-full 26557->26572 (+15 each). # App pentest and supply-chain routes: full-map.md gains one Also line naming app-pentest.md and supply-chain-check.md (+101 bytes); both new references are conditional, off every must-load set. Floors: phase-floor-light 18593->18618, phase-floor-full 26572->26597 (+25 each); web 18851->18870, mobile 13477->13496, api / service 16724->16743, agent / LLM / MCP 18412->18431 (+19 each). # Tool hub: full-map.md names tool-hub.md (one line), offset by wording trims in unpinned lines. Floors: phase-floor-light 18618->18617, phase-floor-full 26597->26596 (-1 each); no archetype row changed. +# Dynamic probe route: full-map.md Also line names dynamic-probe.md (+35 bytes); the reference is conditional, off every must-load set. Floors: phase-floor-light 18617->18626, phase-floor-full 26596->26605 (+9 each); no archetype row changed. diff --git a/scripts/size-budgets.tsv b/scripts/size-budgets.tsv index 42e89fae5..1c57ce2cd 100644 --- a/scripts/size-budgets.tsv +++ b/scripts/size-budgets.tsv @@ -196,7 +196,7 @@ README.md 16118 .claude/skills/deep-code-review/references/review-config.md 6472 .claude/skills/agentic-delivery/references/work-tracking.md 4204 .claude/skills/deep-code-review/references/compact-mode.md 2491 -.claude/skills/deep-code-review/references/full-audit.md 6584 +.claude/skills/deep-code-review/references/full-audit.md 6757 .claude/skills/deep-code-review/references/nextjs-app-router.md 2151 .claude/skills/agentic-delivery/references/red-gate-triage.md 27616 .claude/skills/agentic-delivery/references/worktree-isolation.md 16482 @@ -208,11 +208,11 @@ README.md 16118 .claude/skills/product-discovery/references/full-map.md 12384 .claude/skills/growth-analytics/references/full-map.md 10661 .claude/skills/positioning/references/full-map.md 9520 -.claude/skills/INDEX.md 14885 +.claude/skills/INDEX.md 14993 .claude/skills/agentic-delivery/INDEX.md 4535 .claude/skills/agentic-delivery/INDEX-scripts.md 15782 -.claude/skills/deep-code-review/INDEX.md 20406 -.claude/skills/deep-code-review/INDEX-scripts.md 5356 +.claude/skills/deep-code-review/INDEX.md 20551 +.claude/skills/deep-code-review/INDEX-scripts.md 5498 .claude/skills/agentic-delivery/references/pipeline-optimizer.md 19480 .claude/skills/agentic-delivery/references/focus-kit.md 5235 .claude/skills/communication-structure/references/artifact-templates.md 7029 @@ -224,7 +224,7 @@ README.md 16118 .claude/skills/deep-code-review/references/safety-critical.md 7453 .claude/skills/deep-code-review/references/overlays.md 1437 .claude/skills/agentic-delivery/references/template-onepager.md 1667 -.claude/skills/deep-code-review/references/full-map.md 18985 +.claude/skills/deep-code-review/references/full-map.md 19020 .claude/skills/deep-code-review/references/full-map-principles.md 3060 .claude/skills/deep-code-review/references/full-map-report.md 2171 .claude/skills/deep-code-review/references/appsec-verify.md 7863 @@ -236,3 +236,4 @@ README.md 16118 .claude/skills/deep-code-review/references/supply-chain-check.md 3178 .claude/skills/deep-code-review/references/tool-hub.md 4994 .claude/skills/deep-code-review/references/desktop-native-appsec.md 4724 +.claude/skills/deep-code-review/references/dynamic-probe.md 1890 diff --git a/scripts/test_dynamic_probe.py b/scripts/test_dynamic_probe.py new file mode 100644 index 000000000..dc2c60959 --- /dev/null +++ b/scripts/test_dynamic_probe.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Tests for dynamic_probe.py against a stub localhost http.server (never a real host).""" +import http.server, json, pathlib, subprocess, sys, threading + +S = str(pathlib.Path(__file__).resolve().parent.parent / ".claude/skills/deep-code-review/scripts/dynamic_probe.py") +hits = {"n": 0} + + +class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + code, hdr = 200, {} + if "%zz" in self.path: + code = 500 + elif self.path.startswith("/limited"): + hits["n"] += 1 + code = 429 if hits["n"] > 5 else 200 + elif "next=" in self.path: + code, hdr = 302, {"Location": self.path.split("next=")[1]} + self.send_response(code) + for k, v in hdr.items(): + self.send_header(k, v) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *a): + pass + + +srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), H) +threading.Thread(target=srv.serve_forever, daemon=True).start() +base = f"http://127.0.0.1:{srv.server_port}" +fails = n = 0 + + +def check(name, cond): + global fails, n + n += 1 + if not cond: + fails += 1 + print("FAIL", name) + + +def run(*a): + p = subprocess.run([sys.executable, S, *a], capture_output=True, text=True) + return p.returncode, (json.loads(p.stdout) if p.stdout else []) + + +rc, rows = run("--rate-endpoint", "/open", "--burst", "10", base) +rules = [r["rule"] for r in rows] +check("exit 0", rc == 0) +check("5xx on malformed percent", "server-error" in rules) +check("no rate limit found", "no-rate-limit" in rules) +check("missing headers found", rules.count("missing-header") >= 3) +check("open redirect found", "open-redirect" in rules) +check("row shape", all({"file", "line", "rule", "message", "text", "tool"} <= set(r) for r in rows)) +rc, rows = run("--rate-endpoint", "/limited", base) +check("429 -> clean rate row", any(r["file"] == "/limited" and r["rule"] == "clean" for r in rows)) +rc, rows = run(base) +check("no endpoint -> not-checked", any(r["rule"] == "not-checked" for r in rows)) +rc, rows = run("--rate-endpoint", "/x", "http://127.0.0.1:1") +check("unreachable -> not-checked, no finding", rc == 0 and any(r["rule"] == "not-checked" for r in rows) and not any(r["rule"] == "no-rate-limit" for r in rows)) +for bad in ("http://example.com", "http://127.0.0.1.evil.com", "https://localhost@evil.com", "ftp://localhost", "http://10.0.0.1:80", "http://[::1]:80"): + check(f"refuse {bad}", run(bad)[0] == 2) +check("refuse bad burst", run("--burst", "x", base)[0] == 2) +srv.shutdown() +print(f"dynamic_probe: {n - fails}/{n}") +sys.exit(1 if fails else 0) diff --git a/scripts/test_dynamic_probe_mock.py b/scripts/test_dynamic_probe_mock.py new file mode 100644 index 000000000..90d79bad0 --- /dev/null +++ b/scripts/test_dynamic_probe_mock.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""dynamic_probe.py tests with a mocked connection layer: no socket is bound or opened (runs in any sandbox).""" +import contextlib, http.client, importlib.util, io, json, pathlib, sys + +S = pathlib.Path(__file__).resolve().parent.parent / ".claude/skills/deep-code-review/scripts/dynamic_probe.py" +spec = importlib.util.spec_from_file_location("dynamic_probe", S) +d = importlib.util.module_from_spec(spec) +spec.loader.exec_module(d) +fails = n = 0 + + +def check(name, cond): + global fails, n + n += 1 + if not cond: + fails += 1 + print("FAIL", name) + + +class Resp: + def __init__(s, status, hdr): + s.status, s._h = status, hdr + + def read(s, k=-1): + return b"" + + def getheaders(s): + return list(s._h.items()) + + +def fake_conn(handler, log): + class C: + def __init__(s, host, port, **kw): + log.append(("conn", host, port)) + + def request(s, method, path): + log.append(("req", path)) + s.path = path + + def getresponse(s): + return handler(s.path) + return C + + +def run_main(argv, handler=lambda p: Resp(200, {}), log=None): + log = [] if log is None else log + http.client.HTTPConnection = fake_conn(handler, log) + d.rows.clear() + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = d.main(argv) + return rc, (json.loads(out.getvalue()) if out.getvalue() else []), log + + +def drop(p): + raise ConnectionResetError + + +def rules(rows): + return [r["rule"] for r in rows] + + +# gate bypasses: all refused, and no connection is ever attempted +for bad in ("http://localhost.evil.com", "http://localhost./", "http://127.0.0.1./", "http://0.0.0.0", "http://2130706433", + "http://127.1", "http://127.0.0.2", "http://[::1]", "http://localhost@evil.com", "http://user:p@localhost", "ftp://localhost", + "file:///etc/passwd", "http://localhost:99999", "http://127.0.0.1.evil.com", "localhost"): + rc, rows, log = run_main([bad]) + check(f"refuse {bad}", rc == 2 and not log) +rc, rows, log = run_main(["http://LOCALHOST:8080"]) +check("uppercase LOCALHOST accepted (hostnames are case-insensitive)", rc == 0 and log[0][1] == "localhost") + +# redirect is never followed: exactly the 8 redirect-probe requests plus the others, none to the Location host +rc, rows, log = run_main(["http://127.0.0.1:1"], lambda p: Resp(302, {"Location": "https://evil.example.com"})) +check("redirect not followed (every conn is localhost)", {c[1] for c in log if c[0] == "conn"} == {"127.0.0.1"}) +check("open redirect found", "open-redirect" in rules(rows)) + +# findings from a 5xx on malformed input +rc, rows, _ = run_main(["http://127.0.0.1:1"], lambda p: Resp(500, {}) if "%zz" in p else Resp(200, {})) +check("5xx -> server-error", "server-error" in rules(rows)) + +# drops and timeouts are never clean +for name, exc in (("reset", ConnectionResetError), ("timeout", TimeoutError)): + def h(p, exc=exc): + raise exc + rc, rows, _ = run_main(["--rate-endpoint", "/x", "--burst", "3", "http://127.0.0.1:1"], h) + check(f"{name}: no clean row", "clean" not in rules(rows)) + check(f"{name}: not-checked rows for inputs, rate, headers, redirect", rules(rows).count("not-checked") == 4) + +# a single drop among 200s in the inputs probe (oversized request reset) -> not-checked, not clean +state = {"i": 0} + + +def one_drop(p): + state["i"] += 1 + if len(p) > 100000: + raise ConnectionResetError + return Resp(200, {}) + + +rc, rows, _ = run_main(["http://127.0.0.1:1"], one_drop) +check("one dropped oversized input -> not-checked, no clean for inputs", any(r["rule"] == "not-checked" and "inputs" in r["message"] for r in rows) and not any(r["rule"] == "clean" and r["file"] == "/" and "inputs" in r["message"] for r in rows)) + +# rate: some drops with a 429 -> clean; drops without 429 -> not-checked; none dropped, no 429 -> finding +calls = {"i": 0} + + +def mixed(p): + calls["i"] += 1 + if calls["i"] % 2: + raise ConnectionResetError + return Resp(429, {}) + + +rate = lambda rows: [r for r in rows if r["file"] == "/x"] +d.rows.clear() +http.client.HTTPConnection = fake_conn(mixed, []) +d.probe_rate(d.urlsplit("http://127.0.0.1:1"), "/x", 4) +check("429 amid drops -> clean", rules(d.rows) == ["clean"]) +d.rows.clear() +http.client.HTTPConnection = fake_conn(lambda p: Resp(200, {}), []) +d.probe_rate(d.urlsplit("http://127.0.0.1:1"), "/x", 4) +check("no 429, no drops -> no-rate-limit", rules(d.rows) == ["no-rate-limit"]) + +# burst validation: usage error (exit 2), never a traceback +for v in ("²", "x", "0", "-1", "٣", ""): + rc, _, log = run_main(["--burst", v, "http://127.0.0.1:1"]) + check(f"burst {v!r} rejected", rc == 2 and not log) + +print(f"dynamic_probe mock: {n - fails}/{n}") +sys.exit(1 if fails else 0)