name: write-check-v2 description: Write security checks using the CheckDefinitionV2 system. Use when creating new checks, converting V1 checks to V2, or when the user asks to implement a vulnerability scanner check. Covers defineCheckV2, defineRegexCheck, CheckContext API, parameter injection, testing with testCheck/mockTarget, and registration.
Writing Checks with CheckDefinitionV2
Terminology
"Check" refers exclusively to the scan rule itself — the defineCheckV2 definition with its metadata and execute function. Do not use "check" to name payloads, probes, test cases, or any internal data structures within a check. Use specific names instead: payload, probe, pattern, testCase, etc.
Bad: TransformCheck, allChecks, currentCheckIndex
Good: TransformProbe, allProbes, currentProbeIndex
File Structure
A check doesn't have to be a single file. If the logic is complex, split it into modules within the check's own folder:
checks/my-check/
├── index.ts # defineCheckV2 definition + execute function
├── index.spec.ts # tests
├── probes.ts # payload/probe definitions
└── utils.ts # helper functions specific to this check
Keep index.ts focused on the check definition and orchestration. Move domain logic (probe generation, response analysis, etc.) into separate files.
Overview
V2 checks are async functions that receive a CheckContext with utilities for sending requests, extracting parameters, emitting findings, and controlling iteration. No step-based state machine, no manual index tracking.
Imports: Everything comes from "engine".
import { defineCheckV2, defineRegexCheck, Severity, ScanAggressivity, Result, keyStrategy } from "engine";
defineCheckV2
Use for all checks (passive and active). Returns a Check compatible with the existing engine.
export default defineCheckV2({
id: "my-check",
name: "My Check",
description: "Detects X vulnerability",
type: "passive", // or "active"
tags: ["tag1", "tag2"],
severities: [Severity.MEDIUM],
aggressivity: { minRequests: 0, maxRequests: 0 }, // passive: 0/0, active: set real bounds
// Optional fields
dedupeKey: keyStrategy().withHost().withPort().withPath().build(),
when: (target) => target.response !== undefined,
minAggressivity: ScanAggressivity.LOW,
dependsOn: ["other-check-id"],
skipIfFoundBy: ["other-check-id"],
async execute(ctx) {
// Check logic here
// Optionally return output for dependent checks:
// return { someData: "value" };
},
});
CheckContext API
The ctx object passed to execute provides:
ctx.target (TargetAccessor)
Wraps the original ScanTarget with convenience methods:
| Method | Returns | Description |
|---|---|---|
ctx.target.request |
Request |
The original request object |
ctx.target.response |
Response | undefined |
The original response object |
ctx.target.hasParameters() |
boolean |
Has query params or body |
ctx.target.hasBody() |
boolean |
Request has a body |
ctx.target.isMethod("POST", "PUT") |
boolean |
Check request method (case-insensitive) |
ctx.target.header("content-type") |
string | undefined |
Get first response header value (case-insensitive) |
ctx.target.bodyText() |
string | undefined |
Get response body as text |
ctx.parameters(opts?)
Extracts parameters from query string and request body (form/JSON). Handles content-type detection automatically.
const params = ctx.parameters();
const reflected = ctx.parameters({ reflected: true }); // Only params whose value appears in response body
Each Parameter has:
name: stringvalue: stringsource: "query" | "body" | "header"inject(newValue: string): RequestSpec— creates a new RequestSpec with that parameter replaced
ctx.send(spec)
Sends an HTTP request. Returns Result<SendOk, SendErr>. Handles interruption automatically (throws on interrupt, no manual check needed).
const result = await ctx.send(spec);
if (Result.isErr(result)) return; // Request failed
const { request, response } = result.value;
ctx.finding(input)
Emits a finding. Automatically uses the target request if request is not provided. Optional impact, recommendation, and artifacts fields are appended as markdown sections.
ctx.finding({
name: "Vulnerability Found",
severity: Severity.HIGH,
description: "The parameter is vulnerable to X.",
impact: "An attacker could...", // optional, appended as ## Impact
recommendation: "Sanitize input...", // optional, appended as ## Recommendation
artifacts: { title: "Payloads", items: ["payload1", "payload2"] }, // optional
request: sentRequest, // optional, defaults to ctx.target.request
});
ctx.limit(items, limits)
Slices an array based on scan aggressivity. Use for payload lists.
const payloads = ctx.limit(ALL_PAYLOADS, { low: 3, medium: 7, high: 13 });
ctx.interrupted
Boolean flag. Check in long loops to bail early. Note: ctx.send() already handles interruption by throwing, so you only need this for CPU-bound loops that don't send requests.
ctx.sdk, ctx.runtime, ctx.config
Full access to the Caido SDK, runtime utilities (HTML parsing, dependencies), and scan configuration. Same as V1's RuntimeContext.
Result Type
ctx.send() returns Result<SendOk, SendErr>. Use the helpers:
Result.isOk(result) // Type guard for { kind: "Ok", value: SendOk }
Result.isErr(result) // Type guard for { kind: "Error", error: SendErr }
defineRegexCheck
Shortcut for passive checks that match regex patterns against response bodies. No execute needed.
export default defineRegexCheck({
id: "my-regex-check",
name: "Pattern Disclosure",
description: "Detects sensitive patterns in responses",
tags: ["disclosure"],
severity: Severity.LOW,
patterns: [/pattern1/i, /pattern2/i],
dedupeKey: keyStrategy().withHost().withPort().withPath().build(),
when: (target) => target.response !== undefined,
toFinding: (matches) => ({
name: "Sensitive Pattern Found",
description: `Found: ${matches.join(", ")}`,
}),
});
Patterns
Passive check (inspect response only)
export default defineCheckV2({
id: "missing-header",
name: "Missing Security Header",
description: "Detects missing X-Frame-Options header",
type: "passive",
tags: ["security-headers"],
severities: [Severity.INFO],
aggressivity: { minRequests: 0, maxRequests: 0 },
dedupeKey: keyStrategy().withHost().withPort().withPath().build(),
when: (target) => target.response !== undefined,
async execute(ctx) {
const header = ctx.target.header("x-frame-options");
if (header !== undefined) return;
ctx.finding({
name: "Missing X-Frame-Options",
severity: Severity.INFO,
description: "Response is missing the X-Frame-Options header.",
recommendation: "Add X-Frame-Options header with DENY or SAMEORIGIN.",
});
},
});
Active check (send requests with parameter injection)
export default defineCheckV2({
id: "my-injection",
name: "Injection Check",
description: "Tests parameters for injection",
type: "active",
tags: ["injection"],
severities: [Severity.HIGH],
aggressivity: { minRequests: 1, maxRequests: "Infinity" },
dedupeKey: keyStrategy().withMethod().withHost().withPort().withPath().withQueryKeys().build(),
when: (target) => target.response !== undefined,
async execute(ctx) {
if (!ctx.target.hasParameters()) return;
const params = ctx.parameters();
const payloads = ctx.limit(ALL_PAYLOADS, { low: 3, medium: 7, high: 13 });
for (const param of params) {
for (const payload of payloads) {
const spec = param.inject(param.value + payload.value);
const result = await ctx.send(spec);
if (Result.isErr(result)) continue;
const { request, response } = result.value;
const body = response.getBody()?.toText();
if (body !== undefined && payload.pattern.test(body)) {
ctx.finding({
name: `Injection in '${param.name}'`,
severity: Severity.HIGH,
description: `Parameter \`${param.name}\` is vulnerable.`,
request,
});
return;
}
}
}
},
});
Active check (send custom requests, not parameter-based)
export default defineCheckV2({
id: "endpoint-probe",
name: "Endpoint Probe",
description: "Probes for sensitive endpoints",
type: "active",
tags: ["discovery"],
severities: [Severity.MEDIUM],
aggressivity: { minRequests: 1, maxRequests: 5 },
dedupeKey: keyStrategy().withHost().withPort().withBasePath().build(),
async execute(ctx) {
const paths = ctx.limit(["/admin", "/.env", "/debug"], { low: 1, medium: 2, high: 3 });
for (const path of paths) {
const spec = ctx.target.request.toSpec();
spec.setPath(path);
const result = await ctx.send(spec);
if (Result.isErr(result)) continue;
if (result.value.response.getCode() === 200) {
ctx.finding({
name: `Sensitive endpoint: ${path}`,
severity: Severity.MEDIUM,
description: `Endpoint \`${path}\` returned 200 OK.`,
request: result.value.request,
});
}
}
},
});
Testing
Use testCheck, testChecks, and mockTarget from "engine". Tests use vitest.
import { mockTarget, testCheck, Severity } from "engine";
import { describe, expect, it } from "vitest";
import myCheck from "./index";
describe("My Check", () => {
it("should detect vulnerability", async () => {
const target = mockTarget({
request: { id: "1", host: "example.com", method: "GET", path: "/page" },
response: { id: "1", code: 200, headers: {}, body: "vulnerable content" },
});
const { findings } = await testCheck(myCheck, target);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
name: "Expected Finding",
severity: "medium",
});
});
it("should not produce findings for safe response", async () => {
const target = mockTarget({
request: { id: "2", host: "example.com", method: "GET", path: "/safe" },
response: { id: "2", code: 200, headers: {}, body: "safe content" },
});
const { findings } = await testCheck(myCheck, target);
expect(findings).toHaveLength(0);
});
});
Testing active checks (with sendHandler)
it("should detect injection via sent request", async () => {
const target = mockTarget({
request: { id: "1", host: "example.com", method: "POST", path: "/api", body: "user=admin" },
response: { id: "1", code: 200, headers: { "Content-Type": ["application/x-www-form-urlencoded"] } },
});
const { findings } = await testCheck(myCheck, target, {
sendHandler: async (spec) => {
const body = spec.getBody()?.toText() ?? "";
return {
request: mockTarget({ request: { id: "sent-1", host: "example.com" } }).request,
response: mockTarget({
request: { id: "sent-1", host: "example.com" },
response: { id: "sent-1", code: 200, body: body.includes("payload") ? "vulnerable" : "safe" },
}).response!,
};
},
});
expect(findings).toHaveLength(1);
});
mockTarget options
mockTarget({
request: {
id: string; // required
host: string; // required
method?: string; // default: "GET"
path?: string; // default: "/"
query?: string; // default: ""
headers?: Record<string, string[]>;
body?: string;
port?: number;
tls?: boolean;
},
response?: { // omit for no response
id: string; // required
code: number; // required
headers?: Record<string, string[]>;
body?: string;
},
});
Registration
After creating the check file at packages/backend/src/checks/<check-name>/index.ts:
- Import and add to
packages/backend/src/checks/index.ts:
import myCheckScan from "./my-check";
export const Checks = {
// ... existing
MY_CHECK: "my-check",
};
export const checks = [
// ... existing
myCheckScan,
];
- Add to presets in
packages/backend/src/stores/presets/:- Light: passive only if zero requests; active only if very few requests
- Balanced: usually enable in active; passive only if lightweight
- Bug Bounty: active usually yes; passive only if high-value finding
- Heavy: all checks enabled by default, no changes needed
keyStrategy
Builder for deduplication keys. Chain methods and call .build():
keyStrategy().withHost().withPort().withPath().build()
keyStrategy().withMethod().withHost().withPort().withPath().withQueryKeys().build()
Available: withHost(), withPort(), withPath(), withBasePath(), withMethod(), withQuery(), withQueryKeys().
Checklist
-
idis unique kebab-case -
typeis"passive"(no requests sent) or"active"(sends requests) -
severitiesarray matches whatctx.finding()actually emits -
aggressivityis{ minRequests: 0, maxRequests: 0 }for passive -
dedupeKeyset to avoid duplicate runs on same target -
whenfilters out irrelevant targets early - Active checks use
ctx.limit()for payload lists -
Result.isErr()checked after everyctx.send() - Tests cover: finding produced, no finding on safe input,
whenfiltering - Registered in
checks/index.tsand added to presets