From dd230c77f5e78366685411d3db8592f18c458442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20Eckerstr=C3=B6m?= Date: Wed, 3 Jun 2026 08:02:57 +0000 Subject: [PATCH 1/5] Add security plugin (vulnerability audit) White-box, dynamically-verified security audit. /security:audit recons a target repo, hunts OWASP Top 10:2025 vulnerabilities, proves them with live PoCs in isolated git worktrees, and writes a high-signal senior-engineer report (proven findings with a high-level proposed fix, not speculative noise). --- .claude-plugin/marketplace.json | 8 + README.md | 1 + plugins/security/.claude-plugin/plugin.json | 13 + plugins/security/AGENTS.md | 197 +++++++ plugins/security/README.md | 76 +++ plugins/security/docs/issue-tracking.md | 104 ++++ .../prompts/finders/access-control.md | 290 +++++++++++ .../security/prompts/finders/auth-session.md | 391 ++++++++++++++ plugins/security/prompts/finders/crypto.md | 324 ++++++++++++ plugins/security/prompts/finders/csrf-cors.md | 346 ++++++++++++ .../prompts/finders/deserialization.md | 338 ++++++++++++ plugins/security/prompts/finders/dos-redos.md | 339 ++++++++++++ plugins/security/prompts/finders/injection.md | 300 +++++++++++ .../prompts/finders/logging-errors.md | 355 +++++++++++++ plugins/security/prompts/finders/misconfig.md | 312 +++++++++++ plugins/security/prompts/finders/path-file.md | 337 ++++++++++++ plugins/security/prompts/finders/secrets.md | 311 +++++++++++ plugins/security/prompts/finders/ssrf.md | 251 +++++++++ .../security/prompts/finders/supply-chain.md | 371 +++++++++++++ plugins/security/prompts/finders/xss-ssti.md | 300 +++++++++++ plugins/security/prompts/playbooks/ci-iac.md | 400 ++++++++++++++ plugins/security/prompts/playbooks/crystal.md | 257 +++++++++ .../prompts/playbooks/generic-docker.md | 408 +++++++++++++++ plugins/security/prompts/playbooks/go.md | 411 +++++++++++++++ .../security/prompts/playbooks/java-jvm.md | 491 ++++++++++++++++++ plugins/security/prompts/playbooks/node.md | 339 ++++++++++++ plugins/security/prompts/playbooks/php.md | 399 ++++++++++++++ plugins/security/prompts/playbooks/python.md | 335 ++++++++++++ plugins/security/prompts/playbooks/ruby.md | 265 ++++++++++ plugins/security/prompts/playbooks/rust.md | 458 ++++++++++++++++ plugins/security/prompts/recon.md | 222 ++++++++ plugins/security/prompts/report-template.md | 91 ++++ plugins/security/skills/audit/SKILL.md | 77 +++ plugins/security/workflows/vuln-audit.js | 229 ++++++++ 34 files changed, 9346 insertions(+) create mode 100644 plugins/security/.claude-plugin/plugin.json create mode 100644 plugins/security/AGENTS.md create mode 100644 plugins/security/README.md create mode 100644 plugins/security/docs/issue-tracking.md create mode 100644 plugins/security/prompts/finders/access-control.md create mode 100644 plugins/security/prompts/finders/auth-session.md create mode 100644 plugins/security/prompts/finders/crypto.md create mode 100644 plugins/security/prompts/finders/csrf-cors.md create mode 100644 plugins/security/prompts/finders/deserialization.md create mode 100644 plugins/security/prompts/finders/dos-redos.md create mode 100644 plugins/security/prompts/finders/injection.md create mode 100644 plugins/security/prompts/finders/logging-errors.md create mode 100644 plugins/security/prompts/finders/misconfig.md create mode 100644 plugins/security/prompts/finders/path-file.md create mode 100644 plugins/security/prompts/finders/secrets.md create mode 100644 plugins/security/prompts/finders/ssrf.md create mode 100644 plugins/security/prompts/finders/supply-chain.md create mode 100644 plugins/security/prompts/finders/xss-ssti.md create mode 100644 plugins/security/prompts/playbooks/ci-iac.md create mode 100644 plugins/security/prompts/playbooks/crystal.md create mode 100644 plugins/security/prompts/playbooks/generic-docker.md create mode 100644 plugins/security/prompts/playbooks/go.md create mode 100644 plugins/security/prompts/playbooks/java-jvm.md create mode 100644 plugins/security/prompts/playbooks/node.md create mode 100644 plugins/security/prompts/playbooks/php.md create mode 100644 plugins/security/prompts/playbooks/python.md create mode 100644 plugins/security/prompts/playbooks/ruby.md create mode 100644 plugins/security/prompts/playbooks/rust.md create mode 100644 plugins/security/prompts/recon.md create mode 100644 plugins/security/prompts/report-template.md create mode 100644 plugins/security/skills/audit/SKILL.md create mode 100644 plugins/security/workflows/vuln-audit.js 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..98ea224 --- /dev/null +++ b/plugins/security/README.md @@ -0,0 +1,76 @@ +# 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 output **bundle** is written to `/vuln-audit-reports//` (or +`--out`): `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..94901da --- /dev/null +++ b/plugins/security/docs/issue-tracking.md @@ -0,0 +1,104 @@ +# 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 + 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), `sev:{critical,high,medium}`, + `vuln:`, `fp:`, `status:{confirmed,likely}` (verification outcome). +- **Two distinct "statuses":** *verification* (confirmed/likely — a scan output, + carried as the finding's badge + the `status:` label) 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.) + +## 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. + +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 + `