diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 76b3a07..c03099a 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -12,6 +12,14 @@
"version": "0.1.0",
"category": "ruby",
"keywords": ["ruby", "bundler", "gem", "dependencies"]
+ },
+ {
+ "name": "security",
+ "source": "./plugins/security",
+ "description": "White-box, dynamically-verified security audit. /security:audit recons a repo, hunts OWASP Top 10:2025 vulnerabilities, proves them with live PoCs in isolated worktrees, and writes a high-signal senior-engineer report.",
+ "version": "0.1.0",
+ "category": "security",
+ "keywords": ["security", "pentest", "vulnerability", "audit", "owasp", "appsec"]
}
]
}
diff --git a/README.md b/README.md
index 5ee7465..0b9e3c1 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,7 @@ If the plugin's commands don't show up in the `/` menu, run `/reload-plugins`.
| Plugin | Description |
| --- | --- |
| [gem](plugins/gem) | Ruby gem helpers. Includes `/gem:bump` for changelog-rich dependency bumps. |
+| [security](plugins/security) | Dynamically-verified security audit. `/security:audit` proves vulnerabilities with live PoCs and writes a senior-engineer report. |
## Developing plugins
diff --git a/plugins/security/.claude-plugin/plugin.json b/plugins/security/.claude-plugin/plugin.json
new file mode 100644
index 0000000..0cfc554
--- /dev/null
+++ b/plugins/security/.claude-plugin/plugin.json
@@ -0,0 +1,13 @@
+{
+ "name": "security",
+ "version": "0.1.0",
+ "description": "White-box, dynamically-verified security audit. /security:audit recons a repo, hunts vulnerabilities across the OWASP Top 10:2025 classes, proves them with live PoCs in isolated worktrees, and writes a high-signal senior-engineer report.",
+ "author": {
+ "name": "84codes",
+ "url": "https://github.com/84codes"
+ },
+ "homepage": "https://github.com/84codes/claude-plugins/tree/main/plugins/security",
+ "repository": "https://github.com/84codes/claude-plugins",
+ "license": "MIT",
+ "keywords": ["security", "pentest", "vulnerability", "audit", "owasp", "appsec", "sast"]
+}
diff --git a/plugins/security/AGENTS.md b/plugins/security/AGENTS.md
new file mode 100644
index 0000000..fc21577
--- /dev/null
+++ b/plugins/security/AGENTS.md
@@ -0,0 +1,197 @@
+# vuln-audit — agent & design spec
+
+A Claude Code **skill + workflow** that runs a white-box, dynamically-verified
+security audit of a target repository: a
+multi-phase pipeline (recon → triage → deep review → adversarial verify →
+dynamic repro → report) that produces **proven, high-signal findings with
+patches**, not speculative noise.
+
+> Read this file before touching the workflow or prompts. It is the source of
+> truth for the data contracts, taxonomy, severity model, and signal policy.
+
+## Invocation
+
+```
+/security:audit /path/to/target-repo [--no-dynamic] [--classes injection,ssrf] [--out
]
+```
+
+The skill (`skills/audit/SKILL.md`) is the agent-facing entry point. It parses the
+target, picks a writable `outDir`, preflights host capabilities, then calls the
+workflow (`workflows/vuln-audit.js`) with everything assembled in `args` —
+`toolRoot` = `${CLAUDE_PLUGIN_ROOT}` (read-only, holds the prompts), `outDir` =
+where the bundle is written.
+
+## Pipeline
+
+| Phase | What | Primitive |
+|-------|------|-----------|
+| 1. Recon | Detect stack, map attack surface & trust boundaries, pick run strategy, select relevant finder classes | single agent (`prompts/recon.md`) |
+| 2. Triage | One finder per vuln class scans its surface, emits candidate findings | `parallel()` finders |
+| 3. Dedup | Collapse same-root-cause findings across call sites | plain JS in the workflow |
+| 4. Deep review | Re-examine each candidate with surrounding context (callers, sanitizers, related files); confirm a reachable source→sink path | `pipeline()` stage |
+| 5. Adversarial verify | Independent skeptics, each a distinct lens, try to **refute** the finding; majority-refute kills it | `parallel()` skeptic panel |
+| 6. Dynamic repro | Survivors are built & run in an isolated git **worktree** (docker-first); a real PoC is fired and impact observed | `agent(..., {isolation:'worktree'})` |
+| 7. Report | Synthesize the senior-engineer report (`prompts/report-template.md`) | single agent |
+
+## Reference evaluation (why we adopt what we adopt)
+
+- **Anthropic security-guidance** (`code.claude.com/docs/en/security-guidance`)
+ — **adopt methodology.** Validates our core moves: (a) review independence —
+ the reviewer is a *fresh-context* agent, never the author, "instructed only to
+ find problems"; (b) read callers/sanitizers/related files before reporting to
+ keep false positives low. Our tool is the deepest layer: in-session plugin →
+ `/security-review` (branch) → Code Review (PR) → **vuln-audit (on-demand,
+ dynamically verified PoCs)**. We honor its extension convention: if the target
+ has a `.claude/claude-security-guidance.md`, we load it as extra threat-model
+ context.
+- **OWASP Top 10:2025** — **adopt as primary taxonomy.** Current edition; new
+ categories A03 Software Supply Chain Failures and A10 Mishandling of
+ Exceptional Conditions; SSRF folded into A01. Every finder maps to a 2025 ID.
+- **OWASP ASVS v5.0** (17 chapters, ~350 reqs) — **reference only, not a walked
+ checklist.** Walking 350 requirements is exactly the low-signal sidetrack we
+ avoid. Used two ways: (a) coverage map so the finder taxonomy has no blind
+ spots; (b) cite a requirement/chapter ID in findings as a terse, authoritative
+ reference for senior readers.
+- **OSSF Scorecard** — **partial adopt, code-exploitable checks only.** Scorecard
+ scores project *hygiene/posture* (Maintained, License, SBOM, Security-Policy,
+ Contributors) — out of scope for findings. But its CI/CD checks ARE real
+ exploitable issues and feed our `supply-chain` finder: Dangerous-Workflow
+ (`pull_request_target` + untrusted checkout, `${{ }}` script injection),
+ Token-Permissions (over-broad `GITHUB_TOKEN`), Pinned-Dependencies (unpinned
+ actions/deps), Vulnerabilities (known-vuln deps via OSV). Posture/process
+ checks are relegated to the Info appendix, never the high-priority body.
+
+## Vuln-class taxonomy (finders)
+
+Each maps to OWASP Top 10:2025 + CWE + an ASVS v5.0 chapter. One prompt file per
+class under `prompts/finders/.md`.
+
+| key | title | OWASP 2025 | ASVS |
+|-----|-------|-----------|------|
+| access-control | Broken Access Control & IDOR | A01 | V8 |
+| ssrf | Server-Side Request Forgery | A01 | V4 |
+| injection | Injection (SQL/NoSQL/OS/LDAP) | A05 | V1/V2 |
+| xss-ssti | XSS & Template Injection | A05 | V1/V3 |
+| auth-session | Authentication & Session | A07 | V6/V7/V9/V10 |
+| crypto | Cryptographic Failures | A04 | V11 |
+| deserialization | Insecure Deserialization & Integrity | A08 | V2/V15 |
+| path-file | Path Traversal & File Handling | A01 | V5 |
+| secrets | Hardcoded Secrets & Credentials | A02 | V14 |
+| misconfig | Security Misconfiguration | A02 | V13 |
+| supply-chain | Software Supply Chain & CI/CD | A03 | V15 |
+| logging-errors | Logging, Error & Exception Handling | A09/A10 | V16 |
+| dos-redos | Denial of Service & ReDoS | A06 | V2 |
+| csrf-cors | CSRF, CORS & Clickjacking | A01 | V3 |
+
+Insecure Design (A06) is cross-cutting and handled in recon/synthesis, not a
+grep-able finder.
+
+## Data contracts
+
+### Finding (finders + deep review)
+`id` · `title` · `vuln_class` · `owasp` (A0x:2025) · `cwe` · `asvs` ·
+`severity` (critical|high|medium|low|info) · `status` (confirmed|likely|triage) ·
+`confidence` (low|medium|high) · `file` · `line` · `end_line` ·
+`code_excerpt` · `source` (untrusted origin) · `sink` (dangerous op) ·
+`data_flow` (source→sink, sanitizers noted) · `sanitizers_checked` (mitigations
+verified absent/ineffective — the FP guard) · `rationale` · `exploit_sketch` ·
+`dynamic_poc_plan` · `proposed_fix` (high-level direction of the change, not a
+patch — implementation is left to whoever takes the issue).
+
+After the pipeline, each finding is also stamped with `fp` (stable fingerprint =
+`djb2(vuln_class | file | sink)`, the cross-scan dedup key), `display_id`
+(`--`, provisional until the courier swaps in the GitHub issue
+number), `status`, `kept`, `reject_reason`, `verdicts`, and `repro`.
+
+### Verdict (adversarial verify)
+`finding_id` · `lens` · `refuted` (bool) · `confidence` · `reasoning`.
+
+### Repro (dynamic verify)
+`finding_id` · `reproduced` (bool) · `method`
+(live-exploit|unit-test|build-only|static-poc) · `environment` ·
+`setup_commands` · `poc` · `observed` (evidence) · `impact` · `notes`.
+
+## Severity model (exploitability × impact)
+
+- **Critical** — remote, unauth → RCE / full data breach / auth bypass; reachable.
+- **High** — low barrier (authenticated or realistic conditions); significant
+ impact (priv-esc, sensitive data, injection with a real sink).
+- **Medium** — unusual conditions or limited impact, or partial mitigations.
+- **Low** — minor info leak, defense-in-depth gap, hard to exploit.
+- **Info** — hygiene/posture, no direct exploit path.
+
+`status` is orthogonal and drives report placement: **confirmed** (dynamically
+reproduced or statically proven + survived verify), **likely** (strong proof, no
+live repro), **triage** (unverified / split verdicts). Only confirmed+likely go
+in the report body; triage goes to an appendix.
+
+## Signal discipline (the anti-noise contract)
+
+The report is for senior engineers. Stay high-signal — enforced in deep review
+and verify:
+
+- Report only issues with a **reachable** path from untrusted input to a
+ dangerous sink. Check for sanitizers/validators/authz on the path first; if
+ present and effective, drop it.
+- No style/lint nits. No generic "defense-in-depth" without a concrete sink. No
+ unreachable/dead code.
+- Posture/process items (missing SECURITY.md, SBOM, license, maintainership) →
+ Info appendix only, never the body.
+- Dedup: one finding per root cause, list N locations.
+- Prefer few proven findings over many speculative ones. Every High+ finding
+ carries a PoC or an explicit source→sink trace.
+
+## Layout
+
+```
+.claude-plugin/plugin.json # plugin manifest (name: security)
+skills/audit/SKILL.md # agent-facing orchestrator (/security:audit)
+workflows/vuln-audit.js # the Workflow script (the engine)
+prompts/recon.md # phase-1 recon prompt
+prompts/finders/.md # one finder prompt per vuln class
+prompts/playbooks/.md # per-ecosystem build/run/exploit playbook
+prompts/report-template.md # the report format (phase 7)
+docs/issue-tracking.md # output bundle → GitHub issues + naming rules
+```
+(The output bundle is written to a writable `outDir`, NOT into the plugin root,
+which is read-only/ephemeral.)
+
+Schemas live inline in the workflow (the JS sandbox has no filesystem access at
+runtime); prose content lives in `prompts/` so it is editable without touching
+the script, and is passed into the workflow via `args`.
+
+## Output bundle (VM → courier handoff)
+
+The scan runs on a VM and emits a self-contained **bundle** at
+`reports//`; a separate "courier" agent SSHes in, fetches it, and files the
+issues (the courier holds the only GitHub creds — the VM holds none). Bundle:
+
+- `report.md` — the human report (findings referenced by `display_id`).
+- `findings.json` — the structured findings array, verbatim; the machine
+ interface the courier reconciles against, **keyed by `fp`**.
+- `manifest.json` — `{ tool, schema, repo (owner/repo), target_path, ref,
+ commit, slug, date, dynamic, classes_assessed, counts }`; `repo` tells the
+ courier where to file.
+- `evidence/` — optional captured PoC output (repro evidence also lives inline
+ in `findings.json`).
+
+**Issue tracking & the vulnerability ID/naming rules** (scan epic → finding
+sub-issues, reconcile by `fp`, `display_id` = `--`, the
+courier emitter, and what each host needs) live in
+[`docs/issue-tracking.md`](docs/issue-tracking.md) — the portable source of truth
+that travels with the repo.
+
+## Runtime notes (gotchas)
+
+- **`args` arrives as a JSON string.** The Workflow runtime delivers the `args`
+ payload to the script as a JSON *string*, not a parsed object (verified
+ empirically). `vuln-audit.js` normalizes it (`typeof args === 'string' ?
+ JSON.parse(args) : args`) before reading any input — do not remove this.
+- **Invoke by `scriptPath`, not `name`, mid-session.** Named-workflow discovery
+ only registers files that existed at session start.
+- **Subagents have full tools** (Read/Grep/Bash/Write/ast-grep, and web via
+ ToolSearch) and operate on the *target*; only the orchestration JS is
+ sandboxed. Dynamic repro creates its own `git worktree` of the target — the
+ `isolation:'worktree'` option is about the tool repo and is not used here.
+- **Host adaptivity:** pass `hostNotes` so recon picks a runnable strategy
+ (docker vs native) the host can actually execute.
diff --git a/plugins/security/README.md b/plugins/security/README.md
new file mode 100644
index 0000000..458961a
--- /dev/null
+++ b/plugins/security/README.md
@@ -0,0 +1,85 @@
+# security (vulnerability audit)
+
+A white-box, **dynamically-verified** security-audit plugin for internal
+pentests. `/security:audit` points at a repo you own, recons it, hunts
+vulnerabilities across the OWASP Top 10:2025 classes, **proves them with live
+PoCs in isolated git worktrees**, and writes a terse, senior-engineer report —
+proven findings with a high-level proposed fix, not speculative noise.
+
+## Install
+
+```
+/plugin marketplace add 84codes/claude-plugins
+/plugin install security@84codes
+```
+
+Then run `/reload-plugins` if the command doesn't appear.
+
+## Usage
+
+```
+/security:audit /abs/path/to/target-repo
+/security:audit /abs/path/to/target-repo --no-dynamic
+/security:audit /abs/path/to/target-repo --classes injection,ssrf,access-control --ref v1.2.0
+/security:audit /abs/path/to/target-repo --out /abs/writable/dir
+```
+
+The first argument is the path to the target repo (required). The flags:
+
+| Flag | Meaning |
+|------|---------|
+| `--no-dynamic` | Skip the build/run/PoC phase — static review + adversarial verify only. |
+| `--classes` | Comma-separated vuln-class keys to restrict the audit to (e.g. `injection,ssrf,access-control`; see [`AGENTS.md`](AGENTS.md) for the full taxonomy). Default: classes picked by recon. |
+| `--ref` | Git ref to audit. Default: `HEAD`. |
+| `--out` | Writable directory for the output bundle. Default: `/vuln-audit-reports`. |
+
+The output **bundle** is written to `//`: `report.md` +
+`findings.json` + `manifest.json`.
+
+## How it works
+
+```
+recon → triage → consolidate → deep review → adversarial verify → dynamic PoC → report
+```
+
+| Phase | Purpose |
+|-------|---------|
+| Recon | Detect stack, map attack surface, pick relevant vuln classes + run strategy. |
+| Triage | One finder agent per relevant class emits candidates. |
+| Consolidate | Dedup by root cause, assign IDs, drop low-signal noise. |
+| Deep review | Confirm a reachable source→sink path with no mitigation. |
+| Adversarial verify | Independent skeptics try to refute each finding; majority kills it. |
+| Dynamic PoC | Build + run the target in an isolated worktree; fire a real exploit. |
+| Report | Senior-engineer report: severity-first, reference-backed, PoC-evidenced. |
+
+## Requirements
+
+- `git` (target must be a git repo for worktree isolation + the live-PoC phase).
+- `docker` for dynamic verification (works via `sudo` if the daemon needs it);
+ otherwise repro falls back to unit-test/static PoCs (`--no-dynamic` skips it).
+- No security scanners required — the tool is LLM-native and uses
+ `semgrep`/`gitleaks`/`trivy` only opportunistically if present.
+
+## Output & issue tracking
+
+Findings carry a stable fingerprint (`fp`) and a `display_id`
+(`--`). The bundle is designed to be filed to GitHub issues by a
+separate courier step (scan epic + per-finding sub-issues for Critical/High/
+Medium, reconciled by `fp`). See [`docs/issue-tracking.md`](docs/issue-tracking.md).
+
+## Design
+
+Full pipeline spec, vuln-class taxonomy (OWASP 2025 + CWE + ASVS), data
+contracts, and the signal-discipline policy are in
+[`AGENTS.md`](AGENTS.md).
+
+## Safety & scope
+
+Authorized testing only — audit repositories you own or are explicitly cleared
+to test. All PoC traffic is contained to local processes/containers; the tool
+never fires exploits at external hosts, uses real credentials, or exfiltrates
+data.
+
+## License
+
+MIT
diff --git a/plugins/security/docs/issue-tracking.md b/plugins/security/docs/issue-tracking.md
new file mode 100644
index 0000000..36e972b
--- /dev/null
+++ b/plugins/security/docs/issue-tracking.md
@@ -0,0 +1,118 @@
+# Output handling — findings → GitHub issues
+
+How a scan's findings become tracked, fixable, closeable GitHub issues. This is
+the source of truth for the **vulnerability ID / naming rules** and the
+scan→courier→GitHub pipeline. (Design locked 2026-06-02.)
+
+## Topology: scanner VM + courier
+
+Scans run on a **VM**; a separate **courier** agent SSHes in, fetches the scan's
+output, and files it to GitHub. The two run on different hosts on purpose:
+
+- The **VM** runs `/security:audit`, handles untrusted code and working exploits, and
+ holds **no GitHub credentials**.
+- The **courier** holds the only GitHub creds, fetches the bundle read-only over
+ SSH, and creates/updates issues. It is a *pure function of the bundle* — it
+ needs no access to the target source or the VM's git state.
+
+## The bundle (the scan→courier interface)
+
+Each scan drops a self-contained bundle at `reports//` on the VM:
+
+| File | Purpose |
+|------|---------|
+| `report.md` | Human report (findings headed by `display_id`). |
+| `findings.json` | Structured findings array, **verbatim**; the machine interface, **keyed by `fp`**. |
+| `manifest.json` | `{ tool, schema, repo (owner/repo), target_path, ref, commit, slug, date, dynamic, classes_assessed, counts }`. `repo` tells the courier where to file. |
+| `evidence/` | Optional captured PoC output (repro evidence also lives inline in `findings.json`). |
+
+## Vulnerability ID / naming rules
+
+- **Fingerprint** `fp = djb2(vuln_class | file | sink)` (lowercased; line number
+ excluded to reduce churn). This is the **stable, cross-scan dedup key** — same
+ bug → same `fp`, computed identically on the VM and the courier with no shared
+ state. Stored on each issue as a `fp:` label.
+- **Display ID** `--` — e.g. `training-tool-AC-42`. `` is the
+ repo name, `` the short class code (AC, SSRF, INJ, XSS, AUTH, CRYPTO,
+ DESER, PATH, SEC, MISC, SUPPLY, LOG, DOS, CSRF), and **`` is the GitHub issue
+ number**. So `training-tool-AC-42` *is* `84codes/training-tool#42` — one number,
+ both meanings, permanent (GitHub never reuses issue numbers).
+- **Provisional form** `--` (first 4 hex of `fp`, e.g.
+ `training-tool-AC-b4a0`) — used in the VM-side `report.md` *before* an issue
+ exists. The courier stamps the final `-` ID into the issue at filing;
+ `fp` is the glue linking the two forms.
+- Numbers are **not contiguous per class** (GitHub shares the counter with PRs and
+ other issues) — that is fine; the class prefix carries the meaning.
+
+## Issue model
+
+- **Scan issue** (epic), one per run: holds the report (as a comment, see
+ **Report comment** below) + general comments; closes when all its finding
+ sub-issues close.
+- **Finding sub-issue**, one per **Critical / High / Medium** (confirmed+likely).
+ **Low/Info stay in the report appendix — never issues** (same high-signal
+ contract as the report).
+- **Title:** `[Critical] training-tool-AC-42: (access-control)`.
+- **Body:** the report's finding block (refs · location · PoC · impact ·
+ proposed fix) + backlink to the scan issue + the `fp` marker.
+- **Labels:** `security`, `security-scan` (epic), and `fp:` (the dedup
+ key). Severity and class aren't labels — they live in the title
+ (`[Critical] … (access-control)`) and the display ID, so a `sev:`/`vuln:`
+ label would just duplicate that text.
+- **Two distinct "statuses":** *verification* (confirmed/likely — a scan output,
+ carried as the finding's badge in the title/body) vs *lifecycle* (open/fixed —
+ owned entirely by the GitHub issue). The **report has no status table**; the
+ scan epic and its sub-issues are the live status.
+- **PoC handling:** repos are private/internal, so full PoC commands go in the
+ issues (the remediation is a high-level *proposed fix*, not a patch). (If a target were public, use GitHub Security Advisories for
+ Critical/High instead.)
+- **Report comment:** the full `report.md` is **embedded** in a comment on the
+ scan epic, wrapped in a `…` block (collapsed by
+ default) so the long report never buries the epic's sub-issue checklist. Always
+ embed the report text itself — **never** reference a local bundle path
+ (`reports//…`) or any filesystem location, which is unreachable from
+ GitHub. The epic body points readers to this comment, not to disk.
+
+## Reconcile algorithm (idempotent, keyed by `fp`)
+
+For each Critical/High/Medium finding in `findings.json`, look up existing issues
+by the `fp:` label (`gh issue list --search "label:fp:" --state all`):
+
+- **no match** → create the finding issue, link it under the scan epic.
+- **open match** → comment "still present in scan ``" (no duplicate).
+- **closed match that still reproduces** → reopen as a regression + comment.
+- **previously open, now absent / not reproduced** (dynamic re-verify) → comment +
+ close.
+
+The **report comment** on the epic is upserted the same way: tag it with a
+hidden marker (``), then find-and-edit that comment
+on re-run instead of posting a new one — so the epic never accumulates
+duplicate report blocks.
+
+Re-running the courier on the same bundle is a no-op. The dynamic-repro phase
+doubles as the fix-verifier, so "everything closed when done" is provable, not
+manual.
+
+## Close loop
+
+Fix PRs use `Fixes #N` to auto-close the finding issue on merge; the next scan
+confirms via dynamic re-verify. When all finding sub-issues are closed, the scan
+epic closes.
+
+## Build status
+
+1. **Done (2026-06-02)** — the workflow emits the bundle and stamps `fp` +
+ provisional `display_id`. See `workflows/vuln-audit.js`.
+2. **Not built yet** — the `/security:track ` courier skill +
+ `gh` emitter. Blocked on `gh` being installed + authed on the courier host.
+3. **Always gated** — creating real issues on a repo needs an explicit go-ahead.
+
+## What each host needs
+
+| Host | Role | Requirements |
+|------|------|--------------|
+| **VM (scanner)** | runs `/security:audit`, produces the bundle | Claude Code · this repo · `git` · `docker` · **no `gh`, no GitHub creds** |
+| **Courier** | fetches bundle, files issues | Claude Code · this repo (for `/security:track`) · **`gh` + `gh auth login`** (token: Issues read/write) · **SSH key to the VM** (`ssh`/`rsync`) · `jq` (optional) |
+
+Sub-issue linking uses GitHub's GraphQL API, which `gh api graphql` covers — no
+extra tooling.
diff --git a/plugins/security/prompts/finders/access-control.md b/plugins/security/prompts/finders/access-control.md
new file mode 100644
index 0000000..bf6a5ef
--- /dev/null
+++ b/plugins/security/prompts/finders/access-control.md
@@ -0,0 +1,290 @@
+# Finder — Broken Access Control & IDOR (`access-control`)
+
+OWASP A01:2025 · CWE-639/862/863/601 · ASVS v5.0 V8
+
+## 1. Objective
+
+Hunt for handlers that act on a resource or perform a state change without an
+**ownership/role check that ties the actor to the target**: IDOR (object id
+straight from the request → DB/file lookup with no scope), missing/incorrect
+authorization (`@login_required` ≠ `is_owner`), privilege escalation (role/flag
+set from request, vertical bypass), forced browsing (unguarded admin/internal
+routes), mass assignment (request body bound to a model with sensitive
+attributes), and open redirect (user-controlled `Location`/`returnUrl`).
+
+## 2. Where to look
+
+Map the **router → handler → data access** path. The flaw lives in the handler:
+an untrusted id/role/url reaches a sink with no per-actor check on the path.
+
+- **Route tables / decorators**: `routes.rb`, `config/routes`, Rails
+ `resources`, Sinatra/Kemal/Lucky `get "/x/:id"`, Express `app.get/router.use`,
+ Flask/FastAPI/Django `@app.route`/`urls.py`/`path()`, Gin/Echo/chi
+ `r.GET("/:id")`, Spring `@GetMapping`/`@PreAuthorize`, Laravel
+ `Route::resource`, actix/axum `.route(...)`.
+- **Auth middleware vs. authZ**: a global `authenticate`/`requireLogin`
+ middleware proves *identity*, not *authorization*. The gap is the handler that
+ trusts the authenticated session but never checks the object belongs to that
+ user. Look for routes mounted **outside** the auth middleware (forced
+ browsing) and admin/debug/internal routes with no role gate.
+- **Object lookups keyed by request param**: `find(params[:id])`,
+ `findById(req.params.id)`, `get_object_or_404(pk=request.GET['id'])`,
+ `WHERE id = $1` where `$1` is request-derived, file paths from `req.query`.
+- **Mass assignment**: `Model.update(params)`, `User(**request.json)`,
+ `Object.assign(user, req.body)`, `model.save(req.body)`, struct-tag binding
+ (`c.Bind(&user)`, `json.Unmarshal(body, &user)`), `$request->all()`.
+- **Role / privilege fields**: anything writing `role`, `is_admin`, `isAdmin`,
+ `admin`, `permissions`, `account_type`, `org_id`, `tenant_id`, `user_id`,
+ `owner_id`, `price`, `balance`, `status` from request input.
+- **Redirects**: `redirect(params[:url])`, `res.redirect(req.query.next)`,
+ `RedirectResponse(url)`, `http.Redirect(w,r,url,302)`,
+ `header("Location: $url")`, `sendRedirect`, OAuth/login `returnTo`/`next`/
+ `callback`/`redirect_uri`.
+- **Signals per language**:
+ - **Crystal** (Kemal/Lucky/Amber): `env.params.url["id"]`, `User.find(id)`,
+ `env.redirect params["url"]`; check for an `Authorize`/`before_action`
+ pipe that scopes by `current_user`.
+ - **Ruby/Rails**: `Model.find(params[:id])` vs.
+ `current_user.models.find(...)`; CanCanCan `authorize!`/Pundit
+ `authorize`; `permit!`/`params.permit(...)`; `redirect_to params[:return_to]`.
+ - **Node/TS**: `Model.findById(req.params.id)`, `req.user` trusted but no
+ `where: { userId: req.user.id }`; `{ ...req.body }` spread into update;
+ `res.redirect(req.query.url)`.
+ - **Python**: Django `.get(pk=...)` w/o `.filter(owner=request.user)`;
+ DRF `queryset` without `get_queryset` scoping or `permission_classes`;
+ FastAPI path param → `db.query(Item).get(id)`; `setattr(obj, k, v)` loops.
+ - **Go**: `db.First(&x, c.Param("id"))`, `c.Bind(&u)` then `db.Save(&u)`,
+ role compared as string from header/JWT claim without verification.
+ - **PHP/Laravel**: `Model::find($id)` w/o policy; `$user->update($request->all())`;
+ `Gate`/`@can`/`authorize` absence; `redirect($request->input('url'))`.
+ - **Java/Spring**: `repo.findById(id)` w/o `@PreAuthorize`/owner check;
+ `@ModelAttribute User user` (binder) without `@InitBinder` allow-list;
+ `response.sendRedirect(request.getParameter("url"))`.
+ - **Rust** (axum/actix): `Path(id)` → `sqlx::query!(... WHERE id = ?)` with no
+ `AND owner_id = $session_user`; `Redirect::to(¶ms.url)`.
+
+## 3. Detection heuristics
+
+The pattern is always: **request-controlled identifier/role/url (SOURCE) reaches
+a resource access, mutation, or redirect (SINK) with no check that the actor is
+entitled to that specific object/operation.**
+
+SOURCES (untrusted): path/query/body params, headers, cookies, JWT/claims that
+are attacker-supplied or unverified, multipart fields, GraphQL args, webhook
+payloads.
+
+SINKS (dangerous ops): ORM/SQL lookup or mutation keyed by the id; file/blob
+fetch by id/path; field assignment of privileged attributes; HTTP redirect;
+admin/internal action dispatch.
+
+- **IDOR — object access without scope**
+
+ ```ruby
+ # Rails — id straight from params, no current_user scope
+ invoice = Invoice.find(params[:id]) # SINK: any id readable
+ send_data invoice.pdf # vs. current_user.invoices.find(...)
+ ```
+ ```ts
+ // Express — findById trusts the session for identity, not ownership
+ const doc = await Document.findById(req.params.id); // SINK
+ res.json(doc); // no { where: { ownerId: req.user.id } }
+ ```
+ ```python
+ # Django — pk from request, no owner filter
+ order = Order.objects.get(pk=request.GET["id"]) # SINK
+ # safe form: Order.objects.get(pk=..., user=request.user)
+ ```
+ ```go
+ db.First(&account, c.Param("id")) // SINK: no AND user_id = claims.Sub
+ ```
+
+- **Missing function-level authZ / forced browsing** — route handler does a
+ privileged action with only an authentication gate (or none):
+
+ ```python
+ @app.route("/admin/users//delete", methods=["POST"])
+ @login_required # identity only; no role check
+ def delete_user(id): User.delete(id) # SINK: any logged-in user deletes anyone
+ ```
+ ```java
+ @GetMapping("/internal/metrics") // mounted outside security filter chain
+ public Metrics metrics() { ... } // forced browsing
+ ```
+
+- **Privilege escalation via mass assignment** — request body binds onto a model
+ with privileged columns:
+
+ ```ruby
+ user.update(params[:user]) # SINK: params[:user][:admin]=true
+ # safe: params.require(:user).permit(:name, :email)
+ ```
+ ```ts
+ await User.update({ ...req.body }, { where: { id } }); // role/isAdmin writable
+ ```
+ ```go
+ c.Bind(&user); db.Save(&user) // user.Role from JSON body
+ ```
+ ```php
+ $user->update($request->all()); // no $fillable allow-list / guarded
+ ```
+
+- **Privilege escalation, direct** — role/flag set from request, or self-escalate
+ on own record: `current_user.update(role: params[:role])`, comparing a header/
+ claim string `if req.headers["x-role"] == "admin"`.
+
+- **Open redirect** — user input flows to the redirect target:
+
+ ```ts
+ res.redirect(req.query.next); // SINK
+ ```
+ ```python
+ return redirect(request.args["url"]) # SINK
+ ```
+ ```php
+ header("Location: " . $_GET["url"]); // SINK
+ ```
+ ```ruby
+ redirect_to params[:return_to] # SINK (Rails ≥7 warns; older silent)
+ ```
+
+## 4. Not-a-finding (false-positive guard)
+
+Before flagging, confirm NONE of these neutralize the path. If an effective
+control sits between source and sink, do **not** report.
+
+- **Object scoped to the actor**: lookup is constrained to the caller —
+ `current_user.invoices.find(id)`, `.filter(owner=request.user)`,
+ `WHERE id = ? AND user_id = ?`, `repo.findByIdAndOwner(id, principal)`. The id
+ is request-controlled but the row is fenced.
+- **Explicit authorization on the path**: Pundit `authorize @record`, CanCanCan
+ `authorize! :update, @x`, Spring `@PreAuthorize("hasRole('ADMIN')")` /
+ `@PostAuthorize("returnObject.owner == principal")`, Laravel
+ `$this->authorize('update', $model)` / `@can`, Django DRF
+ `permission_classes`/object-level `has_object_permission`, a middleware that
+ checks role **and** is provably mounted on this route. Verify it actually runs
+ for this handler and covers this verb/object, not a sibling route.
+- **Mass assignment guarded**: strong params (`params.permit(:a,:b)`),
+ serializer/DTO allow-list, Laravel `$fillable`/`$guarded`, an explicit field
+ map (`user.name = body.name`), Spring `@InitBinder setAllowedFields` or a
+ dedicated request record. A model with **no** privileged columns at all is not
+ exploitable for priv-esc.
+- **Open redirect tamed**: target validated against an allow-list of
+ hosts/paths, forced relative (`url.startsWith("/") && !startsWith("//")`),
+ same-origin check, or a server-side lookup table (id→url). Framework helpers
+ that only allow local paths (Django `url_has_allowed_host_and_scheme`, Spring
+ redirect to a mapped view name) are safe.
+- **Non-guessable + non-enumerable id is mitigation-lite, not a pass**: a random
+ UUID/opaque token raises the bar but is NOT authorization. Flag at reduced
+ severity if the id leaks elsewhere (logs, listings, referers) or is otherwise
+ obtainable; treat strong randomness as a partial control, never a clean pass.
+- **Read of genuinely public data** (published posts, public profile) with no
+ tenant/PII boundary — not a finding.
+- **Unreachable**: route not registered, handler dead, control flow returns
+ before the sink, or the "source" is server-derived (`session.user_id`), not
+ attacker-controlled.
+
+A control counts only if it is **on the path, runs before the sink, and matches
+the object/verb**. A global `authenticate` middleware does NOT satisfy the
+ownership requirement — note it as identity-only and keep hunting for the authZ
+check.
+
+## 5. Severity guidance
+
+- **Critical** — unauthenticated or trivially-authenticated IDOR/missing-authZ
+ over **enumerable** ids giving read/write of other tenants' data, account
+ takeover, or admin action (delete/role-grant). Mass assignment that sets
+ `is_admin`/`role` to escalate. Reachable, no effective control.
+- **High** — authenticated horizontal IDOR (read or modify another user's
+ resource) over enumerable ids; vertical priv-esc requiring a realistic
+ precondition; mass assignment of a sensitive-but-not-admin field
+ (`org_id`, `balance`, `price`). Open redirect used in an auth/OAuth flow
+ (token/credential theft chain).
+- **Medium** — IDOR limited to low-sensitivity data, or gated by a
+ non-guessable id that is plausibly obtainable; standalone open redirect
+ (phishing only); priv-esc needing chained unlikely conditions.
+- **Low/Info** — redirect constrained to a small known set; theoretical gap with
+ a partial control present; info-only with no PII/tenant boundary.
+
+Escalate one level if the same root cause hits many endpoints, or if the
+resource is auth material / payment / PII.
+
+## 6. Emit findings as
+
+One object per root cause (list extra call sites in `data_flow`/`rationale`).
+JSON object with EXACTLY these fields:
+
+- `id` — stable slug, e.g. `ac-idor-invoice-show`.
+- `title` — one line, names flaw + endpoint.
+- `vuln_class` — `access-control`.
+- `owasp` — `A01:2025`.
+- `cwe` — most specific: `CWE-639` (IDOR/authZ-by-key), `CWE-862` (missing
+ authZ), `CWE-863` (incorrect authZ), `CWE-601` (open redirect), `CWE-915`
+ (mass assignment); list multiple if apt.
+- `asvs` — a V8 requirement id (e.g. `V8.1.x`); add `V3.x` for open redirect.
+- `severity` — `critical|high|medium|low|info` per §5.
+- `status` — `confirmed` (proven/reproduced) | `likely` (clear trace, no live
+ PoC) | `triage` (needs verification).
+- `confidence` — `low|medium|high`.
+- `file`, `line`, `end_line` — the sink location.
+- `code_excerpt` — the minimal vulnerable lines (sink + binding).
+- `source` — exact untrusted origin, e.g. `req.params.id` (HTTP path param),
+ `request.json["role"]` (request body).
+- `sink` — exact dangerous op, e.g. `Invoice.find(id)`, `user.update(body)`,
+ `res.redirect(url)`.
+- `data_flow` — `source -> ... -> sink`, naming each hop, and **explicitly
+ noting any sanitizer/authz seen and why it is insufficient** (wrong object,
+ wrong verb, not on path).
+- `sanitizers_checked` — the FP guard from §4 you verified: which controls you
+ looked for (ownership scope, `authorize`, strong params, redirect allow-list)
+ and that each is **absent or ineffective**. This field is mandatory; an empty
+ or hand-wavy value means the finding is not yet credible.
+- `rationale` — why reachable + exploitable; cite the missing check.
+- `exploit_sketch` — concrete attacker steps (e.g. "log in as user A, GET
+ `/invoices/124` where 124 is user B's id → read B's data").
+- `dynamic_poc_plan` — the live request(s) and the observed result that proves
+ it (see §7).
+- `proposed_fix` — high-level direction, not a patch: in 1-2 sentences, state
+ WHAT must change and WHY (e.g. "Scope the lookup to the authenticated owner so
+ one user cannot read another's invoice" / "Bind updates through an explicit
+ allow-list so privileged fields like `role` are not mass-assignable"). No code
+ diff, exact code, or line-level/step-by-step edits — leave the implementation
+ to the engineer/agent who picks up the issue.
+
+Fill `source`, `sink`, `data_flow`, and `sanitizers_checked` precisely — they
+are the evidence a reviewer re-checks. A finding without a clear source→sink and
+a verified-absent control is `triage` at best.
+
+## 7. Dynamic PoC strategy
+
+Goal: prove a **cross-actor** or **privilege** boundary is crossed against a
+running instance. Generic recipe:
+
+1. **Provision two principals** of the same tier: user A and user B (and, for
+ vertical tests, a low-priv user vs. an admin-only action). Seed one record
+ per user.
+2. **IDOR (read/write)**: authenticate as A; send A's session/token to the
+ handler but with **B's object id** (path/query/body). Enumerate adjacent ids
+ (`n±1`, sequential, or a leaked uuid).
+ - *Proof*: response returns B's data, or a follow-up read as B shows A's
+ mutation took effect — with a `200`/changed state where a `403/404` is
+ correct. Diff against the same request using A's own id (which should
+ succeed) to show only ownership differs.
+3. **Missing function-level authZ / forced browsing**: as a low-priv (or
+ anonymous) principal, hit the privileged route directly (`POST
+ /admin/users/{id}/delete`). *Proof*: `200`/effect instead of `403`.
+4. **Mass assignment / priv-esc**: as a normal user, `PATCH /users/me` (or the
+ update endpoint) with an extra field — `{"role":"admin"}`,
+ `{"is_admin":true}`, `{"org_id":}`. *Proof*: re-fetch the profile
+ and observe the privileged field changed; then exercise an admin-only action
+ to confirm the elevation is live.
+5. **Open redirect**: request the redirecting endpoint with
+ `?next=https://evil.example/` (and bypass variants: `//evil.example`,
+ `https:evil.example`, `/\evil.example`, `%2f%2fevil.example`, whitelisted-
+ host-as-prefix `https://trusted.example.evil.example`). *Proof*: a `30x`
+ with `Location: https://evil.example/...` to an off-origin host.
+
+Capture the exact request (method, path, headers/cookie, body), the actor
+identity used, and the response status/body/`Location` proving the boundary
+broke. Record this in `dynamic_poc_plan`; on success set `status: confirmed`.
+Negative control (the same request with the actor's own id returning the
+expected `403/404`) makes the proof unambiguous.
diff --git a/plugins/security/prompts/finders/auth-session.md b/plugins/security/prompts/finders/auth-session.md
new file mode 100644
index 0000000..0b39638
--- /dev/null
+++ b/plugins/security/prompts/finders/auth-session.md
@@ -0,0 +1,391 @@
+
+
+# Finder — Authentication & Session (`auth-session`)
+
+**Class key:** `auth-session` · **OWASP:** A07:2025 (Identification & Authentication
+Failures) · **CWE:** CWE-287 (improper auth) / CWE-384 (session fixation) /
+CWE-620 (unverified password change) / CWE-640 (weak reset mechanism) / CWE-521
+(weak password reqs) · **ASVS:** V6 (Authentication) / V7 (Session) / V9 (Tokens
+& JWT) / V10 (OAuth/OIDC)
+
+## 1. Objective
+
+Find where an attacker can **become or impersonate a user without their
+credential**: forge/bypass a token or session (JWT `alg:none`/weak secret,
+guessable session id, fixation), hijack the login/reset/OAuth flow (no token
+check, predictable reset token, open `redirect_uri`, missing `state`), or where
+credentials are stored so weakly that a DB read = mass account takeover
+(plaintext, MD5/SHA1, unsalted, fast hash).
+
+## 2. Where to look
+
+Trace the **identity lifecycle**: login → session/token issuance → per-request
+verification → privileged action → logout/reset. The bug is a step that trusts
+attacker-controllable material as proof of identity.
+
+- **Login / credential check:** `login`, `sign_in`, `authenticate`,
+ `verify_password`, `check_password`, `password_verify`, controllers under
+ `auth/`, `sessions/`, `accounts/`, `Devise`, `passport`, `next-auth`,
+ `Spring Security`, `omniauth`. Look at the comparison and what happens on each
+ branch.
+- **Token mint & verify:** anything touching `jwt`, `jsonwebtoken`, `jose`,
+ `pyjwt`, `golang-jwt`, `jjwt`, `ruby-jwt`, `firebase/php-jwt`; `sign`/`encode`
+ and `verify`/`decode`/`decodeJwt`. The verify call and its options are the
+ hot spot (algorithm allow-list, secret/key source, audience/issuer/expiry).
+- **Session config & store:** `express-session`, `cookie-session`, Rails
+ `config/session_store`, `flask.session`/`Flask-Login`, Django `SESSION_*`,
+ `gorilla/sessions`, PHP `session_start`/`ini`, Lucky/Kemal session handlers.
+ Check cookie flags, session-id regeneration on login, store integrity.
+- **Password reset / email verify:** `forgot`, `reset_password`,
+ `reset-token`, `confirmation_token`, `magic-link`, OTP/2FA verify. Examine
+ token generation (RNG, length, lifetime, single-use, binding to the user) and
+ the verify branch.
+- **OAuth/OIDC:** `/callback`, `/oauth`, `redirect_uri`, `state`, `nonce`,
+ `id_token` handling, provider config; PKCE for public clients; `state`
+ CSRF binding; signature & `aud`/`iss` validation of `id_token`.
+- **Credential storage:** user model migrations/schema, `password`/
+ `password_hash`/`encrypted_password` columns, the hashing call at write time,
+ API-key/token columns, "remember me" tokens.
+
+Per-language SINK / sensitive-call signals:
+
+- **Crystal:** `JWT.decode(token, verify: false)` or no `algorithm:` pin;
+ `Crypto::Bcrypt::Password.create`/`.verify` (good) vs. `Digest::MD5`/`SHA1`
+ on a password; Kemal `env.session` without `session.set` regen on login;
+ `Random` (non-secure) for tokens vs. `Random::Secure`.
+- **Ruby:** `JWT.decode(tok, nil, false)` (verify off) / missing `algorithm:`;
+ `Digest::SHA1.hexdigest(pw)`, `Digest::MD5`; `==` on a token/HMAC instead of
+ `ActiveSupport::SecurityUtils.secure_compare`; Devise `pepper`/`stretches`
+ misset; `SecureRandom` (good) vs. `rand`/`Time.now` for reset tokens;
+ `session_store` without `secret_key_base`; no `reset_session` after sign-in.
+- **Node/TS:** `jwt.verify(tok, key, { algorithms:['none'] })` or no
+ `algorithms` option (defaults can accept attacker's `alg`); `jwt.decode()`
+ used as if it verifies; HS256 with a short/literal secret; `bcrypt`/`argon2`
+ (good) vs. `crypto.createHash('md5'|'sha1')` or `===` on passwords;
+ `express-session` `{ secret:'keyboard cat', cookie:{ secure:false,
+ httpOnly:false } }`, no `req.session.regenerate()` on login; `Math.random()`
+ for tokens; OAuth callback with no `state` compare.
+- **Python:** `jwt.decode(tok, options={'verify_signature': False})` or
+ `algorithms` omitted; `hashlib.md5/sha1(pw)`, `hashlib.sha256` unsalted vs.
+ `bcrypt`/`argon2`/`pbkdf2_hmac` with enough iterations; `==` token compare vs.
+ `hmac.compare_digest`; Flask `SECRET_KEY` empty/guessable, `SESSION_COOKIE_*`
+ off; `random.random()`/`uuid1` for reset tokens vs. `secrets.token_urlsafe`;
+ Django `check_password` good, raw compare bad.
+- **Go:** `jwt.Parse` whose `Keyfunc` doesn't assert `token.Method` (accepts
+ `none`/RS↔HS confusion); `ParseWithClaims` ignoring `Valid`; `md5.Sum`/
+ `sha1.Sum` on passwords vs. `bcrypt.CompareHashAndPassword`/`argon2`;
+ `subtle.ConstantTimeCompare` (good) vs. `==`/`bytes.Equal` on secrets;
+ `math/rand` for tokens vs. `crypto/rand`; session cookie without
+ `Secure`/`HttpOnly`/`SameSite`.
+- **PHP:** `JWT::decode($t, $key, ['none'])` (firebase/php-jwt) or no allowed-alg
+ array; `md5($pw)`/`sha1($pw)` vs. `password_hash($pw, PASSWORD_BCRYPT|ARGON2)`
+ + `password_verify`; `==`/`===` on hashes vs. `hash_equals`;
+ `session.cookie_secure=0`, no `session_regenerate_id(true)` after login;
+ `mt_rand`/`uniqid` for reset tokens vs. `random_bytes`/`bin2hex`.
+- **Java:** `Jwts.parser().parseClaimsJws` without `setSigningKey` /
+ `parse(token)` accepting unsigned (`alg:none`) JWS; `MessageDigest
+ .getInstance("MD5"|"SHA-1")` on passwords vs. `BCryptPasswordEncoder`/
+ `Argon2`; `String.equals` on tokens vs. `MessageDigest.isEqual`; Spring
+ Security `permitAll()` over a protected matcher, `csrf().disable()` paired
+ with cookie sessions; `new Random()`/`Math.random()` for tokens vs.
+ `SecureRandom`; `id_token` accepted without signature check.
+- **Rust:** `jsonwebtoken::decode` with `Validation` whose `algorithms` allows
+ attacker control or `insecure_disable_signature_validation`; `md5`/`sha1`
+ crate on passwords vs. `argon2`/`bcrypt`; `==` on `&[u8]` secret vs.
+ `subtle`/`constant_time_eq`; `rand::thread_rng` for tokens vs. a CSPRNG with
+ enough entropy; `actix-session`/`tower-sessions` cookie without secure flags.
+
+## 3. Detection heuristics
+
+The pattern is always: **attacker-controllable identity material (SOURCE)
+reaches a trust decision or is the credential of record (SINK) without a correct
+verification** — signature/algorithm pinned, secret strong & secret, token
+random+single-use+bound, session id regenerated, redirect/state validated, hash
+slow+salted.
+
+SOURCES (untrusted): the `Authorization`/`Cookie`/`X-*` headers, JWT/`id_token`
+strings and their `alg`/`kid` header, session cookie value, login form
+username/password, `redirect_uri`/`state`/`code` query params, reset/confirm
+token from URL, OTP from body, "remember me" cookie, any header claiming a
+role/user id.
+
+SINKS (trust decisions / credential ops): JWT/`id_token` verify-decode that
+yields `current_user`; password comparison branch; session creation /
+`current_user =` assignment; reset-token lookup → password set; OAuth callback →
+session issuance; the hashing call that persists a credential; the cookie/header
+that is later trusted as identity.
+
+- **JWT `alg:none` / unverified decode** — verification disabled or algorithm
+ not pinned, so an attacker forges claims:
+
+ ```js
+ // Node — no algorithms allow-list; "alg":"none" or HS/RS confusion accepted
+ const claims = jwt.verify(req.cookies.tok, PUBLIC_KEY); // SINK
+ req.user = claims.sub; // trusts forged sub
+ ```
+ ```python
+ jwt.decode(tok, key, options={"verify_signature": False}) # SINK: any token valid
+ ```
+ ```go
+ jwt.Parse(tok, func(t *jwt.Token) (interface{}, error) {
+ return secret, nil }) // SINK: no t.Method.(*jwt.SigningMethodHMAC) check → none/RS↔HS
+ ```
+
+- **Weak/leaked JWT secret or signing key** — HS256 with a guessable literal
+ secret (`"secret"`, `"changeme"`, an env default), or the public key used as
+ the HMAC secret (RS→HS confusion). Attacker brute-forces or re-signs.
+
+ ```ruby
+ JWT.encode(payload, "secret", "HS256") # SINK: brute-forceable secret
+ ```
+
+- **Session fixation** — session id NOT regenerated at privilege change (login),
+ so an attacker who plants a known id rides the victim's authenticated session:
+
+ ```python
+ # Flask — login sets user in the SAME session id the attacker pre-seeded
+ session["user_id"] = user.id # SINK: no session.regenerate / new id
+ ```
+ ```php
+ $_SESSION['uid'] = $user->id; // SINK: no session_regenerate_id(true)
+ ```
+
+- **Insecure session cookie / store** — cookie missing `HttpOnly`/`Secure`/
+ `SameSite`, predictable session id, signed-but-not-encrypted client-side
+ session holding trust flags, hardcoded session secret:
+
+ ```js
+ app.use(session({ secret:'keyboard cat',
+ cookie:{ secure:false, httpOnly:false } })); // SINK: theftable, non-secure
+ ```
+
+- **Insecure password reset / magic link** — token from a weak RNG, too short,
+ no expiry, reusable, or not bound to the user; or reset proceeds without
+ proving possession:
+
+ ```ruby
+ token = rand(1_000_000).to_s # SINK: 1e6 space, guessable/brute
+ user.update(reset_token: token)
+ ```
+ ```python
+ token = str(uuid.uuid1()) # SINK: time/MAC-based, predictable
+ ```
+ Also: reset endpoint that takes `user_id` + new password with **no token**
+ (CWE-620 unverified change), or accepts the token but never checks expiry/
+ single-use.
+
+- **Plaintext / weak-hash credential storage** — password stored as-is, or with
+ a fast/unsalted hash; a DB leak = instant mass ATO:
+
+ ```python
+ user.password = hashlib.md5(pw.encode()).hexdigest() # SINK: fast, unsalted
+ ```
+ ```php
+ $hash = sha1($password); // SINK
+ ```
+ ```sql
+ INSERT INTO users(email, password) VALUES (?, ?) -- SINK: raw plaintext pw
+ ```
+
+- **Non-constant-time secret compare** — token/HMAC/password-hash compared with
+ `==`/`equals`/`bytes.Equal`, leaking via timing (lower severity, but real for
+ remote-guessable tokens):
+
+ ```go
+ if token == stored { ... } // SINK: use subtle.ConstantTimeCompare
+ ```
+
+- **OAuth/OIDC flaws** — callback issues a session without validating `state`
+ (login CSRF / code injection), `redirect_uri` not allow-listed (token/code
+ exfil), `id_token` accepted without signature/`aud`/`iss`/`nonce` check,
+ public client without PKCE:
+
+ ```js
+ // Express — no state compare, redirect_uri reflected from request
+ app.get('/callback', async (req,res)=>{
+ const tok = await exchange(req.query.code); // SINK: no state check
+ req.session.user = tok.sub; });
+ ```
+
+- **Auth bypass logic** — a branch that grants identity on an attacker-set
+ condition: `if (req.headers['x-user']) req.user = ...`, default/empty password
+ accepted, `verify_password` returning truthy on empty input, debug/backdoor
+ account, or comparison that short-circuits (`password == undefined` both).
+
+## 4. Not-a-finding (false-positive guard)
+
+Before flagging, confirm NONE of these neutralize the path. If an effective
+control sits on the identity path, do **not** report.
+
+- **JWT verified correctly:** `verify`/`decode` with an explicit `algorithms`
+ allow-list that excludes `none` and matches the key type (HS* with a secret,
+ RS*/ES* with a public key), signature validation ON, and `exp`/`aud`/`iss`
+ checked. Go `Keyfunc` that asserts `token.Method.(*jwt.SigningMethodHMAC)` (or
+ the expected method). A strong, env-injected, high-entropy secret/key is not a
+ finding for "weak secret".
+- **`decode` used only for non-trust display** (logging, UI) where the result is
+ NOT used for an authorization/identity decision — re-verify reachability;
+ no trust sink → not a finding.
+- **Session id regenerated on privilege change:** Rails `reset_session` /
+ `form_authenticity_token` rotation, `req.session.regenerate()`,
+ `session_regenerate_id(true)`, Django's login cycling the key, framework that
+ rotates by default on auth. Fixation is then closed.
+- **Secure cookie config present:** `HttpOnly` + `Secure` + `SameSite`
+ (Lax/Strict), server-side opaque session store or an encrypted+signed cookie
+ with a strong secret. Missing `Secure` only matters if the app serves over
+ HTTPS / is internet-facing — note the precondition.
+- **Strong reset/verify token:** CSPRNG (`SecureRandom`, `secrets`,
+ `crypto.randomBytes`, `random_bytes`, `crypto/rand`) ≥128 bits, single-use,
+ short TTL, bound to the user, and the new password set ONLY after the token is
+ validated. A signed/HMAC'd token with server-side expiry is fine.
+- **Strong password hashing:** `bcrypt`/`scrypt`/`argon2`/`PBKDF2` with sane
+ cost, per-user salt (these include it), verified with the library's compare.
+ This is correct storage — not a finding even if the rest is imperfect.
+- **Constant-time compare** for tokens/HMACs: `secure_compare`,
+ `hmac.compare_digest`, `subtle.ConstantTimeCompare`, `MessageDigest.isEqual`,
+ `hash_equals`, `constant_time_eq`. Timing finding is closed.
+- **OAuth done right:** `state` generated + stored + compared (CSRF bound),
+ `redirect_uri` matched against a server allow-list, `id_token` signature +
+ `aud`/`iss`/`exp`/`nonce` validated, PKCE on public clients.
+- **Server-derived identity:** the value driving the decision is set by the
+ server from an already-authenticated session (not re-read from an
+ attacker-controllable header/claim) → not attacker-controlled.
+- **Unreachable:** route unregistered, the insecure option behind a dev/test
+ flag that is off in the audited config, handler dead, or the call returns
+ before the sink.
+
+A control counts only if it is **on the identity path and runs before the trust
+decision**. "There is a login screen" is not authorization; a global
+`authenticate` proving identity does not fix a forgeable token. Note any
+identity-only control and keep hunting for the verification that's actually
+missing.
+
+## 5. Severity guidance
+
+- **Critical** — unauthenticated, reachable full auth bypass / account takeover:
+ JWT `alg:none` or signature-off accepted on a trust path, trivially
+ brute-forceable/leaked signing secret, reset token guessable or reset without
+ any token, plaintext or unsalted-MD5/SHA1 password storage (DB read → mass
+ ATO), OAuth callback issuing a session with no signature/`state` check. No
+ effective control on the path.
+- **High** — auth/session compromise under a realistic precondition: session
+ fixation (attacker must plant an id), session secret/key with limited entropy,
+ reset token with a real but bounded weakness (no expiry / reusable but
+ high-entropy), open `redirect_uri` enabling code/token theft, predictable
+ session ids, missing `state` where same-site mitigates partially.
+- **Medium** — exploitation needs unusual conditions or yields limited gain:
+ non-constant-time compare of a remotely-guessable token, missing `HttpOnly`/
+ `Secure` on a non-sensitive cookie or HTTP-only deployment, weak password
+ policy that meaningfully enables credential stuffing, fast-but-salted hash
+ (e.g. single-round SHA256 + salt).
+- **Low/Info** — timing leak on a non-guessable secret, hardening gaps with a
+ compensating control present, password-policy nits with no concrete bypass.
+
+Escalate one level if the same root cause covers all auth (one verify helper, one
+session config) or the credential is reused across systems.
+
+## 6. Emit findings as
+
+One object per root cause (list extra call sites in `data_flow`/`rationale`).
+JSON object with EXACTLY these fields:
+
+- `id` — stable slug, e.g. `as-jwt-alg-none-verify`, `as-reset-token-weak-rng`.
+- `title` — one line naming the flaw + location (e.g. "JWT verified without
+ algorithm pin in `auth/jwt.ts`").
+- `vuln_class` — `auth-session`.
+- `owasp` — `A07:2025`.
+- `cwe` — most specific: `CWE-287` (improper auth / JWT bypass / OAuth),
+ `CWE-384` (session fixation), `CWE-620` (unverified credential change),
+ `CWE-640` (weak reset mechanism), `CWE-521` (weak password reqs); add
+ `CWE-916`/`CWE-759`/`CWE-256` for weak/unsalted/plaintext storage,
+ `CWE-330`/`CWE-338` for weak token RNG, `CWE-547`/`CWE-798` for hardcoded
+ secrets, `CWE-208` for timing compare. List multiple if apt.
+- `asvs` — the closest requirement id: V6.x (auth/passwords/storage), V7.x
+ (sessions), V9.x (JWT/tokens), V10.x (OAuth/OIDC).
+- `severity` — `critical|high|medium|low|info` per §5.
+- `status` — `confirmed` (proven/reproduced) | `likely` (clear trace, no live
+ PoC) | `triage` (needs verification).
+- `confidence` — `low|medium|high`.
+- `file`, `line`, `end_line` — the sink (the verify/compare/store/session call).
+- `code_excerpt` — the minimal vulnerable lines (the decode/verify options, the
+ hash call, the session config, the token gen).
+- `source` — exact untrusted origin, e.g. `req.cookies.tok` (JWT from cookie),
+ `request.args["token"]` (reset token from URL), `req.query.code` (OAuth code).
+- `sink` — exact dangerous op, e.g. `jwt.verify(tok, key)` (no `algorithms`),
+ `hashlib.md5(pw)`, `session["uid"]=...` (no regen), `token == stored`.
+- `data_flow` — `source -> ... -> sink`, naming each hop, and **explicitly
+ noting any verification/sanitizer seen and why it is insufficient** (no alg
+ pin, signature off, secret guessable, no expiry, fast hash, non-const compare).
+- `sanitizers_checked` — the §4 FP guard you verified: which controls you looked
+ for (alg allow-list, signature on, strong secret, session regen, secure cookie
+ flags, CSPRNG + single-use + expiry token, slow salted hash, constant-time
+ compare, `state`/`redirect_uri`/`id_token` validation) and that each is
+ **absent, disabled, or ineffective**. Mandatory; empty/hand-wavy ⇒ not credible.
+- `rationale` — why reachable + exploitable; cite the exact missing check.
+- `exploit_sketch` — concrete attacker steps (e.g. "craft a JWT with
+ `{"alg":"none"}` and `sub:1`, strip the signature, send as cookie → server
+ trusts it as admin").
+- `dynamic_poc_plan` — the live request(s) and the observed result that proves it
+ (see §7).
+- `proposed_fix` — high-level direction, not a patch: 1-2 sentences naming WHAT
+ must change and WHY, leaving the exact code to the engineer/agent who picks up
+ the issue. E.g. "Pin the accepted JWT algorithm to the issuer's signing scheme
+ and reject unsigned/`none` tokens so forged claims can't be trusted." No code
+ diff, exact code, line-level edits, or step-by-step implementation.
+
+Fill `source`, `sink`, `data_flow`, and `sanitizers_checked` precisely — they are
+the evidence a reviewer re-checks. A finding without a clear source→sink and a
+verified-absent control is `triage` at best.
+
+## 7. Dynamic PoC strategy
+
+Goal: prove identity can be **forged, bypassed, fixed, or recovered** against a
+running instance. Capture the exact request (method, path, headers/cookie, body),
+the response, and a negative control. On success set `status: confirmed`.
+
+1. **JWT `alg:none` / unverified:** take a valid token (or mint one with the
+ claimed structure), set the header to `{"alg":"none","typ":"JWT"}`, change
+ `sub`/`role` to a target (admin), drop the signature (keep trailing dot), send
+ it. *Proof*: an authenticated/privileged response (`200`, admin data) where a
+ tampered token must yield `401`. Also try HS-signing with the server's public
+ key (RS→HS confusion) and a wordlist brute of an HS secret (`jwt_tool`,
+ `hashcat -m 16500`).
+2. **Weak signing secret:** crack the JWT offline; re-sign a forged admin token
+ with the recovered secret and replay. *Proof*: server accepts the re-signed
+ token as that identity.
+3. **Session fixation:** obtain a session id pre-auth (or set a chosen one),
+ have the victim authenticate within that session, then reuse the SAME id.
+ *Proof*: the pre-login id is now authenticated (no rotation observed in
+ `Set-Cookie` after login).
+4. **Insecure cookie / theft:** inspect `Set-Cookie` for missing `HttpOnly`/
+ `Secure`/`SameSite`; *proof* is the flags' absence plus a reachable XSS or
+ non-TLS path that would exfiltrate it (note the chain dependency).
+5. **Weak/replayable reset token:** trigger a reset, capture the token; show it
+ is short/predictable (enumerate the space, or derive from time/uuid1), reuse
+ it twice, or use it after the stated TTL. *Proof*: a password change accepted
+ with a guessed/reused/expired token. For CWE-620: hit the reset endpoint with
+ only `user_id` + new password (no token) and confirm the change.
+6. **Plaintext/weak hash:** if a self-registration + DB-dump or admin export is
+ reachable, show the stored value equals `md5(pw)`/the plaintext. Otherwise
+ prove statically from the write path and treat as `likely`.
+7. **OAuth `state`/`redirect_uri`:** start a flow, drop or alter `state` on the
+ callback — *proof*: a session still issued (login CSRF). Supply an attacker
+ `redirect_uri` not on the server allow-list — *proof*: the code/token is sent
+ to the attacker origin. Submit an unsigned/wrong-`aud` `id_token` — *proof*:
+ session issued.
+8. **Auth-bypass logic:** send the attacker-controlled trust input
+ (`X-User: admin`, empty/null password, default account) — *proof*: a
+ privileged response instead of `401`.
+
+Negative control (a correctly-signed token, a fresh post-login session id, a
+valid single-use token, a well-formed `state`) returning the EXPECTED behavior
+makes the proof unambiguous. Record the request, actor, and response in
+`dynamic_poc_plan`.
diff --git a/plugins/security/prompts/finders/crypto.md b/plugins/security/prompts/finders/crypto.md
new file mode 100644
index 0000000..10fb6ef
--- /dev/null
+++ b/plugins/security/prompts/finders/crypto.md
@@ -0,0 +1,324 @@
+
+
+# Finder — Cryptographic Failures (`crypto`)
+
+**Class key:** `crypto` · **OWASP:** A04:2025 · **CWE:** CWE-327 (broken/weak
+algo) / CWE-328 (weak hash) / CWE-326 (inadequate strength) / CWE-330 (weak RNG)
+/ CWE-331 (insufficient entropy) / CWE-916 (unsalted/fast password hash) /
+CWE-295 (improper cert validation) · **ASVS:** V11
+
+## 1. Objective
+
+Find places where data that needs cryptographic protection is protected with a
+**broken primitive, a broken mode, a predictable parameter, or no real
+verification** — such that an attacker can decrypt, forge, predict, MITM, or
+crack it. The bug is the crypto choice/parameterization itself, reachable on
+data an attacker controls or wants. The fix is a correct primitive (AEAD, KDF,
+strong hash, CSPRNG, validated TLS).
+
+## 2. Where to look
+
+Crypto failures cluster at a handful of surfaces. Map the data first: *what is
+being protected, who can reach it, what breaks if it's forged/decrypted/cracked.*
+
+- **Auth & credential storage:** password hashing in user/account models,
+ `set_password`/`hash_password`, session-token & API-key generation,
+ password-reset / email-verify / invite token mint, "remember me" cookies,
+ TOTP/2FA secret handling. Weak hash or weak RNG here is the highest-value find.
+- **Token / signature layers:** JWT signing & verification (`alg`, key choice),
+ HMAC over webhooks/callbacks, signed URLs / signed cookies, CSRF tokens,
+ license/entitlement signing, document/PDF signing.
+- **At-rest encryption:** field-level encryption in ORMs/models (PII, card data,
+ secrets columns), "encrypt this blob" helpers, config/secret encryptors,
+ backup encryption, KMS/envelope-encryption wrappers, cookie/session
+ serializers that encrypt.
+- **TLS / transport clients:** every outbound HTTP/DB/SMTP/LDAP/gRPC/MQ client —
+ look for verification toggles in client construction, custom `TrustManager`/
+ `SSLContext`, `verify=False`, `rejectUnauthorized:false`, `InsecureSkipVerify`,
+ custom hostname verifiers, pinning that's been disabled.
+- **Crypto utility modules:** `crypto.rb`, `cipher.go`, `encryption.py`,
+ `Crypto.cr`, `util/hash`, `security/`, anything `*encrypt*`/`*cipher*`/`*sign*`/
+ `*token*`/`*nonce*`/`*salt*`/`*kdf*`.
+- **Key material:** where keys/IVs/salts/secrets are *sourced* — hardcoded
+ literals, committed PEM/`.key`/`.pem`/`keystore`/`.jks`, default-valued config,
+ keys derived from a low-entropy/static seed, IV/salt declared as a constant or
+ reused across messages.
+
+Per-language SINK / API signals:
+
+- **Crystal:** `Digest::MD5`/`Digest::SHA1` over secrets, `OpenSSL::Cipher.new`
+ with `"des"`/`"rc4"`/`"aes-128-ecb"`, `Random` (non-secure) vs
+ `Random::Secure` for tokens, `OpenSSL::SSL::Context` with
+ `verify_mode = OpenSSL::SSL::VERIFY_NONE`, hardcoded `Crypto::Subtle` keys,
+ `Random.rand`/`rand` for token bytes.
+- **Ruby:** `Digest::MD5/SHA1.hexdigest(password)`, `OpenSSL::Cipher.new('DES'
+ /'RC4'/'AES-128-ECB')`, `cipher.iv = "0"*16` / fixed IV, `rand`/`SecureRandom`
+ misuse, `OpenSSL::SSL::VERIFY_NONE`, Net::HTTP `verify_mode=`, `JWT.decode(t,
+ nil, false)` / `algorithm: 'none'`, `ActiveSupport::MessageEncryptor` with a
+ short/static key, `Digest::SHA256` used as a password KDF (no salt/stretch).
+- **Node/TS:** `crypto.createHash('md5'|'sha1')` for passwords,
+ `crypto.createCipheriv('aes-256-ecb'|'des'|'rc4', ...)`, fixed/zero IV buffer,
+ `Math.random()` for tokens/IDs/secrets, `crypto.randomBytes` good vs
+ `Math.random` bad, `rejectUnauthorized:false` / `NODE_TLS_REJECT_UNAUTHORIZED=
+ '0'`, `https.Agent({rejectUnauthorized:false})`, `jwt.verify(t, key, {
+ algorithms:['none']})` / `jwt.decode` used as verify, `jsonwebtoken` with a
+ weak/hardcoded secret, bcrypt rounds `< 10` / plain `pbkdf2` low iters.
+- **Python:** `hashlib.md5/sha1(pw)`, `Crypto.Cipher.DES`/`ARC4`/`AES.new(key,
+ AES.MODE_ECB)`, static `iv=b'\x00'*16`, `random.random()`/`random.randint`/
+ `random.choice` for tokens (vs `secrets`/`os.urandom`), `ssl._create_unverified_
+ context()` / `verify=False` (requests) / `cert_reqs=ssl.CERT_NONE` /
+ `check_hostname=False`, `jwt.decode(t, verify=False)` / `options={'verify_
+ signature':False}` / `algorithms=['none']`, `hashlib.pbkdf2_hmac` low iters,
+ Django `make_password` overridden to MD5.
+- **Go:** `crypto/md5`/`crypto/sha1`/`crypto/des`/`crypto/rc4` for security,
+ `cipher.NewCBCEncrypter` with a static IV / ECB-style block loop, `math/rand`
+ (incl. `rand.Seed(time.Now())`) for tokens/keys instead of `crypto/rand`,
+ `tls.Config{InsecureSkipVerify:true}`, custom `VerifyConnection` that returns
+ nil, `jwt.ParseWithClaims` accepting `none`/no key check, `x509`
+ `InsecureSkipVerify`.
+- **PHP:** `md5($pw)`/`sha1($pw)`/`crypt()` w/ DES, `mcrypt_*`,
+ `openssl_encrypt($d,'aes-256-ecb',...)` or `'des-ede3'`/`'rc4'`, fixed `$iv`,
+ `rand()`/`mt_rand()`/`uniqid()` for tokens (vs `random_bytes`/
+ `random_int`), `CURLOPT_SSL_VERIFYPEER=>false` / `CURLOPT_SSL_VERIFYHOST=>0`,
+ `'verify'=>false` (Guzzle), `password_hash` good vs raw hash, JWT libs with
+ `'none'`/HS256 confusion.
+- **Java:** `MessageDigest.getInstance("MD5"|"SHA-1")` for passwords,
+ `Cipher.getInstance("DES"|"RC4"|"AES/ECB/PKCS5Padding"|"AES")` (bare "AES" =
+ ECB), `new IvParameterSpec(new byte[16])` static IV, `new Random()` /
+ `Math.random()` for tokens (vs `SecureRandom`), custom `X509TrustManager` with
+ empty `checkServerTrusted`, `setHostnameVerifier((h,s)->true)` /
+ `ALLOW_ALL_HOSTNAME_VERIFIER`, `SSLContext` w/ trust-all, JWT `none`,
+ hardcoded `SecretKeySpec(literal.getBytes(), ...)`.
+- **Rust:** `md5`/`sha1`/`md-5` crates for secrets, `Des`/`Rc4`/ECB block modes,
+ `rand::thread_rng()` for tokens that need a CSPRNG (vs `rand::rngs::OsRng` /
+ `getrandom`), `danger_accept_invalid_certs(true)` / `danger_accept_invalid_
+ hostnames(true)` (reqwest), `rustls` `dangerous()` custom verifier returning
+ Ok, hardcoded key bytes, static `nonce`/`iv` arrays for `aes-gcm`/`chacha20`.
+
+## 3. Detection heuristics
+
+**Taint perspective.** This class is partly *parameter-driven* (the SOURCE is the
+crypto config/key/IV/RNG choice in the code, not always external input) and
+partly *flow-driven* (untrusted ciphertext/token/MITM position reaches a sink
+that fails to verify). Capture both in `source`/`sink`:
+
+- **SOURCE** = the data being protected and *who can reach or supply it*: a
+ password an attacker can offline-crack after a DB leak; a token an attacker
+ receives and must not be able to forge/predict; ciphertext/cookie the attacker
+ holds; a network position where the attacker can MITM the TLS client; the
+ attacker-supplied `alg`/header that a verifier trusts.
+- **SINK** = the weak crypto operation: the hash/cipher/mode/IV/RNG/verify call
+ that is broken or unverified.
+
+Vulnerable patterns to confirm (each needs a reachable SOURCE):
+
+- **Broken hash for passwords (CWE-916/328):** `MD5`/`SHA1`/`SHA-256`/`SHA-512`
+ used *directly* as a password store. Fast hashes (even SHA-256) are wrong for
+ passwords — they must use a memory-hard/iterated KDF (bcrypt/scrypt/argon2/
+ PBKDF2-high-iter). Reachable via any DB compromise → offline cracking. Confirm
+ the hash output is the stored credential and no KDF wraps it.
+- **Broken hash for integrity/signature (CWE-328):** MD5/SHA1 in an HMAC-less
+ "signature", a hand-rolled `hash(secret + msg)` (length-extension), or MD5
+ collision-relevant contexts.
+- **Broken/weak cipher (CWE-327):** DES, 3DES, RC4, Blowfish for new data; RSA
+ with PKCS#1 v1.5 in a padding-oracle-prone spot; export-grade params.
+- **ECB mode (CWE-327):** any `*-ECB` / bare `Cipher.getInstance("AES")`
+ (defaults to ECB) / manual block loop without chaining — identical plaintext
+ blocks leak as identical ciphertext blocks.
+- **Static / reused / predictable IV or nonce (CWE-329/330):** IV hardcoded
+ (`\x00`*16), derived from a constant, or reused across messages with the same
+ key. Catastrophic for CTR/GCM/ChaCha20 (nonce reuse breaks confidentiality and
+ forgeability) and weakens CBC.
+- **Static / missing salt, or fast unsalted hash (CWE-916/759/760):** one global
+ salt, no salt, or salt = username; enables rainbow-table / cross-account
+ cracking.
+- **Weak RNG for security (CWE-330/338):** `Math.random`, `rand`, `mt_rand`,
+ `random.random`, `math/rand`, `java.util.Random`, `time`-seeded RNG, or
+ incrementing/`uniqid`/timestamp used to mint **session tokens, password-reset
+ tokens, API keys, IVs, salts, OTPs, CSRF tokens, password salts, or key
+ material**. Predictable → forgeable/guessable. (RNG for non-security shuffles/
+ jitter is fine.)
+- **Disabled TLS verification (CWE-295):** verification turned off on an outbound
+ client to a security-relevant peer — `verify=False`, `rejectUnauthorized:false`,
+ `InsecureSkipVerify:true`, `VERIFY_NONE`, trust-all `TrustManager`, hostname
+ verifier returning true, `danger_accept_invalid_certs`. Enables MITM →
+ credential/data theft, response forgery.
+- **Hardcoded / committed key, IV, or secret (CWE-321/798):** symmetric key,
+ HMAC/JWT secret, or private key as a string literal, default config value, or
+ committed file — anyone with source/binary can decrypt/forge. (If it's purely a
+ *secret leak* with no crypto-op context, that's the `secrets` finder; here the
+ point is the key feeding a crypto sink that's now defeated.)
+- **JWT alg/verify failures (CWE-327/347):** `alg:none` accepted, signature
+ verification skipped (`decode` used where `verify` is required), HS/RS
+ algorithm confusion (RSA public key used as HMAC secret), unconstrained
+ `algorithms` list, or symmetric secret that is weak/guessable.
+- **Bad key management (CWE-320/322):** key derived from a low-entropy
+ passphrase without a KDF, no separation between signing/encryption keys, key
+ reused as both IV and key, ECDH/RSA without authentication.
+
+## 4. Not-a-finding (false-positive guard) — check BEFORE flagging
+
+Do NOT report if any of these holds on the path:
+
+- **Correct password KDF in place:** `bcrypt`, `scrypt`, `argon2`/`argon2id`, or
+ `PBKDF2` with a sane iteration/cost (bcrypt cost ≥ 10/12, PBKDF2 ≥ ~100k iters,
+ argon2 default params) and a per-user salt. A plain SHA-256 you *thought* was
+ the store but is actually wrapped by `password_hash`/`bcrypt`/Devise/
+ `Argon2`/Django's `PBKDF2PasswordHasher` is safe — trace what's actually
+ persisted.
+- **Strong AEAD with unique nonce:** AES-GCM / ChaCha20-Poly1305 / AES-CBC+HMAC
+ (encrypt-then-MAC) where the IV/nonce is freshly generated per message from a
+ CSPRNG (`randomBytes`/`os.urandom`/`SecureRandom`/`crypto/rand`/`OsRng`). A
+ random per-message IV is correct even if the variable name is `iv` — verify
+ it's regenerated, not constant.
+- **Non-security use of weak primitive:** MD5/SHA1/CRC for cache keys,
+ ETags, content-addressing/dedup, checksums of non-adversarial data, file
+ fingerprints, sharding, bloom filters — **not** a finding (note it only if it
+ guards a trust decision). `Math.random` for UI jitter, A/B bucketing, retry
+ backoff, or non-secret IDs is fine. The bar is: does breaking it grant an
+ attacker anything?
+- **CSPRNG actually used:** the token/IV/salt comes from `crypto.randomBytes`,
+ `secrets.token_*`/`os.urandom`, `SecureRandom`, `crypto/rand`, `OsRng`,
+ `Random::Secure` — even if a weak RNG exists elsewhere in the file for
+ non-security purposes.
+- **TLS verification is on / toggle is unreachable:** `verify=False` etc. gated
+ behind a dev/test-only branch that cannot run in production (env guard you can
+ confirm), or pointed only at a localhost/test fixture, or the disable is in a
+ test file / mock. Confirm the branch is actually unreachable in prod before
+ dropping; if the toggle keys off an attacker- or operator-misconfigurable env
+ var that defaults insecure, it IS a finding.
+- **Key sourced from real secret management:** key/secret read from env, a
+ vault/KMS/HSM, or a runtime-injected config — not a literal. A literal that is
+ obviously a *placeholder/example* in a `.example`/test fixture with no prod
+ wiring is not a live finding (note it; it may be a `secrets` item).
+- **JWT verified correctly:** `verify` with a pinned algorithm allowlist that
+ matches the key type (RS256 with a public key, HS256 with a server secret),
+ `none` rejected, `kid`/issuer/audience checked. The mere presence of `decode`
+ is fine if a `verify` happens first.
+- **Legacy compatibility with a real migration/guard:** a weak verifier kept only
+ to *read* legacy records but rehashing/re-encrypting on next use, with no path
+ that lets an attacker force the weak path. Confirm the upgrade-on-verify exists.
+
+If a guard exists but is bypassable — bcrypt cost too low to matter, PBKDF2 with
+1k iters, AEAD whose nonce is actually a counter that resets, TLS verify gated on
+a header/param an attacker sets, JWT allowlist that still includes a confusable
+alg, "salt" that is constant — it is NOT a mitigation. Flag it and name the
+exact bypass in `sanitizers_checked`.
+
+## 5. Severity guidance
+
+- **Critical** — break yields direct, unauth, high-impact compromise: predictable
+ password-reset/session tokens from a weak RNG (account takeover); `alg:none`/
+ skipped JWT verification or HS/RS confusion (auth bypass / forge any token);
+ disabled TLS verification on a path carrying credentials or auth tokens to a
+ MITM-reachable peer; hardcoded key/secret that decrypts production data or
+ forges signatures for all users; nonce reuse on AES-GCM exposing plaintext or
+ enabling forgery of authenticated messages.
+- **High** — realistically-conditioned high impact: fast/unsalted password hash
+ (MD5/SHA1/raw-SHA256) — full offline cracking after any DB leak; ECB/DES/RC4 or
+ static IV protecting PII/secrets at rest that an attacker can obtain; weak RNG
+ for API keys behind authn; disabled TLS verify on an internal-but-sensitive
+ client.
+- **Medium** — constrained: weak crypto over data with limited sensitivity or
+ high attacker cost; static salt with an otherwise-strong KDF; weak RNG for a
+ token with short TTL + rate limiting; padding-oracle-prone construction needing
+ specific conditions; partial mitigation that raises but doesn't close the bar.
+- **Low/Info** — weak primitive in a non-security context, or theoretical with no
+ reachable SOURCE — usually downgrade or drop per §4. A committed example key
+ with no prod wiring → Info/`secrets`.
+
+## 6. Emit findings as
+
+One JSON object per distinct root cause (dedup call sites; list extras in
+`rationale`). Fields:
+
+```json
+{
+ "id": "crypto-001",
+ "title": "Unsalted SHA1 used as the password store — offline-crackable on DB leak",
+ "vuln_class": "crypto",
+ "owasp": "A04:2025",
+ "cwe": "CWE-916",
+ "asvs": "V11",
+ "severity": "high",
+ "status": "likely",
+ "confidence": "high",
+ "file": "app/models/user.py",
+ "line": 41,
+ "end_line": 43,
+ "code_excerpt": "self.password_hash = hashlib.sha1(password.encode()).hexdigest()",
+ "source": "user passwords for all accounts; attacker reaches them via any DB read/dump (SQLi, backup, insider) and cracks offline",
+ "sink": "hashlib.sha1(...).hexdigest() persisted as the credential — a single-pass fast hash, no salt, no key-stretching",
+ "data_flow": "password -> sha1() (one pass, no salt) -> users.password_hash column; verification recomputes the same sha1. No KDF/salt/cost between the password and the stored value.",
+ "sanitizers_checked": "no bcrypt/scrypt/argon2/PBKDF2 wrapper anywhere on set or verify; no per-user salt column; not Django make_password (raw hashlib); SHA1 is ~GH/s on commodity GPUs so cost factor is effectively zero",
+ "rationale": "Reachable for the entire user table the moment the DB leaks. Same pattern at admin.py:88 (admin reset). Single root cause: the hashing helper.",
+ "exploit_sketch": "Obtain users.password_hash (e.g. via the SQLi at report.py:71). hashcat -m 100 against the unsalted SHA1 list cracks weak/common passwords in minutes, recovering plaintext for credential reuse.",
+ "dynamic_poc_plan": "Register a user with a known password via the live signup endpoint; read the stored hash from the DB/test harness; show hashlib.sha1(known_pw) == stored value (proves no salt/KDF), then crack a second weak password with hashcat to demonstrate recovery.",
+ "proposed_fix": "Move password storage onto a memory-hard, salted KDF instead of a single-pass unsalted hash, so a DB leak no longer enables practical offline cracking. (High-level direction, not a patch — the implementing engineer chooses the KDF, parameters, and legacy-migration approach.)"
+}
+```
+
+Accuracy bar: `source`, `sink`, `data_flow`, and `sanitizers_checked` must be
+concrete and true. State explicitly *what is protected and who reaches it*
+(`source`), the *exact weak operation* with the real API name (`sink`), how the
+data reaches that operation and why the primitive/parameter is broken
+(`data_flow`), and which §4 mitigation is absent or, if present, the exact bypass
+(`sanitizers_checked`). A weak primitive with **no reachable thing it protects**
+is not a finding. Pick `cwe` by failure mode: 327 broken/weak algo or mode,
+328 weak hash, 916 unsalted/fast password hash, 326 inadequate strength,
+330/331/338 weak RNG/entropy, 329 static IV/nonce, 295/347 cert/signature
+verification. Use `status:"likely"` for a proven static trace, `"confirmed"`
+only after dynamic repro, `"triage"` if the protected SOURCE or reachability is
+uncertain.
+
+## 7. Dynamic PoC strategy
+
+Goal: prove the chosen primitive/parameter is actually broken and exploitable on
+the running system. Pick the oracle matching the failure mode:
+
+1. **Weak password hash.** Register/seed a user with a known password via the
+ live endpoint; extract the stored hash (DB, debug route, or test harness).
+ **Observed proof** = `weak_hash(known_pw [+salt])` reproduces the stored value
+ bit-for-bit (no KDF/salt), and a second weak password cracks under `hashcat`/
+ `john` with the matching mode — recovering plaintext.
+2. **Predictable token (weak RNG).** Trigger many token mints (signup, password
+ reset, API-key create) and capture the values. **Observed proof** = tokens are
+ sequential/correlated, or — given the seed source (PID/time) — you predict the
+ next token and use it to claim another user's reset/session, completing an
+ account takeover against the live app.
+3. **ECB / static IV / nonce reuse.** Submit two plaintexts with identical
+ blocks (or the same plaintext twice) through the encrypt endpoint and capture
+ ciphertext. **Observed proof** = identical plaintext blocks yield identical
+ ciphertext blocks (ECB), or two messages share the IV/nonce (CTR/GCM reuse) —
+ then recover XOR of plaintexts / forge a GCM tag to demonstrate decryption or
+ forgery.
+4. **Disabled TLS verification.** Point the client at a host you control with a
+ self-signed/mismatched cert (DNS override, `/etc/hosts`, or a proxy like
+ mitmproxy). **Observed proof** = the client completes the request against the
+ bad cert (no error), and you capture/alter the plaintext payload (e.g. the
+ credentials/token it sent) — proving MITM.
+5. **JWT alg/verify failure.** Take a valid token, set header `alg:none` and
+ strip the signature, or sign with the public key as an HMAC secret (HS/RS
+ confusion), or forge with the hardcoded/guessed secret. **Observed proof** =
+ the live app accepts the forged token (returns the victim's data / an
+ authenticated session) — impossible if verification were correct.
+6. **Hardcoded key.** Use the literal key from source to decrypt a captured
+ ciphertext/cookie or forge a valid signed token, then replay it. **Observed
+ proof** = the app accepts the forged/decrypted artifact as authentic.
+
+Record the exact payload/command and observed evidence in the `Repro` object
+(`reproduced`, `method:"live-exploit"`, `poc`, `observed`, `impact`). A
+reproduced forgery/decryption/MITM or a bit-for-bit weak-hash match proves the
+class — set `method:"live-exploit"`. If the app can't be run, fall back to a
+focused unit test that drives the crypto helper and asserts the broken property
+(ECB block equality, static IV, hash without salt, `verify` accepting a forged
+token) — `method:"unit-test"`.
diff --git a/plugins/security/prompts/finders/csrf-cors.md b/plugins/security/prompts/finders/csrf-cors.md
new file mode 100644
index 0000000..ace849f
--- /dev/null
+++ b/plugins/security/prompts/finders/csrf-cors.md
@@ -0,0 +1,346 @@
+
+
+# Finder — CSRF, CORS & Clickjacking (csrf-cors)
+
+**Class key:** `csrf-cors` · **OWASP:** A01:2025 · **CWE:** CWE-352, CWE-1021, CWE-942 · **ASVS:** V3
+
+## 1. Objective
+
+Find state-changing endpoints that a foreign web origin can drive on a
+logged-in victim's behalf — via a forged cross-site request (no anti-CSRF
+token / no SameSite cookie), a permissive CORS policy that lets an attacker
+origin read credentialed responses, or a missing framing defense that allows
+clickjacking of a sensitive action.
+
+## 2. Where to look
+
+The attack target is the **ambient-credential boundary**: any endpoint
+authenticated by a cookie/session, HTTP Basic, or a client TLS cert that the
+browser attaches automatically on cross-site requests. Bearer tokens read from
+JS-controlled storage (`Authorization: Bearer`) are NOT auto-attached, so they
+are generally CSRF-immune — confirm the auth mechanism before flagging.
+
+Entry points / surfaces:
+
+- **State-changing routes:** `POST`/`PUT`/`PATCH`/`DELETE` handlers, but also
+ `GET` handlers that mutate (logout, "delete via link", `/transfer?to=...`,
+ toggle/enable/disable, admin actions). GET-that-mutates is forgeable with a
+ bare ``/``.
+- **Global CSRF config:** the framework's CSRF middleware enable/disable site,
+ and per-route/per-controller `skip`/`exempt` annotations. The bug is usually
+ the *exemption*, not the absence.
+- **Cookie/session setup:** where the session cookie is issued — its
+ `SameSite`, `Secure`, `HttpOnly` attributes drive cross-site
+ exploitability.
+- **CORS config:** middleware/handlers that set `Access-Control-Allow-Origin`
+ (ACAO), `-Allow-Credentials` (ACAC), `-Allow-Methods`, `-Allow-Headers`,
+ `-Expose-Headers`; reflected-origin logic; preflight (`OPTIONS`) handlers.
+- **Framing/headers:** where `X-Frame-Options` / CSP `frame-ancestors` are
+ set (or globally not set) for sensitive pages (login, OAuth consent, fund
+ transfer, account settings, admin).
+- **Cross-origin message channels:** browser `window.postMessage` handlers
+ (`message` event listeners) that act on data without checking
+ `event.origin` — a CSRF-adjacent cross-origin sink.
+
+Route/handler & config signals to grep:
+
+- **Crystal:** Lucky `protect_from_forgery`, Amber `CSRF` pipe / `csrf_token`,
+ Kemal — *no built-in CSRF*, so cookie-auth Kemal apps are bare unless they
+ roll their own; `Access-Control-Allow-Origin` header writes via
+ `context.response.headers`.
+- **Ruby/Rails:** `protect_from_forgery`, `skip_before_action
+ :verify_authenticity_token`, `skip_forgery_protection`,
+ `protect_from_forgery with: :null_session`, `config.action_controller
+ .forgery_protection_origin_check`, `Rack::Cors` `allow do origins ...`,
+ Sinatra `Rack::Protection` (and `Rack::Protection` *disabled*).
+- **Node/TS:** `csurf`/`csrf-csrf`/`@fastify/csrf-protection` (presence &
+ exemptions); `cors` package `origin: true`/`origin: '*'` with
+ `credentials: true`; manual `res.setHeader('Access-Control-Allow-Origin',
+ req.headers.origin)`; `helmet` framing config; Express session cookie
+ `sameSite`.
+- **Python:** Django `@csrf_exempt`, `CsrfViewMiddleware` removed from
+ `MIDDLEWARE`, `CSRF_TRUSTED_ORIGINS`, `CORS_ALLOW_ALL_ORIGINS`/
+ `CORS_ORIGIN_ALLOW_ALL`, `CORS_ALLOWED_ORIGIN_REGEXES`,
+ `CORS_ALLOW_CREDENTIALS`, `django-cors-headers`; Flask `flask-wtf`
+ `CSRFProtect` (presence) / `WTF_CSRF_ENABLED=False` / `@csrf.exempt`,
+ `flask-cors` `CORS(app, ...)`; `SESSION_COOKIE_SAMESITE`,
+ `X_FRAME_OPTIONS`/`SecurityMiddleware`, FastAPI `CORSMiddleware
+ allow_origins=["*"], allow_credentials=True`.
+- **Go:** `rs/cors` `AllowedOrigins: []string{"*"}` + `AllowCredentials: true`
+ or `AllowOriginFunc: func(o string){ return true }`; gin
+ `cors.Config{AllowAllOrigins:true}`; manual
+ `w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))`;
+ most Go routers have *no* CSRF by default — look for `gorilla/csrf`,
+ `nosurf`; `http.SetCookie` SameSite field.
+- **PHP:** Laravel `VerifyCsrfToken` `$except` array / `csrf_field()`;
+ Symfony `csrf_protection: false` / `is_csrf_token_valid`; raw apps with no
+ token at all; `header("Access-Control-Allow-Origin: " . $_SERVER
+ ['HTTP_ORIGIN'])`, `header("Access-Control-Allow-Credentials: true")`.
+- **Java:** Spring Security `.csrf().disable()` /
+ `csrf(AbstractHttpConfigurer::disable)` / `.ignoringRequestMatchers(...)`;
+ `CorsConfiguration.setAllowedOrigins(List.of("*"))` /
+ `addAllowedOriginPattern("*")` + `setAllowCredentials(true)`;
+ `@CrossOrigin(origins="*", allowCredentials="true")`;
+ `setAllowedOriginPatterns` with `*`; framing via
+ `headers().frameOptions().disable()`.
+- **Rust:** `actix-cors` `Cors::permissive()` / `allow_any_origin()` +
+ `supports_credentials()`; `tower-http` `CorsLayer::permissive()` /
+ `AllowOrigin::any()` / `AllowOrigin::mirror_request()` +
+ `allow_credentials(true)`; most Rust frameworks have *no* CSRF
+ middleware — cookie-auth handlers are bare unless a token scheme is rolled.
+
+## 3. Detection heuristics
+
+**Taint SOURCES** (untrusted / attacker-controlled): the *cross-site request*
+itself (forced by attacker HTML/JS from another origin while the victim is
+logged in) and, for CORS, the attacker-chosen `Origin` request header
+reflected into a response header. For postMessage, the cross-origin message
+`event.data`. The "input" here is the request's *provenance*, not a parameter
+value — the question is whether a foreign origin can issue/read it.
+
+**Taint SINKS** (dangerous op):
+- **CSRF:** a state-changing handler (DB write, money/permission/account
+ mutation, OS/admin action) reachable with **ambient credentials** and **no
+ unguessable token tied to the session** required.
+- **CORS:** writing `Access-Control-Allow-Origin` to a value derived from /
+ equal to the request `Origin`, **together with** `Access-Control-Allow-
+ Credentials: true` — letting the attacker origin's JS read the credentialed
+ response body.
+- **Clickjacking:** rendering a sensitive, state-changing UI with **no**
+ `X-Frame-Options: DENY/SAMEORIGIN` and **no** CSP `frame-ancestors`.
+- **postMessage:** acting on `event.data` (navigation, token relay, state
+ change) without validating `event.origin` against an allowlist.
+
+Vulnerable patterns to confirm:
+
+- **CSRF protection disabled / exempted on a mutating, cookie-auth route:**
+ - Rails: `skip_before_action :verify_authenticity_token` (or
+ `protect_from_forgery with: :null_session`) on a controller that writes.
+ - Django: `@csrf_exempt` on a `POST` view that mutates; or
+ `CsrfViewMiddleware` absent from `MIDDLEWARE`.
+ - Flask: app uses session cookies but no `CSRFProtect`/`flask-wtf`, or
+ `@csrf.exempt` / `WTF_CSRF_ENABLED=False`.
+ - Spring: `http.csrf(csrf -> csrf.disable())` while `formLogin`/session
+ cookies are in use.
+ - Laravel: route/path listed in `VerifyCsrfToken::$except`.
+ - Go/Rust/Kemal: cookie-session app with **no token scheme present at all**
+ on mutating handlers.
+- **GET that mutates:** `get "/account/delete"`, `app.get('/logout', ...)` that
+ ends a session or writes — forgeable with a plain `` regardless of
+ token middleware (which typically only guards unsafe methods).
+- **Reflected-origin CORS with credentials:** ACAO set to the request Origin
+ (or `*` paired — illegally but some stacks coerce — with credentials) and
+ ACAC `true`:
+ - Node: `res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
+ res.setHeader('Access-Control-Allow-Credentials','true')`.
+ - Express `cors`: `cors({ origin: true, credentials: true })` (reflects any
+ origin).
+ - Go: `w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))`
+ + `...Allow-Credentials","true"`.
+ - PHP: `header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
+ header("Access-Control-Allow-Credentials: true");`.
+ - Spring: `@CrossOrigin(origins = "*", allowCredentials = "true")` or
+ `config.setAllowedOriginPatterns(List.of("*"))` +
+ `setAllowCredentials(true)`.
+ - FastAPI/Starlette: `allow_origins=["*"], allow_credentials=True`
+ (Starlette silently mirrors the origin in this combo).
+ - Rust: `Cors::permissive().supports_credentials()` /
+ `CorsLayer::permissive()` then `.allow_credentials(true)`.
+- **Sloppy origin allowlist (bypassable):** origin check by substring/prefix/
+ suffix or unanchored regex:
+ - `origin.endsWith("trusted.com")` → `trusted.com.evil.com` or
+ `nottrusted.com`.
+ - `origin.startsWith("https://trusted.com")` →
+ `https://trusted.com.evil.com`.
+ - `origin.includes("trusted.com")` → `https://evil.com?trusted.com`.
+ - regex `/trusted\.com/` (no anchors / unescaped `.`) → matches
+ `trustedxcom.evil.com`, `eviltrusted.com`.
+ - reflecting `null` origin (`Allow-Origin: null`) — reachable from sandboxed
+ iframes / `data:` documents the attacker controls.
+- **Missing framing defense on sensitive pages:** no `X-Frame-Options` and no
+ `frame-ancestors` on login / OAuth-consent / transfer / settings / admin
+ pages, where a framed UI + a transparent overlay tricks the victim into
+ clicking a real button (clickjacking). Pair with a state-changing action to
+ be a finding, not a bare missing header.
+- **postMessage without origin check:**
+ `window.addEventListener('message', e => { /* uses e.data, no e.origin
+ check */ })` — a foreign frame can drive the handler.
+
+## 4. Not-a-finding (false-positive guard) — check BEFORE flagging
+
+Do NOT report if any of these is present AND effective on the path:
+
+- **No ambient credentials on the route.** If the endpoint authenticates only
+ via a `Authorization: Bearer`/JWT/API-key header read from JS (not a cookie,
+ not Basic, not client cert), a cross-site page cannot attach it → no CSRF.
+ Likewise an endpoint that requires no auth and exposes no per-user state is
+ not a CSRF target. Confirm the actual auth mechanism in code.
+- **Effective anti-CSRF token** present and verified on every unsafe method:
+ framework default (Rails `protect_from_forgery` active, Django
+ `CsrfViewMiddleware` enabled + `{% csrf_token %}`, Spring `csrf()` default,
+ Laravel `VerifyCsrfToken` not exempting the route, flask-wtf `CSRFProtect`
+ active) — a synchronizer/double-submit token bound to the session and
+ unguessable. A double-submit cookie counts only if the token cookie is
+ `__Host-`/`SameSite` and the server compares header-vs-cookie.
+- **`SameSite=Lax` or `Strict` session cookie** (and the route is *not* a
+ top-level GET navigation for `Lax`). Lax is the modern browser default and
+ blocks cross-site POST cookie attachment; with Lax, the remaining CSRF
+ surface is top-level `GET` navigations only — so a Lax cookie largely
+ neutralizes cross-site POST CSRF. Note: state-changing GETs are still
+ exploitable under Lax via top-level navigation; Strict blocks those too.
+- **Origin/Referer validation done correctly** on unsafe methods: parse the
+ `Origin` (or `Referer`) header and **exact-match** against a closed allowlist
+ of full origins (scheme+host+port) — Rails `forgery_protection_origin_check`,
+ a hand-rolled `Origin == "https://app.example.com"` check. Anchored,
+ fully-escaped regex matching a closed set also counts.
+- **CORS that is safe by construction:**
+ - ACAO is a **static, closed allowlist** of exact origins (not the reflected
+ request origin, not `*`), each compared by equality; OR
+ - ACAO is `*` **with credentials NOT enabled** (`Access-Control-Allow-
+ Credentials` absent/false) — browsers refuse to send cookies, and `*`
+ cannot be combined with credentials, so no credentialed read; the response
+ is treated as public anyway. Only a finding if the data behind it is meant
+ to be private and is in fact served (then it is an access-control issue,
+ flag under that class); OR
+ - the response carries **no credentials and no sensitive data** (truly public
+ API). CORS only governs *reading* the response — it never bypasses CSRF
+ protections for *writing*, so a permissive CORS policy on a token-protected,
+ non-credentialed endpoint is not exploitable here.
+- **Framing defense present:** `X-Frame-Options: DENY`/`SAMEORIGIN` **or** CSP
+ `frame-ancestors 'none'`/`'self'`/closed allowlist covering the sensitive
+ page. Either one suffices; do not flag a missing `X-Frame-Options` if
+ `frame-ancestors` is set (and vice-versa). Non-sensitive, non-state-changing
+ pages (marketing, docs) being frameable is not a finding.
+- **postMessage handler validates `event.origin`** against an allowlist before
+ acting (and ideally checks `event.source`).
+- **Method genuinely safe & side-effect-free:** a `GET`/`HEAD` that only reads
+ is not a CSRF sink (reading via forged request yields nothing the attacker
+ can see cross-origin unless CORS leaks it — which is the CORS finding, not
+ CSRF).
+
+If a guard exists but is bypassable (token not actually verified, exemption on
+a mutating route, substring/unanchored-regex origin check, reflected origin with
+credentials, `SameSite=None` without a token, `X-Frame-Options` set but
+duplicated/invalid value browsers ignore, `frame-ancestors` with a wildcard) it
+is NOT a mitigation — flag it and name the exact bypass in `sanitizers_checked`.
+
+## 5. Severity guidance
+
+- **Critical** — forgeable/cross-origin-readable path to a full account or
+ privilege takeover with realistic preconditions: CSRF on
+ change-password/change-email/add-admin/disable-2FA/create-API-key with no
+ token and no SameSite, OR reflected-origin-with-credentials CORS exposing
+ session/admin data or a CSRF-token-bearing response (which then unlocks
+ further CSRF). Unauthenticated-to-admin or one-click account takeover.
+- **High** — CSRF on a significant but not total-takeover action (fund
+ transfer, data deletion, permission change, settings mutation) on a
+ cookie-auth route with a bypassable/absent token; or credentialed CORS with a
+ bypassable origin allowlist (substring/regex) exposing per-user data; or
+ clickjacking of a single-click sensitive state change (delete account,
+ authorize OAuth, transfer).
+- **Medium** — CSRF/CORS where exploitation needs unusual conditions or yields
+ limited impact: SameSite=Lax present so only a state-changing top-level GET
+ is forgeable; CORS leaks non-critical per-user data; clickjacking requiring
+ multi-step drag/social engineering; `null`-origin-only CORS reflection.
+- **Low/Info** — missing framing header on a sensitive-but-not-mutating page,
+ `*` CORS without credentials on a non-sensitive endpoint, or a
+ defense-in-depth gap with no demonstrable cross-origin action. Usually an
+ Info-appendix note, not a body finding.
+
+Note in `rationale` whether the action is one-click vs. multi-step and whether
+auth is required, since that drives the severity.
+
+## 6. Emit findings as
+
+One JSON object per distinct root cause (dedup call sites / shared config;
+list extras in `rationale`). Fields:
+
+```json
+{
+ "id": "csrf-cors-001",
+ "title": "Reflected-origin CORS with credentials exposes authenticated /api/account to any origin",
+ "vuln_class": "csrf-cors",
+ "owasp": "A01:2025",
+ "cwe": "CWE-942",
+ "asvs": "V3",
+ "severity": "critical",
+ "status": "likely",
+ "confidence": "high",
+ "file": "src/middleware/cors.ts",
+ "line": 11,
+ "end_line": 14,
+ "code_excerpt": "res.setHeader('Access-Control-Allow-Origin', req.headers.origin);\nres.setHeader('Access-Control-Allow-Credentials', 'true');",
+ "source": "attacker-chosen Origin request header (req.headers.origin) reflected verbatim; victim has a session cookie auto-attached cross-site",
+ "sink": "Access-Control-Allow-Origin set to the request Origin + Access-Control-Allow-Credentials:true — lets attacker-origin JS read the credentialed response",
+ "data_flow": "req.headers.origin -> res ACAO header (no allowlist/equality check) ; ACAC=true ; applied globally including /api/account which returns the session user's PII and CSRF token",
+ "sanitizers_checked": "no origin allowlist (any origin reflected); credentials explicitly enabled; not gated to safe public endpoints; SameSite irrelevant — CORS read bypasses it; evil.com fetch('/api/account',{credentials:'include'}) succeeds and reads body",
+ "rationale": "Any malicious site visited by a logged-in user can read /api/account (PII + the anti-CSRF token), enabling account-data theft and downstream CSRF against token-protected writes. Same middleware also fronts /api/admin (admin.ts:9).",
+ "exploit_sketch": "On evil.com: fetch('https://app.example.com/api/account',{credentials:'include'}).then(r=>r.json()).then(d=>exfil(d)). ACAO echoes https://evil.com, ACAC:true -> browser exposes the response.",
+ "dynamic_poc_plan": "Authenticate to get a session cookie; replay GET /api/account with header 'Origin: https://evil.com' and observe the response carries 'Access-Control-Allow-Origin: https://evil.com' + 'Access-Control-Allow-Credentials: true' alongside the user's private body — proving cross-origin credentialed read.",
+ "proposed_fix": "Stop trusting the attacker-controlled Origin: the credentialed CORS policy must only echo origins from a closed, trusted allowlist so a foreign site can no longer read authenticated responses. (High-level direction; the exact mechanism is left to the implementing engineer.)"
+}
+```
+
+Accuracy bar: `source`, `sink`, `data_flow`, and `sanitizers_checked` must be
+concrete and true. For this class, `source` names the cross-origin provenance
+(forged request / reflected Origin / cross-origin message) and the auth
+mechanism that makes it exploitable (cookie/Basic — auto-attached). `sink` is
+the precise unguarded mutating handler or the exact ACAO/ACAC/framing config.
+`data_flow` traces how a foreign origin issues/reads the request and names every
+guard encountered and why it fails (token absent/exempt, SameSite=None,
+substring origin match, ACAC+reflection). `sanitizers_checked` is the §4 FP
+guard made explicit — list each control (token, SameSite, Origin check, CORS
+allowlist, framing header, postMessage origin check) and state it is absent or
+name the exact bypass. A route with no ambient credentials, an effective token,
+a SameSite-Lax/Strict cookie on a non-GET sink, or a closed-allowlist CORS
+policy is NOT a finding. Use `status:"likely"` for a proven static trace,
+`"confirmed"` only after dynamic repro, `"triage"` if the auth mechanism /
+reachability is uncertain.
+
+## 7. Dynamic PoC strategy
+
+Goal: prove a foreign origin can drive or read a credentialed action. Establish
+a real authenticated session first (the cookie is the ammunition), then attack
+from a *different* origin.
+
+1. **CSRF (cross-site write):** with a valid session cookie held by the
+ browser/agent, replay the state-changing request **without** the anti-CSRF
+ token and **with** a foreign/absent `Origin`/`Referer`
+ (`Origin: https://evil.example`). **Observed proof:** the server performs the
+ mutation (200 + the side effect verified out-of-band — record changed,
+ password reset, role granted). If it 403s on the missing token / bad Origin,
+ the guard holds → not a finding. For the realistic browser PoC, stand up an
+ attacker page that auto-submits a form / fires `fetch(..,
+ {credentials:'include', mode:'no-cors'})` to the target and confirm the
+ side effect lands while only the cookie (no token) traveled.
+2. **CORS (cross-origin read):** replay a credentialed request to the sensitive
+ endpoint with `Origin: https://evil.example`. **Observed proof:** the
+ response includes `Access-Control-Allow-Origin: https://evil.example` (echoed
+ or wildcard) **and** `Access-Control-Allow-Credentials: true`, and the body
+ contains private/session data — meaning attacker JS would be allowed to read
+ it. Run the bypass probes when an allowlist exists: `Origin:
+ https://trusted.com.evil.example` (suffix), `https://eviltrusted.com`
+ (unanchored regex), `Origin: null` — and check which the server reflects.
+3. **Clickjacking:** request the sensitive page and inspect response headers for
+ `X-Frame-Options` and CSP `frame-ancestors`. **Observed proof:** both absent
+ (or a permissive `frame-ancestors *`); confirm by loading the page in an
+ `