Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions capabilities/web-security/capability.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions capabilities/web-security/docker/Dockerfile.runtime
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
12 changes: 12 additions & 0 deletions capabilities/web-security/scripts/install_tools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <root>/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).
Expand Down
1 change: 1 addition & 0 deletions capabilities/web-security/skills/caido-mode/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
336 changes: 336 additions & 0 deletions capabilities/web-security/skills/caido-mode/README.md

Large diffs are not rendered by default.

588 changes: 588 additions & 0 deletions capabilities/web-security/skills/caido-mode/SKILL.md

Large diffs are not rendered by default.

928 changes: 928 additions & 0 deletions capabilities/web-security/skills/caido-mode/caido-client.ts

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions capabilities/web-security/skills/caido-mode/lib/client.ts
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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));
}
Loading
Loading