Instruction file imported from caido-community/scanner (
.cursor/rules/checks.mdc). Copyright stays with the author.
description: Guidelines for implementing checks using the Engine step model globs: /checks//*.ts alwaysApply: false
Checks Writing Guidelines
Before implementing a new check, always review existing checks in the codebase to understand established patterns and solutions. Study how similar vulnerability types are detected, what utilities are available, and how state management is handled across steps.
Research First
- Browse
packages/backend/src/checks/to find checks that target similar vulnerabilities - Examine how existing checks use the engine step model
- Look for reusable utilities and helper functions that can be leveraged
- Study the test patterns in
*.spec.tsfiles to understand testing approaches - Review how findings are structured and what correlation data is included
Follow Established Patterns
- Use existing utilities for common tasks (payload generation, response redirection analysis, etc.)
- Match the naming conventions and structure of similar checks
- Reuse proven techniques for state transitions and error handling
- Follow the same approach for metadata definition and aggressivity settings
Checks Structure
Design checks around short, sequential steps. Each step should either finish with done(...) or continue to another step with continueWith({ state, nextStep: '...' }).
Define a Check
Use defineCheck to declare metadata, lifecycle hooks, and initial state. Keep dedupeKey small and deterministic, and make when fast.
import { continueWith, defineCheck, done, Severity } from "engine";
export default defineCheck<{
exampleState?: string;
}>(({ step }) => {
step("exampleStep", async (state, context) => {
if (context.target.response !== undefined && context.target.response.getCode() === 200) {
const body = context.target.response.getBody()?.toText();
return continueWith({ state: { exampleState: body }, nextStep: "report" });
}
return done({ state });
});
step("report", async (state, context) => {
const finding = {
name: "HTTP 200 OK",
description: `Target responded with 200 OK. Response body: ${state.exampleState}`,
severity: Severity.INFO,
correlation: {
requestID: context.target.request.getId(),
locations: [],
},
};
return done({ state, findings: [finding] });
});
return {
metadata: {
id: "example-check",
name: "Example Check",
description: "This is an example check",
type: "passive",
tags: ["example"],
severities: [Severity.INFO],
aggressivity: { minRequests: 0, maxRequests: 0 },
},
initState: () => ({ exampleState: undefined }),
dedupeKey: (context) =>
context.request.getHost() + context.request.getPort() + context.request.getPath(),
when: (context) => context.response !== undefined,
};
});
Check Metadata
The CheckMetadata type is a crucial part of defining a check, as it contains all the static information about the check. Here's a breakdown of its components:
- id: A unique identifier for the check. This is required and ensures that each check can be distinctly referenced.
- name: A human-readable name for the check, which is displayed in the UI.
- description: A detailed explanation of what the check does and the vulnerabilities it detects. This helps users understand the check's functionality and scope.
- tags: An array of tags used for categorization and filtering. Tags help in organizing checks and making them easily searchable.
- aggressivity: This defines the request limits for the check. It uses the
CheckAggressivitytype, which specifiesminRequestsandmaxRequests. If the request count is dynamic, useInfinityformaxRequests. - type: Indicates whether the check is
passiveoractive. This helps in determining how the check interacts with the target. Usepassiveif the scan is silent enough to run in the background without causing noise, andactiveif the scan requires more noticeable interaction with the target. - severities: An array of possible severity levels that the check can report. This is used for filtering, and the engine will throw an error if a finding is returned with a severity not included in this array.
- dependsOn (optional): An array of check IDs that must run before this check. This ensures that dependencies are resolved before execution.
- minAggressivity (optional): The minimum scan aggressivity level required for this check to run. This allows checks to be gated by the scan's aggressivity level.
- skipIfFoundBy (optional): An array of check IDs. If any of these checks have found findings during the scan, this check will be skipped.
export type CheckAggressivity = {
minRequests: number;
maxRequests: number | "Infinity";
};
export type CheckType = "passive" | "active";
export type CheckMetadata = {
/** Unique identifier for the check */
id: string;
/** Human-readable name displayed in the UI */
name: string;
/** Detailed description of what the check does and what vulnerabilities it detects */
description: string;
/** Array of tags used for categorization and filtering */
tags: string[];
/** Defines the request limits for this check. Please use Infinity if it's dynamic. */
aggressivity: CheckAggressivity;
/** Whether this is a passive or active check */
type: CheckType;
/**
* Array of possible severity levels this check can report.
* This is used for filtering.
* Engine will throw an error if you return a finding with a severity that is not in this array.
**/
severities: Severity[];
/** Optional: Array of check IDs that must run before this check */
dependsOn?: string[];
/** Optional: Minimum scan aggressivity level required for this check to run */
minAggressivity?: ScanAggressivity;
/** Optional: array of check IDs - if any of these check IDs have found any findings during the scan, skip this check */
skipIfFoundBy?: string[];
};
Steps and Looping
Keep ticks short. If you need to iterate (e.g., parameters, paths), reuse the same step and shrink the remaining work in state.
step("testParam", async (state, context) => {
const [currentParam, ...remainingParams] = state.urlParams;
if (currentParam === undefined) return done({ state });
return continueWith({
nextStep: "testParam",
state: { ...state, urlParams: remainingParams },
});
});
State and Context
- State: Pass data between steps. Always provide
initState. - Context: Access
sdk,target(request/response),runtime, andconfig.
Runtime SDK
Utilities available during a scan:
context.runtime.html.parse(requestID);
context.runtime.dependencies.get("<check-id>");
Outputs and Dependencies
If needed, produce JSON-serializable outputs and consume them from dependent checks. The engine orders checks so dependencies complete first.
export const helloWorldProvider = defineCheck(({ step }) => {
return {
metadata: {
id: "example-output",
name: "Example Output Provider",
description: "Produces a simple string output",
type: "passive",
tags: ["example"],
severities: [Severity.INFO],
aggressivity: { minRequests: 0, maxRequests: 0 },
},
output: ({ state, context }) => {
return "Hello world!";
},
initState: () => ({}),
dedupeKey: (context) =>
context.request.getHost() + context.request.getPort() + context.request.getPath(),
when: (context) => context.response !== undefined && context.response.getCode() === 200,
};
});
export const helloWorldConsumer = defineCheck(({ step }) => {
step("example-step", async (state, context) => {
const dependency = context.runtime.dependencies.get("example-output") as string;
return done({ state });
});
return {
metadata: {
id: "example-consumer",
name: "Example Consumer",
description: "Consumes output from example-output",
type: "passive",
tags: ["example"],
severities: [Severity.INFO],
aggressivity: { minRequests: 0, maxRequests: 0 },
dependsOn: ["example-output"],
},
initState: () => ({}),
dedupeKey: (context) =>
context.request.getHost() + context.request.getPort() + context.request.getPath(),
when: (context) => context.response !== undefined && context.response.getCode() === 200,
};
});
Utilities
The engine exposes helpers:
- Redirection detection:
findRedirection(requestID, context) - URL bypass payloads:
createUrlBypassGenerator({ expectedHost, attackerHost, protocol })
Example open-redirect probing within a single step:
step("testOpenRedirect", async (state, context) => {
const currentParam = "redirect";
const generator = createUrlBypassGenerator({
expectedHost: context.target.request.getHost(),
attackerHost: "attacker.com",
protocol: "https:",
});
for (const payloadRecipe of generator) {
const payload = payloadRecipe.generate();
const params = new URLSearchParams(context.target.request.getQuery());
params.set(currentParam, payload.value);
const spec = context.target.request.toSpec();
spec.setQuery(params.toString());
const { request } = await context.sdk.requests.send(spec);
const redirectInfo = await findRedirection(request.getId(), context);
if (redirectInfo.hasRedirection) {
const redirectUrl = new URL(redirectInfo.location, context.target.request.getUrl());
if (payload.validatesWith(redirectUrl)) {
return done({
state,
findings: [
{
name: `Open Redirect in parameter '${currentParam}'`,
description: "Target redirected to an external domain.",
severity: Severity.MEDIUM,
correlation: { requestID: request.getId(), locations: [] },
},
],
});
}
}
}
return done({ state });
});
Register the Check
- Add the check in
packages/backend/src/checks/index.ts(import,Checksentry, append tochecksarray).
Add to Presets
- Update
packages/backend/src/stores/presets/*.tsto include the check in default presets.
Light Preset:
- enable in active part only if it sends a few basic requests and doesn't do resource-heavy things
- enable in passive part only if it sends no requests and doesn't do resource-heavy things
Balanced Preset:
- usually we want to enable it in the active part, unless it's very resource-heavy
- enable in passive part only if it sends a few basic requests and doesn't do resource-heavy things
Bug Bounty Preset:
- usually we want to enable it in the active part, unless it's very resource-heavy
- enable in passive part ONLY if finding is valuable for a Bug Bounty hunter. Examples: CSP/cookie findings would be disabled, but Open Redirect or XSS are juicy and bug bounty hunters want to test those.
Heavy Preset:
- no need to modify anything here, by default all checks are enabled both in passive and active
Severity Assignment
Assign severity based on real-world impact. Avoid inflating low-risk issues.
export const Severity = {
INFO: "info",
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
CRITICAL: "critical",
} as const;
Testing
- Write unit tests in
index.spec.tsnext to the check. - Use
runCheckfrom the engine to test checks:
import { createMockRequest, createMockResponse, runCheck } from "engine";
import { describe, expect, it } from "vitest";
import myCheck from "./index";
describe("My Check", () => {
it("should detect vulnerability when check runs", async () => {
const request = createMockRequest({
id: "1",
host: "example.com",
method: "GET",
path: "/test",
});
const response = createMockResponse({
id: "1",
code: 200,
headers: { "content-type": ["text/html"] },
body: "Response body",
});
const executionHistory = await runCheck(myCheck, [
{ request, response },
]);
expect(executionHistory).toMatchObject([
{
checkId: "my-check",
targetRequestId: "1",
status: "completed",
steps: [
{
stepName: "myStep",
findings: [
{
name: "Expected Finding",
severity: "low",
},
],
result: "done",
},
],
},
]);
});
it("should not run when conditions are not met", async () => {
const request = createMockRequest({
id: "2",
host: "example.com",
method: "GET",
path: "/test",
});
const response = createMockResponse({
id: "2",
code: 404, // This might not meet the check's 'when' condition
headers: {},
body: "Not found",
});
const executionHistory = await runCheck(myCheck, [
{ request, response },
]);
// When check doesn't run, execution history is empty
expect(executionHistory).toMatchObject([]);
});
it("should find no issues when no vulnerability exists", async () => {
const request = createMockRequest({
id: "3",
host: "example.com",
method: "GET",
path: "/test",
});
const response = createMockResponse({
id: "3",
code: 200, // Meets the check's 'when' condition
headers: { "content-type": ["text/html"] },
body: "Safe response body",
});
const executionHistory = await runCheck(myCheck, [
{ request, response },
]);
// When check runs but finds no issues, execution history is non-empty with empty findings
expect(executionHistory).toMatchObject([
{
checkId: "my-check",
targetRequestId: "3",
status: "completed",
steps: [
{
stepName: "myStep",
findings: [], // Empty findings array
result: "done",
},
],
},
]);
});
});
Important: runCheck returns an ExecutionHistory which is an array of CheckExecutionRecord objects. Each record contains:
checkId: The check IDtargetRequestId: The request IDstatus: "completed" or "failed"steps: Array of step execution records with findingsfinalOutput: The check output (if completed)
Critical: There are two distinct scenarios when a check doesn't produce findings:
- Check doesn't run at all (when
whencondition returns false) → Execution history is empty ([]) - Check runs but finds no issues (when
whencondition returns true but no findings) → Execution history is non-empty with empty findings array
Use descriptive test names to distinguish these scenarios:
-
"should not run on X"for scenario 1 -
"should find no issues on X"for scenario 2 -
Validate locally from the repo root:
pnpm lint
pnpm typecheck
pnpm test
pnpm build