diff --git a/capabilities/web-security/capability.yaml b/capabilities/web-security/capability.yaml index 302b4a0..3d8b4a5 100644 --- a/capabilities/web-security/capability.yaml +++ b/capabilities/web-security/capability.yaml @@ -1,13 +1,16 @@ schema: 1 name: web-security -version: "1.6.0" +version: "1.7.0" description: > Web application penetration testing with 70+ attack technique playbooks covering request smuggling, cache poisoning, SSRF, SSTI, DOM vulnerabilities, authentication bypasses, parser differentials, AEM/Sling exploitation, GraphQL, OAuth, and client-side attacks. Includes HTTP client tooling with OOB callbacks via interactsh, Caido - and Burp proxy integration via MCP, browser automation via + integration via MCP, the Python caido-sdk-client, and the caido-mode + TypeScript SDK CLI (@caido/sdk-client / caido-ts) for curl-through-Caido + testing, match & replace rules, and replay handoffs; Burp proxy + integration via MCP, browser automation via agent-browser, JS static analysis via jxscout, AST-based code pattern search via ast-grep, protobuf inspection via protoscope, credential management, DNS rebinding, blind SQLi @@ -167,6 +170,8 @@ checks: command: command -v caido-cli - name: caido-mcp-server command: 'command -v caido-mcp-server >/dev/null 2>&1 || test -x "$HOME/bin/caido-mcp-server"' + - name: caido-mode + command: 'test -f skills/caido-mode/caido-client.ts && test -d skills/caido-mode/node_modules' - name: burp command: test -f /opt/burp/burpsuite.jar - name: waymore diff --git a/capabilities/web-security/docker/Dockerfile.runtime b/capabilities/web-security/docker/Dockerfile.runtime index e0cb820..6b48372 100644 --- a/capabilities/web-security/docker/Dockerfile.runtime +++ b/capabilities/web-security/docker/Dockerfile.runtime @@ -26,8 +26,14 @@ # # Tools with bundled SDK/MCP integration (require a running instance # reachable by network — the client library and MCP server are included): -# - Caido — caido-sdk-client + MCP server bundled; set CAIDO_URL -# to a running Caido instance +# - Caido — caido-sdk-client (Python) + caido-mcp-server + the +# caido-mode TypeScript SDK CLI (@caido/sdk-client / caido-ts, +# vendored under skills/caido-mode) are all wired in; set +# CAIDO_URL to a running Caido instance. The caido-mode skill's +# node_modules are installed at provision time by +# scripts/install_tools.sh (the skill dir is mounted at runtime, +# not baked into this image); tsx is pre-installed globally here +# as a cold-start aid. # # Tools NOT included (require external setup): # - Burp — set burp MCP url to a running instance @@ -114,6 +120,12 @@ RUN npm install -g agent-browser \ && agent-browser install || true ENV CHROME_PATH="/usr/bin/chromium" +# ── tsx (for the caido-mode skill's TypeScript SDK CLI) ───────────── +# The caido-mode skill (skills/caido-mode) is mounted at runtime and its +# node_modules are installed by scripts/install_tools.sh at provision time. +# Pre-install tsx globally so `npx tsx caido-client.ts` has a warm runner. +RUN npm install -g tsx + # ── Python packages used by MCP servers ───────────────────────────── # Pre-install so `uv run` doesn't fetch on every cold start. RUN pip install --no-cache-dir \ diff --git a/capabilities/web-security/scripts/install_tools.sh b/capabilities/web-security/scripts/install_tools.sh index 4f71d7c..034d032 100755 --- a/capabilities/web-security/scripts/install_tools.sh +++ b/capabilities/web-security/scripts/install_tools.sh @@ -149,6 +149,18 @@ fi npm install -g agent-browser agent-browser install || true +# -- caido-mode skill deps (Caido TypeScript SDK CLI) ----------------------- +# The caido-mode skill bundles a tsx CLI built on @caido/sdk-client (caido-ts). +# Pre-install its node_modules so `npx tsx caido-client.ts` resolves offline at +# runtime. Path is relative to the capability root (CAPABILITY_ROOT if exported, +# else the script's own location, which is /scripts). +CAIDO_MODE_DIR="${CAPABILITY_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}/skills/caido-mode" +if [ -f "$CAIDO_MODE_DIR/package.json" ]; then + ( cd "$CAIDO_MODE_DIR" && npm install --no-audit --no-fund ) \ + && echo "caido-mode skill deps installed (@caido/sdk-client / caido-ts)" \ + || echo "WARN: caido-mode npm install failed, skipping" +fi + # -- ast-grep (AST-based code pattern search) --------------------------------- # Tree-sitter based structural code matching for JS/TS/HTML. Lightweight # alternative to semgrep for pattern matching (no taint analysis). diff --git a/capabilities/web-security/skills/caido-mode/.gitignore b/capabilities/web-security/skills/caido-mode/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/capabilities/web-security/skills/caido-mode/README.md b/capabilities/web-security/skills/caido-mode/README.md new file mode 100644 index 0000000..8a337c4 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/README.md @@ -0,0 +1,336 @@ +# Caido Mode + +Full SDK CLI for [Caido](https://caido.io) built on the official [`@caido/sdk-client`](https://github.com/caido/sdk-js) package. Search HTTP history, test with curl proxied through Caido (caching auth into reusable static curl config files), add match & replace rules, organize handoffs into named replay sessions/collections, manage scopes/filters/environments, create findings, and fuzz — all from the terminal. + +## Why? + +Cookies and auth tokens are huge. Instead of copy-pasting 2KB of session cookies into every test request, you find an organic request in Caido's history that already has valid auth and work from it. Two modes, kept strictly separate: + +1. **Testing → curl, proxied through Caido.** `export-curl --config` caches a base request's auth into a reusable `-K` config under `/tmp/caido//` — a **faithful static snapshot** of *all* its auth/identity headers + inline cookies; then probe with `curl -K auth.cfg "$BASE/path"`. All traffic goes through Caido into history; the big auth blob stays in a file. +2. **Handoff → named replay sessions in named collections.** Only when handing requests to the user do you materialize them in Caido's UI. + +## What's Covered + +| Category | Commands | +|----------|----------| +| **HTTP History** | `search`, `recent`, `get`, `get-response`, `raw`, `export-curl` | +| **curl testing** | `export-curl` (full command), `export-curl --config` (faithful static `-K` config: all auth headers + cookies), `raw` (dump bytes) | +| **Edit & Replay** | `edit`, `replay`, `send-raw`, `edit-session` | +| **Replay Tab Lookup** | `get-session`, `replay-entries`, `session-entries` | +| **Sessions** | `create-session`, `rename-session`, `move-session`, `sessions`, `delete-sessions` | +| **Collections** | `collections`, `create-collection`, `rename-collection`, `delete-collection` | +| **Fuzzing** | `create-automate-session`, `fuzz` | +| **Scopes** | `scopes`, `create-scope`, `update-scope`, `delete-scope` | +| **Filter Presets** | `filters`, `create-filter`, `update-filter`, `delete-filter` | +| **Environments** | `envs`, `create-env`, `select-env`, `env-set`, `delete-env` | +| **Findings** | `findings`, `get-finding`, `create-finding`, `update-finding` | +| **Tasks** | `tasks`, `cancel-task` | +| **Projects** | `projects`, `select-project` | +| **Hosted Files** | `hosted-files`, `delete-hosted-file` | +| **Intercept** | `intercept-status`, `intercept-enable`, `intercept-disable` | +| **Match & Replace** | `mr-rules`, `mr-collections`, `create-mr-rule`, `test-mr-rule`, `toggle-mr-rule`, `rename-mr-rule`, `move-mr-rule`, `update-mr-rule`, `delete-mr-rule`, `create/rename/delete-mr-collection` | +| **Info** | `viewer`, `plugins`, `health` | +| **Auth** | `setup`, `auth-status` | + +## Setup + +Requires [Node.js](https://nodejs.org) (v24+), a running Caido instance and a [PAT](https://docs.caido.io/dashboard/guides/create_pat.html). + +```bash +# Install dependencies +npm install + +# 1. Create a PAT in Dashboard → Developer → Personal Access Tokens +# 2. Setup (validates PAT via SDK and caches access token) +npx tsx caido-client.ts setup + +# 3. Verify it works +npx tsx caido-client.ts health +npx tsx caido-client.ts recent --limit 1 + +# Or use env var instead +export CAIDO_PAT= +``` + +The `setup` command uses the SDK's device code flow (auto-approved by your PAT) to obtain an access token, then saves the PAT and cached token to `~/.claude/config/secrets.json` via a custom `TokenCache` implementation. Subsequent runs load the cached token directly, and a valid cached token can be used even when the PAT is absent. + +**Multiple instances:** credentials are keyed by instance URL, so two Caido instances on one machine never clobber each other. `setup ` stores that instance (and makes it the active default); setting up a second URL adds it rather than overwriting. The active instance is `CAIDO_URL` env → stored default → `http://localhost:8080` — select per shell with `CAIDO_URL` (concurrency-safe). `auth-status` lists all configured instances and the active one. + +```bash +npx tsx caido-client.ts setup http://localhost:8080 +npx tsx caido-client.ts setup http://localhost:8081 +CAIDO_URL=http://localhost:8081 npx tsx caido-client.ts recent --compact +``` + +## File Structure + +``` +caido-client.ts # CLI entry point — arg parsing + command dispatch +lib/ + client.ts # SDK Client singleton, SecretsTokenCache, auth config + graphql.ts # gql documents for features not yet in SDK + output.ts # Output formatting (truncation, headers-only, raw→curl) + types.ts # Shared types (OutputOpts) + commands/ + requests.ts # search, recent, get, get-response, raw, export-curl (+ --config) + replay.ts # replay, send-raw, edit, replay-tab lookup, sessions, collections, automate, fuzz + findings.ts # findings, get-finding, create-finding, update-finding + management.ts # scopes, filters, environments, projects, hosted-files, tasks + intercept.ts # intercept-status, intercept-enable, intercept-disable + matchreplace.ts # match & replace (tamper) rules — all sections/operations + test-mr-rule + info.ts # viewer, plugins, health, setup, auth-status +``` + +## Usage + +All commands output JSON. Run `npx tsx caido-client.ts --help` for the complete list. + +### Search & Browse + +```bash +# Search with HTTPQL (Caido's query language) +npx tsx caido-client.ts search 'req.method.eq:"POST" AND resp.code.eq:200' +npx tsx caido-client.ts search 'req.host.cont:"api"' --limit 50 +npx tsx caido-client.ts search 'req.host.cont:"api"' --recent --compact # newest first, terse + +# Get recent requests +npx tsx caido-client.ts recent --limit 10 --compact + +# Full request details with raw HTTP (JSON) +npx tsx caido-client.ts get + +# Just the response +npx tsx caido-client.ts get-response + +# Dump raw bytes to a file (e.g. seed a request body) +npx tsx caido-client.ts raw --out /tmp/caido/target.com/body.json +``` + +### Primary testing workflow (curl through Caido) + +Cache a base request's auth once, then probe with curl. Every request goes through Caido into history; the auth blob stays in a file. + +```bash +# 1. find an authenticated base request +npx tsx caido-client.ts search 'req.host.cont:"target.com" AND req.path.cont:"/api/user"' --recent --compact +# 2. ONCE: write a reusable curl config (faithful static snapshot of all auth headers + cookies) +npx tsx caido-client.ts export-curl 8431 --config +# → /tmp/caido/target.com/auth.cfg (proxy + insecure + compressed + every auth header), BASE=… +# 3. test (the config carries the Caido proxy + the full captured auth) +BASE=https://target.com +curl -K /tmp/caido/target.com/auth.cfg "$BASE/api/user/999" +curl -K /tmp/caido/target.com/auth.cfg -X POST "$BASE/api/profile" --data-binary @body.json +``` + +The config is for **internal** testing. When you hand the user a reproduction, always give a **full self-contained** curl (all headers inline): + +```bash +npx tsx caido-client.ts export-curl 8431 # full, portable curl command for the user +``` + +Refresh lazily: only on 401/403 do you re-run `export-curl --config`. The proxy defaults to the Caido URL; override with `setup --proxy ` or `CAIDO_PROXY`. + +### Edit & Replay (handoff / explicit in-Replay testing) + +Take an existing authenticated request and modify only what you need — cookies, auth headers, User-Agent are preserved. Use this when handing a request to the user, or when the user asks you to test inside Replay. New sessions require `--name`; editing an existing session requires `--no-name-change`/`--nonach` or `--new-name`. + +```bash +# Edit into a NEW named session +npx tsx caido-client.ts edit --path /api/user/999 --name "IDOR victim 999" + +# Edit an EXISTING session (declare name intent) +npx tsx caido-client.ts edit-session "IDOR victim 999" --body '{"role":"admin"}' --nonach +npx tsx caido-client.ts edit --set-header "X-Forwarded-For: 127.0.0.1" --session "IDOR victim 999" --new-name "XFF bypass" + +# Find/replace text anywhere in the request +npx tsx caido-client.ts edit --replace "user123:::user456" --name "IDOR replace" +``` + +`edit`, `replay`, and `send-raw` support connection overrides for virtual-host and upstream routing tests: `--sni`, `--connect-host`, `--connect-port`, `--connect-tls`, and `--connect-no-tls`. + +### Replay Tab Lookup + +Work directly from an existing Caido replay tab/session. + +```bash +npx tsx caido-client.ts get-session --compact +npx tsx caido-client.ts replay-entries --limit 20 +npx tsx caido-client.ts replay-entries --raw --compact +npx tsx caido-client.ts edit-session --body '{"test":true}' --nonach --compact +``` + +`session-entries` is accepted as an alias for `replay-entries`. + +### Raw Replay (through Caido — creates a named session) + +`send-raw` and `replay` create a replay session, so `--name` is required. For ephemeral testing prefer the curl-through-Caido workflow above; use these when you want the request to land in Caido's Replay UI for handoff. + +```bash +npx tsx caido-client.ts send-raw --host example.com --raw @request.txt --name "G /" +cat request.txt | npx tsx caido-client.ts send-raw --host example.com --raw - --name "G / (stdin)" +npx tsx caido-client.ts replay --name "repro" --connect-host 10.0.0.5 --connect-port 8443 --sni example.com +``` + +`--raw` accepts a string with C-style escapes, `@file`, or `-` for stdin. + +### Export to curl + +```bash +npx tsx caido-client.ts export-curl # full self-contained command (for the user) +npx tsx caido-client.ts export-curl --config # faithful static -K config (internal) +npx tsx caido-client.ts export-curl --config --out /tmp/caido/host/auth.cfg +npx tsx caido-client.ts export-curl --config --cookie-jar # follow Set-Cookie rotation (opt-in) +``` + +### Findings + +```bash +npx tsx caido-client.ts findings +npx tsx caido-client.ts get-finding +npx tsx caido-client.ts create-finding \ + --title "IDOR in user profile" \ + --description "Can access other users' data" \ + --reporter "rez0" +npx tsx caido-client.ts update-finding --title "Updated title" +``` + +### Scopes + +```bash +npx tsx caido-client.ts scopes +npx tsx caido-client.ts create-scope "Target" --allow "*.target.com" --deny "*.cdn.target.com" +npx tsx caido-client.ts update-scope --allow "*.target.com,*.api.target.com" +npx tsx caido-client.ts delete-scope +``` + +### Filter Presets + +```bash +npx tsx caido-client.ts filters +npx tsx caido-client.ts create-filter "API Errors" --query 'req.path.cont:"/api/" AND resp.code.gte:400' +npx tsx caido-client.ts create-filter "Auth" --query 'req.path.regex:"/(login|auth)/"' --alias "auth" +npx tsx caido-client.ts delete-filter +``` + +### Environments + +```bash +npx tsx caido-client.ts envs +npx tsx caido-client.ts create-env "IDOR-Test" +npx tsx caido-client.ts env-set victim_id "user_456" +npx tsx caido-client.ts select-env +npx tsx caido-client.ts delete-env +``` + +### Sessions & Collections (handoff) + +Names are mandatory for sessions, and collections are referred to by name. Query existing collections before placing a session; one-off requests go in the default collection, multi-request handoffs get their own named collection. + +```bash +npx tsx caido-client.ts collections # query first +npx tsx caido-client.ts create-collection "Vuln chain - IDOR to ATO" +npx tsx caido-client.ts create-session --name "1. login" --collection "Vuln chain - IDOR to ATO" +npx tsx caido-client.ts rename-session "1. login" "1. authenticate" +npx tsx caido-client.ts move-session "1. authenticate" "Vuln chain - IDOR to ATO" +npx tsx caido-client.ts sessions +npx tsx caido-client.ts delete-sessions , + +npx tsx caido-client.ts rename-collection "Vuln chain - IDOR to ATO" "Vuln chain - account takeover" +npx tsx caido-client.ts delete-collection "Vuln chain - account takeover" +``` + +### Fuzzing + +```bash +npx tsx caido-client.ts create-automate-session +# Configure payload markers and wordlists in Caido UI first +npx tsx caido-client.ts fuzz +``` + +### Tasks, Projects, Info & Health + +```bash +npx tsx caido-client.ts tasks +npx tsx caido-client.ts cancel-task +npx tsx caido-client.ts projects +npx tsx caido-client.ts select-project +npx tsx caido-client.ts viewer +npx tsx caido-client.ts plugins +npx tsx caido-client.ts health +``` + +### Intercept Control + +```bash +npx tsx caido-client.ts intercept-status +npx tsx caido-client.ts intercept-enable +npx tsx caido-client.ts intercept-disable +``` + +### Output Control + +| Flag | Default | Description | +|------|---------|-------------| +| `--max-body ` | 200 | Max response body lines (0 = unlimited) | +| `--max-body-chars ` | 5000 | Max response body chars (0 = unlimited) | +| `--no-request` | off | Skip request raw in output | +| `--headers-only` | off | Show only HTTP headers, no body | +| `--compact` | off | Shorthand for `--no-request --max-body 50 --max-body-chars 5000` | + +## HTTPQL Quick Reference + +Caido's query language for searching HTTP history. String values must be quoted, integers are not. + +``` +req.method.eq:"POST" # Match method +req.host.cont:"api" # Host contains +req.path.regex:"/users/[0-9]+/" # Regex on path +resp.code.gte:400 # Status code range +resp.len.gt:100000 # Large responses +"password" OR "secret" # Search req+resp raw +req.method.eq:"POST" AND resp.code.eq:200 # Combine with AND/OR +source:"replay" # Filter by source +preset:"My Filter" # Use saved filter preset +``` + +### Match & Replace (Tamper rules) + +Auto-rewrite requests/responses passing through Caido. A rule is a **section** × **operation** × **matcher** × **replacer** (+ optional HTTPQL `--condition` and `--sources`). New rules are created **disabled** (`toggle-mr-rule --on`); they default to the "Default Collection" and `sources: INTERCEPT`. + +```bash +# preview a rule without creating it (no-op against the live engine) +npx tsx caido-client.ts test-mr-rule --section req-header --operation add \ + --match-name X-Test --replace hi --raw 'GET / HTTP/1.1\r\nHost: t.com\r\n\r\n' + +# inject auth on all proxied requests to a host, then enable +ID=$(npx tsx caido-client.ts create-mr-rule --section req-header --operation add \ + --match-name Authorization --replace "Bearer …" --condition 'req.host.eq:"t.com"' | jq -r '.created.id') +npx tsx caido-client.ts toggle-mr-rule "$ID" --on + +npx tsx caido-client.ts mr-rules +``` + +Sections: `req-method req-path req-query req-body req-first-line req-header req-all req-sni resp-body resp-status resp-first-line resp-header resp-all ws-up ws-down`. Operations: `raw` (matcher `--match-value`/`--match-regex`/`--match-full`), or `update`/`add`/`remove` for headers/query (`--match-name`). Replacer: `--replace ` or `--workflow `. + +## Architecture + +Built on `@caido/sdk-client` v0.2.0+. Multi-file architecture with clean separation: + +- **High-level SDK methods** for most features (requests, replay, findings, scopes, filters, environments, projects, hosted files, tasks, user) +- **`client.graphql.query()`/`mutation()`** with `gql` tagged templates for features not yet in SDK (intercept, plugins, automate/fuzz, **match & replace**) +- **No raw fetch anywhere** — everything goes through the SDK + +## Claude Code Integration + +This repo is designed to work as a [Claude Code skill](https://docs.anthropic.com/en/docs/claude-code). The `SKILL.md` file provides Claude with full context on how to use every command, HTTPQL syntax, and testing workflows. + +To install as a skill: + +```bash +cp -r . ~/.claude/skills/caido-mode/ +cd ~/.claude/skills/caido-mode && npm install +``` + +## License + +MIT diff --git a/capabilities/web-security/skills/caido-mode/SKILL.md b/capabilities/web-security/skills/caido-mode/SKILL.md new file mode 100644 index 0000000..00d07a9 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/SKILL.md @@ -0,0 +1,588 @@ +--- +name: caido-mode +description: "Full Caido TypeScript SDK CLI (the official @caido/sdk-client / caido-ts library). Search HTTP history with HTTPQL, test with curl proxied through Caido (caching auth in reusable static curl config files), add match & replace (tamper) rules, manage findings/scopes/filters/environments, and organize handoffs into named replay sessions and collections. Use for rich write-side Caido automation (M&R rules, replay handoff, curl-through-Caido) that the lightweight caido-sdk (Python) and caido-proxy (MCP) skills do not cover. Requires Node.js + a reachable Caido instance." +tags: [worker] +--- + +# Caido Mode Skill + +A CLI over Caido's API, built on the official **Caido TypeScript SDK** (`@caido/sdk-client`, +a.k.a. `caido-ts`), for HTTP-history-driven testing. + +## Where this lives / bootstrap + +This skill bundles its own TypeScript CLI. It is vendored into the web-security capability at +`skills/caido-mode/` (relative to the capability root, which is the agent's working directory). +Run every command **from the skill directory** so `tsx` and the SDK resolve: + +```bash +cd skills/caido-mode # from the capability root +# Node deps are pre-installed in the hosted runtime image. For local dev, once: +[ -d node_modules ] || npm install +``` + +Every command is then `npx tsx caido-client.ts ` and outputs JSON unless noted. (You can +also run `npx --prefix skills/caido-mode tsx skills/caido-mode/caido-client.ts ` without +`cd`, but `cd skills/caido-mode` is simplest.) + +## Relationship to the other Caido skills (no interference) + +The capability ships three independent Caido surfaces — pick one, they don't collide: + +- **`caido-mode` (this skill)** — TypeScript SDK CLI. Best for curl-through-Caido testing, + Match & Replace rules, and replay-session/collection handoffs. +- **`caido-sdk`** — direct Python `caido-sdk-client` calls for quick read/replay in-process. +- **`caido-proxy` / `caido-go` MCP** — MCP tool surface for history search, replay, findings. + +All target the **same** Caido instance via `CAIDO_URL`/`CAIDO_PAT`. This skill caches its own auth +token in `~/.claude/config/secrets.json` (under `.caido`), which is **separate** from the +`~/.caido-mcp/token.json` used by the Python SDK skill and the MCP servers — so setting up +`caido-mode` never clobbers the other skills' auth, and vice-versa. + +## How to operate (read this first) + +There are **two distinct modes**: + +1. **Testing → use `curl`, always proxied through Caido.** Find a real authenticated request in + history, cache its auth into a reusable curl config (a faithful static snapshot of its headers + + cookies), then probe with `curl -K auth.cfg "$BASE/path"`. **All traffic must go through + Caido** (the config carries the + proxy), so every request lands in HTTP history. +2. **Handoff → use replay sessions + collections.** Only when handing a request (or a set) to the + *user* do you materialize it as a named replay session inside a named collection. + +Hard rules: + +- **Everything goes through Caido — except high-volume bruteforce/fuzzing.** Never curl a single + target request directly; always via the Caido proxy (the generated config does this; otherwise add + `-x `). **The one exception:** don't proxy bruteforce/fuzzing tools (`ffuf`, etc.) or any + batch of **100+ requests at once** through Caido — it bloats HTTP history. Run those **direct** (no + `-x`), then bring any interesting hit *back* into Caido (re-send it through the proxy / promote to + Replay) to investigate and hand off. +- **Test with `curl`.** Don't spin up replay sessions for probing — that's handoff only. +- **To show the operator a request, send it to Replay.** Whenever you want the operator to *see* a + specific request, create a **named replay session** for it (in a named collection if there's more + than one) — that's how they inspect and re-run it in Caido. A request you tested via curl only + becomes something the operator can work with once you promote it into Replay + (`create-session --name …`, or `send-raw … --name …` for a crafted one). +- **Cache auth in files, don't re-paste it.** Use `export-curl --config` once per target; then + reference the config. Don't dump cookies/JWTs into every command (or repeatedly into context). +- **If you hand the operator a runnable command, make it a FULL self-contained curl** (all headers + inline, via `export-curl`) — for a PoC or something they'll run outside Caido. The `-K` config is + for your internal testing only; never hand them a `curl -K /tmp/…` line. +- **Replay session names are mandatory**, and editing a session forces explicit name intent. +- **Use collections for multi-request handoffs**; refer to sessions/collections by **name, not ID**. + +--- + +## The primary workflow (do this by default) + +```bash +# 1. Find a base request that already has the auth/cookies you need. +npx tsx caido-client.ts search 'req.host.cont:"target.com" AND req.path.cont:"/api/user"' --compact +# → 8431 200 GET target.com/api/user/me + +# 2. ONCE per target: cache its auth into a reusable curl config. +npx tsx caido-client.ts export-curl 8431 --config +# → writes /tmp/caido/target.com/auth.cfg — a FAITHFUL STATIC snapshot: +# proxy + insecure + compressed + ALL the request's auth/identity headers +# (cookies, Authorization, Origin/Referer, X-*, Sec-*, app-specific headers) +# and prints BASE + the captured header list + +# 3. Test with curl. -K carries the proxy + auth, so it goes through Caido into history. +BASE=https://target.com +curl -K /tmp/caido/target.com/auth.cfg "$BASE/api/user/999" # IDOR +curl -K /tmp/caido/target.com/auth.cfg -X POST "$BASE/api/profile" \ + -H 'Content-Type: application/json' --data-binary @/tmp/caido/target.com/body.json +``` + +Iterate step 3 freely — it's cheap, it's all in Caido, and the big auth blob stays in the file. +Confirm a probe landed in Caido with `search 'req.host.cont:"target.com"' --compact`. + +### Send the path exactly as written + +When testing **path traversal / path-normalization** (`../`, `/..`, `/./`, encoded variants), pass +**`curl --path-as-is`** — otherwise curl collapses `../` and `/./` *client-side* before sending, so +the server never sees the payload and the test silently passes. Keep the path verbatim: + +```bash +curl --path-as-is -K /tmp/caido/target.com/auth.cfg "$BASE/api/../../../etc/passwd" +``` + +(Likewise add `-g`/`--globoff` if the URL contains `[ ] { }` you don't want curl to interpret.) + +### The config is a faithful STATIC snapshot (important) + +`export-curl --config` captures **every** auth/identity header from the base request (not a +curated subset) and **inlines the cookies statically**. Two deliberate choices, both learned the +hard way: + +- **All headers, not an allowlist.** Modern apps gate authorization on app-specific headers you + can't predict — `x-goog-ext-*`, `X-Browser-Validation`, `X-Client-Data`, `Origin`, `Referer`, + `X-Same-Domain`, `Sec-*`, … A narrow allowlist silently drops these and you get opaque + `403`/`PERMISSION_DENIED`. The config now mirrors what actually authorized the request. Only + truly per-request/volatile headers are dropped: `Host`, `Content-Length`, `Content-Type`, + `Connection`, `Accept-Encoding` (curl manages these per request). + - **⚠ Because `Content-Type` is dropped, you MUST pass it yourself on every POST/PUT/PATCH:** + `curl -K auth.cfg -X POST "$BASE/path" -H 'Content-Type: application/json' --data-binary @body`. + Use the exact `Content-Type` the endpoint expects (e.g. Google `batchexecute` needs + `application/x-www-form-urlencoded;charset=UTF-8`) — a wrong/missing one is a common cause of + `400`/`403`. curl sets `Content-Length` itself; don't add it. +- **Static cookies, no jar.** It does **not** use `cookie-jar` by default, so curl never writes a + response's rotated `Set-Cookie` back over your captured-good cookies (servers like Google rotate + on *every* response, including error responses — a write-back jar drifts the session into + failure). Need to follow rotation? `export-curl --config --cookie-jar` opts in. + +To drop a specific header: `--exclude ` (repeatable). To omit cookies entirely (e.g. when a +Match & Replace rule injects auth): `--exclude cookie`. + +### Other conventions + +- **Per-target scratch dir:** `/tmp/caido//` holds `auth.cfg`, body files, notes. +- **`$BASE`:** set `BASE=https://` once; write requests as `"$BASE/path"`. +- **Bodies in files:** save large/complex bodies once and send with `--data-binary @body.json` + (the correct use of `--data-binary` — a byte-exact *body*). Add `-H 'Content-Type: …'` per + request since the config omits it. +- **Lazy refresh:** the snapshot is static, so when a request starts returning **401/403** (token + expired / cookies aged out), re-run `export-curl --config` to re-snapshot, then retry. +- **CSRF:** the matching `X-CSRF*`/double-submit header is captured automatically. For tokens that + rotate per action, fetch fresh: `T=$(curl -sK auth.cfg "$BASE/csrf" | jq -r .token)`. +- **Proxy-injected auth (alternative):** instead of a config, a Match & Replace rule can inject + `Authorization`/cookies on all proxied traffic — then `curl -x -k "$BASE/path"` needs no + headers. See **Match & Replace**. + +### Giving commands to the user + +To surface a request *inside Caido* for the operator, send it to **Replay** (see "Replay sessions" +below) — that's the default. This section is for the other case: handing them a runnable **command** +(a PoC, or something to run outside Caido). Then **always produce a full, self-contained curl** — +every header inline, no `-K`: + +```bash +npx tsx caido-client.ts export-curl 8431 # full curl, all headers inline (portable PoC) +``` + +Drop `-x`/`-k` for a portable PoC the user can run anywhere; keep them only if the user is meant +to run it through their own Caido. **Never hand the user a `curl -K /tmp/...` line** — that file +is yours. + +--- + +## The proxy + +All curl testing must go through Caido's proxy. Its address **defaults to the Caido URL** (proxy +and API share an address). Discover/confirm it any time: + +```bash +npx tsx caido-client.ts auth-status # prints "proxy": "http://localhost:8080" +``` + +`export-curl --config` bakes the proxy into the config (`proxy = "…"`). For an ad-hoc curl, add +`-x -k` yourself. Override the proxy only if its listener differs from the API URL — +`setup --proxy ` or `export CAIDO_PROXY=`. + +> Get the proxy from `auth-status` (the `proxy`/`activeUrl` fields) — **don't parse `secrets.json` +> directly.** Auth is URL-keyed now: the address lives under `.caido.default` / `.caido.instances`, +> not `.caido.url`. + +--- + +## Authentication setup + +In the hosted runtime, `CAIDO_URL` (and often `CAIDO_PAT`) are already provided as env vars — if so, +skip `setup` and go straight to `auth-status`/`health`. Otherwise: + +```bash +# One-time: create a PAT in Caido (Dashboard → Developer → Personal Access Tokens), then: +npx tsx caido-client.ts setup +npx tsx caido-client.ts setup http://192.168.1.100:8080 # non-default instance +npx tsx caido-client.ts setup http://localhost:8080 --proxy http://localhost:8080 + +# Or env vars +export CAIDO_PAT=caido_xxxxx +export CAIDO_URL=http://localhost:8080 +export CAIDO_PROXY=http://localhost:8080 # only if the proxy differs from the URL + +npx tsx caido-client.ts auth-status # check (also prints the proxy) +npx tsx caido-client.ts health # verify instance is up +``` + +`setup` validates the PAT via the SDK's device-code flow (auto-approved by the PAT), then caches +the PAT + access token (+ proxy) to `~/.claude/config/secrets.json`. Subsequent runs use the +cached token; a valid cached token works even without the PAT. + +### Multiple Caido instances + +Credentials are **keyed by instance URL** — two instances on one machine never clobber each other. +`setup ` stores that instance under its URL (and makes it the active default); +setting up a second URL adds a second entry rather than overwriting the first. + +```bash +npx tsx caido-client.ts setup http://localhost:8080 +npx tsx caido-client.ts setup http://localhost:8081 # added, not overwritten +npx tsx caido-client.ts auth-status # lists configuredInstances + activeUrl +``` + +The **active instance** is `CAIDO_URL` env → stored default → `http://localhost:8080`. Select per +shell/agent with `CAIDO_URL` (concurrency-safe — no shared "current instance" to race on), e.g. +`CAIDO_URL=http://localhost:8081 npx tsx caido-client.ts recent`. `CAIDO_PAT`/`CAIDO_PROXY` env +override the active instance's stored values. + +--- + +## Searching HTTP history (HTTPQL) + +```bash +npx tsx caido-client.ts search 'req.method.eq:"POST" AND resp.code.eq:200' --compact +npx tsx caido-client.ts search 'req.host.cont:"api"' --limit 50 +npx tsx caido-client.ts search 'req.host.cont:"api"' --asc --limit 50 # oldest first (rarely wanted) +npx tsx caido-client.ts recent --compact # newest requests, one line each +npx tsx caido-client.ts get 8431 --compact # full details (JSON) when you need them +npx tsx caido-client.ts get-response 8431 --compact +npx tsx caido-client.ts raw 8431 --out /tmp/caido/target.com/body.json # dump bytes (e.g. a body) +``` + +- **`search` is NEWEST FIRST by default** (descending by request id). `--limit N` therefore returns + the newest N matches. Pass `--asc` (alias `--oldest`) only when you actually want oldest first. +- **To get "the most recent matching X", just run `search '' --limit N`** — do NOT pull a + large `--limit` and re-sort client-side (e.g. `jq 'sort_by(.createdAt) | reverse'`). That sorts + only the truncated window you fetched, so any request newer than the Nth result is silently + invisible — you'll mistake stale traffic for the latest. Let Caido do the ordering. +- `recent` is always newest-first but takes **no filter**; use `search --limit N` for newest-matching-a-filter. +- `--compact` → one terse line per request (`id status METHOD host/path`). +- Prefer `search`/`recent --compact` for browsing; `get`/`export-curl` once you've picked one. + +See the **HTTPQL Reference** below for the full query language. + +--- + +## Replay sessions — for handoff only + +Use these when giving a request to the **user**. Normal testing uses curl (above), not sessions. +Sessions created from a raw request have their **header line endings normalized to CRLF +automatically** — a handoff session is never built with bare-LF (`\n`) endings. + +```bash +# Create a NAMED session from a history request (name is REQUIRED). +npx tsx caido-client.ts create-session 8431 --name "IDOR /api/user/:id" +npx tsx caido-client.ts sessions # list (alias: replay-sessions) +npx tsx caido-client.ts rename-session "IDOR /api/user/:id" "IDOR - confirmed" +npx tsx caido-client.ts move-session "IDOR - confirmed" "Vuln chain - IDOR to ATO" + +# Build a handoff session from a raw request file (CRLF auto-normalized): +npx tsx caido-client.ts send-raw --host target.com --raw @/tmp/req.txt --name "crafted repro" +``` + +### Editing a session forces name intent + +If the user asks you to test *inside* Replay, use `edit` / `edit-session`. Because an edit changes +what a session contains, declare what happens to its **name** — pass exactly one of +`--no-name-change` (`--nonach`) or `--new-name ""`: + +```bash +npx tsx caido-client.ts edit 8431 --path /api/user/999 --name "IDOR victim 999" # new session +npx tsx caido-client.ts edit-session "IDOR victim 999" --body '{"role":"admin"}' --nonach --compact +npx tsx caido-client.ts edit 8431 --path /api/admin --session "IDOR victim 999" --new-name "priv-esc" +``` + +`edit` preserves cookies/auth from the original request; it supports `--method`, `--path`, +`--set-header`, `--remove-header`, `--body` (auto Content-Length), `--replace :::`, and +connection overrides (`--sni`, `--connect-host`, …). + +### Inspecting an existing replay tab + +When a replay tab is already open in Caido and you want to work from its current state, look it +up by **name or id** (no need to re-create it): + +```bash +npx tsx caido-client.ts get-session "IDOR victim 999" --compact # session + its active entry +npx tsx caido-client.ts replay-entries "IDOR victim 999" --limit 20 # request/response history in the tab +npx tsx caido-client.ts replay-entries "IDOR victim 999" --raw --compact # include raw bytes +``` + +`session-entries` is an alias for `replay-entries`. Use these to read what's in a tab; use +`edit-session` (above) to send a modified request into it. + +--- + +## Collections — use them heavily + +Collections organize sessions for handoff. **Before creating a session, list existing collections +and decide where it belongs.** Names are mandatory and collections are never auto-created. + +```bash +npx tsx caido-client.ts collections # query first +npx tsx caido-client.ts create-collection "Swagger - petstore.yaml" +npx tsx caido-client.ts rename-collection "old name" "new name" +``` + +| Situation | Collection decision | +|-----------|--------------------| +| **One** request reproduced for the user | Default collection — **don't** create one. Name the session and tell the user the name. | +| A replay tab per endpoint in a **JS file** | New collection `JS File Endpoints`. | +| A replay tab per endpoint in a **Swagger spec** | New collection `Swagger - `. | +| A **multi-request chain** for a vuln | New collection `Vuln chain - `, steps named `1. …`, `2. …`. | +| All endpoints under **`/api/v2`** | New collection `/api/v2/*`. | + +Pass collections by **name**; the CLI resolves it (and tells you to create it first if missing): + +```bash +npx tsx caido-client.ts create-session 8431 --name "1. login" --collection "Vuln chain - IDOR to ATO" +``` + +When you report back, name the collection and sessions — never IDs. + +--- + +## Match & Replace — auto-rewrite traffic + +Match & Replace (Caido calls these **"Tamper" rules** internally) rewrites requests/responses +**automatically as they pass through Caido**. The killer use: **inject auth at the proxy** so your +curl commands don't carry it — add a rule that sets `Authorization` on every proxied request, then +`curl -x -k "$BASE/path"` is authenticated with no `-K`/headers at all. + +A rule is one **section** (which part) × one **operation** × a **matcher** × a **replacer**, with +optional **condition** (HTTPQL scope) and **sources**: + +| Piece | Choices | +|------|---------| +| **section** | req: `req-method req-path req-query req-body req-first-line req-header req-all req-sni` · resp: `resp-body resp-status resp-first-line resp-header resp-all` · ws: `ws-up ws-down` | +| **operation** | `raw` (match within the section) · `update`/`add`/`remove` (header & query only, by name) · method/status only `update` | +| **matcher** | `--match-value ` · `--match-regex ` · `--match-full` (whole section) · `--match-name ` (header/query update/add/remove) | +| **replacer** | `--replace ` (literal; `""` allowed) · `--workflow ` (run a workflow) | +| **condition** | `--condition ''` — only apply when the request matches (e.g. one host) | +| **sources** | `--sources INTERCEPT,REPLAY,…` — which traffic it applies to | + +Four gotchas, all defaulted for you: +- **New rules are created DISABLED.** Enable with `toggle-mr-rule --on`. +- **Default collection** is Caido's "Default Collection" (override with `--collection `). +- **Default sources** is `INTERCEPT` (proxy traffic), matching Caido. Add `--sources` to broaden. +- **JS targets — pick matcher based on what you're matching against.** `--match-value` is fine for stable literals (string constants, JSON keys, fixed API paths). Use `--match-regex` when matching near minified identifiers: symbol names rotate on every bundle deploy (e.g. `_.ex` → `_.Ww`), so a literal rule silently stops matching with no error. Anchor the regex to structurally stable neighbours — surrounding string literals, known function names, fixed JSON keys — rather than the minified identifier itself. + +**Preview before committing:** `test-mr-rule` applies a rule to a raw request *without creating +anything* — use it to confirm a rule does what you expect. + +```bash +# Preview: would this add the header correctly? +npx tsx caido-client.ts test-mr-rule --section req-header --operation add \ + --match-name X-Test --replace hi --raw 'GET / HTTP/1.1\r\nHost: t.com\r\n\r\n' + +# Inject auth on all proxied requests to one host (then enable it) +ID=$(npx tsx caido-client.ts create-mr-rule --section req-header --operation add \ + --match-name Authorization --replace "Bearer eyJ…" \ + --condition 'req.host.eq:"target.com"' --name "auth inject" | jq -r '.created.id') +npx tsx caido-client.ts toggle-mr-rule "$ID" --on + +# Other patterns +npx tsx caido-client.ts create-mr-rule --section req-header --operation remove \ + --match-name If-None-Match --sources REPLAY --name "drop INM" # strip a header +npx tsx caido-client.ts create-mr-rule --section req-body --match-regex '"admin":false' \ + --replace '"admin":true' --name "force admin" # body regex +npx tsx caido-client.ts create-mr-rule --section resp-status --replace 403 --name "fake 403" # response + +npx tsx caido-client.ts mr-rules # list rules (+ enabled state) +npx tsx caido-client.ts toggle-mr-rule --off +npx tsx caido-client.ts delete-mr-rule +``` + +Manage collections with `mr-collections`, `create-mr-collection`, `rename-mr-collection`, +`delete-mr-collection`; `move-mr-rule `; `update-mr-rule …` re-specs a rule +(same flags as create); `rename-mr-rule `. + +--- + +## Output control (works with `get`, `get-response`, `replay`, `edit`, `send-raw`, `edit-session`) + +| Flag | Description | +|------|-------------| +| `--max-body ` | Max response body lines (default 200, 0 = unlimited) | +| `--max-body-chars ` | Max body chars (default 5000, 0 = unlimited) | +| `--no-request` | Omit the request raw from output | +| `--headers-only` | Headers only, no body | +| `--compact` | Shorthand: `--no-request --max-body 50 --max-body-chars 5000` | + +--- + +## HTTPQL Reference + +Caido's query language for searching HTTP history. + +**CRITICAL**: String values MUST be quoted; integers are NOT. + +**CRITICAL**: HTTPQL has NO `NOT` operator. Use the negated operator variant instead: +- `ncont` (not contains), `nlike`, `nregex`, `ne` (not equals) +- Wrong: `NOT req.path.cont:"/admin"` — Right: `req.path.ncont:"/admin"` + +### Namespaces and Fields + +| Namespace | Field | Type | Description | +|-----------|-------|------|-------------| +| `req` | `ext` | string | File extension (includes `.`) | +| `req` | `host` | string | Hostname | +| `req` | `method` | string | HTTP method (uppercase) | +| `req` | `path` | string | URL path | +| `req` | `query` | string | Query string | +| `req` | `raw` | string | Full raw request | +| `req` | `port` | int | Port number | +| `req` | `len` | int | Request body length | +| `req` | `created_at` | date | Creation timestamp | +| `req` | `tls` | bool | Is HTTPS | +| `resp` | `raw` | string | Full raw response | +| `resp` | `code` | int | Status code | +| `resp` | `len` | int | Response body length | +| `resp` | `roundtrip` | int | Roundtrip time (ms) | +| `row` | `id` | int | Request ID | +| `source` | - | special | `"intercept"`, `"replay"`, `"automate"`, `"workflow"` | +| `preset` | - | special | Filter preset reference | + +### Operators + +- **String:** `eq`, `ne`, `cont`, `ncont`, `like`, `nlike`, `regex`, `nregex` +- **Integer:** `eq`, `ne`, `gt`, `gte`, `lt`, `lte` +- **Boolean:** `eq`, `ne` +- **Logical:** `AND`, `OR`, parentheses for grouping + +### Examples + +```httpql +req.method.eq:"POST" AND resp.code.eq:200 # POSTs with 200s +req.host.cont:"api" OR req.path.cont:"/api/" # API traffic +"password" OR "secret" OR "api_key" # bare string searches req AND resp raw +resp.code.gte:400 AND resp.code.lt:500 # 4xx +resp.len.gt:100000 # large responses (data exposure) +req.path.regex:"/(login|auth|signin|oauth)/" # auth endpoints +source:"replay" OR source:"intercept" # tool-generated vs proxied traffic +req.created_at.gt:"2024-01-01T00:00:00Z" # date filter +req.path.ncont:"/static" # exclude (no NOT keyword) +preset:"My Filter" # saved filter preset +``` + +--- + +## Other capabilities (reference) + +### Findings — surface in Caido's Findings tab +```bash +npx tsx caido-client.ts findings --limit 50 +npx tsx caido-client.ts create-finding 8431 --title "IDOR on /api/user/:id" \ + --description "Reads other users' profiles by changing id" --reporter "rez0" --dedupe-key "idor-user" +npx tsx caido-client.ts update-finding --title "…" --description "…" +``` + +### Scopes / Filter presets / Environments +```bash +npx tsx caido-client.ts create-scope "Target" --allow "*.target.com" --deny "*.cdn.target.com" +npx tsx caido-client.ts create-filter "API 4xx" --query 'req.path.cont:"/api/" AND resp.code.gte:400' --alias "api4xx" +npx tsx caido-client.ts search 'preset:"API 4xx"' --compact +npx tsx caido-client.ts create-env "IDOR-Test"; npx tsx caido-client.ts env-set victim_id "user_999" +``` + +### Fuzzing / intercept / projects / tasks / info +```bash +npx tsx caido-client.ts create-automate-session 8431 # configure payloads in UI, then: fuzz +npx tsx caido-client.ts intercept-status | intercept-enable | intercept-disable +npx tsx caido-client.ts projects ; npx tsx caido-client.ts viewer ; npx tsx caido-client.ts plugins +``` + +--- + +## Full command reference + +Every command (run `npx tsx caido-client.ts `). Sessions/collections accept a **name or +id**; output is JSON unless noted. Run `--help` for full flag lists. + +| Command | What it does | +|---|---| +| **History & testing** | | +| `search ` | Search history, **newest first**. `--limit --after --ids-only --asc/--oldest --compact` | +| `recent` | Newest requests. `--limit --compact` | +| `get ` / `get-response ` | Full request / just the response (output-control flags) | +| `raw ` | Dump byte-exact raw request. `--out --response` | +| `export-curl ` | Full self-contained curl (for the user) | +| `export-curl --config` | Reusable `-K` config — faithful static snapshot of all auth headers + inline cookies (internal). `--out ` · `--cookie-jar` (follow rotation) · `--exclude ` | +| **Send / edit** | | +| `replay --name ` | Replay into a new named session. `--raw --collection` + connection overrides | +| `send-raw --host --raw --name ` | Send a raw request via a new named session. `--port --tls/--no-tls --collection` | +| `edit ` | Edit + send into replay. `--method --path --set-header --remove-header --body --replace --session --name/--new-name/--nonach --collection` | +| `edit-session ` | Edit + send from a session's active entry (requires `--nonach` or `--new-name`) | +| **Replay tab lookup** | | +| `get-session ` | Session + active entry. `--compact` | +| `replay-entries ` | Request history in a tab (alias `session-entries`). `--limit --raw` | +| **Sessions** | | +| `create-session --name ` | New named session from a request. `--collection` | +| `rename-session ` · `move-session ` | Rename / move | +| `sessions` (alias `replay-sessions`) · `delete-sessions ` | List / delete | +| **Collections** | | +| `collections` (alias `replay-collections`) | List collections | +| `create-collection ` · `rename-collection ` · `delete-collection ` | Create / rename / delete | +| **Fuzzing** | `create-automate-session ` · `fuzz ` (configure payloads in UI) | +| **Findings** | `findings` · `get-finding ` · `create-finding --title …` · `update-finding ` | +| **Scopes** | `scopes` · `create-scope --allow --deny` · `update-scope ` · `delete-scope ` | +| **Filters** | `filters` · `create-filter --query [--alias]` · `update-filter ` · `delete-filter ` | +| **Environments** | `envs` · `create-env ` · `env-set ` · `select-env [id]` · `delete-env ` | +| **Projects** | `projects` · `select-project ` | +| **Tasks** | `tasks` · `cancel-task ` | +| **Hosted files** | `hosted-files` · `delete-hosted-file ` | +| **Intercept** | `intercept-status` · `intercept-enable` · `intercept-disable` | +| **Match & Replace** | `mr-rules` · `mr-collections` · `create-mr-rule --section … [--operation] [--match-*] [--replace/--workflow] [--name --collection --condition --sources]` · `test-mr-rule --raw … --section …` (preview, no-op) · `toggle-mr-rule --on\|--off` · `rename-mr-rule ` · `move-mr-rule ` · `update-mr-rule …` · `delete-mr-rule ` · `create-mr-collection ` · `rename-mr-collection ` · `delete-mr-collection ` | +| **Info / auth** | `viewer` · `plugins` · `health` · `setup [url] [--proxy]` · `auth-status` | + +--- + +## Architecture + +Built on `@caido/sdk-client` v0.4.0+ (the Caido TypeScript SDK / `caido-ts`). No raw `fetch` — high-level SDK methods plus +`client.graphql.query/mutation` with `gql` documents for the few features the SDK doesn't expose. + +``` +caido-client.ts # CLI entry — arg parsing + dispatch +lib/ + client.ts # SDK Client singleton, SecretsTokenCache, auth, resolveProxy + graphql.ts # gql docs for features not in the SDK + output.ts # raw formatting (truncation, headers-only, raw→curl) + types.ts # OutputOpts + commands/ + requests.ts # search, recent, get, get-response, raw, export-curl (+ --config) + replay.ts # replay, send-raw, edit, sessions, collections (CRLF-normalized), automate + findings.ts # findings + management.ts # scopes, filters, environments, projects, hosted-files, tasks + intercept.ts # intercept status/enable/disable + matchreplace.ts # match & replace (tamper) rules — buildTamperSection + commands + info.ts # viewer, plugins, health, setup, auth-status +``` + +--- + +## Instructions for Claude (checklist) + +1. **Test with `curl`, always through Caido** — the proxy must be in the path (config does this; + otherwise `-x `). **Exception:** bruteforce/fuzzing (`ffuf`) or 100+ requests at once go + **direct** to avoid bloating HTTP history; bring interesting hits back into Caido. +2. **Cache auth once:** `export-curl --config` → `/tmp/caido//auth.cfg` (faithful static + snapshot of ALL auth headers + inline cookies). Test with `curl -K auth.cfg "$BASE/path"`. +3. **Refresh lazily** — only on 401/403/login-redirect, regenerate the config from a fresh request. +4. **Give the user FULL self-contained curl** (`export-curl `); never a `-K` line. +5. **Browse with `--compact`;** `get`/`export-curl` only the request you'll work from. +6. **To show the operator a request, send it to Replay** (named session). Replay = handoff only, + names mandatory; collections used heavily, referred to by NAME. +7. **Editing a session requires `--nonach` or `--new-name`.** +8. **Create findings** for anything real. +9. **NEVER use `NOT` in HTTPQL** — use `ne`/`ncont`/`nlike`/`nregex`. + +## Operational notes (shell gotchas) + +- **Don't batch CLI calls inside `while`/`for` loops.** Some shells strip `PATH` inside loop + subshells, so `head`/`python3`/etc. become "command not found" and the loop body fails silently + (sessions look like they weren't created). Run each `send-raw`/`create-session` as an individual + top-level command, or as a standalone `bash` script with an explicit `export PATH=…`. +- **`search --ids-only` returns a JSON array** (`["123"]`), not a bare id — unwrap before reuse, + e.g. `ID=$(… --ids-only | jq -r '.[0]')`. +- **`sessions` / `collections` now list everything** (paginated, not just the first page), so + freshly-created items always appear. `--limit N` caps the count if you want a short list. + +## Error handling + +- **Auth errors** → `auth-status`, re-`setup ` (or set `CAIDO_PAT`). +- **curl gets 401/403/login redirect** → token expired; refresh the config from a fresh request. +- **curl can't connect via proxy** → confirm the proxy with `auth-status`; Caido must be running. +- **Connection refused / not ready** → Caido isn't up or is still starting; check `health`. diff --git a/capabilities/web-security/skills/caido-mode/caido-client.ts b/capabilities/web-security/skills/caido-mode/caido-client.ts new file mode 100644 index 0000000..187794e --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/caido-client.ts @@ -0,0 +1,928 @@ +#!/usr/bin/env -S npx tsx +/** + * Caido SDK Client v3.1 + * Clean multi-file CLI built entirely on @caido/sdk-client. + * No raw fetch — uses SDK methods + client.graphql.query/mutation with gql documents. + */ + +import { parseOutputOpts, DEFAULT_OUTPUT_OPTS } from "./lib/types"; + +// Commands +import { cmdSearch, cmdRecent, cmdGet, cmdGetResponse, cmdRaw, cmdExportCurl, cmdExportCurlConfig } from "./lib/commands/requests"; +import { cmdReplay, cmdSendRaw, cmdEdit, cmdGetSession, cmdReplayEntries, cmdEditSession, cmdReplaySessions, cmdCreateSession, cmdRenameSession, cmdMoveSession, cmdDeleteSessions, cmdReplayCollections, cmdCreateCollection, cmdRenameCollection, cmdDeleteCollection, cmdCreateAutomateSession, cmdFuzz } from "./lib/commands/replay"; +import type { ConnectionOverrides, NameChange, EditTarget } from "./lib/commands/replay"; +import { cmdFindings, cmdGetFinding, cmdCreateFinding, cmdUpdateFinding } from "./lib/commands/findings"; +import { cmdScopes, cmdCreateScope, cmdUpdateScope, cmdDeleteScope, cmdFilters, cmdCreateFilter, cmdUpdateFilter, cmdDeleteFilter, cmdEnvs, cmdCreateEnv, cmdSelectEnv, cmdEnvSet, cmdDeleteEnv, cmdProjects, cmdSelectProject, cmdHostedFiles, cmdDeleteHostedFile, cmdTasks, cmdCancelTask } from "./lib/commands/management"; +import { cmdInterceptStatus, cmdInterceptSet } from "./lib/commands/intercept"; +import { cmdMrRules, cmdMrCollections, cmdCreateMrRule, cmdUpdateMrRule, cmdDeleteMrRule, cmdToggleMrRule, cmdRenameMrRule, cmdMoveMrRule, cmdTestMrRule, cmdCreateMrCollection, cmdRenameMrCollection, cmdDeleteMrCollection } from "./lib/commands/matchreplace"; +import type { MrRuleOpts } from "./lib/commands/matchreplace"; +import { cmdViewer, cmdPlugins, cmdHealth, cmdSetup, cmdAuthStatus } from "./lib/commands/info"; + +const DEBUG = process.env.DEBUG === "1"; + +function parseConnectionOverrides(args: string[], startIdx: number): ConnectionOverrides { + const overrides: ConnectionOverrides = {}; + for (let i = startIdx; i < args.length; i++) { + if (args[i] === "--sni" && args[i + 1]) { overrides.sni = args[i + 1]; i++; } + else if (args[i] === "--connect-host" && args[i + 1]) { overrides.connectHost = args[i + 1]; i++; } + else if (args[i] === "--connect-port" && args[i + 1]) { overrides.connectPort = parseInt(args[i + 1], 10); i++; } + else if (args[i] === "--connect-tls") { overrides.connectTls = true; } + else if (args[i] === "--connect-no-tls") { overrides.connectTls = false; } + } + return overrides; +} + +function parseCollectionId(args: string[], startIdx: number): string | undefined { + for (let i = startIdx; i < args.length; i++) { + if (args[i] === "--collection" && args[i + 1]) return args[i + 1]; + } + return undefined; +} + +function parseSessionName(args: string[], startIdx: number): string | undefined { + for (let i = startIdx; i < args.length; i++) { + if (args[i] === "--name" && args[i + 1]) return args[i + 1]; + } + return undefined; +} + +/** + * Replay session names are mandatory. Pull --name or exit with guidance. + */ +function requireName(args: string[], startIdx: number, ctx: string): string { + const name = parseSessionName(args, startIdx); + if (!name) { + console.error(`Error: --name "" is required when ${ctx}.`); + console.error(`Replay sessions must be named so they are identifiable on handoff (never refer to them by ID).`); + process.exit(1); + } + return name; +} + +/** + * Editing an existing replay session requires explicit name intent: + * --no-name-change / --nonach OR --new-name "". Exactly one. + */ +function requireNameChange(args: string[], startIdx: number): NameChange { + let keep = false; + let newName: string | undefined; + for (let i = startIdx; i < args.length; i++) { + if (args[i] === "--no-name-change" || args[i] === "--nonach") keep = true; + else if (args[i] === "--new-name" && args[i + 1]) { newName = args[i + 1]; i++; } + } + if (keep && newName !== undefined) { + console.error("Error: pass only one of --no-name-change/--nonach or --new-name, not both."); + process.exit(1); + } + if (!keep && newName === undefined) { + console.error("Error: editing a replay session requires explicit name intent."); + console.error(" Keep the current name: --no-name-change (alias --nonach)"); + console.error(' Set a new name: --new-name "descriptive name"'); + process.exit(1); + } + return keep ? { kind: "keep" } : { kind: "rename", name: newName! }; +} + +/** Parse Match & Replace rule options. --replace/--match-value accept empty strings. */ +function parseMrOpts(args: string[], startIdx: number): MrRuleOpts { + const o: MrRuleOpts = { section: "" }; + for (let i = startIdx; i < args.length; i++) { + const a = args[i]; + const v = args[i + 1]; + if (a === "--section" && v) { o.section = v; i++; } + else if ((a === "--operation" || a === "--op") && v) { o.operation = v; i++; } + else if (a === "--match-value" && v !== undefined) { o.matchValue = v; i++; } + else if (a === "--match-regex" && v) { o.matchRegex = v; i++; } + else if (a === "--match-full") { o.matchFull = true; } + else if (a === "--match-name" && v) { o.matchName = v; i++; } + else if (a === "--replace" && v !== undefined) { o.replace = v; i++; } + else if (a === "--workflow" && v) { o.workflowId = v; i++; } + else if (a === "--name" && v) { o.name = v; i++; } + else if (a === "--condition" && v) { o.condition = v; i++; } + else if (a === "--sources" && v) { o.sources = v.split(",").map(s => s.trim()).filter(Boolean); i++; } + } + return o; +} + +function printUsage() { + console.log(` +Caido SDK Client v3.1 — Built on @caido/sdk-client + +Usage: + caido-client.ts [options] + +═══════════════════════════════════════════════ + HTTP HISTORY & TESTING +═══════════════════════════════════════════════ + + search Search requests using HTTPQL — NEWEST FIRST by default + --limit Max results (default: 20) + --after Pagination cursor + --ids-only Output only request IDs + --asc / --oldest Sort OLDEST first (default is newest first) + --compact Terse one-line-per-request output (low token) + + recent Get recent requests + --limit Max results (default: 20) + --compact Terse one-line-per-request output + + get Get full request details with raw data + + get-response Get just the response for a request + + raw Dump byte-exact raw request (no JSON wrapper) + --out Write to a file instead of stdout + --response Dump the raw response instead of the request + → npx tsx caido-client.ts raw 123 --out /tmp/req.txt + + replay Replay into a NEW named replay session (handoff) + --name Session name (REQUIRED) + --raw Override with custom raw request + --collection Put session in this collection (must exist) + --sni TLS Server Name Indication override + --connect-host Connect to a different host + --connect-port Connect to a different port + --connect-tls Force TLS on override connection + --connect-no-tls Force plain HTTP on override connection + + send-raw Send a raw request via a NEW named replay session + --host Target host (required) + --port Target port (default: 443) + --tls / --no-tls Use TLS (default: true) + --raw Raw HTTP request (required) + --name Session name (REQUIRED) + --collection Put session in this collection (must exist) + --sni TLS Server Name Indication override + --connect-host Connect to a different host + --connect-port Connect to a different port + --connect-tls Force TLS on override connection + --connect-no-tls Force plain HTTP on override connection + + edit Edit + send into a replay session (keeps cookies/auth) + --method Change HTTP method + --path Change request path + --set-header Set header (repeatable) + --remove-header Remove header (repeatable) + --body Set request body + --replace ::: Replace text in request (repeatable) + --session Reuse an existing session (then requires name intent:) + --no-name-change/--nonach Keep the session's current name + --new-name Rename the session + --name REQUIRED when creating a new session (no --session) + --collection Put new session in this collection (must exist) + --sni / --connect-host / --connect-port / --connect-tls / --connect-no-tls + + export-curl Export a FULL self-contained curl command (give to user) + --config Instead, write a reusable curl -K config for INTERNAL + testing: a FAITHFUL STATIC snapshot of ALL the request's + auth/identity headers + static cookies, proxied via Caido + --out Config path (default: /tmp/caido//auth.cfg) + --cookie-jar Use a read/write cookie jar instead of static cookies + (follows Set-Cookie rotation; can drift — opt-in) + --exclude
Omit a header from the config (repeatable) + +═══════════════════════════════════════════════ + REPLAY TAB LOOKUP +═══════════════════════════════════════════════ + + get-session Get a replay session and active entry + replay-entries List request history within a replay session + --limit Max results (default: 20) + --raw Include raw replay/request/response data + edit-session Edit + send from a session's active entry + --method/--path/--body/--set-header/--remove-header/--replace + --no-name-change/--nonach Keep the session name (one of these is REQUIRED) + --new-name Rename the session (the other option) + +═══════════════════════════════════════════════ + REPLAY SESSIONS & COLLECTIONS (always refer by NAME, not ID) +═══════════════════════════════════════════════ + + create-session Create a NAMED replay session from a request + --name Session name (REQUIRED) + --collection Add session to this collection (must exist) + rename-session Rename a replay session + move-session Move a session to a collection (by name or id) + sessions | replay-sessions List replay sessions (all, paginated) + --limit Cap the number returned + delete-sessions Delete replay sessions + + collections | replay-collections List replay collections (all, paginated) + --limit Cap the number returned + create-collection Create a replay collection (name mandatory) + rename-collection Rename a collection (by name or id) + delete-collection Delete a replay collection + +═══════════════════════════════════════════════ + AUTOMATE & FUZZING +═══════════════════════════════════════════════ + + create-automate-session Create an automate session for fuzzing + fuzz Start fuzzing (configure payloads in Caido UI) + +═══════════════════════════════════════════════ + FINDINGS +═══════════════════════════════════════════════ + + findings List findings + --limit Max results (default: 20) + get-finding Get a finding by ID + create-finding Create a finding from a request + --title Finding title (required) + --description <desc> Finding description + --reporter <name> Reporter name (default: "caido-mode") + --dedupe-key <key> Deduplication key + update-finding <id> Update a finding + --title <title> New title + --description <desc> New description + --hidden Hide the finding + --visible Unhide the finding + +═══════════════════════════════════════════════ + PROJECT MANAGEMENT +═══════════════════════════════════════════════ + + projects List all projects + select-project <id> Switch active project + +═══════════════════════════════════════════════ + SCOPE MANAGEMENT +═══════════════════════════════════════════════ + + scopes List all scopes + create-scope <name> Create a scope + --allow <patterns> Comma-separated allowlist patterns + --deny <patterns> Comma-separated denylist patterns + update-scope <id> Update a scope + --name <name> New name + --allow <patterns> New allowlist patterns + --deny <patterns> New denylist patterns + delete-scope <id> Delete a scope + +═══════════════════════════════════════════════ + FILTER PRESETS +═══════════════════════════════════════════════ + + filters List saved filter presets + create-filter <name> Create a filter preset + --query <httpql> HTTPQL query (required) + --alias <alias> Short alias for quick access + update-filter <id> Update a filter preset + --name <name> New name + --query <httpql> New HTTPQL query + --alias <alias> New alias + delete-filter <id> Delete a filter preset + +═══════════════════════════════════════════════ + ENVIRONMENT VARIABLES +═══════════════════════════════════════════════ + + envs List all environments + create-env <name> Create an environment + select-env [id] Select active environment (omit id to deselect) + env-set <env-id> <name> <v> Set a variable in an environment + delete-env <id> Delete an environment + +═══════════════════════════════════════════════ + HOSTED FILES +═══════════════════════════════════════════════ + + hosted-files List hosted files + delete-hosted-file <id> Delete a hosted file + +═══════════════════════════════════════════════ + TASKS +═══════════════════════════════════════════════ + + tasks List active tasks + cancel-task <id> Cancel a running task + +═══════════════════════════════════════════════ + INTERCEPT +═══════════════════════════════════════════════ + + intercept-status Check intercept status + intercept-enable Enable request interception + intercept-disable Disable request interception + +═══════════════════════════════════════════════ + MATCH & REPLACE (auto-rewrite proxied traffic; Caido "Tamper" rules) +═══════════════════════════════════════════════ + + mr-rules List all M&R rules + mr-collections List M&R collections + create-mr-rule Create a rule (auto-applies to proxied traffic) + --section <s> REQUIRED. What to tamper (see sections below) + --operation <op> raw | update | add | remove (default: section's only/raw) + --match-value <str> Match a literal term (raw ops) + --match-regex <re> Match a regex (raw ops) + --match-full Match the entire section (raw ops) + --match-name <name> Header/param name (header/query update/add/remove) + --replace <term> Replacement string (empty string allowed) + --workflow <id> Replace via a workflow instead of a term + --name <name> Rule name + --collection <name|id> Put the rule in this M&R collection + --condition <httpql> Only apply when the request matches this HTTPQL + --sources <a,b,…> Limit to sources: INTERCEPT,REPLAY,AUTOMATE,WORKFLOW,… + update-mr-rule <id> Replace a rule's config (same flags as create) + rename-mr-rule <id> <name> Rename a rule + toggle-mr-rule <id> --on|--off Enable/disable a rule + move-mr-rule <id> <coll> Move a rule to a collection (by name or id) + delete-mr-rule <id> Delete a rule + test-mr-rule --raw <…> Preview a rule on a raw request WITHOUT creating it + (same --section/op/match/replace flags) + create-mr-collection <name> · rename-mr-collection <c> <n> · delete-mr-collection <c> + + Sections: + request : req-method req-path req-query req-body req-first-line req-header req-all req-sni + response: resp-body resp-status resp-first-line resp-header resp-all + websocket: ws-up ws-down + Operations per section: header/query → raw|update|add|remove; method/status → update; + sni → raw (replacer only); everything else → raw (matcher+replacer). + + Example — strip a header on every replayed request: + create-mr-rule --section req-header --operation remove --match-name "If-None-Match" \ + --name "drop INM" --sources REPLAY + Example — inject auth on all proxied traffic to a host: + create-mr-rule --section req-header --operation add --match-name "Authorization" \ + --replace "Bearer eyJ…" --condition 'req.host.eq:"target.com"' --name "auth inject" + +═══════════════════════════════════════════════ + INFO +═══════════════════════════════════════════════ + + viewer Get current user info + plugins List installed plugins + health Check Caido instance health + +═══════════════════════════════════════════════ + OUTPUT CONTROL (works with get, get-response, replay, edit, send-raw) +═══════════════════════════════════════════════ + + --max-body <n> Max response body lines (default: 200, 0=unlimited) + --max-body-chars <n> Max response body chars (default: 5000, 0=unlimited) + --no-request Don't include request raw (saves tokens) + --headers-only Show only HTTP headers, no body + --compact Shorthand: --no-request --max-body 50 --max-body-chars 5000 + +═══════════════════════════════════════════════ + SETUP & AUTH +═══════════════════════════════════════════════ + + setup <pat> [url] Save PAT (keyed by URL) and validate via SDK + (url defaults to http://localhost:8080) + --proxy <addr> Caido proxy for curl -x (defaults to the Caido URL) + auth-status Check auth for the active instance + list all instances + + Multiple instances: credentials are keyed by URL; setup a second URL to add it. + Active instance = CAIDO_URL env → stored default → http://localhost:8080. + + Or set env vars: + export CAIDO_PAT=<token> + export CAIDO_URL=http://localhost:8081 # selects the active instance per shell + export CAIDO_PROXY=... # only if the proxy listener differs from CAIDO_URL + +Primary testing workflow (curl, proxied through Caido — NOT replay): + npx tsx caido-client.ts search 'req.path.cont:"/api/user"' --recent --compact + npx tsx caido-client.ts export-curl 12345 # grab a base request as curl + # then test with curl, always proxying through Caido so it lands in history: + curl -x http://127.0.0.1:8080 -k 'https://target.com/api/user/999' -H 'Cookie: ...' + +Handoff to the user (named replay sessions in named collections): + npx tsx caido-client.ts create-collection "Vuln chain - IDOR to ATO" + npx tsx caido-client.ts create-session 12345 --name "1. login" --collection "Vuln chain - IDOR to ATO" + npx tsx caido-client.ts edit 12345 --path /api/admin --new-name "2. priv-esc" --session "1. login" + npx tsx caido-client.ts edit-session "2. priv-esc" --body '{"role":"admin"}' --nonach --compact + +Other: + npx tsx caido-client.ts create-finding 12345 --title "IDOR" --reporter "rez0" + npx tsx caido-client.ts create-scope "Target" --allow "*.example.com" + npx tsx caido-client.ts sessions --limit 10 + npx tsx caido-client.ts health +`); +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { + printUsage(); + process.exit(0); + } + + const command = args[0]; + + switch (command) { + // ── HTTP History ── + case "search": { + const filter = args[1] || ""; + let limit = 20; + let after: string | undefined; + let idsOnly = false; + let desc = true; // newest-first by default — see cmdSearch + let compact = false; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--limit" && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } + else if (args[i] === "--after" && args[i + 1]) { after = args[i + 1]; i++; } + else if (args[i] === "--ids-only") { idsOnly = true; } + else if (args[i] === "--asc" || args[i] === "--ascending" || args[i] === "--oldest") { desc = false; } + // --desc/--latest/--recent are now the default; still accepted so old invocations don't break. + else if (args[i] === "--desc" || args[i] === "--latest" || args[i] === "--recent") { desc = true; } + else if (args[i] === "--compact") { compact = true; } + } + await cmdSearch(filter, limit, after, idsOnly, desc, compact); + break; + } + + case "recent": { + let limit = 20; + let compact = false; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--limit" && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } + else if (args[i] === "--compact") { compact = true; } + } + await cmdRecent(limit, compact); + break; + } + + case "get": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + await cmdGet(args[1], parseOutputOpts(args, 2)); + break; + } + + case "get-response": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + await cmdGetResponse(args[1], parseOutputOpts(args, 2)); + break; + } + + case "raw": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + let out: string | undefined, response = false; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--out" && args[i + 1]) { out = args[i + 1]; i++; } + else if (args[i] === "--response") { response = true; } + } + await cmdRaw(args[1], { out, response }); + break; + } + + case "replay": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + const name = requireName(args, 2, "creating a replay session with `replay`"); + let rawOverride: string | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--raw" && args[i + 1]) { rawOverride = args[i + 1]; i++; } + } + await cmdReplay( + args[1], + rawOverride, + name, + parseOutputOpts(args, 2), + parseConnectionOverrides(args, 2), + parseCollectionId(args, 2), + ); + break; + } + + case "send-raw": { + let host: string | undefined, port = 443, tls = true, raw: string | undefined; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--host" && args[i + 1]) { host = args[i + 1]; i++; } + else if (args[i] === "--port" && args[i + 1]) { port = parseInt(args[i + 1], 10); i++; } + else if (args[i] === "--tls") { tls = true; } + else if (args[i] === "--no-tls") { tls = false; } + else if (args[i] === "--raw" && args[i + 1]) { raw = args[i + 1]; i++; } + } + if (!host || !raw) { + console.error("Error: --host and --raw are required"); + process.exit(1); + } + const name = requireName(args, 1, "creating a replay session with `send-raw`"); + await cmdSendRaw( + host, + port, + tls, + raw, + name, + parseOutputOpts(args, 1), + parseConnectionOverrides(args, 1), + parseCollectionId(args, 1), + ); + break; + } + + case "edit": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + let method: string | undefined, path: string | undefined, body: string | undefined, sessionRef: string | undefined; + const setHeaders: string[] = [], removeHeaders: string[] = [], replacements: string[] = []; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--method" && args[i + 1]) { method = args[i + 1]; i++; } + else if (args[i] === "--path" && args[i + 1]) { path = args[i + 1]; i++; } + else if (args[i] === "--body" && args[i + 1]) { body = args[i + 1]; i++; } + else if (args[i] === "--set-header" && args[i + 1]) { setHeaders.push(args[i + 1]); i++; } + else if (args[i] === "--remove-header" && args[i + 1]) { removeHeaders.push(args[i + 1]); i++; } + else if (args[i] === "--replace" && args[i + 1]) { replacements.push(args[i + 1]); i++; } + else if (args[i] === "--session" && args[i + 1]) { sessionRef = args[i + 1]; i++; } + } + // Existing session → require name-change intent. New session → require --name. + const target: EditTarget = sessionRef + ? { kind: "session", ref: sessionRef, nameChange: requireNameChange(args, 2) } + : { kind: "new", name: requireName(args, 2, "`edit` creates a new replay session"), collectionRef: parseCollectionId(args, 2) }; + await cmdEdit( + args[1], + { method, path, body, setHeaders, removeHeaders, replacements }, + target, + parseOutputOpts(args, 2), + parseConnectionOverrides(args, 2), + ); + break; + } + + case "export-curl": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + let asConfig = false, ecOut: string | undefined, cookieJar = false; + const ecExclude: string[] = []; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--config") { asConfig = true; } + else if (args[i] === "--out" && args[i + 1]) { ecOut = args[i + 1]; i++; } + else if (args[i] === "--cookie-jar") { cookieJar = true; } + else if (args[i] === "--exclude" && args[i + 1]) { ecExclude.push(args[i + 1]); i++; } + } + if (asConfig) await cmdExportCurlConfig(args[1], { out: ecOut, cookieJar, exclude: ecExclude }); + else await cmdExportCurl(args[1]); + break; + } + + // ── Replay Tab Lookup ── + case "get-session": { + if (!args[1]) { console.error("Error: session id or name required"); process.exit(1); } + await cmdGetSession(args[1], parseOutputOpts(args, 2)); + break; + } + + case "replay-entries": + case "session-entries": { + if (!args[1]) { console.error("Error: session id or name required"); process.exit(1); } + let limit = 20; + let includeRaw = false; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--limit" && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } + else if (args[i] === "--raw") { includeRaw = true; } + } + await cmdReplayEntries(args[1], limit, parseOutputOpts(args, 2), includeRaw); + break; + } + + case "edit-session": { + if (!args[1]) { console.error("Error: session id or name required"); process.exit(1); } + let esMethod: string | undefined, esPath: string | undefined, esBody: string | undefined; + const esSetHeaders: string[] = [], esRemoveHeaders: string[] = [], esReplacements: string[] = []; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--method" && args[i + 1]) { esMethod = args[i + 1]; i++; } + else if (args[i] === "--path" && args[i + 1]) { esPath = args[i + 1]; i++; } + else if (args[i] === "--body" && args[i + 1]) { esBody = args[i + 1]; i++; } + else if (args[i] === "--set-header" && args[i + 1]) { esSetHeaders.push(args[i + 1]); i++; } + else if (args[i] === "--remove-header" && args[i + 1]) { esRemoveHeaders.push(args[i + 1]); i++; } + else if (args[i] === "--replace" && args[i + 1]) { esReplacements.push(args[i + 1]); i++; } + } + await cmdEditSession( + args[1], + { method: esMethod, path: esPath, body: esBody, setHeaders: esSetHeaders, removeHeaders: esRemoveHeaders, replacements: esReplacements }, + requireNameChange(args, 2), + parseOutputOpts(args, 2), + parseConnectionOverrides(args, 2), + ); + break; + } + + // ── Replay Sessions ── + case "create-session": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + const name = requireName(args, 2, "creating a replay session"); + await cmdCreateSession(args[1], name, parseCollectionId(args, 2)); + break; + } + + case "rename-session": { + if (!args[1] || !args[2]) { console.error("Error: session (id or name) and new name required"); process.exit(1); } + await cmdRenameSession(args[1], args[2]); + break; + } + + case "move-session": { + if (!args[1] || !args[2]) { console.error("Error: session and collection (id or name) required"); process.exit(1); } + await cmdMoveSession(args[1], args[2]); + break; + } + + case "sessions": + case "replay-sessions": { + let limit: number | undefined; // default: paginate all + for (let i = 1; i < args.length; i++) { + if (args[i] === "--limit" && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } + } + await cmdReplaySessions(limit); + break; + } + + case "delete-sessions": { + if (!args[1]) { console.error("Error: comma-separated session IDs required"); process.exit(1); } + await cmdDeleteSessions(args[1].split(",").map(s => s.trim())); + break; + } + + // ── Replay Collections ── + case "collections": + case "replay-collections": { + let limit: number | undefined; // default: paginate all + for (let i = 1; i < args.length; i++) { + if (args[i] === "--limit" && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } + } + await cmdReplayCollections(limit); + break; + } + + case "create-collection": { + if (!args[1]) { console.error("Error: collection name required (names are mandatory)"); process.exit(1); } + await cmdCreateCollection(args[1]); + break; + } + + case "rename-collection": { + if (!args[1] || !args[2]) { console.error("Error: collection (id or name) and new name required"); process.exit(1); } + await cmdRenameCollection(args[1], args[2]); + break; + } + + case "delete-collection": { + if (!args[1]) { console.error("Error: collection (id or name) required"); process.exit(1); } + await cmdDeleteCollection(args[1]); + break; + } + + // ── Automate & Fuzzing ── + case "create-automate-session": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + await cmdCreateAutomateSession(args[1]); + break; + } + + case "fuzz": { + if (!args[1]) { console.error("Error: session-id required"); process.exit(1); } + await cmdFuzz(args[1], []); + break; + } + + // ── Findings ── + case "findings": { + let limit = 20; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--limit" && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } + } + await cmdFindings(limit); + break; + } + + case "get-finding": { + if (!args[1]) { console.error("Error: finding-id required"); process.exit(1); } + await cmdGetFinding(args[1]); + break; + } + + case "create-finding": { + if (!args[1]) { console.error("Error: request-id required"); process.exit(1); } + let title: string | undefined, desc: string | undefined, reporter: string | undefined, dedupeKey: string | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--title" && args[i + 1]) { title = args[i + 1]; i++; } + else if (args[i] === "--description" && args[i + 1]) { desc = args[i + 1]; i++; } + else if (args[i] === "--reporter" && args[i + 1]) { reporter = args[i + 1]; i++; } + else if (args[i] === "--dedupe-key" && args[i + 1]) { dedupeKey = args[i + 1]; i++; } + } + if (!title) { console.error("Error: --title required"); process.exit(1); } + await cmdCreateFinding(args[1], title, desc, reporter, dedupeKey); + break; + } + + case "update-finding": { + if (!args[1]) { console.error("Error: finding-id required"); process.exit(1); } + let uTitle: string | undefined, uDesc: string | undefined, uHidden: boolean | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--title" && args[i + 1]) { uTitle = args[i + 1]; i++; } + else if (args[i] === "--description" && args[i + 1]) { uDesc = args[i + 1]; i++; } + else if (args[i] === "--hidden") { uHidden = true; } + else if (args[i] === "--visible") { uHidden = false; } + } + await cmdUpdateFinding(args[1], uTitle, uDesc, uHidden); + break; + } + + // ── Projects ── + case "projects": { await cmdProjects(); break; } + case "select-project": { + if (!args[1]) { console.error("Error: project id required"); process.exit(1); } + await cmdSelectProject(args[1]); + break; + } + + // ── Scopes ── + case "scopes": { await cmdScopes(); break; } + case "create-scope": { + if (!args[1]) { console.error("Error: scope name required"); process.exit(1); } + let allow: string[] = [], deny: string[] = []; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--allow" && args[i + 1]) { allow = args[i + 1].split(",").map(s => s.trim()); i++; } + else if (args[i] === "--deny" && args[i + 1]) { deny = args[i + 1].split(",").map(s => s.trim()); i++; } + } + await cmdCreateScope(args[1], allow, deny); + break; + } + case "update-scope": { + if (!args[1]) { console.error("Error: scope id required"); process.exit(1); } + let sName: string | undefined, sAllow: string[] | undefined, sDeny: string[] | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--name" && args[i + 1]) { sName = args[i + 1]; i++; } + else if (args[i] === "--allow" && args[i + 1]) { sAllow = args[i + 1].split(",").map(s => s.trim()); i++; } + else if (args[i] === "--deny" && args[i + 1]) { sDeny = args[i + 1].split(",").map(s => s.trim()); i++; } + } + await cmdUpdateScope(args[1], sName, sAllow, sDeny); + break; + } + case "delete-scope": { + if (!args[1]) { console.error("Error: scope id required"); process.exit(1); } + await cmdDeleteScope(args[1]); + break; + } + + // ── Filters ── + case "filters": { await cmdFilters(); break; } + case "create-filter": { + if (!args[1]) { console.error("Error: filter name required"); process.exit(1); } + let fQuery: string | undefined, fAlias: string | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--query" && args[i + 1]) { fQuery = args[i + 1]; i++; } + else if (args[i] === "--alias" && args[i + 1]) { fAlias = args[i + 1]; i++; } + } + if (!fQuery) { console.error("Error: --query required"); process.exit(1); } + await cmdCreateFilter(args[1], fQuery, fAlias); + break; + } + case "update-filter": { + if (!args[1]) { console.error("Error: filter id required"); process.exit(1); } + let ufName: string | undefined, ufQuery: string | undefined, ufAlias: string | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--name" && args[i + 1]) { ufName = args[i + 1]; i++; } + else if (args[i] === "--query" && args[i + 1]) { ufQuery = args[i + 1]; i++; } + else if (args[i] === "--alias" && args[i + 1]) { ufAlias = args[i + 1]; i++; } + } + await cmdUpdateFilter(args[1], ufName, ufQuery, ufAlias); + break; + } + case "delete-filter": { + if (!args[1]) { console.error("Error: filter id required"); process.exit(1); } + await cmdDeleteFilter(args[1]); + break; + } + + // ── Environments ── + case "envs": { await cmdEnvs(); break; } + case "create-env": { + if (!args[1]) { console.error("Error: environment name required"); process.exit(1); } + await cmdCreateEnv(args[1]); + break; + } + case "select-env": { await cmdSelectEnv(args[1]); break; } + case "env-set": { + if (!args[1] || !args[2] || args[3] === undefined) { + console.error("Error: env-set requires <env-id> <var-name> <value>"); + process.exit(1); + } + await cmdEnvSet(args[1], args[2], args[3]); + break; + } + case "delete-env": { + if (!args[1]) { console.error("Error: environment id required"); process.exit(1); } + await cmdDeleteEnv(args[1]); + break; + } + + // ── Hosted Files ── + case "hosted-files": { await cmdHostedFiles(); break; } + case "delete-hosted-file": { + if (!args[1]) { console.error("Error: hosted file id required"); process.exit(1); } + await cmdDeleteHostedFile(args[1]); + break; + } + + // ── Tasks ── + case "tasks": { await cmdTasks(); break; } + case "cancel-task": { + if (!args[1]) { console.error("Error: task id required"); process.exit(1); } + await cmdCancelTask(args[1]); + break; + } + + // ── Intercept ── + case "intercept-status": { await cmdInterceptStatus(); break; } + case "intercept-enable": { await cmdInterceptSet(true); break; } + case "intercept-disable": { await cmdInterceptSet(false); break; } + + // ── Match & Replace (Tamper) ── + case "mr-rules": { await cmdMrRules(); break; } + case "mr-collections": { await cmdMrCollections(); break; } + + case "create-mr-rule": { + await cmdCreateMrRule(parseMrOpts(args, 1), parseCollectionId(args, 1)); + break; + } + case "update-mr-rule": { + if (!args[1]) { console.error("Error: rule id required"); process.exit(1); } + await cmdUpdateMrRule(args[1], parseMrOpts(args, 2)); + break; + } + case "delete-mr-rule": { + if (!args[1]) { console.error("Error: rule id required"); process.exit(1); } + await cmdDeleteMrRule(args[1]); + break; + } + case "toggle-mr-rule": { + if (!args[1]) { console.error("Error: rule id required"); process.exit(1); } + const off = args.includes("--off") || args.includes("--disable"); + const on = args.includes("--on") || args.includes("--enable"); + if (off === on) { console.error("Error: pass exactly one of --on/--enable or --off/--disable"); process.exit(1); } + await cmdToggleMrRule(args[1], on); + break; + } + case "rename-mr-rule": { + if (!args[1] || !args[2]) { console.error("Error: rule id and new name required"); process.exit(1); } + await cmdRenameMrRule(args[1], args[2]); + break; + } + case "move-mr-rule": { + if (!args[1] || !args[2]) { console.error("Error: rule id and collection (name or id) required"); process.exit(1); } + await cmdMoveMrRule(args[1], args[2]); + break; + } + case "test-mr-rule": { + let mrRaw: string | undefined; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--raw" && args[i + 1]) { mrRaw = args[i + 1]; i++; } + } + if (!mrRaw) { console.error("Error: --raw <str|@file|-> required (the request/response to tamper)"); process.exit(1); } + await cmdTestMrRule(parseMrOpts(args, 1), mrRaw); + break; + } + + case "create-mr-collection": { + if (!args[1]) { console.error("Error: collection name required"); process.exit(1); } + await cmdCreateMrCollection(args[1]); + break; + } + case "rename-mr-collection": { + if (!args[1] || !args[2]) { console.error("Error: collection (id or name) and new name required"); process.exit(1); } + await cmdRenameMrCollection(args[1], args[2]); + break; + } + case "delete-mr-collection": { + if (!args[1]) { console.error("Error: collection (id or name) required"); process.exit(1); } + await cmdDeleteMrCollection(args[1]); + break; + } + + // ── Info ── + case "viewer": { await cmdViewer(); break; } + case "plugins": { await cmdPlugins(); break; } + case "health": { await cmdHealth(); break; } + + // ── Setup & Auth ── + case "setup": { + const pat = args[1]; + if (!pat) { + console.error("Usage: npx tsx caido-client.ts setup <pat> [url] [--proxy <addr>]"); + console.error("\nGet a PAT from: Caido → Settings → Developer → Personal Access Tokens"); + process.exit(1); + } + let url: string | undefined, proxy: string | undefined; + for (let i = 2; i < args.length; i++) { + if (args[i] === "--proxy" && args[i + 1]) { proxy = args[i + 1]; i++; } + else if (!args[i].startsWith("--") && !url) { url = args[i]; } + } + url = url || process.env.CAIDO_URL || "http://localhost:8080"; + await cmdSetup(pat, url, proxy); + break; + } + case "auth-status": { await cmdAuthStatus(); break; } + + default: + console.error(`Unknown command: ${command}`); + printUsage(); + process.exit(1); + } +} + +main().catch((e) => { + console.error(`Error: ${e.message}`); + if (DEBUG) console.error(e.stack); + process.exit(1); +}); diff --git a/capabilities/web-security/skills/caido-mode/lib/client.ts b/capabilities/web-security/skills/caido-mode/lib/client.ts new file mode 100644 index 0000000..e56bab2 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/client.ts @@ -0,0 +1,241 @@ +/** + * Caido SDK client with URL-keyed (multi-instance) auth. + * + * Credentials live in ~/.claude/config/secrets.json under `.caido`, keyed by + * instance URL so two Caido instances on one machine never clobber each other: + * + * "caido": { + * "default": "http://localhost:8080", + * "instances": { + * "http://localhost:8080": { "pat": "...", "proxy": "...", "cachedToken": {...} }, + * "http://localhost:8081": { "pat": "...", "cachedToken": {...} } + * } + * } + * + * Active instance = CAIDO_URL env → stored `default` → http://localhost:8080. + * The legacy flat shape ({ url, pat, cachedToken, ... }) is migrated on read. + */ + +import { Client, type TokenCache, type CachedToken } from "@caido/sdk-client"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "fs"; +import { homedir } from "os"; +import { join, dirname } from "path"; + +const SECRETS_PATH = join(homedir(), ".claude", "config", "secrets.json"); +const DEFAULT_URL = "http://localhost:8080"; + +/** + * Canonicalize an instance URL used as a storage key, so the same instance + * referenced as `…:8080`, `…:8080/`, or with a differently-cased host all map + * to one entry. Strips trailing slashes and lowercases the scheme+authority, + * leaving any path untouched. + */ +export function canonicalUrl(u: string): string { + const s = u.trim().replace(/\/+$/, ""); + return s.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]+)/, (m) => m.toLowerCase()); +} + +export type AuthMode = "pat" | "cached-token"; + +export interface CaidoConfig { + url: string; + pat: string; // empty string when authMode === "cached-token" + authMode: AuthMode; +} + +export interface CaidoInstance { + pat?: string; + proxy?: string; + cachedToken?: CachedToken; +} + +export interface CaidoRoot { + default?: string; + instances?: Record<string, CaidoInstance>; +} + +/** Read the whole secrets.json (all services), tolerating a missing/corrupt file. */ +function readSecretsFile(): Record<string, any> { + if (!existsSync(SECRETS_PATH)) return {}; + try { + return JSON.parse(readFileSync(SECRETS_PATH, "utf-8")) || {}; + } catch { + return {}; + } +} + +/** Migrate the legacy flat `.caido` ({ url, pat, proxy, cachedToken, …dead keys }) to URL-keyed. */ +function normalizeRoot(raw: any): CaidoRoot { + if (!raw || typeof raw !== "object") return { instances: {} }; + if (raw.instances && typeof raw.instances === "object" && !Array.isArray(raw.instances)) { + return { default: raw.default, instances: raw.instances }; + } + + const url = canonicalUrl(typeof raw.url === "string" ? raw.url : DEFAULT_URL); + const inst: CaidoInstance = {}; + if (raw.pat) inst.pat = raw.pat; + if (raw.proxy) inst.proxy = raw.proxy; + if (raw.cachedToken?.accessToken) inst.cachedToken = raw.cachedToken; + const hasAny = inst.pat || inst.proxy || inst.cachedToken; + return { default: url, instances: hasAny ? { [url]: inst } : {} }; +} + +/** Read + normalize the `.caido` root from secrets.json. */ +export function readCaidoRoot(): CaidoRoot { + return normalizeRoot(readSecretsFile().caido); +} + +/** + * Persist the normalized `.caido` root, leaving other services' secrets intact. + * Writes a temp file and renames it into place (atomic on the same filesystem) so a + * concurrent reader/writer can never observe or persist a torn secrets.json — which + * matters because the file is shared across services. + */ +function writeCaidoRoot(root: CaidoRoot): void { + const dir = dirname(SECRETS_PATH); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const secrets = readSecretsFile(); + secrets.caido = { default: root.default, instances: root.instances ?? {} }; + const tmp = `${SECRETS_PATH}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify(secrets, null, 2)); + renameSync(tmp, SECRETS_PATH); +} + +/** Create/merge one instance's config (and optionally make it the default/active one). */ +export function upsertCaidoInstance(url: string, patch: Partial<CaidoInstance>, setDefault = false): void { + const key = canonicalUrl(url); + const root = readCaidoRoot(); + root.instances = root.instances ?? {}; + root.instances[key] = { ...root.instances[key], ...patch }; + if (setDefault || !root.default) root.default = key; + writeCaidoRoot(root); +} + +export function getCaidoInstance(root: CaidoRoot, url: string): CaidoInstance { + return root.instances?.[canonicalUrl(url)] ?? {}; +} + +/** Active instance URL: CAIDO_URL env → stored default → localhost:8080. */ +export function resolveActiveUrl(): string { + if (process.env.CAIDO_URL) return canonicalUrl(process.env.CAIDO_URL); + return readCaidoRoot().default || DEFAULT_URL; +} + +export function isCachedTokenValid(instance: CaidoInstance): boolean { + const t = instance.cachedToken; + if (!t?.accessToken || !t.expiresAt) return false; + const exp = Date.parse(t.expiresAt); + return Number.isFinite(exp) && exp > Date.now(); +} + +/** + * Resolve Caido's proxy listener for `curl -x` for the ACTIVE instance. + * Caido's proxy and API share an address, so it defaults to the instance URL. + * Precedence: CAIDO_PROXY env → instance.proxy → the instance URL. + */ +export function resolveProxy(): string { + if (process.env.CAIDO_PROXY) return process.env.CAIDO_PROXY; + const url = resolveActiveUrl(); + return getCaidoInstance(readCaidoRoot(), url).proxy || url; +} + +/** + * URL-keyed token cache: persists the access token under instances[url].cachedToken, + * so concurrent/alternating instances on one machine never overwrite each other's auth. + * Construct one per active URL. + */ +export class SecretsTokenCache implements TokenCache { + private _cachedToken: CachedToken | null = null; + constructor(private readonly url: string) {} + + async load(): Promise<CachedToken | undefined> { + if (this._cachedToken) return this._cachedToken; + const instance = getCaidoInstance(readCaidoRoot(), this.url); + // Only hand back a still-valid token — never an expired one, so the SDK + // falls through to PAT auth and re-mints instead of using a dead token. + if (instance.cachedToken?.accessToken && isCachedTokenValid(instance)) { + this._cachedToken = instance.cachedToken; + return this._cachedToken; + } + return undefined; + } + + async save(token: CachedToken): Promise<void> { + this._cachedToken = token; + upsertCaidoInstance(this.url, { cachedToken: token }); + } + + async clear(): Promise<void> { + this._cachedToken = null; + const root = readCaidoRoot(); + if (root.instances?.[this.url]) { + delete root.instances[this.url].cachedToken; + writeCaidoRoot(root); + } + } +} + +export function loadConfig(): CaidoConfig { + const url = resolveActiveUrl(); + const instance = getCaidoInstance(readCaidoRoot(), url); + + const envPat = process.env.CAIDO_PAT; + if (envPat) return { url, pat: envPat, authMode: "pat" }; + if (instance.pat) return { url, pat: instance.pat, authMode: "pat" }; + if (isCachedTokenValid(instance)) return { url, pat: "", authMode: "cached-token" }; + + if (instance.cachedToken?.accessToken) { + console.error(`Error: Cached access token for ${url} expired at ${instance.cachedToken.expiresAt}.`); + console.error(`Re-run: npx tsx caido-client.ts setup <pat> ${url}`); + } else { + console.error(`Error: No Caido auth found for instance ${url}.`); + console.error(" - No PAT in env (CAIDO_PAT) or stored for this instance"); + console.error(" - No unexpired cached token for this instance"); + console.error(""); + console.error(`Setup: npx tsx caido-client.ts setup <pat> ${url}`); + console.error(`(Select an instance with CAIDO_URL or the stored default.)`); + } + process.exit(1); +} + +/** + * Keep the SDK's chatter (e.g. "[caido] Loaded token from cache") off stdout so command + * output stays pure JSON. Warnings/errors still surface on stderr. + */ +export const QUIET_LOGGER = { + debug() {}, + info() {}, + warn: (message: string, ...args: unknown[]) => console.error(message, ...args), + error: (message: string, ...args: unknown[]) => console.error(message, ...args), +}; + +let _client: Client | null = null; + +export async function getClient(): Promise<Client> { + if (_client) return _client; + + const config = loadConfig(); + const cache = new SecretsTokenCache(config.url); + + _client = new Client({ + url: config.url, + auth: { pat: config.pat, cache }, + logger: QUIET_LOGGER, + }); + + try { + await _client.connect({ ready: { retries: 3, timeout: 5000, interval: 1000 } }); + } catch (err: any) { + if (err.message?.includes("not ready")) { + console.error("Error: Caido instance is not ready. Is Caido running?"); + console.error(` Tried: ${config.url}`); + } else { + console.error(`Connection error: ${err.message}`); + } + process.exit(1); + } + + return _client; +} + +export { SECRETS_PATH }; diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/findings.ts b/capabilities/web-security/skills/caido-mode/lib/commands/findings.ts new file mode 100644 index 0000000..7a17125 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/findings.ts @@ -0,0 +1,74 @@ +/** Findings commands: list, get, create, update */ + +import { getClient } from "../client"; + +export async function cmdFindings(limit: number) { + const client = await getClient(); + const connection = await client.finding.list().first(limit); + + const results = connection.edges.map(e => ({ + id: e.node.id, + title: e.node.title, + reporter: e.node.reporter, + host: e.node.host, + path: e.node.path, + hidden: e.node.hidden, + dedupeKey: e.node.dedupeKey, + createdAt: e.node.createdAt, + })); + + console.log(JSON.stringify({ results, count: results.length }, null, 2)); +} + +export async function cmdGetFinding(findingId: string) { + const client = await getClient(); + const finding = await client.finding.get(findingId); + + if (!finding) { + console.error(`Finding ${findingId} not found`); + process.exit(1); + } + + console.log(JSON.stringify(finding, null, 2)); +} + +export async function cmdCreateFinding( + requestId: string, + title: string, + description?: string, + reporter?: string, + dedupeKey?: string, +) { + const client = await getClient(); + const finding = await client.finding.create(requestId, { + title, + reporter: reporter || "caido-mode", + description, + dedupeKey, + }); + + console.log(JSON.stringify(finding, null, 2)); +} + +export async function cmdUpdateFinding( + findingId: string, + title?: string, + description?: string, + hidden?: boolean, +) { + const client = await getClient(); + const existing = await client.finding.get(findingId); + + if (!existing) { + console.error(`Finding ${findingId} not found`); + process.exit(1); + } + + const finding = await client.finding.update(findingId, { + title: title ?? existing.title, + description: description ?? existing.description ?? "", + hidden: hidden ?? existing.hidden, + }); + + console.log(JSON.stringify(finding, null, 2)); +} diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/info.ts b/capabilities/web-security/skills/caido-mode/lib/commands/info.ts new file mode 100644 index 0000000..b4b0f39 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/info.ts @@ -0,0 +1,107 @@ +/** Info commands: viewer, plugins, health, setup, auth-status */ + +import { Client } from "@caido/sdk-client"; +import { + getClient, resolveProxy, resolveActiveUrl, readCaidoRoot, getCaidoInstance, + upsertCaidoInstance, SecretsTokenCache, SECRETS_PATH, isCachedTokenValid, QUIET_LOGGER, +} from "../client"; +import { PLUGIN_PACKAGES_QUERY } from "../graphql"; + +export async function cmdViewer() { + const client = await getClient(); + const viewer = await client.user.viewer(); + console.log(JSON.stringify(viewer, null, 2)); +} + +export async function cmdPlugins() { + const client = await getClient(); + const result = await client.graphql.query(PLUGIN_PACKAGES_QUERY, {}); + console.log(JSON.stringify((result as any).pluginPackages, null, 2)); +} + +export async function cmdHealth() { + const client = await getClient(); + const health = await client.health(); + console.log(JSON.stringify(health, null, 2)); +} + +export async function cmdSetup(pat: string, url: string, proxy?: string) { + console.log(`Connecting to ${url}...`); + + // Cache the access token under THIS instance's slot (clear any stale one first). + const setupCache = new SecretsTokenCache(url); + await setupCache.clear(); + + const client = new Client({ + url, + auth: { pat, cache: setupCache }, + logger: QUIET_LOGGER, + }); + + try { + await client.connect({ ready: { retries: 3, timeout: 5000, interval: 1000 } }); + } catch (err: any) { + console.error(`Failed to connect: ${err.message}`); + console.error("\nMake sure:"); + console.error(` 1. Caido is running at ${url}`); + console.error(" 2. The PAT was created in Caido → Settings → Developer → Personal Access Tokens"); + process.exit(1); + } + + const viewer = await client.user.viewer(); + console.log(`Authenticated as: ${(viewer as any).username || (viewer as any).id || JSON.stringify(viewer)}`); + + // Persist PAT (+ proxy) under instances[url] and make it the active default. + // The access token was already cached under instances[url] during connect. + upsertCaidoInstance(url, { pat, ...(proxy ? { proxy } : {}) }, true); + + console.log(`\nSaved to ${SECRETS_PATH} (instance: ${url})`); + console.log(`PAT: ${pat.slice(0, 12)}...`); + console.log(`Access token: cached`); + console.log(`Proxy (curl -x): ${resolveProxy()}`); + console.log(`\nActive instance is now ${url}. Switch instances per shell with CAIDO_URL=<url>.`); +} + +export async function cmdAuthStatus() { + const url = resolveActiveUrl(); + const root = readCaidoRoot(); + const instance = getCaidoInstance(root, url); + + const hasPat = !!process.env.CAIDO_PAT || !!instance.pat; + const cachedTokenValid = isCachedTokenValid(instance); + const cachedTokenExpiresAt = instance.cachedToken?.expiresAt ?? null; + const authMode = hasPat ? "pat" : (cachedTokenValid ? "cached-token" : "none"); + + const base = { + activeUrl: url, + defaultUrl: root.default ?? null, + configuredInstances: Object.keys(root.instances ?? {}), + authMode, + hasPat, + cachedTokenExpiresAt, + cachedTokenValid, + proxy: resolveProxy(), + }; + + if (!hasPat && !cachedTokenValid) { + console.log(JSON.stringify({ + authenticated: false, + ...base, + error: `No usable auth for ${url}. Run: setup <pat> ${url} (or set CAIDO_PAT / CAIDO_URL).`, + }, null, 2)); + return; + } + + const statusCache = new SecretsTokenCache(url); + const pat = process.env.CAIDO_PAT || instance.pat || ""; + const client = new Client({ url, auth: { pat, cache: statusCache }, logger: QUIET_LOGGER }); + + try { + await client.connect({ ready: { retries: 2, timeout: 3000, interval: 1000 } }); + const viewer = await client.user.viewer(); + const health = await client.health(); + console.log(JSON.stringify({ authenticated: true, ...base, user: viewer, health }, null, 2)); + } catch (err: any) { + console.log(JSON.stringify({ authenticated: false, ...base, error: err.message }, null, 2)); + } +} diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/intercept.ts b/capabilities/web-security/skills/caido-mode/lib/commands/intercept.ts new file mode 100644 index 0000000..14f3bbf --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/intercept.ts @@ -0,0 +1,26 @@ +/** Intercept commands: status, enable, disable */ + +import { getClient } from "../client"; +import { INTERCEPT_OPTIONS_QUERY, PAUSE_INTERCEPT, RESUME_INTERCEPT } from "../graphql"; + +export async function cmdInterceptStatus() { + const client = await getClient(); + try { + const result = await client.graphql.query(INTERCEPT_OPTIONS_QUERY, {}); + console.log(JSON.stringify((result as any).interceptOptions, null, 2)); + } catch (err: any) { + console.log(JSON.stringify({ error: err.message, hint: "Intercept may not be available" }, null, 2)); + } +} + +export async function cmdInterceptSet(enabled: boolean) { + const client = await getClient(); + try { + const mutation = enabled ? RESUME_INTERCEPT : PAUSE_INTERCEPT; + const result = await client.graphql.mutation(mutation, {}); + const key = enabled ? "resumeIntercept" : "pauseIntercept"; + console.log(JSON.stringify((result as any)[key], null, 2)); + } catch (err: any) { + console.error(`Failed to ${enabled ? "enable" : "disable"} intercept: ${err.message}`); + } +} diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/management.ts b/capabilities/web-security/skills/caido-mode/lib/commands/management.ts new file mode 100644 index 0000000..67dd8d6 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/management.ts @@ -0,0 +1,185 @@ +/** Management commands: scopes, filters, environments, projects, hosted files, tasks */ + +import { getClient } from "../client"; + +// ── Scopes ── + +export async function cmdScopes() { + const client = await getClient(); + const scopes = await client.scope.list(); + console.log(JSON.stringify(scopes, null, 2)); +} + +export async function cmdCreateScope(name: string, allow: string[], deny: string[]) { + const client = await getClient(); + const scope = await client.scope.create({ + name, + allowlist: allow, + denylist: deny, + }); + console.log(JSON.stringify(scope, null, 2)); +} + +export async function cmdUpdateScope( + scopeId: string, + name?: string, + allow?: string[], + deny?: string[], +) { + const client = await getClient(); + const existing = await client.scope.get(scopeId); + + if (!existing) { + console.error(`Scope ${scopeId} not found`); + process.exit(1); + } + + const scope = await client.scope.update(scopeId, { + name: name ?? existing.name, + allowlist: allow ?? existing.allowlist, + denylist: deny ?? existing.denylist, + }); + console.log(JSON.stringify(scope, null, 2)); +} + +export async function cmdDeleteScope(scopeId: string) { + const client = await getClient(); + await client.scope.delete(scopeId); + console.log(JSON.stringify({ deleted: scopeId }, null, 2)); +} + +// ── Filters ── + +export async function cmdFilters() { + const client = await getClient(); + const filters = await client.filter.list(); + console.log(JSON.stringify(filters, null, 2)); +} + +export async function cmdCreateFilter(name: string, query: string, alias?: string) { + const client = await getClient(); + const filter = await client.filter.create({ + name, + clause: query, + alias, + }); + console.log(JSON.stringify(filter, null, 2)); +} + +export async function cmdUpdateFilter( + filterId: string, + name?: string, + query?: string, + alias?: string, +) { + const client = await getClient(); + const existing = await client.filter.get(filterId); + + if (!existing) { + console.error(`Filter ${filterId} not found`); + process.exit(1); + } + + const filter = await client.filter.update(filterId, { + name: name ?? existing.name, + clause: query ?? existing.clause, + alias: alias ?? existing.alias, + }); + console.log(JSON.stringify(filter, null, 2)); +} + +export async function cmdDeleteFilter(filterId: string) { + const client = await getClient(); + await client.filter.delete(filterId); + console.log(JSON.stringify({ deleted: filterId }, null, 2)); +} + +// ── Environments ── + +export async function cmdEnvs() { + const client = await getClient(); + const envs = await client.environment.list(); + console.log(JSON.stringify(envs, null, 2)); +} + +export async function cmdCreateEnv(name: string) { + const client = await getClient(); + // SDK's environment.create calls `options.variables.map(...)` and the + // schema requires a non-null variables list, so pass an empty array. + const env = await client.environment.create({ name, variables: [] }); + console.log(JSON.stringify({ id: env.id, name: env.name }, null, 2)); +} + +export async function cmdSelectEnv(envId?: string) { + const client = await getClient(); + await client.environment.select(envId); + console.log(JSON.stringify({ selected: envId || null }, null, 2)); +} + +export async function cmdEnvSet(envId: string, varName: string, value: string) { + const client = await getClient(); + const env = await client.environment.get(envId); + + if (!env) { + console.error(`Environment ${envId} not found`); + process.exit(1); + } + + // Check if variable exists + const existing = env.variables.find(v => v.name === varName); + if (existing) { + await env.updateVariable(varName, { value }); + } else { + await env.addVariable({ name: varName, value, kind: "PLAIN" }); + } + + console.log(JSON.stringify({ envId, variable: varName, value, action: existing ? "updated" : "created" }, null, 2)); +} + +export async function cmdDeleteEnv(envId: string) { + const client = await getClient(); + await client.environment.delete(envId); + console.log(JSON.stringify({ deleted: envId }, null, 2)); +} + +// ── Projects ── + +export async function cmdProjects() { + const client = await getClient(); + const projects = await client.project.list(); + console.log(JSON.stringify(projects, null, 2)); +} + +export async function cmdSelectProject(projectId: string) { + const client = await getClient(); + await client.project.select(projectId); + console.log(JSON.stringify({ selected: projectId }, null, 2)); +} + +// ── Hosted Files ── + +export async function cmdHostedFiles() { + const client = await getClient(); + const files = await client.hostedFile.list(); + console.log(JSON.stringify(files, null, 2)); +} + +export async function cmdDeleteHostedFile(fileId: string) { + const client = await getClient(); + await client.hostedFile.delete(fileId); + console.log(JSON.stringify({ deleted: fileId }, null, 2)); +} + +// ── Tasks ── + +export async function cmdTasks() { + const client = await getClient(); + const tasks = await client.task.list(); + console.log(JSON.stringify(tasks, null, 2)); +} + +export async function cmdCancelTask(taskId: string) { + const client = await getClient(); + await client.task.cancel(taskId); + console.log(JSON.stringify({ cancelled: taskId }, null, 2)); +} diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/matchreplace.ts b/capabilities/web-security/skills/caido-mode/lib/commands/matchreplace.ts new file mode 100644 index 0000000..8e26224 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/matchreplace.ts @@ -0,0 +1,310 @@ +/** + * Match & Replace (Caido "Tamper" rules). + * + * A rule = one SECTION (which part of the request/response) × one OPERATION + * (raw/update/add/remove) × a MATCHER (value/regex/full/name) × a REPLACER + * (term/workflow), plus optional condition (HTTPQL/StreamQL scope) and sources. + * + * The schema is a deeply-nested exclusive union; `buildTamperSection` constructs + * the exact `TamperSectionInput` and is unit-tested for every section/operation. + * Structures were validated against a live instance via `testTamperRule`. + */ + +import { getClient } from "../client"; +import { resolveRaw, ensureHeaderCrlf } from "./replay"; +import { + TAMPER_RULE_COLLECTIONS, + CREATE_TAMPER_RULE, + UPDATE_TAMPER_RULE, + DELETE_TAMPER_RULE, + TOGGLE_TAMPER_RULE, + RENAME_TAMPER_RULE, + MOVE_TAMPER_RULE, + TEST_TAMPER_RULE, + CREATE_TAMPER_RULE_COLLECTION, + RENAME_TAMPER_RULE_COLLECTION, + DELETE_TAMPER_RULE_COLLECTION, +} from "../graphql"; + +// ── Section / operation specification ── + +type MatcherKind = "raw" | "name" | "none"; +interface OpSpec { opField: string; matcher: MatcherKind; replacer: boolean; } +interface SectionSpec { field: string; ops: Record<string, OpSpec>; } + +// raw op = matcher(value|regex|full) + replacer; name op (header/query update/add) = matcher{name} + replacer; +// remove = matcher{name} only; method/status update + sni raw = replacer only (no matcher). +const RAW: OpSpec = { opField: "raw", matcher: "raw", replacer: true }; +const HEADER_QUERY_OPS: Record<string, OpSpec> = { + raw: RAW, + update: { opField: "update", matcher: "name", replacer: true }, + add: { opField: "add", matcher: "name", replacer: true }, + remove: { opField: "remove", matcher: "name", replacer: false }, +}; + +export const SECTIONS: Record<string, SectionSpec> = { + // ── request ── + "req-method": { field: "requestMethod", ops: { update: { opField: "update", matcher: "none", replacer: true } } }, + "req-path": { field: "requestPath", ops: { raw: RAW } }, + "req-query": { field: "requestQuery", ops: HEADER_QUERY_OPS }, + "req-body": { field: "requestBody", ops: { raw: RAW } }, + "req-first-line": { field: "requestFirstLine", ops: { raw: RAW } }, + "req-header": { field: "requestHeader", ops: HEADER_QUERY_OPS }, + "req-all": { field: "requestAll", ops: { raw: RAW } }, + "req-sni": { field: "requestSNI", ops: { raw: { opField: "raw", matcher: "none", replacer: true } } }, + // ── response ── + "resp-body": { field: "responseBody", ops: { raw: RAW } }, + "resp-status": { field: "responseStatusCode", ops: { update: { opField: "update", matcher: "none", replacer: true } } }, + "resp-first-line": { field: "responseFirstLine", ops: { raw: RAW } }, + "resp-header": { field: "responseHeader", ops: HEADER_QUERY_OPS }, + "resp-all": { field: "responseAll", ops: { raw: RAW } }, + // ── websocket ── + "ws-up": { field: "streamWsMessageUpstream", ops: { raw: RAW } }, + "ws-down": { field: "streamWsMessageDownstream", ops: { raw: RAW } }, +}; + +const SOURCES = ["AUTOMATE", "INTERCEPT", "REPLAY", "WORKFLOW", "SAMPLE", "PLUGIN", "IMPORT"]; + +export interface MrRuleOpts { + section: string; + operation?: string; + matchValue?: string; + matchRegex?: string; + matchFull?: boolean; + matchName?: string; + replace?: string; + workflowId?: string; + name?: string; + condition?: string; + sources?: string[]; +} + +function defaultOp(spec: SectionSpec): string { + return spec.ops.raw ? "raw" : Object.keys(spec.ops)[0]; +} + +export function listSections(): string { + return Object.entries(SECTIONS) + .map(([k, s]) => `${k} (${Object.keys(s.ops).join("/")})`) + .join(", "); +} + +/** Build the TamperSectionInput from CLI options. Throws Error on any invalid combination. */ +export function buildTamperSection(o: MrRuleOpts): any { + const spec = SECTIONS[o.section]; + if (!spec) throw new Error(`Unknown --section "${o.section}".\nValid: ${listSections()}`); + + const opKey = o.operation ?? defaultOp(spec); + const op = spec.ops[opKey]; + if (!op) throw new Error(`--operation "${opKey}" is not valid for section "${o.section}". Valid: ${Object.keys(spec.ops).join(", ")}`); + + const opPayload: any = {}; + + // Matcher + if (op.matcher === "raw") { + const picked = [o.matchValue != null, o.matchRegex != null, !!o.matchFull].filter(Boolean).length; + if (picked !== 1) { + throw new Error(`section "${o.section}" / "${opKey}" needs exactly one matcher: --match-value <str> | --match-regex <re> | --match-full`); + } + if (o.matchValue != null) opPayload.matcher = { value: { value: o.matchValue } }; + else if (o.matchRegex != null) opPayload.matcher = { regex: { regex: o.matchRegex } }; + else opPayload.matcher = { full: { full: true } }; + } else if (op.matcher === "name") { + if (!o.matchName) throw new Error(`section "${o.section}" / "${opKey}" needs --match-name <header/param name>`); + opPayload.matcher = { name: o.matchName }; + } else if (o.matchValue != null || o.matchRegex != null || o.matchFull || o.matchName != null) { + throw new Error(`section "${o.section}" / "${opKey}" takes no matcher (it targets the whole section)`); + } + + // Replacer + if (op.replacer) { + const hasTerm = o.replace != null; + const hasWf = !!o.workflowId; + if (hasTerm === hasWf) { + throw new Error(`section "${o.section}" / "${opKey}" needs exactly one replacer: --replace <term> | --workflow <id>`); + } + opPayload.replacer = hasTerm ? { term: { term: o.replace } } : { workflow: { id: o.workflowId } }; + } else if (o.replace != null || o.workflowId) { + throw new Error(`section "${o.section}" / "${opKey}" takes no replacer (a remove operation only matches)`); + } + + return { [spec.field]: { operation: { [op.opField]: opPayload } } }; +} + +function normalizeSources(sources?: string[]): string[] | undefined { + if (!sources || !sources.length) return undefined; + return sources.map((s) => { + const up = s.trim().toUpperCase(); + if (!SOURCES.includes(up)) throw new Error(`Unknown source "${s}". Valid: ${SOURCES.join(", ")}`); + return up; + }); +} + +/** Build CreateTamperRuleInput (or, without collectionId, UpdateTamperRuleInput). */ +export function buildRuleInput(o: MrRuleOpts, collectionId?: string): any { + const input: any = { section: buildTamperSection(o) }; + if (o.name) input.name = o.name; + if (collectionId) input.collectionId = collectionId; + if (o.condition) { + // WS rules scope with StreamQL; HTTP rules with HTTPQL. + input.condition = o.section.startsWith("ws-") + ? { streamQL: { code: o.condition } } + : { HTTPQL: { code: o.condition } }; + } + // `sources` is required by Caido; default to its own default (proxy/intercept traffic). + input.sources = normalizeSources(o.sources) ?? ["INTERCEPT"]; + return input; +} + +// ── Collection resolution ── + +async function resolveTamperCollectionId(client: any, idOrName: string): Promise<string | undefined> { + const r: any = await client.graphql.query(TAMPER_RULE_COLLECTIONS, {}); + for (const c of r.tamperRuleCollections) { + if (c.id === idOrName || c.name === idOrName) return c.id; + } + return undefined; +} + +async function requireTamperCollection(client: any, ref: string): Promise<string> { + const id = await resolveTamperCollectionId(client, ref); + if (!id) { + console.error(`M&R collection "${ref}" not found.`); + console.error(`List: npx tsx caido-client.ts mr-collections`); + console.error(`Create: npx tsx caido-client.ts create-mr-collection "${ref}"`); + process.exit(1); + } + return id; +} + +/** collectionId is required on create; fall back to Caido's "Default Collection" (or the first one). */ +async function defaultTamperCollectionId(client: any): Promise<string> { + const r: any = await client.graphql.query(TAMPER_RULE_COLLECTIONS, {}); + const cols = r.tamperRuleCollections; + if (!cols.length) { + console.error('No M&R collections exist. Create one: create-mr-collection "<name>"'); + process.exit(1); + } + const def = cols.find((c: any) => /^default/i.test(c.name)) ?? cols[0]; + return def.id; +} + +const b64 = (s: string) => Buffer.from(s, "utf-8").toString("base64"); +const unb64 = (s: string | undefined | null) => (s ? Buffer.from(s, "base64").toString("utf-8") : ""); +const isEnabled = (rule: any) => rule?.enable != null; + +function fmtRule(rule: any) { + return { id: rule.id, name: rule.name, enabled: isEnabled(rule), collection: rule.collection?.name }; +} + +// ── Commands ── + +export async function cmdMrRules() { + const client = await getClient(); + const r: any = await client.graphql.query(TAMPER_RULE_COLLECTIONS, {}); + const rules: any[] = []; + for (const c of r.tamperRuleCollections) { + for (const rule of c.rules || []) { + rules.push({ id: rule.id, name: rule.name, enabled: isEnabled(rule), collection: c.name }); + } + } + console.log(JSON.stringify({ rules, count: rules.length }, null, 2)); +} + +export async function cmdMrCollections() { + const client = await getClient(); + const r: any = await client.graphql.query(TAMPER_RULE_COLLECTIONS, {}); + const results = r.tamperRuleCollections.map((c: any) => ({ id: c.id, name: c.name, ruleCount: (c.rules || []).length })); + console.log(JSON.stringify({ results, count: results.length }, null, 2)); +} + +export async function cmdCreateMrRule(o: MrRuleOpts, collectionRef?: string) { + const client = await getClient(); + // collectionId is required by Caido; default to the "Default Collection". + const collectionId = collectionRef + ? await requireTamperCollection(client, collectionRef) + : await defaultTamperCollectionId(client); + const input = buildRuleInput(o, collectionId); + const r: any = await client.graphql.mutation(CREATE_TAMPER_RULE, { input }); + const payload = r.createTamperRule; + if (payload.error) { + console.error(`Caido rejected the rule: ${payload.error.__typename}`); + process.exit(1); + } + console.log(JSON.stringify({ created: fmtRule(payload.rule), section: input.section }, null, 2)); +} + +export async function cmdUpdateMrRule(id: string, o: MrRuleOpts) { + const client = await getClient(); + const input = buildRuleInput(o); // no collectionId in UpdateTamperRuleInput + const r: any = await client.graphql.mutation(UPDATE_TAMPER_RULE, { id, input }); + const payload = r.updateTamperRule; + if (payload.error) { + console.error(`Caido rejected the update: ${payload.error.__typename}`); + process.exit(1); + } + console.log(JSON.stringify({ updated: fmtRule(payload.rule), section: input.section }, null, 2)); +} + +export async function cmdDeleteMrRule(id: string) { + const client = await getClient(); + const r: any = await client.graphql.mutation(DELETE_TAMPER_RULE, { id }); + console.log(JSON.stringify({ deleted: r.deleteTamperRule.deletedId }, null, 2)); +} + +export async function cmdToggleMrRule(id: string, enabled: boolean) { + const client = await getClient(); + const r: any = await client.graphql.mutation(TOGGLE_TAMPER_RULE, { id, enabled }); + const payload = r.toggleTamperRule; + if (payload.error) { + console.error(`Caido rejected the toggle: ${payload.error.__typename}`); + process.exit(1); + } + console.log(JSON.stringify({ rule: fmtRule(payload.rule), enabled: isEnabled(payload.rule) }, null, 2)); +} + +export async function cmdRenameMrRule(id: string, name: string) { + const client = await getClient(); + const r: any = await client.graphql.mutation(RENAME_TAMPER_RULE, { id, name }); + console.log(JSON.stringify({ renamed: fmtRule(r.renameTamperRule.rule) }, null, 2)); +} + +export async function cmdMoveMrRule(id: string, collectionRef: string) { + const client = await getClient(); + const collectionId = await requireTamperCollection(client, collectionRef); + const r: any = await client.graphql.mutation(MOVE_TAMPER_RULE, { id, collectionId }); + console.log(JSON.stringify({ moved: fmtRule(r.moveTamperRule.rule) }, null, 2)); +} + +export async function cmdTestMrRule(o: MrRuleOpts, raw: string) { + const client = await getClient(); + const section = buildTamperSection(o); + const resolved = ensureHeaderCrlf(await resolveRaw(raw)); + const r: any = await client.graphql.mutation(TEST_TAMPER_RULE, { input: { raw: b64(resolved), section } }); + const payload = r.testTamperRule; + if (payload.error) { + console.error(`Rule could not be applied: ${payload.error.__typename}`); + process.exit(1); + } + console.log(JSON.stringify({ section, result: unb64(payload.raw) }, null, 2)); +} + +export async function cmdCreateMrCollection(name: string) { + const client = await getClient(); + const r: any = await client.graphql.mutation(CREATE_TAMPER_RULE_COLLECTION, { input: { name } }); + console.log(JSON.stringify(r.createTamperRuleCollection.collection, null, 2)); +} + +export async function cmdRenameMrCollection(ref: string, name: string) { + const client = await getClient(); + const id = await requireTamperCollection(client, ref); + const r: any = await client.graphql.mutation(RENAME_TAMPER_RULE_COLLECTION, { id, name }); + console.log(JSON.stringify({ renamed: r.renameTamperRuleCollection.collection }, null, 2)); +} + +export async function cmdDeleteMrCollection(ref: string) { + const client = await getClient(); + const id = await requireTamperCollection(client, ref); + const r: any = await client.graphql.mutation(DELETE_TAMPER_RULE_COLLECTION, { id }); + console.log(JSON.stringify({ deleted: r.deleteTamperRuleCollection.deletedId }, null, 2)); +} diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/replay.ts b/capabilities/web-security/skills/caido-mode/lib/commands/replay.ts new file mode 100644 index 0000000..073411d --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/replay.ts @@ -0,0 +1,692 @@ +/** Replay, Edit, Sessions, Collections, Automate/Fuzz commands */ + +import { getClient } from "../client"; +import { decodeRaw, formatHttpRaw, splitRaw } from "../output"; +import { + CREATE_AUTOMATE_SESSION, + GET_AUTOMATE_SESSION, + START_AUTOMATE_TASK, + CREATE_REPLAY_SESSION_RAW, +} from "../graphql"; +import type { OutputOpts } from "../types"; +import type { ConnectionInfoInput } from "@caido/sdk-client"; + +export interface ConnectionOverrides { + sni?: string; + connectHost?: string; + connectPort?: number; + connectTls?: boolean; +} + +/** + * Editing a replay session is an explicit, named operation: the caller must + * declare intent — keep the current name (--no-name-change/--nonach) or set a + * new one (--new-name). This prevents silently leaving a stale auto-name. + */ +export type NameChange = { kind: "keep" } | { kind: "rename"; name: string }; + +export interface RawEdits { + method?: string; + path?: string; + setHeaders: string[]; + removeHeaders: string[]; + body?: string; + replacements: string[]; +} + +function buildConnection(host: string, port: number, isTLS: boolean, overrides?: ConnectionOverrides): ConnectionInfoInput { + const connection: ConnectionInfoInput = { + host: overrides?.connectHost ?? host, + port: overrides?.connectPort ?? port, + isTLS: overrides?.connectTls ?? isTLS, + }; + if (overrides?.sni) connection.SNI = overrides.sni; + return connection; +} + +async function readStdin(): Promise<string> { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf-8"); +} + +export async function resolveRaw(raw: string): Promise<string> { + if (raw === "-") return readStdin(); + + if (raw.startsWith("@")) { + const { readFile } = await import("node:fs/promises"); + const { resolve } = await import("node:path"); + return readFile(resolve(raw.slice(1)), "utf-8"); + } + + return normalizeRaw(raw); +} + +export function normalizeRaw(raw: string): string { + if (raw.includes("\r\n")) return raw; + return raw.replace(/\\([rnt\\])/g, (_, ch) => { + switch (ch) { + case "r": return "\r"; + case "n": return "\n"; + case "t": return "\t"; + case "\\": return "\\"; + default: return ch; + } + }); +} + +/** + * Normalize a raw request's HEADER line endings to CRLF, so a replay session + * created from scratch is never built with bare-LF (\n) line endings. Only the + * header section is touched; the body is left byte-exact (it may legitimately + * contain \n, e.g. JSON or multipart). Idempotent — re-running is a no-op. + */ +export function ensureHeaderCrlf(raw: string): string { + const { headerBlock, body } = splitRaw(raw); + // Collapse any CRLF to LF, then promote every LF to CRLF — handles bare-LF and + // mixed endings without doubling existing CRLFs. + const headers = headerBlock.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n"); + return body === undefined ? headers : headers + "\r\n\r\n" + body; +} + +export function applyRawEdits(raw: string, edits: RawEdits): string { + for (const rep of edits.replacements) { + const [from, to] = rep.split(":::"); + if (from && to !== undefined) raw = raw.replaceAll(from, to); + } + + // Header/body boundary = the FIRST blank line (CRLF or LF, whichever comes first), + // and the line ending is derived from the header block — never from body content, + // so a body that contains \r\n\r\n (e.g. multipart) can't be mistaken for the split. + const { headerBlock, body: splitBody, sep } = splitRaw(raw); + let bodyPart = splitBody ?? ""; + const lineEnd = sep === "\r\n\r\n" ? "\r\n" : sep === "\n\n" ? "\n" : (raw.includes("\r\n") ? "\r\n" : "\n"); + let hasBody = sep !== undefined; + + const headerLines = headerBlock.split(lineEnd); + let requestLine = headerLines[0]; + let headers = headerLines.slice(1); + + if (edits.method) { + const spaceIdx = requestLine.indexOf(" "); + if (spaceIdx > 0) requestLine = edits.method + requestLine.substring(spaceIdx); + } + + if (edits.path) { + const firstSpace = requestLine.indexOf(" "); + const lastSpace = requestLine.lastIndexOf(" "); + if (firstSpace > 0 && lastSpace > firstSpace) { + requestLine = requestLine.substring(0, firstSpace + 1) + edits.path + requestLine.substring(lastSpace); + } + } + + for (const name of edits.removeHeaders) { + headers = headers.filter(h => !h.toLowerCase().startsWith(name.toLowerCase() + ":")); + } + + for (const header of edits.setHeaders) { + const colonIdx = header.indexOf(":"); + if (colonIdx > 0) { + const name = header.substring(0, colonIdx).trim(); + headers = headers.filter(h => !h.toLowerCase().startsWith(name.toLowerCase() + ":")); + headers.push(header.trim()); + } + } + + if (edits.body !== undefined) { + bodyPart = edits.body; + const clBytes = new TextEncoder().encode(bodyPart).length; + headers = headers.filter(h => !h.toLowerCase().startsWith("content-length:")); + headers.push(`Content-Length: ${clBytes}`); + hasBody = true; + } + + const head = [requestLine, ...headers].join(lineEnd); + // Only re-attach a body section if the request actually had one (or one was set); + // don't graft a spurious blank line + empty body onto a body-less request. + return hasBody ? head + lineEnd + lineEnd + bodyPart : head; +} + +async function resolveSession(client: any, idOrName: string) { + try { + const byId = await client.replay.sessions.get(idOrName); + if (byId) return byId; + } catch {} + + let after: string | undefined; + while (true) { + const page = after + ? await client.replay.sessions.list().after(after).first(100) + : await client.replay.sessions.list().first(100); + + for (const edge of page.edges) { + if (edge.node.name === idOrName) return edge.node; + } + + if (!page.pageInfo.hasNextPage) break; + after = page.pageInfo.endCursor; + } + + return undefined; +} + +/** + * Resolve a collection by id OR name to its id. Collections are referred to by + * name in this workflow, so `--collection "My Collection"` is the norm. Returns + * undefined if no collection matches (callers should error and tell the user to + * create it explicitly — collection names are mandatory and never auto-created). + */ +async function resolveCollectionId(client: any, idOrName: string): Promise<string | undefined> { + let after: string | undefined; + while (true) { + const page = after + ? await client.replay.collections.list().after(after).first(100) + : await client.replay.collections.list().first(100); + + for (const edge of page.edges) { + if (edge.node.id === idOrName || edge.node.name === idOrName) return edge.node.id; + } + + if (!page.pageInfo.hasNextPage) break; + after = page.pageInfo.endCursor; + } + return undefined; +} + +/** Resolve a --collection ref to an id, exiting with guidance if it doesn't exist. */ +async function requireCollection(client: any, ref: string | undefined): Promise<string | undefined> { + if (!ref) return undefined; + const id = await resolveCollectionId(client, ref); + if (!id) { + console.error(`Collection "${ref}" not found.`); + console.error(`List collections: npx tsx caido-client.ts collections`); + console.error(`Create it (name is mandatory): npx tsx caido-client.ts create-collection "${ref}"`); + process.exit(1); + } + return id; +} + +/** Apply a NameChange to a just-touched session, returning the effective name. */ +async function applyNameChange(client: any, sessionId: string, current: string | undefined, change: NameChange): Promise<string | undefined> { + if (change.kind === "rename") { + await client.replay.sessions.rename(sessionId, change.name); + return change.name; + } + return current; +} + +function buildReplayOutput(sessionId: string, result: any, opts: OutputOpts, modifiedRaw?: string) { + const output: Record<string, any> = { + sessionId, + status: result.status, + error: result.error, + }; + + if (modifiedRaw !== undefined && !opts.noRequest) { + output.modifiedRequest = formatHttpRaw(modifiedRaw, opts); + } + + if (result.entry) { + output.entryId = result.entry.id; + if (result.entry.request) output.requestId = result.entry.request.id; + if (result.entry.response) { + output.response = { + statusCode: result.entry.response.statusCode, + roundtrip: result.entry.response.roundtripTime, + length: result.entry.response.length, + }; + if (result.entry.response.raw) { + output.response.raw = formatHttpRaw(decodeRaw(result.entry.response.raw), opts); + } + } + } + + return output; +} + +async function createRawReplaySession( + client: any, + raw: string, + connection: ConnectionInfoInput, + collectionId?: string, +) { + // A session built from scratch must never carry bare-LF header endings. + raw = ensureHeaderCrlf(raw); + const input: Record<string, any> = { + kind: "HTTP", // required since Caido 0.57 (ReplaySessionKind) + requestSource: { + raw: { + connectionInfo: connection, + raw: Buffer.from(raw, "utf-8").toString("base64"), + }, + }, + }; + if (collectionId) input.collectionId = collectionId; + + const createResult = await client.graphql.mutation(CREATE_REPLAY_SESSION_RAW, { input }); + return (createResult as any).createReplaySession.session; +} + +// -- Replay -- + +export async function cmdReplay( + requestId: string, + rawOverride: string | undefined, + name: string, + opts: OutputOpts, + overrides?: ConnectionOverrides, + collectionRef?: string, +) { + const client = await getClient(); + const original = await client.request.get(requestId, { raw: true }); + if (!original) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + + const collectionId = await requireCollection(client, collectionRef); + const createOpts: any = { requestSource: { id: requestId } }; + if (collectionId) createOpts.collectionId = collectionId; + const session = await client.replay.sessions.create(createOpts); + await client.replay.sessions.rename(session.id, name); + + let raw = rawOverride ? await resolveRaw(rawOverride) : decodeRaw(original.request.raw); + if (!raw) { + console.error("No raw data for this request"); + process.exit(1); + } + // A user-supplied raw override is normalized to CRLF header endings. + if (rawOverride) raw = ensureHeaderCrlf(raw); + + const connection = buildConnection( + original.request.host, + original.request.port, + original.request.isTls, + overrides, + ); + + const result = await client.replay.send(session.id, { raw, connection }); + console.log(JSON.stringify({ sessionName: name, ...buildReplayOutput(session.id, result, opts) }, null, 2)); +} + +export async function cmdSendRaw( + host: string, + port: number, + tls: boolean, + raw: string, + name: string, + opts: OutputOpts, + overrides?: ConnectionOverrides, + collectionRef?: string, +) { + const client = await getClient(); + // Normalize header line endings once so the session and the sent bytes match. + raw = ensureHeaderCrlf(await resolveRaw(raw)); + + const collectionId = await requireCollection(client, collectionRef); + const connection = buildConnection(host, port, tls, overrides); + const session = await createRawReplaySession(client, raw, connection, collectionId); + await client.replay.sessions.rename(session.id, name); + + const result = await client.replay.send(session.id, { raw, connection }); + console.log(JSON.stringify({ sessionName: name, ...buildReplayOutput(session.id, result, opts) }, null, 2)); +} + +// -- Edit -- + +export type EditTarget = + | { kind: "new"; name: string; collectionRef?: string } + | { kind: "session"; ref: string; nameChange: NameChange }; + +export async function cmdEdit( + requestId: string, + edits: RawEdits, + target: EditTarget, + opts: OutputOpts, + overrides?: ConnectionOverrides, +) { + const client = await getClient(); + const original = await client.request.get(requestId, { raw: true }); + if (!original) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + + const raw = decodeRaw(original.request.raw); + if (!raw) { + console.error("No raw data for this request"); + process.exit(1); + } + + const modifiedRaw = applyRawEdits(raw, edits); + + let sessionId: string; + let sessionName: string | undefined; + if (target.kind === "new") { + const collectionId = await requireCollection(client, target.collectionRef); + const session = await client.replay.sessions.create({ + requestSource: { id: requestId }, + ...(collectionId ? { collectionId } : {}), + }); + await client.replay.sessions.rename(session.id, target.name); + sessionId = session.id; + sessionName = target.name; + } else { + const session = await resolveSession(client, target.ref); + if (!session) { + console.error(`Replay session "${target.ref}" not found`); + process.exit(1); + } + sessionId = session.id; + sessionName = await applyNameChange(client, session.id, session.name, target.nameChange); + } + + const connection = buildConnection( + original.request.host, + original.request.port, + original.request.isTls, + overrides, + ); + + const result = await client.replay.send(sessionId, { raw: modifiedRaw, connection }); + console.log(JSON.stringify({ sessionName, ...buildReplayOutput(sessionId, result, opts, modifiedRaw) }, null, 2)); +} + +export async function cmdGetSession(sessionIdOrName: string, opts: OutputOpts) { + const client = await getClient(); + const session = await resolveSession(client, sessionIdOrName); + if (!session) { + console.error(`Replay session "${sessionIdOrName}" not found`); + process.exit(1); + } + + const output: Record<string, any> = { + id: session.id, + name: session.name, + collectionId: session.collectionId, + activeEntryId: session.activeEntryId, + }; + + if (session.activeEntryId) { + const entry = await client.replay.entries.get(session.activeEntryId); + if (entry) output.activeEntry = formatReplayEntry(entry, opts, true); + } + + console.log(JSON.stringify(output, null, 2)); +} + +export async function cmdReplayEntries( + sessionIdOrName: string, + limit: number, + opts: OutputOpts, + includeRaw: boolean, +) { + const client = await getClient(); + const session = await resolveSession(client, sessionIdOrName); + if (!session) { + console.error(`Replay session "${sessionIdOrName}" not found`); + process.exit(1); + } + + const connection = await session.entries() + .includeRaw(includeRaw ? { request: true, response: true, replay: true } : false) + .first(limit); + + const results = connection.edges.map((e: any) => formatReplayEntry(e.node, opts, includeRaw)); + + console.log(JSON.stringify({ + sessionId: session.id, + sessionName: session.name, + activeEntryId: session.activeEntryId, + results, + count: results.length, + }, null, 2)); +} + +export async function cmdEditSession( + sessionIdOrName: string, + edits: RawEdits, + nameChange: NameChange, + opts: OutputOpts, + overrides?: ConnectionOverrides, +) { + const client = await getClient(); + const session = await resolveSession(client, sessionIdOrName); + if (!session) { + console.error(`Replay session "${sessionIdOrName}" not found`); + process.exit(1); + } + + if (!session.activeEntryId) { + console.error(`Session ${session.id} has no active entry`); + process.exit(1); + } + + const entry = await client.replay.entries.get(session.activeEntryId); + if (!entry?.raw) { + console.error(`Could not get raw data for active entry ${session.activeEntryId}`); + process.exit(1); + } + + const raw = decodeRaw(entry.raw); + if (!raw) { + console.error("No raw data for the active entry"); + process.exit(1); + } + + const modifiedRaw = applyRawEdits(raw, edits); + const connection = buildConnection( + entry.connection.host, + entry.connection.port, + entry.connection.isTLS, + overrides, + ); + + const result = await client.replay.send(session.id, { raw: modifiedRaw, connection }); + const sessionName = await applyNameChange(client, session.id, session.name, nameChange); + console.log(JSON.stringify({ sessionName, ...buildReplayOutput(session.id, result, opts, modifiedRaw) }, null, 2)); +} + +function formatReplayEntry(entry: any, opts: OutputOpts, includeRaw: boolean) { + const output: Record<string, any> = { + id: entry.id, + sessionId: entry.sessionId, + createdAt: entry.createdAt, + error: entry.error, + connection: entry.connection ? { + host: entry.connection.host, + port: entry.connection.port, + isTLS: entry.connection.isTLS, + ...(entry.connection.SNI ? { SNI: entry.connection.SNI } : {}), + } : undefined, + }; + + if (entry.request) { + output.request = { + id: entry.request.id, + method: entry.request.method, + host: entry.request.host, + port: entry.request.port, + path: entry.request.path, + query: entry.request.query || undefined, + isTls: entry.request.isTls, + }; + } + + if (entry.response) { + output.response = { + statusCode: entry.response.statusCode, + roundtrip: entry.response.roundtripTime, + length: entry.response.length, + }; + } + + if (includeRaw) { + if (entry.raw) output.raw = formatHttpRaw(decodeRaw(entry.raw), opts); + if (entry.request?.raw) output.request.raw = formatHttpRaw(decodeRaw(entry.request.raw), opts); + if (entry.response?.raw) output.response.raw = formatHttpRaw(decodeRaw(entry.response.raw), opts); + } + + return output; +} + +// -- Pagination helper -- + +async function paginateSdkList<T>( + fetchPage: (after: string | undefined, want: number) => Promise<{ + edges: Array<{ node: T }>; + pageInfo: { hasNextPage: boolean; endCursor?: string | null }; + }>, + mapNode: (node: T) => any, + limit?: number, +): Promise<{ results: any[]; truncated: boolean }> { + const cap = limit && limit > 0 ? limit : 2000; + const results: any[] = []; + let after: string | undefined; + let truncated = false; + while (results.length < cap) { + const want = Math.min(100, cap - results.length); + const page = await fetchPage(after, want); + for (const e of page.edges) results.push(mapNode(e.node)); + if (!page.pageInfo.hasNextPage) break; + if (results.length >= cap) { truncated = true; break; } + after = page.pageInfo.endCursor ?? undefined; + } + return { results, truncated }; +} + +// -- Sessions -- + +export async function cmdReplaySessions(limit?: number) { + const client = await getClient(); + // Sessions can't be sorted (SDK exposes no order field), so a single page can hide + // recently-created sessions on later pages. Paginate fully (capped) by default. + const { results, truncated } = await paginateSdkList( + (after, want) => after + ? client.replay.sessions.list().after(after).first(want) + : client.replay.sessions.list().first(want), + (n: any) => ({ id: n.id, name: n.name, collectionId: n.collectionId, activeEntryId: n.activeEntryId }), + limit, + ); + console.log(JSON.stringify({ results, count: results.length, ...(truncated ? { truncated: true } : {}) }, null, 2)); +} + +export async function cmdCreateSession(requestId: string, name: string, collectionRef?: string) { + const client = await getClient(); + const collectionId = await requireCollection(client, collectionRef); + const session = await client.replay.sessions.create({ + requestSource: { id: requestId }, + ...(collectionId ? { collectionId } : {}), + }); + await client.replay.sessions.rename(session.id, name); + console.log(JSON.stringify({ + id: session.id, + name, + collectionId: session.collectionId ?? collectionId ?? null, + }, null, 2)); +} + +export async function cmdRenameSession(sessionRef: string, name: string) { + const client = await getClient(); + const session = await resolveSession(client, sessionRef); + if (!session) { + console.error(`Replay session "${sessionRef}" not found`); + process.exit(1); + } + await client.replay.sessions.rename(session.id, name); + console.log(JSON.stringify({ id: session.id, name, renamed: true }, null, 2)); +} + +export async function cmdMoveSession(sessionRef: string, collectionRef: string) { + const client = await getClient(); + const session = await resolveSession(client, sessionRef); + if (!session) { + console.error(`Replay session "${sessionRef}" not found`); + process.exit(1); + } + const collectionId = await requireCollection(client, collectionRef); + const moved = await client.replay.sessions.move(session.id, collectionId!); + console.log(JSON.stringify({ + id: moved.id, + name: moved.name, + collectionId: moved.collectionId, + moved: true, + }, null, 2)); +} + +export async function cmdDeleteSessions(ids: string[]) { + const client = await getClient(); + await client.replay.sessions.delete(ids); + console.log(JSON.stringify({ deleted: ids }, null, 2)); +} + +// -- Collections -- + +export async function cmdReplayCollections(limit?: number) { + const client = await getClient(); + const { results, truncated } = await paginateSdkList( + (after, want) => after + ? client.replay.collections.list().after(after).first(want) + : client.replay.collections.list().first(want), + (n: any) => ({ id: n.id, name: n.name }), + limit, + ); + console.log(JSON.stringify({ results, count: results.length, ...(truncated ? { truncated: true } : {}) }, null, 2)); +} + +export async function cmdCreateCollection(name: string) { + const client = await getClient(); + const collection = await client.replay.collections.create({ name }); + console.log(JSON.stringify({ id: collection.id, name: collection.name }, null, 2)); +} + +export async function cmdRenameCollection(collectionRef: string, name: string) { + const client = await getClient(); + const collectionId = await requireCollection(client, collectionRef); + await client.replay.collections.rename(collectionId!, name); + console.log(JSON.stringify({ id: collectionId, name, renamed: true }, null, 2)); +} + +export async function cmdDeleteCollection(collectionRef: string) { + const client = await getClient(); + const collectionId = await requireCollection(client, collectionRef); + await client.replay.collections.delete(collectionId!); + console.log(JSON.stringify({ deleted: collectionId }, null, 2)); +} + +// -- Automate / Fuzz -- + +export async function cmdCreateAutomateSession(requestId: string) { + const client = await getClient(); + const result = await client.graphql.mutation(CREATE_AUTOMATE_SESSION, { + input: { requestSource: { id: requestId } }, + }); + console.log(JSON.stringify((result as any).createAutomateSession.session, null, 2)); +} + +export async function cmdFuzz(sessionId: string, payloads: string[]) { + const client = await getClient(); + + const check = await client.graphql.query(GET_AUTOMATE_SESSION, { id: sessionId }); + const session = (check as any).automateSession; + if (!session) { + console.error(`Automate session ${sessionId} not found`); + process.exit(1); + } + + console.log(JSON.stringify({ + note: "Starting automate task with existing session settings. Configure payloads in Caido UI.", + sessionId, + }, null, 2)); + + const startResult = await client.graphql.mutation(START_AUTOMATE_TASK, { automateSessionId: sessionId }); + const task = (startResult as any).startAutomateTask.automateTask; + + console.log(JSON.stringify({ + sessionId, + taskId: task.id, + status: "started", + }, null, 2)); +} diff --git a/capabilities/web-security/skills/caido-mode/lib/commands/requests.ts b/capabilities/web-security/skills/caido-mode/lib/commands/requests.ts new file mode 100644 index 0000000..a948324 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/commands/requests.ts @@ -0,0 +1,373 @@ +/** HTTP History commands: search, recent, get, get-response, raw, export-curl */ + +import { getClient, resolveProxy } from "../client"; +import { decodeRaw, formatHttpRaw, rawToCurl, splitRaw, CURL_MANAGED_HEADERS } from "../output"; +import type { OutputOpts } from "../types"; + +/** Terse one-line-per-request rendering for fast, low-token browsing. */ +function compactLine(r: { id: string; method: string; host: string; path: string; query?: string; statusCode?: number }) { + const status = r.statusCode != null ? r.statusCode : "—"; + return `${r.id}\t${status}\t${r.method} ${r.host}${r.path}${r.query ? "?" + r.query : ""}`; +} + +export async function cmdSearch(filter: string, limit: number, after?: string, idsOnly?: boolean, desc: boolean = true, compact?: boolean) { + const client = await getClient(); + let builder = client.request.list().filter(filter).first(limit); + // The SDK's list() defaults to ASCENDING by id (oldest first), so .first(limit) + // would return the oldest N matches. We default to descending (newest first) — + // the near-universal intent — so callers get the latest traffic without a + // client-side sort (which, on a truncated result set, silently misses newer + // requests beyond the limit). Pass desc=false (CLI --asc/--oldest) for oldest-first. + if (desc) builder = builder.descending("req", "id"); + if (after) builder = builder.after(after); + + const connection = await builder; + + if (idsOnly) { + const ids = connection.edges.map(e => e.node.request.id); + console.log(JSON.stringify(ids)); + return; + } + + const results = connection.edges.map(e => ({ + id: e.node.request.id, + method: e.node.request.method, + host: e.node.request.host, + path: e.node.request.path, + query: e.node.request.query || undefined, + isTls: e.node.request.isTls, + port: e.node.request.port, + statusCode: e.node.response?.statusCode, + roundtrip: e.node.response?.roundtripTime, + responseLength: e.node.response?.length, + createdAt: e.node.request.createdAt, + cursor: e.cursor, + })); + + if (compact) { + for (const r of results) console.log(compactLine(r)); + console.log(`# ${results.length} result(s)${connection.pageInfo?.hasNextPage ? `, more available (--after ${connection.pageInfo.endCursor})` : ""}`); + return; + } + + console.log(JSON.stringify({ + results, + pageInfo: connection.pageInfo, + count: results.length, + }, null, 2)); +} + +export async function cmdRecent(limit: number, compact?: boolean) { + const client = await getClient(); + const connection = await client.request.list() + .descending("req", "id") + .first(limit); + + const results = connection.edges.map(e => ({ + id: e.node.request.id, + method: e.node.request.method, + host: e.node.request.host, + path: e.node.request.path, + query: e.node.request.query || undefined, + statusCode: e.node.response?.statusCode, + roundtrip: e.node.response?.roundtripTime, + createdAt: e.node.request.createdAt, + })); + + if (compact) { + for (const r of results) console.log(compactLine(r)); + console.log(`# ${results.length} result(s)`); + return; + } + + console.log(JSON.stringify({ results, count: results.length }, null, 2)); +} + +export async function cmdGet(requestId: string, opts: OutputOpts) { + const client = await getClient(); + const result = await client.request.get(requestId, { raw: true }); + + if (!result) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + + const output: Record<string, any> = { + id: result.request.id, + method: result.request.method, + host: result.request.host, + path: result.request.path, + port: result.request.port, + isTls: result.request.isTls, + createdAt: result.request.createdAt, + }; + + if (!opts.noRequest && result.request.raw) { + output.raw = formatHttpRaw(decodeRaw(result.request.raw), opts); + } + + if (result.response) { + output.response = { + statusCode: result.response.statusCode, + roundtrip: result.response.roundtripTime, + length: result.response.length, + }; + if (result.response.raw) { + output.response.raw = formatHttpRaw(decodeRaw(result.response.raw), opts); + } + } + + console.log(JSON.stringify(output, null, 2)); +} + +export async function cmdGetResponse(requestId: string, opts: OutputOpts) { + const client = await getClient(); + const result = await client.request.get(requestId, { + requestRaw: false, + responseRaw: true, + }); + + if (!result) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + + if (!result.response) { + console.log(JSON.stringify({ error: "No response for this request" })); + return; + } + + const output: Record<string, any> = { + statusCode: result.response.statusCode, + roundtrip: result.response.roundtripTime, + length: result.response.length, + }; + + if (result.response.raw) { + output.raw = formatHttpRaw(decodeRaw(result.response.raw), opts); + } + + console.log(JSON.stringify(output, null, 2)); +} + +/** + * raw — dump the byte-exact raw request (or response) for a history request. + * Writes raw bytes (no JSON wrapper) so it can be piped/redirected into a file + * for inspection or to seed a request body. + */ +export async function cmdRaw(requestId: string, opts: { out?: string; response?: boolean }) { + const client = await getClient(); + const result = await client.request.get(requestId, { raw: true }); + + if (!result) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + + const bytes: Uint8Array | undefined = opts.response ? result.response?.raw : result.request.raw; + if (!bytes || bytes.length === 0) { + console.error(`No raw ${opts.response ? "response" : "request"} data for request ${requestId}`); + process.exit(1); + } + + const buf = Buffer.from(bytes); + if (opts.out) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(opts.out, buf); + console.error(`Wrote ${buf.length} bytes to ${opts.out}`); + } else { + process.stdout.write(buf); + } +} + +export async function cmdExportCurl(requestId: string) { + const client = await getClient(); + const result = await client.request.get(requestId, { raw: true }); + + if (!result) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + + const raw = decodeRaw(result.request.raw); + if (!raw) { + console.error("No raw data for this request"); + process.exit(1); + } + + const curl = rawToCurl(raw, result.request.host, result.request.port, result.request.isTls); + console.log(curl); +} + +// ── export-curl --config : reusable curl config + cookie jar (INTERNAL testing) ── +// Pushes the big static auth blob into a `-K` config file + a cookie jar so the +// agent tests with `curl -K auth.cfg "$BASE/path"` instead of re-pasting cookies +// into every command (and re-holding them in context). User-facing commands must +// still be full/self-contained — see `export-curl`. + +// Year 2038; keeps (session) cookies sendable across separate curl invocations. +const JAR_EXPIRY = 2147483647; + +interface AuthConfigResult { + configText: string; + jarText?: string; // only when a cookie jar is requested + included: string[]; + cookieCount: number; + cookieMode: "inline" | "jar" | "none"; + base: string; +} + +/** + * Headers that must NEVER go into a reusable config: per-request, volatile, or + * curl-managed. Everything else is captured faithfully so app-specific auth + * headers (X-CSRF, x-goog-ext-*, X-Client-Data, Origin, Referer, Sec-*, …) are + * never silently dropped — an allowlist can't anticipate them. + * = the curl-managed set, plus Content-Type (per-request; the agent passes it on + * each POST/PUT — inlining it here would duplicate that header). + */ +const SKIP_HEADERS = new Set([...CURL_MANAGED_HEADERS, "content-type"]); + +function parseRawHeaders(raw: string): Array<{ name: string; value: string }> { + const { headerBlock } = splitRaw(raw); + const lines = headerBlock.split(/\r?\n/).slice(1); // drop the request line + const out: Array<{ name: string; value: string }> = []; + for (const line of lines) { + const i = line.indexOf(":"); + if (i > 0) out.push({ name: line.slice(0, i).trim(), value: line.slice(i + 1).trim() }); + } + return out; +} + +/** Build a Netscape cookie jar (control-char-safe) from a Cookie header value. */ +function buildCookieJar(cookieValue: string, host: string): { text: string; count: number } { + const jarLines = ["# Netscape HTTP Cookie File", "# generated by caido-mode"]; + let count = 0; + for (const pair of cookieValue.split(";")) { + const t = pair.trim(); + const eq = t.indexOf("="); + if (eq <= 0) continue; + const name = t.slice(0, eq); + const value = t.slice(eq + 1); + if (/[\t\r\n]/.test(name) || /[\t\r\n]/.test(value)) continue; // no field forgery + jarLines.push(`${host}\tFALSE\t/\tFALSE\t${JAR_EXPIRY}\t${name}\t${value}`); + count++; + } + return { text: jarLines.join("\n") + "\n", count }; +} + +/** + * Pure builder (offline-testable): a FAITHFUL STATIC snapshot of one request's + * auth/identity headers as a curl `-K` config. + * + * - Captures ALL request headers except the volatile/per-request denylist, so + * nothing auth-relevant is missed (the old curated allowlist dropped headers + * like x-custom-ext-*, X-Browser-Validation, Origin/Referer → request rejection). + * - Cookies are inlined STATICALLY by default (no cookie-jar), so curl never + * writes drifting/rotated Set-Cookie values back over the captured-good set. + * Pass `opts.cookieJar` to opt into the read+write jar (rotation capture). + */ +export function buildAuthConfig( + raw: string, + host: string, + port: number, + isTls: boolean, + proxy: string, + opts: { cookieJar?: string; exclude?: string[] } = {}, +): AuthConfigResult { + const headers = parseRawHeaders(raw); + const scheme = isTls ? "https" : "http"; + const portSuffix = (isTls && port === 443) || (!isTls && port === 80) ? "" : `:${port}`; + const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const base = `${scheme}://${urlHost}${portSuffix}`; + // Escape for a curl -K value and strip control chars so a value can't break the + // `header = "..."` line or inject another directive. + const esc = (s: string) => s.replace(/[\r\n]/g, "").replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const exclude = new Set((opts.exclude ?? []).map((s) => s.toLowerCase())); + + const lines: string[] = [ + `# curl config for ${base} — generated by caido-mode (INTERNAL testing only)`, + `# Faithful static snapshot of one request's auth/identity headers.`, + `# Refresh by regenerating from a fresh request; don't hand-edit cookies.`, + `proxy = "${esc(proxy)}"`, + `insecure`, + `compressed`, + ]; + + const included: string[] = []; + let cookieValue: string | undefined; + for (const h of headers) { + const n = h.name.toLowerCase(); + if (n === "cookie") { cookieValue = h.value; continue; } + if (SKIP_HEADERS.has(n) || exclude.has(n)) continue; + lines.push(`header = "${esc(h.name)}: ${esc(h.value)}"`); + included.push(h.name); + } + + let cookieMode: "inline" | "jar" | "none" = "none"; + let cookieCount = 0; + let jarText: string | undefined; + if (cookieValue && !exclude.has("cookie")) { + if (opts.cookieJar) { + const jar = buildCookieJar(cookieValue, host); + jarText = jar.text; + cookieCount = jar.count; + lines.push(`cookie = "${esc(opts.cookieJar)}"`); // read jar + lines.push(`cookie-jar = "${esc(opts.cookieJar)}"`); // write jar (rotation capture — opt-in) + cookieMode = "jar"; + } else { + lines.push(`header = "Cookie: ${esc(cookieValue)}"`); // static — nothing drifts + cookieCount = cookieValue.split(";").filter((p) => p.includes("=")).length; + cookieMode = "inline"; + } + } + + return { configText: lines.join("\n") + "\n", jarText, included, cookieCount, cookieMode, base }; +} + +export async function cmdExportCurlConfig( + requestId: string, + opts: { out?: string; cookieJar?: boolean; exclude?: string[] } = {}, +) { + const client = await getClient(); + const result = await client.request.get(requestId, { raw: true }); + if (!result) { + console.error(`Request ${requestId} not found`); + process.exit(1); + } + const raw = decodeRaw(result.request.raw); + if (!raw) { + console.error("No raw data for this request"); + process.exit(1); + } + + const { host, port, isTls } = result.request; + const { mkdirSync, writeFileSync } = await import("node:fs"); + const { dirname, join } = await import("node:path"); + + // Sanitize the host before using it as a path segment (no traversal/separators). + const safeHost = host.replace(/[^a-zA-Z0-9._-]/g, "_") || "unknown"; + const cfgPath = opts.out ?? `/tmp/caido/${safeHost}/auth.cfg`; + const dir = dirname(cfgPath); + const jarPath = join(dir, "cookies.txt"); + + const built = buildAuthConfig(raw, host, port, isTls, resolveProxy(), { + cookieJar: opts.cookieJar ? jarPath : undefined, + exclude: opts.exclude, + }); + + mkdirSync(dir, { recursive: true }); + writeFileSync(cfgPath, built.configText); + if (built.jarText) writeFileSync(jarPath, built.jarText); + + console.log(JSON.stringify({ + config: cfgPath, + cookieMode: built.cookieMode, // "inline" (static, default) | "jar" | "none" + ...(built.jarText ? { cookieJar: jarPath } : {}), + cookieCount: built.cookieCount, + capturedHeaders: built.included, + base: built.base, + proxy: resolveProxy(), + note: "Faithful static snapshot for INTERNAL testing. Cookies are inline+static (no drift); refresh by regenerating from a fresh request. For the user, always emit a FULL self-contained command via `export-curl`.", + usage: `curl -K ${cfgPath} "${built.base}/path"`, + }, null, 2)); +} diff --git a/capabilities/web-security/skills/caido-mode/lib/graphql.ts b/capabilities/web-security/skills/caido-mode/lib/graphql.ts new file mode 100644 index 0000000..22b5342 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/graphql.ts @@ -0,0 +1,200 @@ +/** + * GraphQL documents for features not yet in the high-level SDK. + * Uses gql tagged templates for proper TypedDocumentNode compatibility. + */ + +import gql from "graphql-tag"; + +// ── Intercept ── +// Caido 0.56+: InterceptRequest/ResponseOptions.filter is now a Query union +// (HTTPQL | StreamQL) that needs subfields; Pause/ResumeInterceptPayload +// no longer have `request`/`response` fields, only `status`. + +export const INTERCEPT_OPTIONS_QUERY = gql` + query { + interceptOptions { + request { + enabled + filter { + ... on HTTPQL { code } + ... on StreamQL { code } + } + } + response { + enabled + filter { + ... on HTTPQL { code } + ... on StreamQL { code } + } + } + scope { scopeId } + } + } +`; + +export const PAUSE_INTERCEPT = gql` + mutation { + pauseIntercept { status } + } +`; + +export const RESUME_INTERCEPT = gql` + mutation { + resumeIntercept { status } + } +`; + +// ── Automate / Fuzz ── + +export const CREATE_AUTOMATE_SESSION = gql` + mutation($input: CreateAutomateSessionInput!) { + createAutomateSession(input: $input) { + session { + id + name + connection { host port isTLS } + raw + } + } + } +`; + +export const GET_AUTOMATE_SESSION = gql` + query($id: ID!) { + automateSession(id: $id) { + id + name + connection { host port isTLS } + raw + settings { + payloads { options { ... on AutomateSimpleListPayload { list } } } + } + } + } +`; + +export const START_AUTOMATE_TASK = gql` + mutation($automateSessionId: ID!) { + startAutomateTask(automateSessionId: $automateSessionId) { + automateTask { id paused } + } + } +`; + +// ── Replay: create session with raw source ── +// Caido 0.56+ types the inner raw field as Blob. SDK 0.2.0's sessions.create +// still passes the raw string through unencoded, so we issue the mutation +// ourselves with base64-encoded bytes for send-raw. + +export const CREATE_REPLAY_SESSION_RAW = gql` + mutation($input: CreateReplaySessionInput!) { + createReplaySession(input: $input) { + session { id name } + } + } +`; + +// ── Plugins ── + +export const PLUGIN_PACKAGES_QUERY = gql` + query { + pluginPackages { + id + manifestId + name + version + plugins { + ... on PluginBackend { id manifestId name enabled state { running error } } + ... on PluginFrontend { id manifestId name enabled } + ... on PluginWorkflow { id manifestId name enabled } + } + } + } +`; + +// ── Match & Replace (Caido calls these "Tamper" rules internally) ── +// Schema validated against a live Caido instance via introspection + testTamperRule. + +const TAMPER_RULE_FIELDS = `id name enable { rank } collection { id name }`; + +export const TAMPER_RULE_COLLECTIONS = gql` + query { + tamperRuleCollections { + id + name + rules { id name enable { rank } } + } + } +`; + +export const CREATE_TAMPER_RULE = gql` + mutation($input: CreateTamperRuleInput!) { + createTamperRule(input: $input) { + rule { ${TAMPER_RULE_FIELDS} } + error { __typename } + } + } +`; + +export const UPDATE_TAMPER_RULE = gql` + mutation($id: ID!, $input: UpdateTamperRuleInput!) { + updateTamperRule(id: $id, input: $input) { + rule { ${TAMPER_RULE_FIELDS} } + error { __typename } + } + } +`; + +export const DELETE_TAMPER_RULE = gql` + mutation($id: ID!) { + deleteTamperRule(id: $id) { deletedId } + } +`; + +export const TOGGLE_TAMPER_RULE = gql` + mutation($id: ID!, $enabled: Boolean!) { + toggleTamperRule(id: $id, enabled: $enabled) { + rule { ${TAMPER_RULE_FIELDS} } + error { __typename } + } + } +`; + +export const RENAME_TAMPER_RULE = gql` + mutation($id: ID!, $name: String!) { + renameTamperRule(id: $id, name: $name) { rule { ${TAMPER_RULE_FIELDS} } } + } +`; + +export const MOVE_TAMPER_RULE = gql` + mutation($id: ID!, $collectionId: ID!) { + moveTamperRule(id: $id, collectionId: $collectionId) { rule { ${TAMPER_RULE_FIELDS} } } + } +`; + +export const TEST_TAMPER_RULE = gql` + mutation($input: TestTamperRuleInput!) { + testTamperRule(input: $input) { + raw + error { __typename } + } + } +`; + +export const CREATE_TAMPER_RULE_COLLECTION = gql` + mutation($input: CreateTamperRuleCollectionInput!) { + createTamperRuleCollection(input: $input) { collection { id name } } + } +`; + +export const RENAME_TAMPER_RULE_COLLECTION = gql` + mutation($id: ID!, $name: String!) { + renameTamperRuleCollection(id: $id, name: $name) { collection { id name } } + } +`; + +export const DELETE_TAMPER_RULE_COLLECTION = gql` + mutation($id: ID!) { + deleteTamperRuleCollection(id: $id) { deletedId } + } +`; diff --git a/capabilities/web-security/skills/caido-mode/lib/output.ts b/capabilities/web-security/skills/caido-mode/lib/output.ts new file mode 100644 index 0000000..9fd77b0 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/output.ts @@ -0,0 +1,135 @@ +/** Output formatting helpers for raw HTTP data */ + +import type { OutputOpts } from "./types"; + +export function decodeRaw(raw: Uint8Array | undefined): string { + if (!raw || raw.length === 0) return ""; + return new TextDecoder().decode(raw); +} + +/** Split a raw HTTP message at the first blank line. Returns headerBlock, body (undefined = no separator found), and the separator string. */ +export function splitRaw(raw: string): { headerBlock: string; body: string | undefined; sep: "\r\n\r\n" | "\n\n" | undefined } { + const idxCrlf = raw.indexOf("\r\n\r\n"); + const idxLf = raw.indexOf("\n\n"); + if (idxCrlf >= 0 && (idxLf < 0 || idxCrlf <= idxLf)) { + return { headerBlock: raw.slice(0, idxCrlf), body: raw.slice(idxCrlf + 4), sep: "\r\n\r\n" }; + } else if (idxLf >= 0) { + return { headerBlock: raw.slice(0, idxLf), body: raw.slice(idxLf + 2), sep: "\n\n" }; + } + return { headerBlock: raw, body: undefined, sep: undefined }; +} + +export function extractHeaders(decoded: string): string { + const doubleCrlf = decoded.indexOf("\r\n\r\n"); + const doubleLf = decoded.indexOf("\n\n"); + if (doubleCrlf >= 0 && (doubleLf < 0 || doubleCrlf <= doubleLf)) { + return decoded.substring(0, doubleCrlf); + } else if (doubleLf >= 0) { + return decoded.substring(0, doubleLf); + } + return decoded; +} + +export function formatHttpRaw(decoded: string, opts: OutputOpts): string { + if (opts.headersOnly) return extractHeaders(decoded); + return truncateBody(decoded, opts.maxBodyLines, opts.maxBodyChars); +} + +export function truncateBody(decoded: string, maxLines: number, maxChars: number): string { + const noLineLimit = maxLines <= 0; + const noCharLimit = maxChars <= 0; + if (noLineLimit && noCharLimit) return decoded; + + const doubleCrlf = decoded.indexOf("\r\n\r\n"); + const doubleLf = decoded.indexOf("\n\n"); + + let splitIndex: number; + let separator: string; + + if (doubleCrlf >= 0 && (doubleLf < 0 || doubleCrlf <= doubleLf)) { + splitIndex = doubleCrlf; + separator = "\r\n\r\n"; + } else if (doubleLf >= 0) { + splitIndex = doubleLf; + separator = "\n\n"; + } else { + return decoded; + } + + const headers = decoded.substring(0, splitIndex); + let body = decoded.substring(splitIndex + separator.length); + + if (!noCharLimit && body.length > maxChars) { + body = body.substring(0, maxChars) + `\n\n[TRUNCATED at ${maxChars} chars, total ${decoded.length - splitIndex - separator.length}]`; + } + + if (!noLineLimit) { + const lines = body.split("\n"); + if (lines.length > maxLines) { + body = lines.slice(0, maxLines).join("\n") + `\n\n[TRUNCATED at ${maxLines} lines, total ${lines.length}]`; + } + } + + return headers + separator + body; +} + +/** Single-quote a value for safe pasting into a POSIX shell. */ +export function shQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'`; +} + +/** Bracket an IPv6 literal host for use in a URL. */ +function urlHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +/** + * Headers curl sets/manages itself per request/connection — inlining a captured + * copy is at best redundant and at worst breaks things (stale Content-Length → + * hang/duplicate; inlined Accept-Encoding without --compressed → unreadable gzip). + * Single source of truth shared by rawToCurl and the curl-config builder. + */ +export const CURL_MANAGED_HEADERS = new Set([ + "host", "content-length", "accept-encoding", "connection", + "transfer-encoding", "proxy-connection", "keep-alive", "upgrade", "te", +]); + +/** + * Build a curl command from a raw HTTP request. + * Every interpolated, request-derived value (URL, method, header name/value, body) + * is shell-quoted — these come from proxied traffic and the output is pasted into a shell. + * `--compressed` is added (and Accept-Encoding dropped) so responses are readable. + */ +export function rawToCurl(rawRequest: string, host: string, port: number, isTls: boolean): string { + const lines = rawRequest.split(/\r?\n/); + if (lines.length === 0) return ""; + + const [method, path] = lines[0].split(" "); + const scheme = isTls ? "https" : "http"; + const portSuffix = (isTls && port === 443) || (!isTls && port === 80) ? "" : `:${port}`; + const url = `${scheme}://${urlHost(host)}${portSuffix}${path ?? ""}`; + + const parts = [`curl --compressed -X ${shQuote(method ?? "GET")} ${shQuote(url)}`]; + + let i = 1; + for (; i < lines.length; i++) { + const line = lines[i]; + if (line === "" || line === "\r") break; + const colonIdx = line.indexOf(":"); + if (colonIdx > 0) { + const name = line.substring(0, colonIdx).trim(); + const value = line.substring(colonIdx + 1).trim(); + if (CURL_MANAGED_HEADERS.has(name.toLowerCase())) continue; + parts.push(` -H ${shQuote(`${name}: ${value}`)}`); + } + } + + const body = lines.slice(i + 1).join("\n").trim(); + if (body) { + // --data-raw (not -d): a body starting with '@' must be sent literally, not + // treated by curl as a "read this file" instruction. + parts.push(` --data-raw ${shQuote(body)}`); + } + + return parts.join(" \\\n"); +} diff --git a/capabilities/web-security/skills/caido-mode/lib/types.ts b/capabilities/web-security/skills/caido-mode/lib/types.ts new file mode 100644 index 0000000..46e18d7 --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/lib/types.ts @@ -0,0 +1,38 @@ +/** Shared types for caido-mode CLI */ + +export interface OutputOpts { + maxBodyLines: number; + maxBodyChars: number; + noRequest: boolean; + headersOnly: boolean; +} + +export const DEFAULT_OUTPUT_OPTS: OutputOpts = { + maxBodyLines: 200, + maxBodyChars: 5000, + noRequest: false, + headersOnly: false, +}; + +export function parseOutputOpts(args: string[], startIdx: number): OutputOpts { + const opts = { ...DEFAULT_OUTPUT_OPTS }; + for (let i = startIdx; i < args.length; i++) { + if (args[i] === "--max-body" && args[i + 1]) { + opts.maxBodyLines = parseInt(args[i + 1], 10); + if (opts.maxBodyLines === 0) opts.maxBodyChars = 0; + i++; + } else if (args[i] === "--max-body-chars" && args[i + 1]) { + opts.maxBodyChars = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--no-request") { + opts.noRequest = true; + } else if (args[i] === "--headers-only") { + opts.headersOnly = true; + } else if (args[i] === "--compact") { + opts.noRequest = true; + opts.maxBodyLines = 50; + opts.maxBodyChars = 5000; + } + } + return opts; +} diff --git a/capabilities/web-security/skills/caido-mode/package-lock.json b/capabilities/web-security/skills/caido-mode/package-lock.json new file mode 100644 index 0000000..8e4e61b --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/package-lock.json @@ -0,0 +1,693 @@ +{ + "name": "caido-mode", + "version": "3.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "caido-mode", + "version": "3.2.0", + "dependencies": { + "@caido/sdk-client": "^0.4.0", + "graphql-tag": "^2.12.6", + "tsx": "^4.19.0" + } + }, + "node_modules/@0no-co/graphql.web": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", + "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", + "license": "MIT", + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, + "peerDependenciesMeta": { + "graphql": { + "optional": true + } + } + }, + "node_modules/@caido/sdk-client": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@caido/sdk-client/-/sdk-client-0.4.0.tgz", + "integrity": "sha512-6CpVRBrw4T9fV/vejS70eh9QVrmRNU59x+F/N73VtjpHxmDHDHLXGrGz3ivxUmGB57dRGL1Fs1clAjW5F+I4Kw==", + "license": "MIT", + "dependencies": { + "@caido/sdk-shared": "^0.2.2", + "@caido/server-auth": "^0.2.0", + "@graphql-typed-document-node/core": "^3.2.0", + "@urql/core": "^6.0.0", + "@urql/exchange-auth": "^3.0.0", + "graphql": "^16.12.0", + "graphql-ws": "^6.0.0", + "semver": "^7.8.0", + "wonka": "^6.0.0", + "zod": "^4.3.0" + } + }, + "node_modules/@caido/sdk-shared": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/@caido/sdk-shared/-/sdk-shared-0.2.2.tgz", + "integrity": "sha512-qzfwXrjujNAmXxedQW2YI5Ls5h+Y/MvzHZxd10vnL6IJbRJStZGusDTnFYDWUyiQpCrc5kGuY3fLr8dYh3UHnw==", + "license": "MIT" + }, + "node_modules/@caido/server-auth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@caido/server-auth/-/server-auth-0.2.0.tgz", + "integrity": "sha512-jQhhjOvr7bOkFZpAuoLDQNfddjAZrrmn1KzXIUHNRBmhkUko/4a2TzoUajawr5LjB8Gk5GX0LA9f+tGLA3J2Rg==", + "license": "MIT", + "dependencies": { + "@urql/core": "^6.0.0", + "graphql": "^16.12.0", + "graphql-tag": "^2.12.0", + "graphql-ws": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@urql/core": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@urql/core/-/core-6.0.1.tgz", + "integrity": "sha512-FZDiQk6jxbj5hixf2rEPv0jI+IZz0EqqGW8mJBEug68/zHTtT+f34guZDmyjJZyiWbj0vL165LoMr/TkeDHaug==", + "license": "MIT", + "dependencies": { + "@0no-co/graphql.web": "^1.0.13", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-auth": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@urql/exchange-auth/-/exchange-auth-3.0.0.tgz", + "integrity": "sha512-tj09xiOR2f1J2h8TE9uZWjRZipCdmDoTewEytOacDQ+0Teo+yIZxm3ppHxolQtiA51OHrGYiNTkMte8HtfvaBw==", + "license": "MIT", + "dependencies": { + "@urql/core": "^6.0.0", + "wonka": "^6.3.2" + }, + "peerDependencies": { + "@urql/core": "^6.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-6.0.8.tgz", + "integrity": "sha512-m3EOaNsUBXwAnkBWbzPfe0Nq8pXUfxsWnolC54sru3FzHvhTZL0Ouf/BoQsaGAXqM+YPerXOJ47BUnmgmoupCw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@fastify/websocket": "^10 || ^11", + "crossws": "~0.3", + "graphql": "^15.10.1 || ^16", + "ws": "^8" + }, + "peerDependenciesMeta": { + "@fastify/websocket": { + "optional": true + }, + "crossws": { + "optional": true + }, + "ws": { + "optional": true + } + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/wonka": { + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", + "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/capabilities/web-security/skills/caido-mode/package.json b/capabilities/web-security/skills/caido-mode/package.json new file mode 100644 index 0000000..9a0725d --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/package.json @@ -0,0 +1,15 @@ +{ + "name": "caido-mode", + "version": "3.2.0", + "description": "Full Caido SDK CLI for Claude Code - search history, test via curl-through-Caido, replay, edit, match & replace (tamper) rules, findings, scopes, filters, and more", + "type": "module", + "scripts": { + "caido": "tsx caido-client.ts", + "test": "node --import tsx --test test/*.test.ts" + }, + "dependencies": { + "@caido/sdk-client": "^0.4.0", + "graphql-tag": "^2.12.6", + "tsx": "^4.19.0" + } +} diff --git a/capabilities/web-security/skills/caido-mode/test/exportcurl.test.ts b/capabilities/web-security/skills/caido-mode/test/exportcurl.test.ts new file mode 100644 index 0000000..71406ff --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/test/exportcurl.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for `export-curl --config` (buildAuthConfig). + * Run: npm test + * + * Regression coverage for two real bugs found during testing: + * 1. a narrow header allowlist dropped app-specific auth headers (x-custom-ext-*, + * X-Browser-Validation, Origin/Referer/X-Same-Domain) → request rejection. + * 2. an always-on cookie-jar wrote rotated Set-Cookie back over the good cookies. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildAuthConfig } from "../lib/commands/requests.ts"; +import { rawToCurl } from "../lib/output.ts"; + +test("export-curl (full command): adds --compressed and drops Accept-Encoding, keeps real headers", () => { + const raw = "GET / HTTP/1.1\r\nHost: h\r\nAccept-Encoding: gzip, br, zstd\r\nAuthorization: tok\r\nConnection: keep-alive\r\n\r\n"; + const curl = rawToCurl(raw, "h", 443, true); + assert.match(curl, /^curl --compressed /); + assert.doesNotMatch(curl, /Accept-Encoding/i); // would otherwise return unreadable gzip + assert.doesNotMatch(curl, /Connection/i); // hop-by-hop, curl-managed + assert.match(curl, /Authorization: tok/); // real headers preserved +}); + +// A request shaped like a typical authenticated JSON-RPC POST with app-specific headers. +const RAW = [ + "POST /api/data/rpc?method=query HTTP/1.1", + "Host: api.example.com", + "Content-Length: 42", + "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", + "Accept-Encoding: gzip, deflate, br", + "Connection: keep-alive", + "User-Agent: Mozilla/5.0", + "Authorization: BEARER abc123", + "Origin: https://api.example.com", + "Referer: https://api.example.com/app", + "X-Same-Domain: 1", + "X-Browser-Validation: deadbeef", + "X-Client-Data: CIa2yQEI", + "x-custom-ext-525001261-jspb: [1,null,0]", + "x-custom-ext-73010989-jspb: [\"en\"]", + "Cookie: __Secure-1PSID=A; __Secure-1PSIDCC=B; SIDCC=C", + "", + "data=%5B%5D&token=xyz", +].join("\r\n"); + +function configHeaders(cfg: string): string[] { + return [...cfg.matchAll(/^header = "([^:]+):/gm)].map((m) => m[1]); +} + +test("captures app-specific auth headers the old allowlist dropped", () => { + const { configText } = buildAuthConfig(RAW, "api.example.com", 443, true, "http://p"); + const hdrs = configHeaders(configText).map((h) => h.toLowerCase()); + for (const need of [ + "user-agent", "authorization", "origin", "referer", "x-same-domain", + "x-browser-validation", "x-client-data", + "x-custom-ext-525001261-jspb", "x-custom-ext-73010989-jspb", + ]) { + assert.ok(hdrs.includes(need), `expected captured header: ${need}`); + } +}); + +test("drops volatile/per-request/curl-managed headers", () => { + const { configText } = buildAuthConfig(RAW, "api.example.com", 443, true, "http://p"); + const hdrs = configHeaders(configText).map((h) => h.toLowerCase()); + for (const skip of ["host", "content-length", "content-type", "accept-encoding", "connection"]) { + assert.ok(!hdrs.includes(skip), `header should be dropped: ${skip}`); + } +}); + +test("proxy + insecure + compressed directives present", () => { + const { configText } = buildAuthConfig(RAW, "h", 443, true, "http://127.0.0.1:8080"); + assert.match(configText, /^proxy = "http:\/\/127\.0\.0\.1:8080"$/m); + assert.match(configText, /^insecure$/m); + assert.match(configText, /^compressed$/m); +}); + +test("cookies are inline + static by default (no jar, no drift)", () => { + const r = buildAuthConfig(RAW, "api.example.com", 443, true, "http://p"); + assert.equal(r.cookieMode, "inline"); + assert.equal(r.jarText, undefined); + assert.match(r.configText, /^header = "Cookie: __Secure-1PSID=A; __Secure-1PSIDCC=B; SIDCC=C"$/m); + assert.doesNotMatch(r.configText, /cookie-jar/); // nothing writes rotated cookies back + assert.equal(r.cookieCount, 3); +}); + +test("--cookie-jar opts into a read/write jar", () => { + const r = buildAuthConfig(RAW, "api.example.com", 443, true, "http://p", { cookieJar: "/tmp/x/cookies.txt" }); + assert.equal(r.cookieMode, "jar"); + assert.match(r.configText, /^cookie = "\/tmp\/x\/cookies\.txt"$/m); + assert.match(r.configText, /^cookie-jar = "\/tmp\/x\/cookies\.txt"$/m); + assert.ok(r.jarText && r.jarText.includes("__Secure-1PSID\tA")); + assert.doesNotMatch(r.configText, /header = "Cookie:/); // not also inline +}); + +test("--exclude drops a named header (and 'cookie' omits cookies entirely)", () => { + const r1 = buildAuthConfig(RAW, "h", 443, true, "http://p", { exclude: ["x-client-data", "referer"] }); + const h1 = configHeaders(r1.configText).map((h) => h.toLowerCase()); + assert.ok(!h1.includes("x-client-data") && !h1.includes("referer")); + assert.ok(h1.includes("origin")); // others still present + + const r2 = buildAuthConfig(RAW, "h", 443, true, "http://p", { exclude: ["cookie"] }); + assert.equal(r2.cookieMode, "none"); + assert.doesNotMatch(r2.configText, /Cookie/); +}); + +test("config-injection: a quote in a header value is escaped, not broken out", () => { + // A double-quote inside a single header value must be escaped, not allowed to + // close the directive and inject another (e.g. a bare `insecure-but-injected`). + const raw = 'GET / HTTP/1.1\r\nHost: h\r\nX-Q: a"b\r\n\r\n'; + const { configText } = buildAuthConfig(raw, "h", 443, true, "http://p"); + assert.match(configText, /^header = "X-Q: a\\"b"$/m); + assert.doesNotMatch(configText, /^insecure-but-injected/m); +}); diff --git a/capabilities/web-security/skills/caido-mode/test/matchreplace.test.ts b/capabilities/web-security/skills/caido-mode/test/matchreplace.test.ts new file mode 100644 index 0000000..c8694ef --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/test/matchreplace.test.ts @@ -0,0 +1,214 @@ +/** + * Tests for Match & Replace (Tamper) rule construction. + * Run: npm test (node --import tsx --test test/matchreplace.test.ts) + * + * These assert the exact TamperSectionInput / CreateTamperRuleInput the CLI sends. + * The expected structures were validated against a live Caido via `testTamperRule`. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildTamperSection, buildRuleInput, SECTIONS } from "../lib/commands/matchreplace.ts"; + +// ── valid section/operation/matcher/replacer combinations ── + +const CASES: Array<{ label: string; opts: any; expect: any }> = [ + // request — single-op sections + { + label: "req-method update → term", + opts: { section: "req-method", replace: "POST" }, + expect: { requestMethod: { operation: { update: { replacer: { term: { term: "POST" } } } } } }, + }, + { + label: "req-path raw regex → term", + opts: { section: "req-path", matchRegex: "/api/test", replace: "/api/ADMIN" }, + expect: { requestPath: { operation: { raw: { matcher: { regex: { regex: "/api/test" } }, replacer: { term: { term: "/api/ADMIN" } } } } } }, + }, + { + label: "req-body raw full → term", + opts: { section: "req-body", matchFull: true, replace: "NEW BODY" }, + expect: { requestBody: { operation: { raw: { matcher: { full: { full: true } }, replacer: { term: { term: "NEW BODY" } } } } } }, + }, + { + label: "req-first-line raw value → term", + opts: { section: "req-first-line", matchValue: "GET /a HTTP/1.1", replace: "GET /b HTTP/1.1" }, + expect: { requestFirstLine: { operation: { raw: { matcher: { value: { value: "GET /a HTTP/1.1" } }, replacer: { term: { term: "GET /b HTTP/1.1" } } } } } }, + }, + { + label: "req-all raw value → workflow replacer", + opts: { section: "req-all", matchValue: "secret", workflowId: "wf-123" }, + expect: { requestAll: { operation: { raw: { matcher: { value: { value: "secret" } }, replacer: { workflow: { id: "wf-123" } } } } } }, + }, + { + label: "req-sni raw → term (no matcher)", + opts: { section: "req-sni", replace: "evil.example.com" }, + expect: { requestSNI: { operation: { raw: { replacer: { term: { term: "evil.example.com" } } } } } }, + }, + // request — header (all four ops) + { + label: "req-header raw value → term", + opts: { section: "req-header", matchValue: "X-Old: 1", replace: "X-Old: 2" }, + expect: { requestHeader: { operation: { raw: { matcher: { value: { value: "X-Old: 1" } }, replacer: { term: { term: "X-Old: 2" } } } } } }, + }, + { + label: "req-header add by name", + opts: { section: "req-header", operation: "add", matchName: "X-Injected", replace: "hi" }, + expect: { requestHeader: { operation: { add: { matcher: { name: "X-Injected" }, replacer: { term: { term: "hi" } } } } } }, + }, + { + label: "req-header update by name", + opts: { section: "req-header", operation: "update", matchName: "X-Old", replace: "999" }, + expect: { requestHeader: { operation: { update: { matcher: { name: "X-Old" }, replacer: { term: { term: "999" } } } } } }, + }, + { + label: "req-header remove by name (no replacer)", + opts: { section: "req-header", operation: "remove", matchName: "If-None-Match" }, + expect: { requestHeader: { operation: { remove: { matcher: { name: "If-None-Match" } } } } }, + }, + { + label: "req-header update → empty-string term", + opts: { section: "req-header", operation: "update", matchName: "Authorization", replace: "" }, + expect: { requestHeader: { operation: { update: { matcher: { name: "Authorization" }, replacer: { term: { term: "" } } } } } }, + }, + // request — query (all four ops) + { + label: "req-query raw value → term", + opts: { section: "req-query", matchValue: "a=1", replace: "a=2" }, + expect: { requestQuery: { operation: { raw: { matcher: { value: { value: "a=1" } }, replacer: { term: { term: "a=2" } } } } } }, + }, + { + label: "req-query add by name", + opts: { section: "req-query", operation: "add", matchName: "b", replace: "2" }, + expect: { requestQuery: { operation: { add: { matcher: { name: "b" }, replacer: { term: { term: "2" } } } } } }, + }, + { + label: "req-query remove by name", + opts: { section: "req-query", operation: "remove", matchName: "a" }, + expect: { requestQuery: { operation: { remove: { matcher: { name: "a" } } } } }, + }, + // response + { + label: "resp-status update → term", + opts: { section: "resp-status", replace: "403" }, + expect: { responseStatusCode: { operation: { update: { replacer: { term: { term: "403" } } } } } }, + }, + { + label: "resp-body raw regex → term", + opts: { section: "resp-body", matchRegex: "admin", replace: "user" }, + expect: { responseBody: { operation: { raw: { matcher: { regex: { regex: "admin" } }, replacer: { term: { term: "user" } } } } } }, + }, + { + label: "resp-first-line raw value → term", + opts: { section: "resp-first-line", matchValue: "HTTP/1.1 200 OK", replace: "HTTP/1.1 401 Unauthorized" }, + expect: { responseFirstLine: { operation: { raw: { matcher: { value: { value: "HTTP/1.1 200 OK" } }, replacer: { term: { term: "HTTP/1.1 401 Unauthorized" } } } } } }, + }, + { + label: "resp-header add by name", + opts: { section: "resp-header", operation: "add", matchName: "X-Frame-Options", replace: "DENY" }, + expect: { responseHeader: { operation: { add: { matcher: { name: "X-Frame-Options" }, replacer: { term: { term: "DENY" } } } } } }, + }, + { + label: "resp-all raw full → term", + opts: { section: "resp-all", matchFull: true, replace: "tampered" }, + expect: { responseAll: { operation: { raw: { matcher: { full: { full: true } }, replacer: { term: { term: "tampered" } } } } } }, + }, + // websocket + { + label: "ws-up raw value → term", + opts: { section: "ws-up", matchValue: "ping", replace: "pong" }, + expect: { streamWsMessageUpstream: { operation: { raw: { matcher: { value: { value: "ping" } }, replacer: { term: { term: "pong" } } } } } }, + }, + { + label: "ws-down raw value → term", + opts: { section: "ws-down", matchValue: "foo", replace: "bar" }, + expect: { streamWsMessageDownstream: { operation: { raw: { matcher: { value: { value: "foo" } }, replacer: { term: { term: "bar" } } } } } }, + }, +]; + +for (const c of CASES) { + test(`buildTamperSection: ${c.label}`, () => { + assert.deepEqual(buildTamperSection(c.opts), c.expect); + }); +} + +test("every section in SECTIONS has at least one working case covered", () => { + const covered = new Set(CASES.map((c) => c.opts.section)); + const missing = Object.keys(SECTIONS).filter((s) => !covered.has(s)); + assert.deepEqual(missing, [], `sections without a test case: ${missing.join(", ")}`); +}); + +// ── validation errors ── + +test("unknown section throws", () => { + assert.throws(() => buildTamperSection({ section: "bogus", replace: "x" }), /Unknown --section/); +}); + +test("invalid operation for section throws", () => { + assert.throws(() => buildTamperSection({ section: "req-method", operation: "add", replace: "x" }), /not valid for section/); +}); + +test("raw op with no matcher throws", () => { + assert.throws(() => buildTamperSection({ section: "req-body", replace: "x" }), /exactly one matcher/); +}); + +test("raw op with two matchers throws", () => { + assert.throws(() => buildTamperSection({ section: "req-body", matchValue: "a", matchRegex: "b", replace: "x" }), /exactly one matcher/); +}); + +test("name op without --match-name throws", () => { + assert.throws(() => buildTamperSection({ section: "req-header", operation: "add", replace: "x" }), /needs --match-name/); +}); + +test("none-matcher section given a matcher throws", () => { + assert.throws(() => buildTamperSection({ section: "req-method", matchValue: "GET", replace: "POST" }), /takes no matcher/); +}); + +test("missing replacer throws", () => { + assert.throws(() => buildTamperSection({ section: "req-header", operation: "add", matchName: "X" }), /exactly one replacer/); +}); + +test("two replacers throws", () => { + assert.throws(() => buildTamperSection({ section: "req-header", operation: "add", matchName: "X", replace: "a", workflowId: "w" }), /exactly one replacer/); +}); + +test("remove op given a replacer throws", () => { + assert.throws(() => buildTamperSection({ section: "req-header", operation: "remove", matchName: "X", replace: "a" }), /takes no replacer/); +}); + +// ── buildRuleInput (name / collection / condition / sources) ── + +test("buildRuleInput: name + collectionId attached", () => { + const input = buildRuleInput({ section: "req-method", replace: "POST", name: "force POST" }, "col-1"); + assert.equal(input.name, "force POST"); + assert.equal(input.collectionId, "col-1"); + assert.ok(input.section.requestMethod); +}); + +test("buildRuleInput: HTTP section condition → HTTPQL", () => { + const input = buildRuleInput({ section: "req-header", operation: "add", matchName: "X", replace: "1", condition: 'req.host.eq:"t.com"' }); + assert.deepEqual(input.condition, { HTTPQL: { code: 'req.host.eq:"t.com"' } }); +}); + +test("buildRuleInput: WS section condition → streamQL", () => { + const input = buildRuleInput({ section: "ws-up", matchValue: "a", replace: "b", condition: "something" }); + assert.deepEqual(input.condition, { streamQL: { code: "something" } }); +}); + +test("buildRuleInput: sources normalized + validated", () => { + const input = buildRuleInput({ section: "req-method", replace: "POST", sources: ["replay", "Intercept"] }); + assert.deepEqual(input.sources, ["REPLAY", "INTERCEPT"]); +}); + +test("buildRuleInput: sources defaults to [INTERCEPT] (required by Caido)", () => { + const input = buildRuleInput({ section: "req-method", replace: "POST" }); + assert.deepEqual(input.sources, ["INTERCEPT"]); +}); + +test("buildRuleInput: invalid source throws", () => { + assert.throws(() => buildRuleInput({ section: "req-method", replace: "POST", sources: ["bogus"] }), /Unknown source/); +}); + +test("buildRuleInput: no collectionId when omitted (valid UpdateTamperRuleInput)", () => { + const input = buildRuleInput({ section: "req-method", replace: "POST" }); + assert.ok(!("collectionId" in input)); +}); diff --git a/capabilities/web-security/skills/caido-mode/test/rawedit.test.ts b/capabilities/web-security/skills/caido-mode/test/rawedit.test.ts new file mode 100644 index 0000000..9d5426b --- /dev/null +++ b/capabilities/web-security/skills/caido-mode/test/rawedit.test.ts @@ -0,0 +1,181 @@ +/** + * Tests for the raw-request edit/normalize pipeline and curl generation: + * - normalizeRaw (lib/commands/replay.ts) — backslash-escape decoding + * - ensureHeaderCrlf (lib/commands/replay.ts) — header CRLF promotion + * - applyRawEdits (lib/commands/replay.ts) — method/path/header/body edits + * - rawToCurl (lib/output.ts) — raw HTTP → curl command + * + * Run: npm test + * + * These guard the invariants the inline comments in those functions promise: + * bodies stay byte-exact (the header/body split is derived from the header block, + * never from body content), line endings are preserved, Content-Length is byte- + * accurate, and a body that starts with '@' is sent literally (--data-raw). + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + normalizeRaw, + ensureHeaderCrlf, + applyRawEdits, + type RawEdits, +} from "../lib/commands/replay.ts"; +import { rawToCurl } from "../lib/output.ts"; + +/** RawEdits with the array fields defaulted, so each test states only what it changes. */ +function edits(partial: Partial<RawEdits> = {}): RawEdits { + return { setHeaders: [], removeHeaders: [], replacements: [], ...partial }; +} + +// --------------------------------------------------------------------------- +// normalizeRaw +// --------------------------------------------------------------------------- + +test("normalizeRaw: decodes \\r\\n\\t\\\\ escape sequences into real bytes", () => { + const out = normalizeRaw("GET / HTTP/1.1\\r\\nHost: h\\r\\nX-Tab:\\ta\\r\\n\\r\\n"); + assert.equal(out, "GET / HTTP/1.1\r\nHost: h\r\nX-Tab:\ta\r\n\r\n"); +}); + +test("normalizeRaw: a string already containing CRLF is returned untouched (no double-decode)", () => { + // A real pasted request whose body legitimately contains the two chars '\' 'n' + // must not have that literal turned into a newline. + const raw = 'POST /x HTTP/1.1\r\nHost: h\r\n\r\n{"path":"a\\nb"}'; + assert.equal(normalizeRaw(raw), raw); +}); + +test("normalizeRaw: lone \\n is decoded to LF when no CRLF is present", () => { + assert.equal(normalizeRaw("a\\nb"), "a\nb"); +}); + +// --------------------------------------------------------------------------- +// ensureHeaderCrlf +// --------------------------------------------------------------------------- + +test("ensureHeaderCrlf: promotes bare-LF headers to CRLF but leaves the body byte-exact", () => { + // Body contains a lone \n (JSON) — it must survive unchanged. + const out = ensureHeaderCrlf('POST / HTTP/1.1\nHost: h\n\n{"a":\n1}'); + assert.equal(out, 'POST / HTTP/1.1\r\nHost: h\r\n\r\n{"a":\n1}'); +}); + +test("ensureHeaderCrlf: is idempotent on already-CRLF input", () => { + const raw = "GET / HTTP/1.1\r\nHost: h\r\nAccept: */*\r\n\r\nbody\nwith-lf"; + assert.equal(ensureHeaderCrlf(raw), raw); +}); + +test("ensureHeaderCrlf: a header-only request (no blank line) gets no spurious body separator", () => { + assert.equal(ensureHeaderCrlf("GET / HTTP/1.1\nHost: h"), "GET / HTTP/1.1\r\nHost: h"); +}); + +test("ensureHeaderCrlf: normalizes mixed CRLF/LF headers without doubling existing CRLFs", () => { + const out = ensureHeaderCrlf("GET / HTTP/1.1\r\nA: 1\nB: 2\r\n\r\nbody"); + assert.equal(out, "GET / HTTP/1.1\r\nA: 1\r\nB: 2\r\n\r\nbody"); + assert.doesNotMatch(out, /\r\r/); +}); + +// --------------------------------------------------------------------------- +// applyRawEdits +// --------------------------------------------------------------------------- + +const REQ = "POST /old?x=1 HTTP/1.1\r\nHost: h\r\nAuthorization: old\r\nCookie: a=b\r\n\r\nhello"; + +test("applyRawEdits: changes the method, preserving path and version", () => { + const out = applyRawEdits(REQ, edits({ method: "PUT" })); + assert.match(out, /^PUT \/old\?x=1 HTTP\/1\.1\r\n/); +}); + +test("applyRawEdits: changes the path, preserving method and version", () => { + const out = applyRawEdits(REQ, edits({ path: "/new" })); + assert.match(out, /^POST \/new HTTP\/1\.1\r\n/); +}); + +test("applyRawEdits: setHeaders replaces an existing header case-insensitively (no duplicate)", () => { + const out = applyRawEdits(REQ, edits({ setHeaders: ["authorization: new"] })); + assert.match(out, /authorization: new/); + assert.doesNotMatch(out, /Authorization: old/); + // exactly one authorization header + assert.equal((out.match(/authorization: /gi) ?? []).length, 1); +}); + +test("applyRawEdits: setHeaders appends a header that wasn't present", () => { + const out = applyRawEdits(REQ, edits({ setHeaders: ["X-New: 1"] })); + assert.match(out, /\r\nX-New: 1\r\n/); +}); + +test("applyRawEdits: removeHeaders drops a header case-insensitively", () => { + const out = applyRawEdits(REQ, edits({ removeHeaders: ["COOKIE"] })); + assert.doesNotMatch(out, /Cookie/i); +}); + +test("applyRawEdits: setting the body recomputes Content-Length in BYTES (multibyte-safe)", () => { + // '€' is one char but three UTF-8 bytes. + const out = applyRawEdits(REQ, edits({ body: "€" })); + assert.match(out, /\r\nContent-Length: 3\r\n/); + assert.ok(out.endsWith("\r\n\r\n€")); + // no stale Content-Length lingers + assert.equal((out.match(/Content-Length:/gi) ?? []).length, 1); +}); + +test("applyRawEdits: replacements run across the whole message; empty 'to' deletes", () => { + const out = applyRawEdits(REQ, edits({ replacements: ["old:::NEW", "hello:::"] })); + assert.match(out, /\/NEW\?x=1/); // path token replaced + assert.match(out, /Authorization: NEW/); // header value replaced + assert.ok(out.endsWith("\r\n\r\n")); // body 'hello' deleted +}); + +test("applyRawEdits: a body containing a blank line (multipart) is preserved byte-exact", () => { + // The split must come from the FIRST blank line (end of headers), not from the + // blank line *inside* the multipart body. + const body = '--X\r\nContent-Disposition: form-data; name="a"\r\n\r\nval\r\n--X--\r\n'; + const raw = "POST /u HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=X\r\n\r\n" + body; + const out = applyRawEdits(raw, edits({ setHeaders: ["X-T: 1"] })); + assert.ok(out.endsWith("\r\n\r\n" + body), "multipart body must be untouched"); + assert.match(out, /\r\nX-T: 1\r\n/); +}); + +test("applyRawEdits: a header-only request gains no spurious empty body", () => { + const out = applyRawEdits("GET / HTTP/1.1\r\nHost: h", edits({ setHeaders: ["X-T: 1"] })); + assert.equal(out, "GET / HTTP/1.1\r\nHost: h\r\nX-T: 1"); + assert.doesNotMatch(out, /\r\n\r\n/); +}); + +test("applyRawEdits: LF-only requests keep LF line endings (no CRLF promotion)", () => { + const out = applyRawEdits("GET / HTTP/1.1\nHost: h\n\nbody", edits({ setHeaders: ["X-T: 1"] })); + assert.doesNotMatch(out, /\r/); + assert.equal(out, "GET / HTTP/1.1\nHost: h\nX-T: 1\n\nbody"); +}); + +// --------------------------------------------------------------------------- +// rawToCurl +// --------------------------------------------------------------------------- + +test("rawToCurl: a body starting with '@' is emitted via --data-raw, not -d", () => { + // -d '@x' makes curl read file x; --data-raw sends the literal text. + const raw = "POST /api HTTP/1.1\r\nHost: h\r\nContent-Type: text/plain\r\n\r\n@not-a-file"; + const curl = rawToCurl(raw, "h", 443, true); + assert.match(curl, /--data-raw '@not-a-file'/); + assert.doesNotMatch(curl, /(^|\s)-d\s/); // never the file-reading short flag +}); + +test("rawToCurl: a GET with no body emits no data flag", () => { + const curl = rawToCurl("GET / HTTP/1.1\r\nHost: h\r\n\r\n", "h", 443, true); + assert.doesNotMatch(curl, /--data-raw|(^|\s)-d\s/); +}); + +test("rawToCurl: omits the port for 443/https and 80/http, includes it otherwise", () => { + assert.match(rawToCurl("GET /p HTTP/1.1\r\nHost: h\r\n\r\n", "h", 443, true), /'https:\/\/h\/p'/); + assert.match(rawToCurl("GET /p HTTP/1.1\r\nHost: h\r\n\r\n", "h", 80, false), /'http:\/\/h\/p'/); + assert.match(rawToCurl("GET /p HTTP/1.1\r\nHost: h\r\n\r\n", "h", 8443, true), /'https:\/\/h:8443\/p'/); +}); + +test("rawToCurl: brackets an IPv6 literal host", () => { + const curl = rawToCurl("GET /p HTTP/1.1\r\nHost: x\r\n\r\n", "::1", 8080, false); + assert.match(curl, /'http:\/\/\[::1\]:8080\/p'/); +}); + +test("rawToCurl: shell-quotes a single quote in a header value (no shell break-out)", () => { + const raw = "GET / HTTP/1.1\r\nHost: h\r\nX-Q: a'b\r\n\r\n"; + const curl = rawToCurl(raw, "h", 443, true); + // POSIX single-quote escaping turns ' into '\'' + assert.match(curl, /-H 'X-Q: a'\\''b'/); +});