agentleFS
zsjstartzsjstart/web-pentest-skill/.claude/skills/web-pentest/SKILL.md

web-pentest

Skill1 starsChanged 2 months agoMITView on GitHub

Conduct a black-box web application penetration test against a URL. Orchestrates two subagent skills — attack-surface-analysis and exploit-and-validate — in a loop, so each phase gets a fresh context instead of drifting over one long session. Use when the user asks to pentest, security-test, or hunt for vulnerabilities on a web app, or references an authorized engagement or bug bounty program. Does not require or use backend source code access. No separate LLM API key — subagents run as Claude Code sessions, same account, no external API integration.

  • Installs packages

11 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use privesc-linux, threat-hunting and 2 more.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • jessefmooreSkill

    privesc-linux

    Linux privilege escalation and persistence — SUID/SGID abuse, kernel exploits, capabilities, sudo misconfig, cron jobs, writable paths, library hijacking, credential hunting, container escape, LXD/LXC, systemd timers, PAM skeleton key, polkit, MOTD, udev, git hooks, shell profile, NetworkManager, package manager hooks, at jobs, XDG autostart, LKM rootkit

    Also in 3 projects that use this

  • allanninalSkill

    threat-hunting

    Hunt threats using Sigma detection rules and security analysis techniques. Use when investigating security incidents, creating detection rules, or analyzing suspicious activity.

    Also in 4 projects that use this

  • TheWatcher01Skill

    osint-recon

    Reconnaissance passive sur un domaine : certificats, DNS, headers HTTP, stack technique. Utiliser pour l'analyse OSINT d'un domaine cible.

    Also in 3 projects that use this

  • jord0-cmdSkill

    RECON

    Deep technical research that persists. Use when: exploring new technologies, investigating frameworks, comparative analysis of tools, understanding complex systems, or any research task that should be saved for future reference. Invokes Claude's built-in research agent and auto-saves results to a knowledge base for retrieval across sessions.

    Also in 3 projects that use this

The file

Show its rules and text

We split this skill into the rules it gives an agent

19 rules. 0 are common, 8 are distinctive, and 11 are very specific to this project.

  • 0

    Common

    Most of the 8 edited versions we read keep these.

  • 8

    Distinctive

    This author's own choices. Worth a look.

  • 11

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every fact this engagement relies on must trace to a live request made against the target this engagement, or to independent reasoning from …

    line 29
  2. Very specific:

    Every command that sends a crafted request to the target, or opens/parses/renders content the target returned (a downloaded file, an HTTP …

    line 67
  3. Distinctive:

    This is where you talk to the user: When a pass reports one or more flagged candidates:

    line 102
  4. Very specific:

    On decline: leave the row Skipped: user declined, see pentest-findings.md and move on — this is a legitimate, permanent resolution for that …

    line 112
  5. Very specific:

    Update pentest-findings.md/coverage to reflect the outcome either way, so Phase 6 reporting sees a resolved state, not a dangling flag.

    line 115
  6. Distinctive:

    Never approve a flagged action yourself without asking — that defeats the entire point of the gate.

    line 118
  7. Very specific:

    Try self-service first, before asking anyone for anything: Check whether the app has a public self-registration/signup route (search …

    line 171
  8. Very specific:

    On approval with a token/cookie: use it directly as the session credential for subsequent requests (the exact header/cookie name depends on …

    line 193
  9. Distinctive:

    On decline: leave the blocked rows/chains exactly as they are — a legitimate, permanent gap for the report, same as any other declined …

    line 209
  10. Very specific:

    If this is not a bug bounty engagement (owned app, signed engagement without a public policy page), record scope.rate_limit: "not …

    line 242
  11. Very specific:

    Environment question — required, don't guess it: Ask explicitly: disposable/sandbox the user fully owns, or shared/production/third-party?

    line 245
  12. Distinctive:

    Credential-testing question — required, don't guess it: Ask explicitly: is password/hash cracking or brute-forcing authorized for this …

    line 248
  13. Distinctive:

    After any write to this file, read it back and parse it: If it doesn't parse, fix it before moving on.

    line 402
  14. Very specific:

    Write findings into recon_notes in the state file.

    line 418
  15. Very specific:

    Write the raw route list into recon_notes.raw_routes_found.

    line 430
  16. Very specific:

    Update control.iteration_count and control.last_attack_surface_pass each time through.

    line 476
  17. Very specific:

    Instead, write .claude/pentest-needs-review.md — a separate file from the report, produced alongside it, containing every …

    line 562
  18. Distinctive:

    Every technique tested in this engagement is found through one two-tier lookup, not two separate systems.

    line 579
  19. Distinctive:

    If you (or a subagent) already know a technique with no matching row above, use it — don't wait for a row to exist first.

    line 639

“Common” is measured: kept by at least 6 in 10 of the 8 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Web Application Penetration Testing (Black-Box, Autonomous)

You are the orchestrator for an authorized black-box web application security assessment. You do authorization, recon, and reporting yourself. For the two phases most prone to losing track of things over a long session — mapping the attack surface, and turning a finding into real impact — you delegate to two subagent skills instead of doing them inline. A subagent starts cold: it has nothing except what you give it, so whatever matters has to be written down. That's the point.

The two subagent skills:

  • attack-surface-analysis — builds and maintains the app model (roles, trust boundaries, workflows, hypothesized attack chains, assets implied but not yet found).
  • exploit-and-validate — tests known assets and immediately chases anything it finds to real impact, in the same pass. No separate "come back to this later."

You run these in a loop (see "The Loop" below) until both are done. You never invoke them yourself by doing their job inline — always spawn them via the Agent tool, subagent_type: general-purpose (needs Bash, Read, Write, and the Skill tool itself), in the foreground (run_in_background: false), and wait for the result before continuing. In the Agent prompt, name the skill to invoke, the target project path, and which mode (initial/review) if applicable — a subagent has nothing except what's in that prompt plus the state file.

Black-box constraint — binding on you and every subagent, every call

Every fact this engagement relies on must trace to a live request made against the target this engagement, or to independent reasoning from such a request. Never use training/prior knowledge of the target's specific identity to guess routes, credentials, known CVEs, standard challenge lists, or assumed architecture — this holds even when the target's name looks familiar or is self-reported in a banner/header; treat that name as an unverified claim, not a lookup key. This applies regardless of which tool the knowledge would come through — recalling it from training data and looking it up live (e.g. a WebSearch keyed on the target's own name/brand) are the same violation.

Restate this constraint explicitly in every attack-surface-analysis/exploit-and-validate spawn prompt — don't rely on the subagent having read this file or on the state file carrying it implicitly. A subagent starts cold each time; if this isn't in the prompt, it isn't guaranteed to apply.

Scope enforcement — hard gate, binding on you and every subagent

"Check against scope" as a prose reminder is not enough — a subagent reading a JS bundle or following a redirect can touch a new host without ever consciously deciding to. Instead of relying on that judgment call every time, run the check mechanically:

Before the first request to any host not already verified this engagement, run: python3 <path-to-skill>/web-pentest/scripts/check_scope.py <url-or-host> (it locates .claude/pentest-state.json on its own by walking up from cwd, or accept --state PATH). It prints ALLOW <host> — matches scope target '...' or DENY <host> — ... and exits 0/1 accordingly. Only proceed on ALLOW. On DENY, stop — don't route around it, don't ask "is this really out of scope" and decide yourself; treat it as out of scope and note it in your summary.

Log every check in pentest-tools.md (one line: host, ALLOW/DENY, which target matched) — this is what lets Phase 6 audit that the gate was actually used on every host touched, not just claimed.

This gate is host-level only. It does not replace path/asset-level scope.exclusions — those still have to be honored when deciding which rows to add to coverage or which requests to send within an in-scope host.

Restate this gate explicitly in every subagent spawn prompt, same as the black-box constraint above — a subagent starts cold and won't know the script exists unless told.

Sandboxed execution — binding on you and every subagent

Every command that sends a crafted request to the target, or opens/parses/renders content the target returned (a downloaded file, an HTTP response body), runs through scripts/sandbox_exec.sh '<command>' instead of bare Bash — full rule and fallback behavior in tool-resolution.md's "Sandboxed execution" entry. This bounds the blast radius of a payload behaving unexpectedly or a malicious response reaching your host filesystem/process space; it is not optional for commands that fit that description, and it is not limited to content you already suspect is dangerous.

You build the sandbox image once, yourself, before Phase 1 (subagents shouldn't each attempt this redundantly): check docker image inspect pentest-sandbox:latest; if missing, run scripts/sandbox/build.sh. If Docker itself isn't usable (not installed, or a permission error even after sg docker -c '...', which sandbox_exec.sh already tries automatically), that's a step-4 ask-the-user case from tool-resolution.md — tell the user exactly what's blocking it and what you need from them (e.g. sudo usermod -aG docker $USER, or Docker installed at all). Don't silently proceed unsandboxed without recording that gap in pentest-tools.md.

Restate this in every subagent spawn prompt, same as the other hard gates — a subagent starts cold and won't know the sandbox exists, or that the image is already built, unless told.

External research — the live half of technique retrieval, available to every subagent and phase

Technique retrieval is two-tiered — see "Capability-card lookup" below for the full explanation and the Tier 0 index itself. Restate that the live tier (external-research.md) exists in every subagent spawn prompt, same as the other hard gates above — don't rely on a subagent discovering it only by reading a specific capability card that happens to mention it.

High-risk action gate — asking the user before anything destructive/irreversible

exploit-and-validate does not talk to the user (see its own SKILL.md) — so when a technique it's about to run is destructive or hard to reverse (completing a reboot/shutdown-type action, mass or non-synthetic deletion, a real resource-exhaustion run, anything sharing that shape — full list in exploit-and-validate/SKILL.md's "High-risk action gate"), it stops short and reports the candidate action back to you in its summary instead of running it, marking that coverage row "Skipped: flagged for user approval".

This is where you talk to the user. When a pass reports one or more flagged candidates:

  1. Ask the user explicitly, one question per candidate (or grouped if genuinely related) via AskUserQuestion — describe exactly what request would be sent and what it would prove, not a vague "can I do something risky." Use the detail the subagent wrote to pentest-findings.md for this.
  2. On approval: either send the single minimal request yourself (if it's simple enough not to warrant a full subagent round-trip), or spawn exploit-and-validate again scoped tightly to just that one action — restate all the usual binding constraints, plus explicitly note the user has approved this specific action so the subagent doesn't re-flag it.
  3. On decline: leave the row Skipped: user declined, see pentest-findings.md and move on — this is a legitimate, permanent resolution for that row, not something to keep re-asking about in later iterations.
  4. Update pentest-findings.md/coverage to reflect the outcome either way, so Phase 6 reporting sees a resolved state, not a dangling flag.

Never approve a flagged action yourself without asking — that defeats the entire point of the gate. Never let "the user already said full-exploitation in Phase 0" substitute for this — PoC depth and destructiveness are different axes (see exploit-and-validate/SKILL.md).

Credential-gap gate — asking the user for a test session when that's the only blocker

credential_testing_policy: not-authorized means don't guess, crack, or brute-force your way into an account — it does not mean "never operate with a session." Those are different things: one is an attack technique on unknown credentials, the other is simply acting as a user the human handed you access as. When exploit-and-validate reports one or more coverage rows/chains that are N/A/blocked specifically because no authenticated session exists — not because a specific technique was declined, but because the entire authenticated surface is unreachable without one — that's worth surfacing to the user directly.

This gate also fires when a session exists but not of the required role. A real engagement self-registered a customer account, found 9 real findings on the customer-facing surface, then found that the target's entire ground-truth vulnerability set lived exclusively behind an admin login — and the gap sat there, silently accepted as "policy-blocked," without ever explicitly asking the human whether admin access could be provided or a bounded credential attempt authorized for that specific, now-concrete scenario. Having some session (customer) is not the same as having the session a specific chain needs (admin) — don't let the existence of a lower-privileged session substitute for asking about the higher-privileged one a blocked chain actually requires.

This ask is mandatory, not discretionary, and applies by default to every engagement — it is not something that needs separate opt-in, and a Phase 0 answer of not-authorized for credential attacking does not pre-empt this specific ask when a concrete scenario makes it relevant. The rule in one sentence: whenever reaching further requires an admin login or a credential-cracking attempt, do not silently mark it as a permanent gap — ask the human first, every time it becomes concretely actionable (once per distinct blocked scenario, not once per blocked coverage row touching the same scenario, and not preemptively before you actually know you'll need it).

A decline closes only the specific scenario under the factual conditions and preconditions that existed when the decision was made. If a later finding changes a precondition that the original request explicitly depended on, the resulting action is a new actionable scenario and must be reconsidered — it is not covered by the earlier decline.

Example: the user is asked whether to attempt credential guessing against an admin account, but the account and admin path cannot be confirmed. The user declines, with the condition that guessing should not proceed unless the path and account can first be confirmed. Several passes later, an unrelated scan discovers the admin path and confirms a real admin account email. The earlier decline should not automatically suppress further action, because the factual precondition that caused the original request to be declined has changed. The orchestrator must recognize this as a new actionable scenario and ask the user again whether to proceed with credential guessing against the now-confirmed account — don't let "we already covered admin access" substitute for checking whether the specific precondition that blocked the earlier ask has since flipped.

Before any hand-crafted path/account guessing for this gate, follow enum_hidden.md's own tool preference — check for ffuf/gobuster plus a real wordlist (SecLists or similar) first; only fall back to a short hand-written guess list if no tool is installable. Skipping straight to hand-crafted guessing (as happened once already: 77 improvised admin-path guesses, none matching, while a real wordlist tool would likely have found it in one run) wastes effort and can produce a false "not discoverable" conclusion that a proper tool run would have disproven.

Try self-service first, before asking anyone for anything. Check whether the app has a public self-registration/signup route (search recon_notes.raw_routes_found and the mapped route list for register/signup/sign-up-shaped paths — this is a live-request question, so the usual scope gate and sandboxed execution rules apply). If one exists, create your own low-privilege account through it — choosing your own credentials for an account you create is not credential testing, it's using the app exactly as a normal new user would. Log in with the account you just created and resume the loop with that session; note in recon_notes that the session came from a self-registered test account (still never persist the password itself in the state file). Only move to step 1 below if self-registration doesn't exist, requires something you can't complete black-box (email/SMS verification, admin approval, a CAPTCHA), or the app is invite-only by design (as with most B2B/admin-console software) — in which case there's no anonymous bootstrap path and asking the human is the only option left, not a shortcut being skipped.

  1. Ask the user explicitly, via AskUserQuestion, with (at least) three concrete options — don't just ask an open-ended "can you help": (a) a session token or cookie value from their own already-logged-in session for the required role, (b) a username/password for an account with that role, or (c) explicit authorization for a specific, bounded credential-guessing attempt against a confirmed account (e.g. "try up to N generic defaults like admin/admin against the admin@example.com account confirmed to exist") — state the actual account, the actual bound, and what it would prove, the same level of concreteness the High-risk action gate requires. Never default to (c) yourself or decide its bound on your own — that's the human's call to size, every time, not a policy you infer once and reapply.
  2. On approval with a token/cookie: use it directly as the session credential for subsequent requests (the exact header/cookie name depends on what the app's own login flow uses — check recon_notes/the auth workflow already recorded). Do not attempt to decode, crack, or otherwise analyze it beyond using it as given. On approval with a username/password: perform exactly one legitimate login with the exact credential given — never a variation, never a guess, never a second attempt with a different password. On approval of a bounded credential-guessing attempt: try exactly what was approved — the exact account, the exact list/count the human sized — and stop the instant it's exhausted, successful or not. Approval of one bounded attempt is not standing authorization for a bigger one later; a materially larger or different attempt needs its own fresh ask. In all three cases: never write the raw secret (password, token, or cookie value) into pentest-state.json, pentest-findings.md, or pentest-tools.md. Record only that an authenticated session was established via user-provided test access, and the role/username it corresponds to if known — the secret itself has no reason to persist in a file a future subagent or the final report will read.
  3. On decline: leave the blocked rows/chains exactly as they are — a legitimate, permanent gap for the report, same as any other declined authorization. Don't ask again for this same, already-declined scenario later in the same engagement — but see the callout above: if a later finding supplies a fact (a confirmed path, a confirmed account) that the decline was conditioned on not having, that's a new scenario, not a repeat of the old one, and does need a fresh ask. What must never happen is skipping the ask itself — accepting a credential-gated gap as final without having concretely asked is the failure this gate exists to prevent, regardless of what a Phase 0 answer implied or what an earlier, differently-conditioned decline covered.
  4. Re-enter the loop with the new session available (if one was obtained) so exploit-and-validate can pick up whatever it previously could only mark N/A: requires auth, no session available.

This is a separate decision from the Phase 0 credential-testing question — that one is about whether attacking authentication is in scope; this one is about whether the human can simply hand you a way in so the rest of the app can be tested at all.

Phase 0 — Authorization and scope (do not skip, do not proceed without this)

You do this yourself. It requires talking to the user directly — a subagent can't do it.

  1. Explicit authorization. Confirm testing is authorized — a named bug bounty program, a signed engagement, or a target the user owns/controls. If unclear, ask before touching anything.
  2. Scope. Precise in-scope assets. Explicit out-of-scope exclusions if any were stated.
  3. Program policy, if this is a bug bounty engagement — treat it as a hard override on this skill's default methodology. Get the actual policy page (ask the user for the link or the text), don't infer it. Pull out, specifically:
    • Rate limit / concurrency cap, if the program states one. Record it verbatim in scope.rate_limit. If the program states none, record "not specified — default conservative" rather than leaving the field blank, so a subagent can't mistake an unset field for "no policy was checked."
    • Forbidden techniques or rules of engagement — e.g. no automated scanners, no DoS-adjacent testing, no social engineering, specific out-of-scope vuln classes. Record each as a short phrase in scope.program_rules, even if the list is empty ([] means "checked, nothing beyond the defaults" — not "didn't check"). If this is not a bug bounty engagement (owned app, signed engagement without a public policy page), record scope.rate_limit: "not applicable — non-bounty engagement" and scope.program_rules: [] rather than skipping the fields.
  4. Environment question — required, don't guess it. Ask explicitly: disposable/sandbox the user fully owns, or shared/production/third-party? This sets the PoC-ceiling mode. If unanswered, default to PoC-only.
  5. Credential-testing question — required, don't guess it. Ask explicitly: is password/hash cracking or brute-forcing authorized for this engagement (e.g. trying a short list of common/weak passwords against an extracted hash, or against a password-protected file like a KeePass DB)? If unanswered, default to not authorized. A weak/exposed hash, or a downloaded protected credential file, is a complete finding on its own — see capabilities/crypto_failures.md — recovering the actual plaintext is a separate, higher-risk action that needs its own sign-off, not something to attempt by default just because a hash or protected file was found.
  6. Create .claude/pentest-state.json with the full schema below — not just scope filled in. Every top-level key (recon_notes, attack_surface, coverage, findings_index, control) needs to exist from the start, even empty, so a subagent reading the file later never hits a missing key. Set control.current_phase: "recon". Check every newly discovered host against scope before touching it. Never expand scope on your own judgment.

Working state

One structured file the orchestrator and both subagents share, plus two prose files for content that doesn't fit a rigid schema.

.claude/pentest-state.json — the source of truth

{
  "scope": {
    "targets": ["..."],
    "authorized_by": "who confirmed, when, how",
    "environment": "owned-sandbox | shared-production",
    "poc_ceiling_mode": "poc-only | full-exploitation",
    "credential_testing_policy": "not-authorized | authorized",
    "exclusions": ["..."],
    "rate_limit": "verbatim program limit, or 'not specified — default conservative', or 'not applicable — non-bounty engagement'",
    "program_rules": ["short phrase per forbidden technique/rule, [] if checked and none apply"]
  },
  "recon_notes": {
    "fingerprint": "server/framework facts from Phase 1",
    "raw_routes_found": ["..."]
  },
  "attack_surface": {
    "roles": [{"name": "...", "notes": "..."}],
    "trust_boundaries": [{"description": "..."}],
    "workflows": [{"name": "...", "steps": ["..."]}],
    "hypothesized_chains": [
      {"id": "C1", "trigger": "...", "expected_target": "...", "tested": false},
      {"id": "C2", "trigger": "...", "expected_target": "...", "tested": false,
       "source": "vuln_components.md tier1-osv.dev | tier2-payloadsallthethings | tier3-websearch",
       "reference": "CVE-ID or advisory/technique-page URL"}
    ],
    "implied_assets": [
      {"name": "...", "source": "...", "located": false}
    ]
  },
  "coverage": [
    {"asset": "...", "method": "GET", "priority": "high | standard | low",
     "owasp": {
       "A01": [{"technique": "authz_idor", "status": "untested"},
                {"technique": "authz_rbac", "status": "untested"}],
       "A02": "untested",
       "A03": [{"technique": "vuln_sqli", "status": "untested"},
                {"technique": "vuln_xss", "status": "untested"},
                {"technique": "vuln_ssti", "status": "untested"},
                {"technique": "vuln_cmdi", "status": "untested"},
                {"technique": "vuln_xxe", "status": "untested"}],
       "A04": "untested", "A05": "untested", "A06": "untested",
       "A07": [{"technique": "auth_session", "status": "untested"},
                {"technique": "auth_jwt", "status": "untested"},
                {"technique": "auth_mfa", "status": "untested"},
                {"technique": "auth_password_reset", "status": "untested"},
                {"technique": "auth_provision", "status": "untested"},
                {"technique": "auth_derive_identity", "status": "untested"},
                {"technique": "auth_weak_credentials", "status": "untested"}],
       "A08": "untested", "A10": "untested"
     },
     "notes": "..."}
  ],
  "findings_index": [
    {"id": "F1", "title": "...", "severity": "Critical", "status": "Confirmed",
     "impact_demonstrated": true, "chain": "C1", "owasp": "A07", "technique": "auth_jwt"},
    {"id": "F2", "title": "...", "severity": "Unrated - unconfirmed",
     "status": "Unconfirmed observation - not counted as a vulnerability finding",
     "impact_demonstrated": false, "chain": null, "owasp": "A01", "technique": "authz_rbac"}
  ],
  "control": {
    "current_phase": "recon | mapping | attack_surface | exploit_validate | reporting",
    "iteration_count": 0,
    "last_attack_surface_pass": "added N new items | nothing new",
    "terminated": false
  }
}

coverage[].priority sizes how much testing effort a row is worth before exploit-and-validate checkpoints and moves on — see that skill's own SKILL.md for exactly how the budget scales. Set by attack-surface-analysis when the row is created, from concrete, checkable properties of the asset — not a fresh subjective judgment call each time, and not proportional to how interesting the asset merely sounds:

  • high: any state-changing (POST/PUT/DELETE/PATCH) endpoint on an authenticated route; any endpoint tied to a hypothesized_chains entry whose expected_target names a financial transaction, credential/session compromise, cross-account data exposure, or admin/back-office access; any endpoint identified as part of a checkout/payment/account-provisioning workflow.
  • standard: authenticated GET routes returning user-specific data (account pages, order history, addresses) — real IDOR-relevant surface, but lower blast radius than a money- or credential-moving action.
  • low: static/informational GET routes with no state-changing action and no user-specific data (catalog listings, CMS pages, sitemap-shaped pages).

A row's priority isn't permanent — if a review pass or exploit-and-validate surfaces a fact that changes the picture (a "low" static page turns out to render user-controlled input into an admin view, say), update it the same way any other stale model fact gets corrected.

Two owasp cell shapes:

  • Single-card categories (A02, A04, A05, A06, A08, A10 — each maps to exactly one capability card, see the lookup table below): a plain string status.
  • Multi-card categories (A01, A03, A07 — map to 2, 5, and 6 capability cards respectively): a list of {"technique": "<capability card name>", "status": "..."} entries, one per card. This exists because a single confirmed vulnerability under one technique (e.g. SQL injection) does not mean the other techniques in that category (XSS, SSTI, command injection, XXE) were also tried — each needs its own status. If a technique with no matching card is used, append a new {"technique": "...", "status": "..."} entry rather than leaving it unrecorded — the card list is a floor, not a ceiling, and the coverage list should reflect that.

Status values, for either shape: "untested", "Vulnerable:F<n>", "Tested/Not Vulnerable", "N/A: <reason>", or "Skipped: <named blocker>". A09 is not a column — see Phase 6.

A real anomaly with no demonstrated impact is not the same thing as a confirmed vulnerability — don't let a coverage cell's "Vulnerable:F<n>" label imply otherwise when F<n>'s impact_demonstrated is false. An observed deviation from expected behavior (a missing auth check that returns no real data, a hash exposed with no way to prove it's exploitable) is real and worth recording, but it reads to a report's audience as a confirmed breach the moment it sits in findings_index with a normal severity and "Confirmed" status — regardless of a boolean flag buried in the same object. So the flag isn't the only signal: when a finding's impact_demonstrated is false, give it "severity": "Unrated - unconfirmed" and "status": "Unconfirmed observation - not counted as a vulnerability finding" (see the F2 example above) instead of a normal severity/"Confirmed" pair. This is what lets Phase 6 partition the report correctly (see Phase 6, Findings) without re-deriving intent from prose.

findings_index's "technique" field mirrors coverage's granularity: set it to the capability card name (e.g. "vuln_sqli", "auth_jwt") whenever "owasp" is a multi-card category (A01, A03, A07), so a finding can be traced back to the exact technique entry it resolved in coverage. Omit it (or leave it null) for single-card categories, where the category alone already identifies the technique.

Keep every free-text value terse across this entire file — a short phrase, not a paragraph. This applies to owasp statuses and coverage[].notes, but equally to attack_surface.roles[].notes and attack_surface.trust_boundaries[].description — in a real multi-iteration engagement, those two fields grow the same way coverage notes do, from repeated attack-surface-analysis review passes each appending a new observation instead of replacing the old one. One sentence, citing a finding ID for detail ("N/A: no XML input accepted", "Vulnerable:F14, see F14 for full evidence"), is the target length everywhere in this file. Full justification belongs in pentest-findings.md, referenced by ID — never inlined here. This file gets read in full by every fresh subagent before it does anything else; if any field carries paragraphs, the file eventually grows past what a subagent can load in one read, and subagents start re-testing rows or re-deriving model facts they can't fully see are already resolved. Terseness here isn't a style preference, it's what keeps this file usable as the engagement gets longer.

After any write to this file, read it back and parse it. If it doesn't parse, fix it before moving on. A broken state file breaks every subagent downstream of it.

.claude/pentest-findings.md

Full evidence prose — the actual requests/responses — one entry per finding, referenced by ID from findings_index. Every entry needs an explicit Impact demonstrated line.

.claude/pentest-tools.md

Audit trail: what was run, tool vs. hand-crafted, per tool-resolution.md.

Phase 1 — Recon

You do this yourself, inline — no subagent. Fingerprint the live surface: server/framework signatures, headers, marker files. Establish an authenticated session if the app has auth. Card: capabilities/recon.md, capabilities/auth_provision.md, capabilities/auth_session.md.

Write findings into recon_notes in the state file. Set control.current_phase: "mapping" when you move to Phase 2.

Phase 2 — Application Understanding / Mapping

You do this yourself, inline. Read the JS bundles — don't just regex for paths, actually read them for HTTP methods, params, multi-step workflows, and any place client-side code trusts an authorization decision. Content-discovery enumeration for routes not in the JS. Cards: capabilities/recon_js.md, capabilities/app_mapping.md, capabilities/enum_hidden.md, capabilities/enum_rest.md, capabilities/enum_graphql.md, capabilities/enum_websocket.md, capabilities/enum_params.md.

Write the raw route list into recon_notes.raw_routes_found. This is raw material — you are not building the app model here. That's the next phase's job, done by a subagent with a clear head. Set control.current_phase: "attack_surface" when you move to Phase 3.

Phase 3 — Attack Surface Analysis (subagent)

Spawn the attack-surface-analysis skill via the Agent tool, foreground, mode: initial. Give it the target project path and tell it explicitly to read .claude/pentest-state.json. Wait for it to return.

It fills in attack_surface and builds the coverage skeleton. Read its summary. This is the end of the one-time setup — everything after this point is the loop. Set control.current_phase: "exploit_validate" before entering it.

The Loop (Phases 4–5, merged)

Repeat until termination:

  1. Spawn exploit-and-validate, foreground. It tests untested rows in coverage, chases any access-yielding finding to real impact in the same pass, updates findings_index and pentest-findings.md. Wait for its summary — including anything it flags as implying new attack surface. If its summary (or a scan of coverage) shows rows blocked purely on "no session available" rather than a declined technique, and you haven't already asked this engagement, run the Credential-gap gate above before continuing the loop.
  2. Spawn attack-surface-analysis, foreground, mode: review. Give it whatever the previous step flagged. It checks the model against that, updates it if needed, and reports "added N new items" or "nothing new."
  3. Check termination:

    • All rows in coverage resolved — no "untested" status anywhere, checked at the leaf level: for multi-card categories (A01, A03, A07), every technique entry in that category's list must be non-"untested", not just the category as a whole — and
    • The latest attack-surface-analysis pass reported nothing new.

    Both must hold. If either is false, go back to step 1.

    Turn count or session length is never a reason to stop early. If it's taking a long time, say so to the user directly and ask whether to continue — don't quietly relabel untested rows as done to make the loop end.

    Safety valve, separate from the above. This is not about time — it's a check against a possible bug (e.g. attack-surface-analysis reporting "new items" that keep turning out not to be real, so the loop never sees "nothing new"). If control.iteration_count passes 15, pause and show the user what's been found and what's still open, and ask whether to keep going. This is a transparent check-in, not a silent stop — don't relabel anything to make the count look lower than it is.

Update control.iteration_count and control.last_attack_surface_pass each time through. When the loop's termination condition is reached, set control.current_phase: "reporting" and move to Phase 6 — control.terminated stays false until Phase 6 actually finishes (see below).

Phase 6 — Reporting

You do this yourself, inline. By the time the loop terminates, the audit gate's usual "re-derive the asset list and diff it" step is already satisfied — that's what termination means here. Still do these checks:

  1. For every Vulnerable/Tested/Not Vulnerable technique entry (or plain cell, for single-card categories), confirm a citable request exists in pentest-findings.md or was described in a subagent's summary. If not, that entry isn't actually resolved — send it back through the loop.
  2. For every findings_index entry, confirm impact_demonstrated is set — true with evidence, or false with a real reason. An entry missing this field entirely means exploit-and-validate didn't finish its job on that finding — loop again. For every entry where it's false, confirm severity reads "Unrated - unconfirmed" and status reads "Unconfirmed observation - not counted as a vulnerability finding" (not a normal severity with "Confirmed" status) — fix in place if a pass left a normal severity on an impact-unconfirmed entry, don't just note it and move on.
  3. Check for the pattern where one exciting finding drowned out the rest of its batch. A Critical on one endpoint is not evidence about any other endpoint — and for multi-card categories specifically, a confirmed finding on one technique (e.g. SQL injection) is not evidence about the other techniques in that same category (XSS, SSTI, command injection, XXE) on the same asset. Check each technique entry was resolved on its own evidence, not inherited from a sibling technique's finding.
  4. Confirm whole-target actions (a scanner run, a dependency audit) have a citable record in pentest-tools.md.
  5. If the target exposes self-reporting ground truth (a challenge list, a scoreboard), compare against findings_index as a sanity check — but only if in scope; respect any exclusions recorded in scope.exclusions. Treat a match cautiously: a target's own "solved" signal can be satisfied by a different bug than the one it's named after (a client-trust bypass tripping the same flag a real DOM XSS would) — don't let it substitute for the technique-specific evidence required by check 1 above.
  6. Scan the whole state file for any status or free-text value longer than roughly one sentence — coverage statuses/notes, attack_surface.roles[].notes, attack_surface.trust_boundaries[].description included. Long inline prose belongs in pentest-findings.md, referenced by finding ID — trim it in place. This keeps the state file within what a fresh subagent can read in one pass on future engagements.
  7. For every findings_index entry whose owasp is a multi-card category, confirm technique is set and matches a real entry in that asset's coverage list. A multi-card finding with no technique field can't be traced back to which coverage entry it resolved.
  8. Confirm no finding required password/hash/credential cracking unless scope.credential_testing_policy is "authorized". If it's unset or "not-authorized", every finding involving a hash or protected credential file must stand on exposure/weak-scheme evidence alone (per capabilities/crypto_failures.md), not on recovered plaintext.
  9. Confirm the scope gate was actually used. List every distinct host that appears in pentest-findings.md and recon_notes. For each, confirm a matching check_scope.py log line exists in pentest-tools.md showing ALLOW. A host with findings/notes but no logged check is a gap — go verify it against scope.targets now (don't retroactively assume it was fine) and record the check before finalizing the report.
  10. Confirm every asset-listing response was actually followed, not just noted. Scan recon_notes/pentest-tools.md for any route whose own response is a list of further script/plugin/asset paths (an extensions manifest, a plugin registry, a source-map index) — confirm each listed path has its own fetch-and-grep log entry per recon_js.md's asset-fetch loop. A manifest route marked "tested" with none of its listed paths ever fetched is an incomplete mapping pass, not a resolved one — send it back through Phase 2 rather than reporting the target as fully mapped. This is the specific gap that let two documented vulnerabilities (routes defined only in unfetched plugin JS) go completely undiscovered in a real engagement; it is cheap to check and easy to miss without a name.
  11. For every coverage cell marked Skipped: <reason> citing a missing/uninstallable tool, confirm the relevant card's own hand-crafted fallback was actually attempted and separately evidenced as having failed — not just that a preferred named tool wasn't installable. A Skipped reason that stops at "package X not found" or "tool Y not installed," with no attempted-and-failed fallback logged in pentest-tools.md, is not a resolved row — send it back through the loop. Restating the card's fallback instructions more forcefully in the card itself is not a reliable fix on its own: a real engagement had a card that already explicitly said to fall back to a hand-crafted listener, and abandoned the check at the first pip install failure anyway, without ever attempting that documented fallback. Catching the gap here, at review time, is what actually closes it — checking whether the refusal was earned, not just asserted.

Set control.terminated: true. Produce the report:

  1. Executive Summary — headline finding counts/severities here are computed only from findings_index entries with impact_demonstrated: true. An unconfirmed observation never contributes to "N vulnerabilities found" or a severity total, no matter how suggestive it looks.
  2. Asset Inventory — from attack_surface + coverage
  3. Coverage Summary — reference pentest-state.json
  4. Findings, split infrastructure-layer vs. application-layer, each with severity, evidence, and its Impact demonstrated line. This section is built only from findings_index entries with impact_demonstrated: true. An entry with impact_demonstrated: false (see the "Unrated - unconfirmed" convention above) does not appear in the report at all — not as a finding, not in a subsection, not in Gaps, nowhere in this document.

    Instead, write .claude/pentest-needs-review.md — a separate file from the report, produced alongside it, containing every impact_demonstrated: false entry: its title, the evidence excerpt from pentest-findings.md, and exactly what's missing to confirm it (a valid ID, a session, a specific payload) framed as a concrete next step. This is the file for a human to review and decide whether to chase further — distinct from the report (which only ever presents proven vulnerabilities) and distinct from pentest-findings.md/pentest-state.json (working files a human isn't expected to read directly). Tell the user this file exists and what's in it when you deliver the report; don't bury its existence in the report's own text.

  5. Attack Paths — from attack_surface.hypothesized_chains, marked tested: true — these are chains that were actually run, not reasoned about after the fact
  6. Exploitation Evidence — state which PoC-ceiling mode was active
  7. Gaps — anything Skipped, cross-referenced with its named blocker, plus the fixed note: "A09 (Logging & Monitoring Failures): not testable black-box; no dedicated capability."
  8. Recommendations

Technique retrieval: capability cards (Tier 0) + external research (used by every subagent)

Every technique tested in this engagement is found through one two-tier lookup, not two separate systems. Tier 0 is the table below — a small, maintained, exhaustively-enumerable index of common techniques, checked first, every time. Tier 1+ is external-research.md — a live lookup, reached for whenever Tier 0 comes up short. It handles two different gaps, defined in full there: Gap A, the technique itself has no matching row below (HTTP request smuggling, cache poisoning, prototype pollution, GraphQL-specific abuse, a framework-specific deserialization chain — whatever the app's actual shape calls for); and Gap B, the technique is known (it's in the table below, or was itself found via a Gap-A lookup) but a target-specific fact is missing (does a live-fingerprinted library+version have a known CVE, what are a fingerprinted product's default credentials). Neither tier is owned by any one card or phase — the orchestrator's own inline Phase 1/2 work, attack-surface-analysis, and exploit-and-validate all consult both the same way, whenever they hit either gap, and both stay inside the black-box constraint the same way (see external-research.md for exactly how).

Tier 0 — the capability-card index

OWASP CategoryCapability cards to checkCoverage shape
A01 Broken Access Controlauthz_idor.md (object-reference IDOR and mass assignment/overposting on create-update endpoints), authz_rbac.md (function-level gating and path-normalization filter bypasses)multi-card — one technique entry per card
A02 Cryptographic Failurescrypto_failures.mdsingle cell
A03 Injectionvuln_sqli.md (SQL and NoSQL), vuln_xss.md, vuln_ssti.md, vuln_cmdi.md, vuln_xxe.md, vuln_log_injection.md (logging-framework injection, e.g. JNDI/Log4Shell-class) — six cards, check all sixmulti-card — one technique entry per card
A04 Insecure Designbusiness_logic.mdsingle cell
A05 Security Misconfigurationvuln_nuclei.md, enum_hidden.mdsingle cell
A06 Vulnerable and Outdated Componentsvuln_components.md (the Gap-B entry point for dependency-version CVEs specifically)single cell
A07 Identification and Authentication Failuresauth_session.md, auth_jwt.md, auth_mfa.md, auth_password_reset.md, auth_provision.md, auth_derive_identity.md, auth_weak_credentials.md (run unprompted whenever credential testing is authorized and a real username is known — see the card, this is not a targeted guessing campaign and doesn't need the Credential-gap gate, and doesn't get deprioritized just because admin access was already achieved some other way) — seven cardsmulti-card — one technique entry per card
A08 Software and Data Integrity Failuresdata_integrity.mdsingle cell
A10 SSRFssrf.mdsingle cell

Cross-cutting: file_upload.md, file_download.md — by asset type, not OWASP category. Any coverage row for an asset that serves a file by name/path/ID (/file/, /download/, /cover/, /avatar/, /export/, /attachment/, or similar) must explicitly note that file_download.md's full traversal battery was applied, not just left as an unexplained "untested" A05 cell. Traversal testing needs no valid, known-good filename to run — a payload that escapes the serving directory works whether or not you've ever found a legitimate filename this app uses. Don't let "no valid file ID located yet" block or delay this test, and don't let it sit as an open implied_asset across multiple passes when the traversal check itself was never actually run. Report-time: chain_analysis.md, poc_validate.md (the evidence bar in poc_validate.md applies identically to a Tier-0 card-sourced finding and a Tier-1+ external-research-sourced one — there is no separate, looser standard for the latter).

A live fingerprint is a mandatory research trigger, not an optional one

The black-box constraint bans target-identity lookups (don't search the target's own self-reported name to import assumed CVEs/routes/architecture) — it does not ban researching a generic technology or library once a live request independently reveals one. These are different things, and conflating them causes real misses: a distinctively-named cookie (rememberMe → Apache Shiro, PHPSESSID → PHP, laravel_session → Laravel), a response header, an error-page banner, or a fingerprinted framework (e.g. Spring Boot confirmed via JSESSIONID + Actuator) is a live, independently-observed fact about a dependency the target happens to run — squarely inside Gap B in external-research.md, the same as a version-to-CVE lookup. Treat it the same way: the moment such a fingerprint appears, treat identifying that library and pulling its known bypass/attack technique classes (not just its CVEs) as required, the same way an untested authz_rbac cell is required — not something to do only if a specific check already "needs" it. A generic technique that applies to a fingerprinted library (e.g. filter-chain path-normalization bypasses for Java/Shiro-style access control) is exactly the kind of thing this research is for, and skipping it because "we don't look things up about this target" throws out real, in-scope research along with the banned target-identity lookup it isn't.

Tier 0 is a floor, not a ceiling — Tier 1+ is how you go past it

If you (or a subagent) already know a technique with no matching row above, use it — don't wait for a row to exist first. If you don't already know one and nothing above fits either, that's a Gap-A lookup in external-research.md, not a reason to skip the check. Either way, for a multi-card category this means appending a new technique entry to that category's coverage list (see Working State schema above), not just using the technique and leaving it unrecorded — same rule whether the technique came from the table or from a live lookup.

A03/A01/A07 finding one vulnerability does not close the category. Confirming SQL injection resolves only the vuln_sqli technique entry — vuln_xss, vuln_ssti, vuln_cmdi, vuln_xxe, and vuln_log_injection on that same asset stay "untested" until each is independently tried or ruled out. Same logic for A01's two techniques and A07's seven, and for any technique entry added via a Gap-A/Gap-B lookup.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.