diff --git a/.vitepress/sidebars/concepts.ts b/.vitepress/sidebars/concepts.ts index 0a690e1..e7bbe26 100644 --- a/.vitepress/sidebars/concepts.ts +++ b/.vitepress/sidebars/concepts.ts @@ -32,6 +32,37 @@ export const conceptsSidebar: DefaultTheme.SidebarItem[] = [ }, ], }, + { + text: "Client", + items: [ + { + text: "Authentication", + items: [ + { + text: "Methods", + link: "/concepts/client/auth_methods", + }, + { + text: "Caching of Tokens", + link: "/concepts/client/auth_caching", + }, + ], + }, + { + text: "Community implementations", + items: [ + { + text: "Golang SDK", + link: "/concepts/client/community_golang", + }, + { + text: "Python SDK", + link: "/concepts/client/community_python", + }, + ], + }, + ], + }, { text: "Essentials", items: [ diff --git a/.vitepress/sidebars/guides.ts b/.vitepress/sidebars/guides.ts index 44d9874..6ba4a52 100644 --- a/.vitepress/sidebars/guides.ts +++ b/.vitepress/sidebars/guides.ts @@ -247,4 +247,68 @@ export const guidesSidebar: DefaultTheme.SidebarItem[] = [ }, ], }, + { + text: "Client Guides", + items: [ + { + text: "Install the SDK", + link: "/guides/client/install", + }, + { + text: "Base Setup", + link: "/guides/client/base_setup", + }, + { + text: "Core Features", + items: [ + { + text: "Extract Requests", + link: "/guides/client/extract_requests", + }, + { + text: "Manage Findings", + link: "/guides/client/manage_findings", + }, + { + text: "Environments and Variables", + link: "/guides/client/environments", + }, + ], + }, + { + text: "Plugins", + items: [ + { + text: "Install a Plugin", + link: "/guides/client/install_plugin", + }, + { + text: "Call a Plugin Function", + link: "/guides/client/call_function", + }, + { + text: "Receive Plugin Events", + link: "/guides/client/receive_events", + }, + { + text: "Use a Plugin's NPM Spec Package", + link: "/guides/client/spec_typing", + }, + ], + }, + { + text: "Advanced", + items: [ + { + text: "Call GraphQL Directly", + link: "/guides/client/graphql_direct", + }, + { + text: "Custom Cache Implementation", + link: "/guides/client/custom_cache", + }, + ], + }, + ], + }, ]; diff --git a/.vitepress/sidebars/tutorials.ts b/.vitepress/sidebars/tutorials.ts index ec70ded..0038e3a 100644 --- a/.vitepress/sidebars/tutorials.ts +++ b/.vitepress/sidebars/tutorials.ts @@ -19,4 +19,13 @@ export const tutorialsSidebar: DefaultTheme.SidebarItem[] = [ }, ], }, + { + text: "Client SDK", + items: [ + { + text: "Using the Scanner API", + link: "/tutorials/client/scanner", + }, + ], + }, ]; diff --git a/src/concepts/client/auth_caching.md b/src/concepts/client/auth_caching.md new file mode 100644 index 0000000..b74155c --- /dev/null +++ b/src/concepts/client/auth_caching.md @@ -0,0 +1,38 @@ +# Caching of Tokens + +After a script authenticates against a Caido instance, the SDK keeps the resulting access token in memory for the rest of the process. To preserve that state across script runs without repeating the [authentication flow](./auth_methods.md) every time, the SDK can serialize it to a cache and reload it on the next connection. + +The SDK ships with two built-in caches (file-based for Node and localStorage-based for browsers) and accepts a custom implementation for everything else. + +## File + +The file cache writes the token state to a JSON file on disk. It is the natural choice for Node.js scripts and CI/CD environments where local disk is available and acceptable. + +The file is written with `0o600` permissions (read and write for the owner only) to limit accidental exposure to other users on the same machine. Beyond that, the file content is plain JSON: anyone with file system access can read the tokens. + +The file cache is not suitable for shared, networked, or untrusted storage. For those scenarios, see [Custom Implementation](#custom-implementation). + +## Local Storage + +The localStorage cache writes the token state to the browser's `localStorage` under a key. It is the built-in choice for SDK use inside a browser environment. + +Like the file cache, the stored value is plain JSON. Browser sandboxing scopes the storage to the origin, but anything running in the same origin (including third-party scripts loaded into the page) can read it. + +## Custom Implementation + +For everything that does not fit the file or localStorage models, scripts can implement the `TokenCache` interface (`load`, `save`, `clear`) and pass an instance to the `Client` constructor. Common motivations include: + +- Encrypted storage on disk +- Tokens stored in a remote secret manager (Vault, AWS Secrets Manager, etc.) +- Shared cache across multiple workers (Redis, database) +- In-memory cache for tests + +A custom cache is opaque to the SDK: it only calls the three interface methods, and the implementation owns where and how the tokens are stored. + +## Refresh + +Access tokens have a limited lifetime. When an authenticated request fails because the access token has expired or been revoked, the SDK uses the cached refresh token to mint a new access token through the instance's `refreshAuthenticationToken` mutation. The new tokens replace the old ones in memory and are written back to the cache. + +The renewal happens reactively rather than on a timer: the SDK does not consult the cached `expiresAt` to refresh proactively. It waits until the server rejects a request, then refreshes and retries. From the script's point of view, the failure is invisible. + +A cache holding only an access token (no refresh token) can serve a single short run but cannot recover when the access token expires. Pairing access tokens with refresh tokens is what keeps cached state usable over time. diff --git a/src/concepts/client/auth_methods.md b/src/concepts/client/auth_methods.md new file mode 100644 index 0000000..4dc6227 --- /dev/null +++ b/src/concepts/client/auth_methods.md @@ -0,0 +1,29 @@ +# Authentication Methods + +The Client SDK supports three methods for authenticating a script against a Caido instance: Personal Access Token, Browser Login, and Direct Token. They all produce the same kind of access token, but they differ in how that token is obtained. + +Two of them use the same OAuth 2.0 Device Authorization grant that the Caido desktop and web applications use, described in [Instance Authentication](https://docs.caido.io/app/concepts/instance_authentication.html). The third skips the flow entirely. + +## Personal Access Token + +A Personal Access Token (PAT) is a long-lived credential created in the Caido Dashboard. When the SDK authenticates with a PAT, it starts the device authorization flow on the instance and then automatically approves it by calling the Caido Cloud API with the PAT as a Bearer credential. No human interaction is required. + +This is the natural choice for headless environments: scripts, CI/CD pipelines, scheduled jobs, and anything that runs without a person nearby. A PAT is tied to the user that created it and inherits that user's permissions on the resources it targets, whether that's a personal account or a Team. + +The PAT itself is not used as the credential for API calls. Once the Cloud approves the device flow, the instance delivers an access token and a refresh token to the script, and those are what subsequent API calls carry as Bearer tokens. + +The full mechanics of PATs as a credential, where to create one, and how they relate to accounts and Teams are covered in [Personal Access Token](https://docs.caido.io/app/concepts/pat.html) in the user docs. + +## Browser Login + +Browser Login uses the same device authorization flow as PAT, but with a person in the loop instead of the Cloud. The SDK starts the flow on the instance, receives a verification URL and a short user code, and surfaces them to the script through an `onRequest` callback. A person opens the URL in a browser, enters the code, and approves the request on the [Caido Dashboard](https://dashboard.caido.io). The SDK waits on a WebSocket subscription until the instance delivers the resulting token pair. + +This suits interactive scripts, developer tools, and one-off automations where the person running the script can complete the approval step at the time of authentication. Outside of who approves the request, the resulting token pair is identical to one obtained through a PAT. + +## Direct Token + +Direct Token skips the device authorization flow entirely. Instead of negotiating with the instance and the Cloud, the script provides an access token, and optionally a refresh token, that it has already obtained through some other channel. The SDK uses the token as-is on every authenticated request. + +This pattern fits when the surrounding system already manages tokens for the script. Examples include a parent process that ran the device flow itself and passed the result down, a service that mints tokens out-of-band, or tests that need deterministic credentials. + +If only an access token is provided, the SDK has no way to obtain a new one when it expires. The script must rotate the token externally or switch to one of the other two methods. Providing both an access token and a refresh token restores the automatic renewal behavior covered in [Caching of Tokens](./auth_caching.md). diff --git a/src/concepts/client/community_golang.md b/src/concepts/client/community_golang.md new file mode 100644 index 0000000..00f0029 --- /dev/null +++ b/src/concepts/client/community_golang.md @@ -0,0 +1,22 @@ +# Golang SDK + +A community-maintained Go port of the official [JavaScript Client SDK](https://github.com/caido/sdk-js), hosted at [`caido-community/sdk-go`](https://github.com/caido-community/sdk-go) under the MIT license. It mirrors the API surface of `@caido/sdk-client` and uses [`genqlient`](https://github.com/Khan/genqlient) for type-safe GraphQL code generation, tracking the same schema the official SDK consumes. + +The Go SDK exposes the same domain-specific clients as the JavaScript SDK (requests, intercept, replay, findings, scopes, projects, environments, hosted files, workflows, tasks, filters, plugins, and more), plus low-level GraphQL access for operations not covered by domain methods. + +Initialization mirrors the JavaScript SDK's `Client` constructor: + +```go +client, _ := caido.NewClient(caido.Options{ + URL: "http://localhost:8080", + Auth: caido.PATAuth("caido_xxxxx"), +}) +client.Connect(context.Background()) +``` + +## Differences from the JavaScript SDK + +Coverage of the authentication model is intentional rather than exhaustive: + +- **Browser Login** is not exposed in the public API. Only [Personal Access Token](./auth_methods.md#personal-access-token) (`caido.PATAuth`) and [Direct Token](./auth_methods.md#direct-token) (`caido.TokenAuth(accessToken, refreshToken)`) are supported. +- **[Token caching](./auth_caching.md)** is not built in. Tokens are held in memory for the lifetime of the `Client`, and refresh is driven by an optional `TokenRefreshFunc` callback that the script provides. Persisting tokens across runs is the script's responsibility. diff --git a/src/concepts/client/community_python.md b/src/concepts/client/community_python.md new file mode 100644 index 0000000..9b26fac --- /dev/null +++ b/src/concepts/client/community_python.md @@ -0,0 +1,27 @@ +# Python SDK + +A community-maintained Python port of the official [JavaScript Client SDK](https://github.com/caido/sdk-js), hosted at [`caido-community/sdk-py`](https://github.com/caido-community/sdk-py) under the MIT license. The client is published to PyPI as [`caido-sdk-client`](https://pypi.org/project/caido-sdk-client/). + +The repository is a monorepo with two packages that mirror the JavaScript split: + +- `caido-sdk-client` is the high-level client that scripts use to interact with a Caido instance. +- `caido-server-auth` is the lower-level authentication library that the client builds on. + +The Python SDK is `asyncio`-based, and its type names match the JavaScript SDK closely (`Client`, `PATAuthOptions`, `AuthCacheFile`, and so on). + +A minimal usage looks like: + +```python +client = Client( + "http://localhost:8080", + auth=PATAuthOptions( + pat="caido_xxxxxx", + cache=AuthCacheFile(file=".secrets.json"), + ), +) +await client.connect() +``` + +## Differences from the JavaScript SDK + +The Python SDK supports all three [authentication methods](./auth_methods.md) and the file and custom variants of [token caching](./auth_caching.md). The `localStorage` cache variant has no equivalent in Python, since the language does not have a browser storage model. diff --git a/src/guides/client/base_setup.md b/src/guides/client/base_setup.md new file mode 100644 index 0000000..a692c19 --- /dev/null +++ b/src/guides/client/base_setup.md @@ -0,0 +1,252 @@ +# Base Setup + +This guide walks through configuring authentication, caching the resulting tokens, connecting to your Caido instance, and making your first API call to confirm everything works. + +## Authenticate + +The `Client` supports three authentication methods, exposed through the `auth` option. Pick the one that matches your environment: + +1. **Personal Access Token** for headless scripts and CI/CD pipelines. +2. **Browser Login** for interactive scripts where a human can approve the login. +3. **Direct Token** when you already hold an access token from another source. + +### 1. Personal Access Token + +The SDK uses the PAT to automatically approve a device authorization flow against the Caido Cloud, so no human interaction is required. + +[Create a PAT](https://docs.caido.io/dashboard/guides/create_pat.html) from your [Caido Dashboard](https://dashboard.caido.io/developer), then pass the token to the `Client` constructor: + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + pat: "caido_xxxxx", + }, +}); +``` + +::: warning +Do not commit your PAT to source control. Read it from an environment variable or a secret manager instead: + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + }, +}); +``` +::: + +### 2. Browser Login + +The SDK starts a device authorization flow and surfaces a verification URL that the user opens in their browser to approve the login. + +Provide an `onRequest` callback to handle the URL: + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + onRequest: (request) => { + console.log(`Open this URL to log in: ${request.verificationUrl}`); + }, + }, +}); +``` + +If `onRequest` is omitted, the SDK logs the URL to the console using its default logger. + +### 3. Direct Token + +Use this when you already have an access token (and optionally a refresh token), for example from a parent process or an earlier session. + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + token: "access_token_string", + }, +}); +``` + +To allow the SDK to refresh the access token automatically, pass a token pair: + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + token: { + accessToken: "...", + refreshToken: "...", + }, + }, +}); +``` + +::: info +A bare access token cannot be refreshed. Once it expires, you must provide a new token or switch to the Personal Access Token or Browser Login method. +::: + +## Cache the Access Token + +Once the SDK exchanges your PAT (or completes the browser flow) for an access token and a refresh token, it can cache them on disk or in `localStorage`. On subsequent runs, the cached tokens are loaded first, so the authentication flow is skipped until the tokens expire. + +### File cache + +For Node.js scripts. The path is absolute or relative to the current working directory. + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, +}); +``` + +The cache file is written with permissions `0o600` (read and write for the owner only). + +### LocalStorage cache + +For browser-based tools. The string is the key under which the cache is stored. + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + pat: "caido_xxxxx", + cache: { localstorage: "caido-token" }, + }, +}); +``` + +::: info +For custom cache implementations (encrypted storage, secret managers, etc.), see the Advanced section. +::: + +## Connect to the Instance + +Before making any API call, call `connect()` to authenticate and verify the instance is reachable: + +```ts +await client.connect(); +``` + +By default, `connect()` waits for the instance to be ready by polling its `/health` endpoint. To skip the ready check or customize the polling: + +```ts +// Skip the ready check entirely +await client.connect({ ready: false }); + +// Custom polling +await client.connect({ + ready: { + interval: 1000, + retries: 10, + timeout: 10000, + }, +}); +``` + +## Get the Viewer + +Once connected, call `client.user.viewer()` to retrieve the authenticated user. This is the recommended first call to confirm that authentication and connectivity are wired up correctly. + +```ts +const viewer = await client.user.viewer(); +console.log(viewer); +``` + +The result is one of three user kinds: + +- `CloudUser`: a cloud-authenticated user with a full profile (email, name, subscription). +- `GuestUser`: a guest user with only an `id`. +- `ScriptUser`: a script-authenticated user with only an `id`. + +You can branch on `viewer.kind` to access the profile of a `CloudUser`: + +```ts +if (viewer.kind === "CloudUser") { + console.log(`Logged in as ${viewer.profile.identity.email}`); +} +``` + +## Examples + +The script below combines everything above using PAT authentication and a file cache. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +async function main() { + const instanceUrl = + process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080"; + const pat = process.env["CAIDO_PAT"]; + + if (!pat) { + console.error("CAIDO_PAT environment variable is required"); + process.exit(1); + } + + const client = new Client({ + url: instanceUrl, + auth: { + pat: pat, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const viewer = await client.user.viewer(); + console.log("Viewer:", JSON.stringify(viewer, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run logs the authentication flow followed by the authenticated user, for example: + +```txt +[caido] Attempting to load cached token +[caido] Failed to load cached token from file +[caido] Starting authentication flow +[caido] Authentication flow completed +[caido] Saving token to cache +Viewer: { + "kind": "CloudUser", + "id": "", + "profile": { + "identity": { + "email": "you@example.com", + "name": "Your Name" + }, + "subscription": { + "plan": { + "name": "" + }, + "entitlements": [ + { + "name": "feature:..." + } + ] + } + } +} +``` + +On subsequent runs, the cached token is reused and the `Starting authentication flow` lines are replaced with `Loaded token from cache`. diff --git a/src/guides/client/call_function.md b/src/guides/client/call_function.md new file mode 100644 index 0000000..e26ec64 --- /dev/null +++ b/src/guides/client/call_function.md @@ -0,0 +1,195 @@ +# Call a Plugin Function + +Once a plugin is installed, the Client SDK lets you invoke functions exposed by its backend over the network. This is useful for driving a plugin from a script, integrating it into a wider automation, or testing its behavior end-to-end. + +This guide covers the explicit `callFunction()` form, which works for any installed plugin without extra setup. For fully typed direct calls (e.g. `pkg.getProviders()` instead of `pkg.callFunction({ name: "getProviders" })`), see the next guide on the npm spec packages. + +::: info +A runnable version of this guide lives in the SDK repository at [`examples/functionCall`](https://github.com/caido/sdk-js/tree/main/packages/sdk-client/examples/functionCall). Function names exposed by a plugin (like `getProviders`, `createSession` for `quickssrf`) come from the plugin's own source. Look for `sdk.api.register("name", ...)` calls in the plugin's backend code or check its repository under [caido-community](https://github.com/caido-community). +::: + +## Get the Plugin Handle + +To call a function on an installed plugin, you first need its **package handle**. Use `client.plugin.pluginPackage()` to look up the installed plugin by its [manifest ID](./install_plugin.md#1-from-the-caido-store): + +```ts +const pkg = await client.plugin.pluginPackage("quickssrf"); +if (pkg === undefined) { + throw new Error("quickssrf is not installed"); +} +``` + +`pluginPackage()` returns `undefined` when no installed plugin matches the manifest ID, so always check the result before calling functions on it. + +::: info +If you are installing the plugin in the same script, you can skip the lookup since `client.plugin.install()` returns the same handle directly. +::: + +## Call a Function + +Use `pkg.callFunction()` with a generic type for the return value. The function name is a string (the same name the backend used in `sdk.api.register()`), and arguments are passed as an array. + +### Without Arguments + +```ts +type Result = + | { kind: "Ok"; value: T } + | { kind: "Error"; error: string }; + +type Provider = { + id: string; + name: string; + kind: string; + url: string; + enabled: boolean; +}; + +const providers = await pkg.callFunction>({ + name: "getProviders", +}); +``` + +### With Arguments + +Pass positional arguments through the `arguments` array. They are JSON-serialized before being sent to the backend, so any JSON-compatible value is accepted. + +```ts +type Session = { + id: string; + providerId: string; + url: string; + status: string; +}; + +const providerId = providers.value[0].id; + +const session = await pkg.callFunction>({ + name: "createSession", + arguments: [providerId], +}); +``` + +::: info +The return shape is defined entirely by the plugin. The `Result` wrapper used above is a `quickssrf` convention, not a Caido SDK one. Other plugins return whatever shape they want, and you supply the matching TypeScript type as the generic parameter. +::: + +## Multi-Backend Plugins + +A package can ship more than one backend plugin. When that is the case, `callFunction()` needs to know which backend owns the function. Pass the `manifestId` of the target backend: + +```ts +await pkg.callFunction({ + name: "myFunction", + manifestId: "specific-backend", + arguments: [], +}); +``` + +If the package has only one backend, the `manifestId` field is optional and the SDK picks it automatically. + +## Errors + +`callFunction()` throws a `PluginFunctionCallError` when the backend returns an error response (for example, when the function name is not registered or the backend itself throws): + +```ts +import { PluginFunctionCallError } from "@caido/sdk-client"; + +try { + await pkg.callFunction({ name: "doesNotExist" }); +} catch (error) { + if (error instanceof PluginFunctionCallError) { + console.error("Function call failed:", error.message); + } else { + throw error; + } +} +``` + +Plugins that use a `Result`-style return value (like `quickssrf`) signal _functional_ failures through the return shape rather than throwing, so the `try/catch` above only covers transport- and runtime-level errors. + +## Examples + +The script below looks up the installed `quickssrf` plugin, fetches its available providers, and creates a session against the first one. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +type Result = + | { kind: "Ok"; value: T } + | { kind: "Error"; error: string }; + +type Provider = { + id: string; + name: string; + kind: string; + url: string; + enabled: boolean; +}; + +type Session = { + id: string; + providerId: string; + url: string; + status: string; +}; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const pkg = await client.plugin.pluginPackage("quickssrf"); + if (pkg === undefined) { + throw new Error("quickssrf is not installed"); + } + + const providers = await pkg.callFunction>({ + name: "getProviders", + }); + if (providers.kind === "Error") { + throw new Error(`getProviders failed: ${providers.error}`); + } + + const firstProvider = providers.value[0]; + if (firstProvider === undefined) { + throw new Error("No providers available"); + } + + const session = await pkg.callFunction>({ + name: "createSession", + arguments: [firstProvider.id], + }); + if (session.kind === "Error") { + throw new Error(`createSession failed: ${session.error}`); + } + + console.log("Session URL:", session.value.url); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints: + +```txt +[caido] Loaded token from cache +Session URL: https://oast.site/abcdef1234567890 +``` diff --git a/src/guides/client/custom_cache.md b/src/guides/client/custom_cache.md new file mode 100644 index 0000000..2875387 --- /dev/null +++ b/src/guides/client/custom_cache.md @@ -0,0 +1,243 @@ +# Custom Cache Implementation + +The SDK ships with two built-in token caches: a file-based one for Node.js scripts and a `localStorage`-based one for browser tools. For everything else (encrypted storage, secret managers, shared state across workers, test mocks), implement the `TokenCache` interface yourself and pass an instance to the `Client` constructor. + +::: info +If you only need a basic file or `localStorage` cache, use the `{ file }` or `{ localstorage }` options described in [Base Setup](./base_setup.md#cache-the-access-token). A custom implementation is only worth the extra code when you need behavior the built-in caches do not provide. +::: + +## The TokenCache Interface + +To plug in your own cache, implement three methods. The SDK exports the interface and the payload type: + +```ts +import type { TokenCache, CachedToken } from "@caido/sdk-client"; + +// For reference, this is the shape you implement: +interface TokenCache { + load(): Promise; + save(token: CachedToken): Promise; + clear(): Promise; +} + +interface CachedToken { + accessToken: string; + refreshToken?: string; + expiresAt?: string; +} +``` + +When the SDK calls each method: + +- **`load()`** runs once at the start of `client.connect()`. Return the cached token, or `undefined` to trigger a fresh authentication flow. +- **`save()`** runs after the SDK obtains a new token: after a PAT exchange, a browser login, a direct token (`auth.token`) being set, or a refresh. +- **`clear()`** is never called automatically by the SDK. It exists for your own code to invalidate the cache when needed (for example, on logout or after detecting a revoked token). + +## Build an In-Memory Cache + +The simplest custom cache holds the token in memory for the lifetime of the process. This is useful for short-lived scripts that make several authenticated calls but should not write to disk: + +```ts +import type { TokenCache, CachedToken } from "@caido/sdk-client"; + +class InMemoryCache implements TokenCache { + private stored: CachedToken | undefined; + + async load(): Promise { + return this.stored; + } + + async save(token: CachedToken): Promise { + this.stored = token; + } + + async clear(): Promise { + this.stored = undefined; + } +} +``` + +Wire it in via the `auth.cache` option: + +```ts +const client = new Client({ + url: "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: new InMemoryCache(), + }, +}); + +await client.connect(); +``` + +The first `connect()` will run the full PAT exchange (since `load()` returns `undefined`) and then call `save()` with the resulting tokens. Subsequent `connect()` calls in the same process (for example, after recreating the `Client`) reuse the cached token without re-authenticating. + +## Encrypted Storage + +To store tokens encrypted at rest (when writing to a shared filesystem, a synced drive, or a CI cache), wrap a persistence layer with your encryption primitive: + +```ts +import { readFile, writeFile, unlink } from "node:fs/promises"; +import type { TokenCache, CachedToken } from "@caido/sdk-client"; + +class EncryptedFileCache implements TokenCache { + private readonly path: string; + private readonly key: Buffer; + + constructor(path: string, key: Buffer) { + this.path = path; + this.key = key; + } + + async load(): Promise { + const ciphertext = await readFile(this.path).catch(() => undefined); + if (!ciphertext) return undefined; + const plaintext = decrypt(ciphertext, this.key); // your encryption primitive + return JSON.parse(plaintext) as CachedToken; + } + + async save(token: CachedToken): Promise { + const plaintext = JSON.stringify(token); + const ciphertext = encrypt(plaintext, this.key); // your encryption primitive + await writeFile(this.path, ciphertext, { mode: 0o600 }); + } + + async clear(): Promise { + await unlink(this.path).catch(() => {}); + } +} +``` + +Use a key from your OS keychain, a secret manager, or a derived key from a passphrase. Avoid hard-coding it in source. + +## Secret Manager + +To store tokens in a remote secret manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, etc.), delegate to that service's SDK from your implementation. Sketch using AWS Secrets Manager: + +```ts +import type { TokenCache, CachedToken } from "@caido/sdk-client"; +import type { SecretsManager } from "@aws-sdk/client-secrets-manager"; + +class SecretsManagerCache implements TokenCache { + private readonly client: SecretsManager; + private readonly secretId: string; + + constructor(client: SecretsManager, secretId: string) { + this.client = client; + this.secretId = secretId; + } + + async load(): Promise { + try { + const result = await this.client.getSecretValue({ SecretId: this.secretId }); + return result.SecretString + ? (JSON.parse(result.SecretString) as CachedToken) + : undefined; + } catch { + return undefined; + } + } + + async save(token: CachedToken): Promise { + await this.client.putSecretValue({ + SecretId: this.secretId, + SecretString: JSON.stringify(token), + }); + } + + async clear(): Promise { + await this.client.deleteSecret({ + SecretId: this.secretId, + ForceDeleteWithoutRecovery: true, + }); + } +} +``` + +The same pattern applies to any backing store: implement the three methods against its API. + +## Manually Clearing the Cache + +The SDK never calls `clear()` on its own. Invoke it from your code when you want to invalidate the cached token, for example after detecting an authentication error or when explicitly signing out: + +```ts +const cache = new InMemoryCache(); +// ... use the client ... +await cache.clear(); +``` + +On the next `connect()`, `load()` will return `undefined` and the SDK will run a fresh authentication flow. + +## Examples + +A complete script that uses an in-memory cache, connects, fetches the viewer, and clears the cache on exit: + +### index.ts + +```ts +import { + Client, + type TokenCache, + type CachedToken, +} from "@caido/sdk-client"; + +class InMemoryCache implements TokenCache { + private stored: CachedToken | undefined; + + async load(): Promise { + return this.stored; + } + + async save(token: CachedToken): Promise { + this.stored = token; + } + + async clear(): Promise { + this.stored = undefined; + } +} + +async function main() { + const cache = new InMemoryCache(); + + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache, + }, + }); + + await client.connect(); + + const viewer = await client.user.viewer(); + console.log("Authenticated as", viewer.kind); + + await cache.clear(); + console.log("Cache cleared"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints the auth flow followed by the viewer kind and the clear confirmation: + +```txt +[caido] Attempting to load cached token +[caido] Starting authentication flow +[caido] Authentication flow completed +[caido] Saving token to cache +Authenticated as CloudUser +Cache cleared +``` diff --git a/src/guides/client/environments.md b/src/guides/client/environments.md new file mode 100644 index 0000000..f00b434 --- /dev/null +++ b/src/guides/client/environments.md @@ -0,0 +1,157 @@ +# Environments and Variables + +The Client SDK exposes Caido's environments and their variables through `client.environment`. Environments hold named values (plain or secret) that you can reference in requests and workflows, which is useful for parameterizing scripts across different targets, API keys, or stages. + +::: info +Environments are project-scoped. A project must be open on the instance for these calls to succeed. Every project also starts with a default `Global` environment that you can use directly or alongside your own. +::: + +## List Environments + +To get every environment in the current project, await `client.environment.list()`: + +```ts +const envs = await client.environment.list(); +for (const env of envs) { + console.log(`${env.name} (${env.variables.length} variable(s))`); +} +``` + +Each entry is a plain `Environment` object with `id`, `name`, `version`, and `variables`. + +## Create an Environment + +To create a new environment, call `client.environment.create()` with a name and the initial variables. Variables can be `"PLAIN"` (visible) or `"SECRET"` (masked): + +```ts +const env = await client.environment.create({ + name: "staging", + variables: [ + { name: "API_URL", value: "https://api.staging.example.com", kind: "PLAIN" }, + { name: "API_KEY", value: "secret-123", kind: "SECRET" }, + ], +}); + +console.log("Created environment", env.id); +``` + +`create()` returns an `EnvironmentInstance` that you keep around to manage variables on this environment. The instance tracks the environment's version internally, so successive variable changes update the local state automatically without needing to refetch. + +## Get a Single Environment + +To pick up an existing environment for variable management, look it up by ID with `client.environment.get(id)`. It returns an `EnvironmentInstance` or `undefined` when the environment does not exist: + +```ts +const env = await client.environment.get("2"); +if (env === undefined) { + throw new Error("Environment not found"); +} +``` + +## Add, Update, and Delete Variables + +To change variables on an environment, use the instance methods. Each call writes through to Caido and refreshes the local state: + +```ts +// Add a new variable +await env.addVariable({ name: "REGION", value: "us-east-1", kind: "PLAIN" }); + +// Update an existing variable by name (merges with the current value) +await env.updateVariable("API_KEY", { value: "secret-rotated" }); + +// Delete a variable by name +await env.deleteVariable("REGION"); + +console.log(env.variables); +``` + +The variable list available on `env.variables` always reflects the latest server state after each call. + +## Select the Active Environment + +Only one environment is active at a time in Caido. To switch the active environment from a script, call `client.environment.select(id)`. Pass `undefined` to deselect the current one: + +```ts +// Make this environment active +const active = await client.environment.select(env.id); +console.log("Active environment:", active?.name); + +// Deselect +await client.environment.select(); +``` + +## Delete an Environment + +To remove an environment, call `client.environment.delete(id)`: + +```ts +await client.environment.delete(env.id); +``` + +::: warning +Deletion is permanent and removes all variables on the environment. Any request or workflow that references variables from the deleted environment will fail until you point them at another environment. +::: + +## Examples + +The script below provisions a `staging` environment with two variables, selects it, and prints the final state: + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const env = await client.environment.create({ + name: "staging", + variables: [ + { name: "API_URL", value: "https://api.staging.example.com", kind: "PLAIN" }, + { name: "API_KEY", value: "secret-123", kind: "SECRET" }, + ], + }); + + await env.addVariable({ name: "REGION", value: "us-east-1", kind: "PLAIN" }); + await env.updateVariable("API_KEY", { value: "secret-rotated" }); + + await client.environment.select(env.id); + + console.log(`Active: ${env.name}`); + for (const v of env.variables) { + const display = v.kind === "SECRET" ? "***" : v.value; + console.log(` ${v.name} = ${display} (${v.kind})`); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints: + +```txt +[caido] Attempting to load cached token +[caido] Loaded token from cache +Active: staging + API_URL = https://api.staging.example.com (PLAIN) + API_KEY = *** (SECRET) + REGION = us-east-1 (PLAIN) +``` diff --git a/src/guides/client/extract_requests.md b/src/guides/client/extract_requests.md new file mode 100644 index 0000000..6680a61 --- /dev/null +++ b/src/guides/client/extract_requests.md @@ -0,0 +1,183 @@ +# Extract Requests + +The Client SDK exposes the proxied HTTP history of a Caido instance through `client.request`. You can list requests with filters, ordering, and pagination, or fetch a single request by ID. This is the foundation for any automation that needs to read or process traffic captured by the proxy. + +::: info +A project must be open on the instance for these calls to succeed. If no project is loaded, the database is not connected and the SDK will throw an error. +::: + +## List Requests + +To list requests, start a chain with `client.request.list()` and await it. The result is a paginated `Connection` with `pageInfo` (cursors) and `edges` (the requests): + +```ts +const page = await client.request.list().first(10); + +for (const edge of page.edges) { + const req = edge.node.request; + const code = edge.node.response?.statusCode ?? "no response"; + console.log(`${req.method} ${req.host}${req.path} -> ${code}`); +} +``` + +Each `edge.node` is a `{ request, response? }` pair. The response is `undefined` when the request did not complete (for example, an in-flight or dropped request). + +## Filter with HTTPQL + +To narrow the list, chain `.filter()` with an [HTTPQL](https://docs.caido.io/reference/httpql.html) query string. The same syntax used in the Caido HTTP History UI works here: + +```ts +const errors = await client.request + .list() + .filter('resp.code.gte:400') + .first(50); + +console.log(`Got ${errors.edges.length} error response(s)`); +``` + +Integer values are unquoted (`resp.code.gte:400`), string values are quoted (`req.host.cont:"example.com"`). Combine clauses with `AND` / `OR` and parentheses, for example `'req.host.cont:"example.com" AND resp.code.gte:400'`. + +## Order Results + +To control sort order, chain `.ascending(target, field)` or `.descending(target, field)`. The `target` is `"req"` or `"resp"`, and the `field` is one of the supported order fields: + +- Request fields: `created_at`, `host`, `method`, `path`, `query`, `ext`, `source`, `id` +- Response fields: `code`, `length`, `roundtrip` + +```ts +const latest = await client.request + .list() + .descending("req", "created_at") + .first(10); +``` + +## Skip Raw Bodies + +To skip raw request and response bodies when you only need metadata (faster, less memory), call `.includeRaw(false)`. By default, every entry comes back with its full raw bytes attached. + +```ts +const meta = await client.request.list().includeRaw(false).first(100); +// meta.edges[i].node.request.raw is undefined +``` + +To selectively keep one side, pass an object: + +```ts +client.request.list().includeRaw({ request: true, response: false }); +``` + +## Scope to a Caido Scope + +To restrict the list to a specific Caido scope (the scopes you define under the **Scope** feature), chain `.scope(scopeId)`. You can list and look up scope IDs via `client.scope`: + +```ts +const inScope = await client.request.list().scope(scopeId).first(50); +``` + +## Paginate + +To walk through more results than fit in a single page, call `.next()` on the returned connection. It returns the next page or `undefined` when there are no more: + +```ts +let page = await client.request.list().first(50); +while (page) { + for (const edge of page.edges) { + // process edge.node + } + page = await page.next(); +} +``` + +Cursors in `page.pageInfo.startCursor` / `endCursor` are opaque strings. Treat them as a black box and feed them back to `.after(cursor)` or `.before(cursor)` if you need manual control. + +## Get a Single Request + +To fetch one request when you already know its ID, use `client.request.get(id)`. It returns the same `{ request, response? }` shape as a list edge, or `undefined` when the request does not exist: + +```ts +const item = await client.request.get("1"); +if (item === undefined) { + throw new Error("Request not found"); +} + +console.log(item.request.method, item.request.host, item.request.path); +console.log("Response status:", item.response?.statusCode); +``` + +Pass an options object to skip the raw bodies on a per-call basis: + +```ts +const item = await client.request.get("1", { + requestRaw: false, + responseRaw: false, +}); +``` + +## Examples + +The script below lists the latest 10 requests ordered by creation time, prints a one-line summary of each, and decodes the raw response of the first one. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const page = await client.request + .list() + .descending("req", "created_at") + .first(10); + + for (const edge of page.edges) { + const req = edge.node.request; + const code = edge.node.response?.statusCode ?? "no response"; + console.log(`${req.method} ${req.host}${req.path} -> ${code}`); + } + + const first = page.edges[0]?.node; + if (first?.response?.raw) { + const text = new TextDecoder().decode(first.response.raw); + console.log("\n--- Raw response of first request ---"); + console.log(text.slice(0, 500)); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints a summary line per request followed by the first 500 bytes of the first response: + +```txt +[caido] Attempting to load cached token +[caido] Loaded token from cache +GET detectportal.firefox.com/success.txt -> 200 +GET detectportal.firefox.com/success.txt -> 200 +GET detectportal.firefox.com/canonical.html -> 200 +... + +--- Raw response of first request --- +HTTP/1.1 200 OK +Server: nginx +Content-Length: 8 +... +``` diff --git a/src/guides/client/graphql_direct.md b/src/guides/client/graphql_direct.md new file mode 100644 index 0000000..58e033d --- /dev/null +++ b/src/guides/client/graphql_direct.md @@ -0,0 +1,211 @@ +# Call GraphQL Directly + +The high-level SDKs on `Client` (`client.request`, `client.finding`, `client.environment`, etc.) cover the most common operations against a Caido instance. When you need something the high-level SDKs do not expose, drop down to `client.graphql` and run your own queries, mutations, or subscriptions against Caido's GraphQL API directly. + +:::: info +The `gql` template tag is not re-exported by the SDK. Install [`@urql/core`](https://www.npmjs.com/package/@urql/core) (the GraphQL client the SDK is built on) to get it: + +::: code-group +```bash [pnpm] +pnpm add @urql/core +``` + +```bash [npm] +npm install @urql/core +``` + +```bash [yarn] +yarn add @urql/core +``` +::: + +Any tag that returns a `TypedDocumentNode` also works, such as `graphql-tag`. +:::: + +## Run a Query + +To run a query, build a document with `gql` and pass it to `client.graphql.query()`. The first generic parameter types the response data: + +```ts +import { gql } from "@urql/core"; + +const ViewerQuery = gql<{ viewer: { __typename: string; id: string } }>` + query CustomViewer { + viewer { + __typename + ... on CloudUser { id } + ... on GuestUser { id } + ... on ScriptUser { id } + } + } +`; + +const result = await client.graphql.query(ViewerQuery); +console.log(result.viewer); +``` + +## Pass Variables + +To parameterize a query, declare its variables with `$` syntax inside the document and pass them as the second argument to `query()`. The second generic parameter types the variable map: + +```ts +const RequestQuery = gql< + { request: { id: string; host: string; method: string; path: string } | null }, + { id: string } +>` + query CustomRequest($id: ID!) { + request(id: $id) { + id + host + method + path + } + } +`; + +const result = await client.graphql.query(RequestQuery, { id: "1" }); +console.log(result.request); +``` + +## Run a Mutation + +Mutations use the same shape with `client.graphql.mutation()`: + +```ts +const RenameMutation = gql< + { + renameReplaySessionCollection: { + collection: { id: string; name: string } | null; + }; + }, + { id: string; name: string } +>` + mutation RenameCollection($id: ID!, $name: String!) { + renameReplaySessionCollection(id: $id, name: $name) { + collection { id name } + } + } +`; + +const result = await client.graphql.mutation(RenameMutation, { + id: "1", + name: "Renamed via raw GraphQL", +}); + +console.log(result.renameReplaySessionCollection.collection); +``` + +## Subscribe to a Subscription + +Subscriptions are exposed through `client.graphql.subscribe()` and return an `AsyncIterable` that yields each event as it arrives. Iterate with `for await` and `break` out of the loop to disconnect: + +```ts +const NewRequests = gql<{ + createdRequest: { + requestEdge: { + cursor: string; + node: { id: string; host: string; method: string; path: string }; + }; + }; +}>` + subscription NewRequests { + createdRequest { + requestEdge { + cursor + node { id host method path } + } + } + } +`; + +for await (const event of client.graphql.subscribe(NewRequests)) { + const req = event.createdRequest.requestEdge.node; + console.log(`New request: ${req.method} ${req.host}${req.path}`); +} +``` + +## Find Available Operations + +Caido's GraphQL endpoint lives at `/graphql` and supports standard introspection. Point any GraphQL explorer (Apollo Studio, Insomnia, GraphiQL, etc.) at that URL with your access token in the `Authorization` header to browse the full schema, including every type, field, and input. + +For TypeScript signatures, the SDK's source repository also ships generated bindings under [`packages/sdk-client/src/graphql/__generated__/`](https://github.com/caido/sdk-js/tree/main/packages/sdk-client/src/graphql/__generated__) that mirror every operation the high-level SDKs use. They are useful as a reference for fragment shapes and required arguments. + +## Errors + +Raw GraphQL calls throw the same error types the high-level SDKs use: + +- `NetworkUserError`: the request did not reach the server +- `OperationUserError`: the server rejected the query (invalid fields, type errors, missing arguments) +- `NoDataUserError`: the server accepted the query but returned no data + +```ts +import { OperationUserError } from "@caido/sdk-client"; + +try { + await client.graphql.query(RequestQuery, { id: "doesNotExist" }); +} catch (error) { + if (error instanceof OperationUserError) { + console.error("GraphQL rejected the query:", error.message); + } else { + throw error; + } +} +``` + +## Examples + +The script below uses a raw query to fetch the authenticated user, replicating what `client.user.viewer()` does internally: + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; +import { gql } from "@urql/core"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const ViewerQuery = gql<{ viewer: { __typename: string; id: string } }>` + query CustomViewer { + viewer { + __typename + ... on CloudUser { id } + ... on GuestUser { id } + ... on ScriptUser { id } + } + } + `; + + const result = await client.graphql.query(ViewerQuery); + console.log( + `Authenticated as ${result.viewer.__typename} ${result.viewer.id}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints: + +```txt +[caido] Loaded token from cache +Authenticated as CloudUser 01JX49W2EBZBSVT8HBRXCDD8XB +``` diff --git a/src/guides/client/install.md b/src/guides/client/install.md new file mode 100644 index 0000000..4a18d74 --- /dev/null +++ b/src/guides/client/install.md @@ -0,0 +1,44 @@ +# Install the SDK + +The Caido Client SDK lets external scripts and tools interact with a Caido instance from outside the application. It handles authentication, GraphQL queries, and REST calls so you can focus on your automation logic. + +::: info Requirements +- [Node.js](https://nodejs.org/en/) 18 or higher +- A package manager: [pnpm](https://pnpm.io/installation), [npm](https://www.npmjs.com/), or [yarn](https://yarnpkg.com/) +::: + +## Add the package + +Install [`@caido/sdk-client`](https://www.npmjs.com/package/@caido/sdk-client) into your project: + +::: code-group +```bash [pnpm] +pnpm add @caido/sdk-client +``` + +```bash [npm] +npm install @caido/sdk-client +``` + +```bash [yarn] +yarn add @caido/sdk-client +``` +::: + +## Import the client + +The SDK exposes a `Client` class as the entry point for every API call. Create an `index.ts` file and instantiate the client with the URL of your Caido instance: + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +const client = new Client({ + url: "http://localhost:8080", +}); +``` + +If your TypeScript or runtime resolves the import without errors, the SDK is installed correctly. + +Continue to [Base Setup](./base_setup.md) to configure authentication and make your first call. diff --git a/src/guides/client/install_plugin.md b/src/guides/client/install_plugin.md new file mode 100644 index 0000000..4daa724 --- /dev/null +++ b/src/guides/client/install_plugin.md @@ -0,0 +1,151 @@ +# Install a Plugin + +The Client SDK can install plugin packages on your Caido instance programmatically. This is useful for scripting plugin deployment, bootstrapping CI/CD environments, or pre-configuring instances before handing them to other users. + +The `client.plugin.install()` method accepts two source shapes: + +1. **Manifest ID** to install a plugin published in the [Caido community store](https://github.com/caido/store). +2. **Package file** to install a plugin from a local `.zip` archive. + +::: info +Both methods enable the installed plugins automatically. No separate enable step is required. +::: + +## 1. Installing from the Caido Store + +To install a plugin from the store, you need its **manifest ID**. This is the `id` field declared in the plugin's `manifest.json` file, and it is what uniquely identifies the plugin in the store. You can find it in the [store manifest](https://github.com/caido/store/blob/main/plugin_packages.json) or in the plugin's repository under [caido-community](https://github.com/caido-community). + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +async function main() { + const client = new Client({ + url: "http://localhost:8080", + auth: { pat: process.env["CAIDO_PAT"]! }, + }); + + await client.connect(); + + const pkg = await client.plugin.install({ manifestId: "scanner" }); + + console.log("Installed:", pkg.manifestId); +} + +main(); +``` + +## 2. Installing from a Local Package File + +To install a plugin from a local archive, you need its **plugin package**. This is a signed `.zip` file attached to each [plugin release](/guides/repository.md#5-create-a-release) on GitHub. Download the archive, load it into a `File` object, then pass it to `install()`. + +### index.ts + +```ts +import { readFileSync } from "node:fs"; + +import { Client } from "@caido/sdk-client"; + +async function main() { + const client = new Client({ + url: "http://localhost:8080", + auth: { pat: process.env["CAIDO_PAT"]! }, + }); + + await client.connect(); + + const buffer = readFileSync("./plugin_package.zip"); + const file = new File([new Uint8Array(buffer)], "plugin_package.zip", { + type: "application/zip", + }); + + const pkg = await client.plugin.install({ file }); + + console.log("Installed:", pkg.manifestId); +} + +main(); +``` + +## Re-installing an Existing Plugin + +To re-install a plugin that is already installed, you need to bypass the version check that `install()` performs by default. Without it, the call rejects with an `AlreadyInstalled` error whenever the new version is not greater than the installed version. Pass `force: true` to overwrite the installed package regardless of version: + +```ts +const pkg = await client.plugin.install({ + manifestId: "scanner", + force: true, +}); +``` + +## Reading the Returned Package Handle + +When `install()` succeeds, it returns a `PluginPackageHandle` that describes the installed package and every plugin inside it. A single package can ship a backend plugin, a frontend plugin, and a workflow plugin, each with its own ID and `enabled` flag. + +```txt +{ + id: "068a80a3-c1ab-4116-8b89-e7ee64be4534", + manifestId: "scanner", + plugins: [ + { kind: "PluginBackend", manifestId: "backend", enabled: true }, + { kind: "PluginFrontend", manifestId: "frontend", enabled: true } + ] +} +``` + +The handle also exposes `callFunction()` and `subscribeEvent()` for interacting with a backend plugin's exported functions and events. Those are covered in the next guides. + +## Examples + +The script below bootstraps a Caido instance by installing several plugins from the store. Plugins that are already installed at the same or newer version are skipped, and the failure is reported without aborting the rest of the run. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const manifestIds = ["scanner", "quickssrf", "autorize"]; + + for (const manifestId of manifestIds) { + try { + const pkg = await client.plugin.install({ manifestId }); + console.log(`Installed ${pkg.manifestId} (${pkg.plugins.length} plugins)`); + } catch (error) { + console.error(`Failed to install ${manifestId}:`, error); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints: + +```txt +[caido] Loaded token from cache +Installed scanner (2 plugins) +Installed quickssrf (2 plugins) +Installed autorize (2 plugins) +``` diff --git a/src/guides/client/manage_findings.md b/src/guides/client/manage_findings.md new file mode 100644 index 0000000..ea03562 --- /dev/null +++ b/src/guides/client/manage_findings.md @@ -0,0 +1,169 @@ +# Manage Findings + +The Client SDK exposes the findings stored in the current Caido project through `client.finding`. Findings are security observations attached to a specific request. You can list them, look one up by ID, and create or update them from a script. + +::: info +Findings are project-scoped. A project must be open on the instance for any of these calls to succeed. +::: + +## List Findings + +To list findings, start a chain with `client.finding.list()` and await it. The result is a `Connection` with `pageInfo` (cursors) and `edges`: + +```ts +const page = await client.finding.list().first(10); + +for (const edge of page.edges) { + const f = edge.node; + console.log(`[${f.reporter}] ${f.title} at ${f.host}${f.path}`); +} +``` + +The `Finding` shape includes `id`, `requestId`, `title`, `reporter`, `description`, `dedupeKey`, `host`, `path`, `hidden`, and `createdAt`. + +## Filter by Reporter + +To narrow the list, chain `.filter({ reporter: "" })`. This is the only filter field currently supported on findings: + +```ts +const mine = await client.finding + .list() + .filter({ reporter: "my-plugin" }) + .first(50); + +console.log(`Got ${mine.edges.length} finding(s) from "my-plugin"`); +``` + +## Order Results + +To control sort order, chain `.order({ by, ordering })`. The `by` field is one of `"CREATED_AT"`, `"HOST"`, `"ID"`, `"PATH"`, `"REPORTER"`, `"TITLE"`, and `ordering` is `"ASC"` or `"DESC"`: + +```ts +const latest = await client.finding + .list() + .order({ by: "CREATED_AT", ordering: "DESC" }) + .first(10); +``` + +## Paginate + +To walk through more findings than fit in a single page, call `.next()` on the returned connection. It returns the next page or `undefined` when there are no more: + +```ts +let page = await client.finding.list().first(50); +while (page) { + for (const edge of page.edges) { + // process edge.node + } + page = await page.next(); +} +``` + +## Get a Single Finding + +To fetch one finding when you already know its ID, use `client.finding.get(id)`. It returns the `Finding` directly, or `undefined` when the finding does not exist: + +```ts +const finding = await client.finding.get("1"); +if (finding === undefined) { + throw new Error("Finding not found"); +} + +console.log(finding.title, "->", finding.description); +``` + +## Create a Finding + +To create a new finding tied to an existing request, use `client.finding.create(requestId, options)`. The `host` and `path` are pulled automatically from the request: + +```ts +const finding = await client.finding.create("1", { + title: "Potential SSRF", + reporter: "my-scanner", + description: "The `redirect_url` parameter is reflected in an outbound request.", + dedupeKey: "ssrf:/canonical.html:redirect_url", +}); + +console.log("Created finding", finding.id); +``` + +The `dedupeKey` is optional but recommended for automated scanners: Caido uses it to skip creating duplicate findings on subsequent runs. + +## Update a Finding + +To change a finding's title, description, or hidden state, use `client.finding.update(id, options)`. All three fields are required, so fetch the finding first if you only want to change one: + +```ts +const current = await client.finding.get("1"); +if (current === undefined) { + throw new Error("Finding not found"); +} + +await client.finding.update(current.id, { + title: current.title, + description: "Updated description with reproduction steps.", + hidden: current.hidden, +}); +``` + +## Examples + +The script below lists every finding in the project, ordered by creation time, and paginates through the full list: + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + let page = await client.finding + .list() + .order({ by: "CREATED_AT", ordering: "DESC" }) + .first(50); + + let total = 0; + while (page) { + for (const edge of page.edges) { + const f = edge.node; + console.log(`[${f.createdAt.toISOString()}] ${f.title} - ${f.host}${f.path}`); + total++; + } + page = await page.next(); + } + + console.log(`\nTotal: ${total} finding(s)`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run prints one line per finding followed by a total count: + +```txt +[caido] Attempting to load cached token +[caido] Loaded token from cache +[2026-05-11T14:02:04.976Z] SDK test finding (updated) - detectportal.firefox.com/canonical.html +[2026-05-11T13:53:58.784Z] SDK test finding (updated) - detectportal.firefox.com/canonical.html + +Total: 2 finding(s) +``` diff --git a/src/guides/client/receive_events.md b/src/guides/client/receive_events.md new file mode 100644 index 0000000..f40df4c --- /dev/null +++ b/src/guides/client/receive_events.md @@ -0,0 +1,182 @@ +# Receive Plugin Events + +Plugin backends can emit events that the Client SDK lets you listen to over a streaming connection. This is useful for surfacing work as it happens (interactions arriving, scans completing, sessions changing) or for reacting to backend state from a long-running script. + +This guide covers the explicit `subscribeEvent("name")` form, which works for any installed plugin without extra setup. For fully typed events (inferred payload types for known events), see the next guide on the npm spec packages. + +::: info +A runnable version of this guide lives in the SDK repository at [`examples/pluginEvent`](https://github.com/caido/sdk-js/tree/main/packages/sdk-client/examples/pluginEvent). Event names emitted by a plugin (like `interaction:received` for `quickssrf`) come from the plugin's own source. Look for `sdk.api.send("name", ...)` calls in the plugin's backend code or check its repository under [caido-community](https://github.com/caido-community). +::: + +## Get the Plugin Handle + +To subscribe to events from an installed plugin, you first need its **package handle**. Use `client.plugin.pluginPackage()` to look up the installed plugin by its [manifest ID](./install_plugin.md#1-from-the-caido-store): + +```ts +const pkg = await client.plugin.pluginPackage("quickssrf"); +if (pkg === undefined) { + throw new Error("quickssrf is not installed"); +} +``` + +`pluginPackage()` returns `undefined` when no installed plugin matches the manifest ID, so always check the result before subscribing. + +## Listen for an Event + +To start listening, call `pkg.subscribeEvent(name)`. It returns an `AsyncIterable` that yields the arguments emitted by the backend each time the event fires. Iterate over it with a `for await` loop: + +```ts +type Interaction = { + protocol: string; + remoteAddress: string; + timestamp: string; +}; + +type InteractionReceivedEvent = { + sessionId: string; + interactions: Interaction[]; +}; + +for await (const [event] of pkg.subscribeEvent("interaction:received")) { + const typed = event as InteractionReceivedEvent; + console.log( + `Got ${typed.interactions.length} interaction(s) for session ${typed.sessionId}`, + ); +} +``` + +The destructuring `[event]` takes the first argument the backend passed to `sdk.api.send("interaction:received", payload)`. Since the explicit form is untyped (the SDK does not know what shape each event carries), cast or annotate the value yourself. + +::: info +For events with multiple arguments, destructure them all: `for await (const [a, b, c] of pkg.subscribeEvent("name"))`. For events with no arguments, the destructured value is `undefined`. +::: + +## Multi-Backend Plugins + +To listen to events from a specific backend in a package that ships more than one, pass the object form of `subscribeEvent` with the target backend's `manifestId`: + +```ts +for await (const [event] of pkg.subscribeEvent({ + name: "myEvent", + manifestId: "specific-backend", +})) { + console.log(event); +} +``` + +If the package has only one backend, the `manifestId` is optional and the SDK picks it automatically. + +## Stop a Subscription + +To stop receiving events, `break` out of the `for await` loop: + +```ts +for await (const [event] of pkg.subscribeEvent("interaction:received")) { + if (someCondition) break; +} +``` + +Internally, the SDK keeps a single upstream connection open while at least one listener is active and tears it down when the last one leaves. + +## Examples + +The script below creates a `quickssrf` session, then listens for `interaction:received` events tied to that session. Once running, trigger the printed SSRF URL externally (for example, `curl `) to see events arrive. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; + +type Result = + | { kind: "Ok"; value: T } + | { kind: "Error"; error: string }; + +type Provider = { + id: string; + name: string; + url: string; +}; + +type Session = { + id: string; + providerId: string; + url: string; +}; + +type Interaction = { + protocol: string; + remoteAddress: string; + timestamp: string; +}; + +type InteractionReceivedEvent = { + sessionId: string; + interactions: Interaction[]; +}; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const pkg = await client.plugin.pluginPackage("quickssrf"); + if (pkg === undefined) { + throw new Error("quickssrf is not installed"); + } + + const providers = await pkg.callFunction>({ + name: "getProviders", + }); + if (providers.kind === "Error") { + throw new Error(`getProviders failed: ${providers.error}`); + } + + const session = await pkg.callFunction>({ + name: "createSession", + arguments: [providers.value[0]!.id], + }); + if (session.kind === "Error") { + throw new Error(`createSession failed: ${session.error}`); + } + + console.log("Listening for interactions on", session.value.url); + + for await (const [event] of pkg.subscribeEvent("interaction:received")) { + const typed = event as InteractionReceivedEvent; + if (typed.sessionId !== session.value.id) continue; + for (const interaction of typed.interactions) { + console.log("Interaction received:", interaction); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +Then trigger the SSRF URL printed at the start, for example with `curl `. The script prints each interaction as it arrives and keeps running until you stop it with Ctrl+C: + +```txt +[caido] Loaded token from cache +Listening for interactions on https://oast.site/abcdef1234567890 +Interaction received: { + protocol: 'http', + remoteAddress: '203.0.113.42', + timestamp: '2026-05-08T19:00:00Z' +} +``` diff --git a/src/guides/client/spec_typing.md b/src/guides/client/spec_typing.md new file mode 100644 index 0000000..2411d57 --- /dev/null +++ b/src/guides/client/spec_typing.md @@ -0,0 +1,158 @@ +# Use a Plugin's NPM Spec Package + +Some plugins publish a small npm package containing the TypeScript types for their backend functions and events. When you install that spec package and pass it as a generic to the Client SDK, you get fully typed function calls, autocomplete on method names, typed event payloads, and zero hand-written type definitions. + +This is the recommended way to call a plugin from a script when a spec package is available. For plugins that do not publish a spec, fall back to the explicit forms shown in [Call a Plugin Function](./call_function.md) and [Receive Plugin Events](./receive_events.md). + +::: info +A runnable version of this guide lives in the SDK repository at [`examples/functionCallSpec`](https://github.com/caido/sdk-js/tree/main/packages/sdk-client/examples/functionCallSpec). Spec packages live under the [`@caido-community`](https://www.npmjs.com/org/caido-community) npm scope. Their source code is in the plugin's repository under [caido-community](https://github.com/caido-community), usually inside a `packages/shared` folder. +::: + +## Install the Spec Package + +To get typed access to a plugin, install its spec package. The convention is `@caido-community/`. For example, the spec for the `quickssrf` plugin is published as `@caido-community/quickssrf`: + +::: code-group +```bash [pnpm] +pnpm add @caido-community/quickssrf +``` + +```bash [npm] +npm install @caido-community/quickssrf +``` + +```bash [yarn] +yarn add @caido-community/quickssrf +``` +::: + +## Pass the Spec to the Client + +To enable typed access, import the `Spec` type from the package and pass it as a generic to `client.plugin.pluginPackage()` (or `client.plugin.install()`): + +```ts +import { Client } from "@caido/sdk-client"; +import type { Spec as QuickSSRFSpec } from "@caido-community/quickssrf"; + +const client = new Client({ url: "http://localhost:8080" }); +await client.connect(); + +const pkg = await client.plugin.pluginPackage("quickssrf"); +if (pkg === undefined) { + throw new Error("quickssrf is not installed"); +} +``` + +From this point, every function and event exposed by the plugin is typed on the `pkg` handle. + +## Call Typed Functions + +To call a function, access it directly on the `pkg` handle as if it were a local method. The SDK proxies the call to the backend and returns a properly typed promise: + +```ts +const providers = await pkg.getProviders(); +// providers is typed as Result + +if (providers.kind === "Error") { + throw new Error(providers.error); +} + +const session = await pkg.createSession(providers.value[0].id); +// session is typed as Result +``` + +A few things that change compared to the explicit `callFunction()` form: + +- **Autocomplete** lists every function declared in the spec (`getProviders`, `createSession`, `addProvider`, etc.) as you type `pkg.` +- **Argument types** are checked at compile time. Passing the wrong number or shape of arguments is a TypeScript error before you ever run the script. +- **Return types** flow through automatically. No need to specify a generic on each call. + +::: info +Under the hood, the typed methods still call the same backend endpoint as `callFunction()`. The spec adds type information at compile time without changing what happens at runtime. +::: + +## Receive Typed Events + +Event subscriptions also benefit from the spec. Event names are autocompleted from the spec's `events` map, and the destructured payload is fully typed: + +```ts +for await (const [event] of pkg.subscribeEvent("interaction:received")) { + // event is typed as { sessionId: string; interactions: Interaction[] } + if (event.sessionId !== session.value.id) continue; + for (const interaction of event.interactions) { + console.log(interaction); + } +} +``` + +For more on event subscriptions (including multi-backend disambiguation and stopping a subscription), see [Receive Plugin Events](./receive_events.md). The mechanics are identical; the spec only adds types. + +## When No Spec Package Exists + +Not every plugin publishes a spec package. When one is not available, use the explicit forms instead: + +- [Call a Plugin Function](./call_function.md) for `pkg.callFunction({ name, arguments })` +- [Receive Plugin Events](./receive_events.md) for `pkg.subscribeEvent("name")` with manually cast payloads + +You can still get the function and event names from the plugin's backend source code (look for `sdk.api.register(...)` and `sdk.api.send(...)` calls in its [caido-community](https://github.com/caido-community) repository). + +## Examples + +The script below uses the `@caido-community/quickssrf` spec to call functions and receive events with full type safety. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; +import type { Spec as QuickSSRFSpec } from "@caido-community/quickssrf"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + const pkg = await client.plugin.pluginPackage("quickssrf"); + if (pkg === undefined) { + throw new Error("quickssrf is not installed"); + } + + const providers = await pkg.getProviders(); + if (providers.kind === "Error") { + throw new Error(`getProviders failed: ${providers.error}`); + } + + const session = await pkg.createSession(providers.value[0]!.id); + if (session.kind === "Error") { + throw new Error(`createSession failed: ${session.error}`); + } + + console.log("Listening for interactions on", session.value.url); + + for await (const [event] of pkg.subscribeEvent("interaction:received")) { + if (event.sessionId !== session.value.id) continue; + for (const interaction of event.interactions) { + console.log("Interaction received:", interaction); + } + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +Then trigger the SSRF URL printed at the start (for example, `curl `) to see typed interactions arrive on the subscription. diff --git a/src/tutorials/client/scanner.md b/src/tutorials/client/scanner.md new file mode 100644 index 0000000..8b66a80 --- /dev/null +++ b/src/tutorials/client/scanner.md @@ -0,0 +1,378 @@ +# Using the Scanner API + +The goal of this tutorial is to drive the [Caido Scanner](https://github.com/caido-community/scanner) plugin from an external script using the [Client SDK](https://github.com/caido/sdk-js). By the end, you will be able to install Scanner against a Caido instance, run an active scan against a request already captured in your proxy history, and read the resulting findings, all without opening the Caido UI. + +This is useful for security automation (running scans on a schedule, scanning batches of requests collected by another tool), CI/CD pipelines (checking new endpoints against a baseline), and any workflow where opening the Caido UI is not an option. + +## 1. Prerequisites + +::: info Requirements +- [Node.js](https://nodejs.org/en/) 18 or higher +- A running Caido instance with an open project +- A [Personal Access Token](https://docs.caido.io/dashboard/concepts/pat.html) (PAT) for your account +- At least one request to your target host in the project's HTTP history. This tutorial uses `caido.local` as the example target, so send any request through your Caido proxy to that host before starting (or substitute your own host throughout). +::: + +## 2. Setting up the script + +### Initializing the project + +Create a working directory for the script and initialize it: + +```bash +mkdir caido-scanner-tutorial +cd caido-scanner-tutorial +pnpm init +``` + +Add `"type": "module"` to `package.json` so Node treats the `.ts` file as an ES module, which the `import` statements below require. + +### Installing dependencies + +Install the Client SDK and the Scanner spec package. The spec package is what makes the Scanner functions and events typed when you call them through the SDK: + +```bash +pnpm add @caido/sdk-client @caido-community/scanner +``` + +::: info +The [`@caido-community/scanner`](https://www.npmjs.com/package/@caido-community/scanner) package is the spec for the Scanner plugin. The Client SDK uses it to type the calls in this tutorial. See [Use a Plugin's NPM Spec Package](/guides/client/spec_typing.md) for the broader concept. +::: + +### Setting environment variables + +Export your PAT and (optionally) the instance URL: + +```bash +export CAIDO_PAT=caido_xxxxx +export CAIDO_INSTANCE_URL=http://localhost:8080 +``` + +::: warning +Never commit the PAT to source control. Treat it like a password and store it in your shell's secret manager or a `.env` file that is gitignored. +::: + +## 3. Connecting and installing Scanner + +Create `index.ts`. The first thing the script does is connect to the Caido instance using the PAT from the environment, then either look up or install the Scanner plugin: + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; +import type { Spec as ScannerSpec } from "@caido-community/scanner"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + console.log("Connected to Caido"); + + // Look up the installed plugin, install if missing + let pkg = await client.plugin.pluginPackage("scanner"); + if (pkg === undefined) { + console.log("Installing Scanner..."); + pkg = await client.plugin.install({ manifestId: "scanner" }); + } + console.log("Scanner ready"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Two things to note. The `ScannerSpec` generic on `pluginPackage` and `install` is what makes the rest of the script's calls typed: `pkg.startActiveScan(...)`, `pkg.getChecks()`, and so on, all autocomplete with the correct argument and return types. The lookup-then-install pattern is idiomatic: `pluginPackage()` returns `undefined` when the plugin is not present, and `install()` returns a fresh handle when it is invoked. + +For more on installing plugins from a script, see the [Install a Plugin](/guides/client/install_plugin.md) guide. + +## 4. Discovering available checks + +Before running a scan, you can list the checks Scanner ships with. This is useful for picking which checks to include or exclude from your scan, and for understanding what Scanner can detect: + +```ts +const checks = await pkg.getChecks(); +if (checks.kind === "Error") { + throw new Error(checks.error); +} + +console.log(`Scanner ships with ${checks.value.length} checks`); +for (const check of checks.value.slice(0, 5)) { + console.log(` ${check.id} [${check.type}] - ${check.name}`); +} +``` + +Each check exposes its `id`, `name`, `description`, `type` (`"passive"` or `"active"`), `tags`, and `severities`. You can filter for specific types or IDs by passing options: + +```ts +const activeOnly = await pkg.getChecks({ type: "active" }); +const specific = await pkg.getChecks({ include: ["reflected-xss", "sql-injection"] }); +``` + +::: info +Scanner functions return a `Result` envelope of the form `{ kind: "Ok"; value }` or `{ kind: "Error"; error }`. Always branch on `result.kind` before using the value. This is a Scanner convention; other plugins may use different shapes. +::: + +## 5. Finding a target request + +Active scans run against existing requests in the project's HTTP history. To get the request ID for a target, use [`client.request.list()`](/guides/client/extract_requests.md) with an [HTTPQL](https://docs.caido.io/reference/httpql.html) filter on the host: + +```ts +const page = await client.request + .list() + .filter('req.host.eq:"caido.local"') + .first(1); + +const target = page.edges[0]?.node.request; +if (target === undefined) { + throw new Error( + "No requests to caido.local found in this project. " + + "Send a request through the Caido proxy to that host first.", + ); +} + +console.log(`Target: ${target.method} ${target.host}${target.path} (id=${target.id})`); +``` + +The filter syntax is the same one you use in the Caido HTTP History UI. Adjust it for your own target host. + +## 6. Configuring and starting the scan + +The active scan is configured with a `ScanConfig` object. Each field controls a different aspect of the scan: + +```ts +const start = await pkg.startActiveScan({ + requestIDs: [target.id], + title: `Scan of ${target.host}${target.path}`, + scanConfig: { + aggressivity: "low", + scopeIDs: [], + concurrentChecks: 2, + concurrentRequests: 3, + concurrentTargets: 1, + requestsDelayMs: 0, + scanTimeout: 60000, + checkTimeout: 30000, + severities: ["info", "low", "medium", "high", "critical"], + }, +}); + +if (start.kind === "Error") { + throw new Error(start.error); +} + +const sessionId = start.value.id; +console.log(`Scan started: ${sessionId} (kind=${start.value.kind})`); +``` + +The fields: + +- `aggressivity`: `"low"`, `"medium"`, or `"high"`. Higher aggressivity sends more probe requests per check. +- `scopeIDs`: limit the scan to requests within specific Caido scopes. An empty array means no scope restriction. +- `concurrentChecks`, `concurrentRequests`, `concurrentTargets`: parallelism knobs that trade speed for load on the target. +- `requestsDelayMs`: delay between requests, useful for rate-limited targets. +- `scanTimeout` and `checkTimeout`: timeouts in milliseconds. +- `severities`: which severities to surface in the results. + +`startActiveScan` returns a `Session` in the `Pending` state. The scan starts asynchronously. + +## 7. Tracking progress + +The session transitions through states as the scan runs: `Pending → Running → Done` (or `Interrupted` / `Error`). The simplest way to track it is to poll `getScanSession` until the kind is no longer `Pending` or `Running`: + +```ts +let session = start.value; +while (session.kind === "Pending" || session.kind === "Running") { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const got = await pkg.getScanSession(sessionId); + if (got.kind === "Error") { + throw new Error(got.error); + } + session = got.value; + const done = session.kind === "Running" ? session.progress.checksHistory.length : 0; + const total = session.kind === "Running" ? session.progress.checksTotal : 0; + console.log(` status=${session.kind} progress=${done}/${total}`); +} + +console.log(`Scan finished with status: ${session.kind}`); +``` + +::: tip +For long-running scans, subscribe to the `session:progress` event instead of polling. See [Receive Plugin Events](/guides/client/receive_events.md). Polling is shown here because it keeps the example self-contained. +::: + +## 8. Reading the findings + +When the session reaches `Done`, the `progress.checksHistory` array contains one `CheckExecution` for every check that ran. Each execution carries the findings it produced: + +```ts +if (session.kind !== "Done") { + console.log("Scan did not complete successfully:", session.kind); + return; +} + +const findings = []; +for (const execution of session.progress.checksHistory) { + for (const finding of execution.findings) { + findings.push({ checkID: execution.checkID, finding }); + } +} + +console.log(`\nTotal findings: ${findings.length}`); +for (const { checkID, finding } of findings) { + console.log(` [${finding.severity}] ${finding.name} (${checkID})`); + console.log(` ${finding.description}`); +} +``` + +Each `Finding` has a `name`, `description`, `severity`, and a `correlation` block that pins the finding to a request and optionally a byte range within it. + +## 9. Cleaning up + +Scan sessions persist in the project until they are deleted. Remove the session at the end of the script to keep the project tidy: + +```ts +await pkg.deleteScanSession(sessionId); +console.log("Scan session deleted"); +``` + +## Examples + +The script below combines every step into a single file. It connects, ensures Scanner is installed, finds the first `caido.local` request, runs an active scan, prints the findings, and cleans up. + +### index.ts + +```ts +import { Client } from "@caido/sdk-client"; +import type { Spec as ScannerSpec } from "@caido-community/scanner"; + +async function main() { + const client = new Client({ + url: process.env["CAIDO_INSTANCE_URL"] ?? "http://localhost:8080", + auth: { + pat: process.env["CAIDO_PAT"]!, + cache: { file: ".caido-token.json" }, + }, + }); + + await client.connect(); + + // 1. Look up or install Scanner + let pkg = await client.plugin.pluginPackage("scanner"); + if (pkg === undefined) { + pkg = await client.plugin.install({ manifestId: "scanner" }); + } + + // 2. Find a target request + const page = await client.request + .list() + .filter('req.host.eq:"caido.local"') + .first(1); + const target = page.edges[0]?.node.request; + if (target === undefined) { + throw new Error("Send a request to caido.local through the Caido proxy first"); + } + + // 3. Start the scan + const start = await pkg.startActiveScan({ + requestIDs: [target.id], + title: `Scan of ${target.host}${target.path}`, + scanConfig: { + aggressivity: "low", + scopeIDs: [], + concurrentChecks: 2, + concurrentRequests: 3, + concurrentTargets: 1, + requestsDelayMs: 0, + scanTimeout: 60000, + checkTimeout: 30000, + severities: ["info", "low", "medium", "high", "critical"], + }, + }); + if (start.kind === "Error") { + throw new Error(start.error); + } + const sessionId = start.value.id; + console.log(`Scan started: ${sessionId}`); + + // 4. Wait for the scan to finish + let session = start.value; + while (session.kind === "Pending" || session.kind === "Running") { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const got = await pkg.getScanSession(sessionId); + if (got.kind === "Error") { + throw new Error(got.error); + } + session = got.value; + } + + // 5. Print findings + if (session.kind === "Done") { + const findings = session.progress.checksHistory.flatMap((execution) => + execution.findings.map((finding) => ({ + checkID: execution.checkID, + finding, + })), + ); + console.log(`Scan finished with ${findings.length} finding(s):`); + for (const { checkID, finding } of findings) { + console.log(` [${finding.severity}] ${finding.name} (${checkID})`); + } + } else { + console.log(`Scan ended with status: ${session.kind}`); + } + + // 6. Cleanup + await pkg.deleteScanSession(sessionId); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +Run it with: + +```bash +export CAIDO_PAT=caido_xxxxx +npx tsx ./index.ts +``` + +A successful run against a `caido.local` target prints something like: + +```txt +[caido] Attempting to load cached token +[caido] Loaded token from cache +Scan started: ascan-xxxxxxxxxxx +Scan finished with 1 finding(s): + [medium] Missing X-Frame-Options Header (anti-clickjacking) +``` + +Exact findings will vary based on what the target's response contains. + +## Script Breakdown + +The script performs the following operations: + +1. **Connect**: authenticates against the Caido instance using a PAT and caches the resulting tokens on disk so subsequent runs skip the auth flow. See [Base Setup](/guides/client/base_setup.md) for details. +2. **Plugin handle**: looks up Scanner by manifest ID, installing it via the SDK if it is not yet present. See [Install a Plugin](/guides/client/install_plugin.md). +3. **Target lookup**: queries the HTTP history with an HTTPQL filter to find a request to scan. See [Extract Requests](/guides/client/extract_requests.md) and the [HTTPQL reference](https://docs.caido.io/reference/httpql.html). +4. **Active scan**: builds a `ScanConfig`, calls `startActiveScan`, and polls the resulting `Session` until it transitions out of `Running`. +5. **Findings**: flattens `progress.checksHistory[*].findings` into a single list and prints each by severity and check ID. +6. **Cleanup**: deletes the session so it does not accumulate in the project. + +## Next Steps + +You can extend this tutorial in several directions: + +- Subscribe to the `session:progress` event instead of polling, as described in [Receive Plugin Events](/guides/client/receive_events.md). +- Scan multiple requests in one session by passing more IDs to `requestIDs`. +- Persist findings to Caido as native [findings](/guides/client/manage_findings.md) tied to the original request.