# ExploitSpec format — version 1 This document describes the YAML format implemented by the current ExploitSpec runner. It is a reference for existing behavior, not a promise that unlisted syntax is supported. An ExploitSpec document represents one executable security invariant as an ordered HTTP workflow. Each step runs as a named actor, evaluates assertions, and can capture values for later steps. ## File conventions and parsing Recognized file suffixes are: - `.exploit.yaml` - `.exploit.yml` - `.exploitspec.yaml` - `.exploitspec.yml` When a path is a directory, ExploitSpec searches it recursively. Hidden directories, `vendor`, and `node_modules` are skipped. Discovery is sorted, deduplicated, and limited to 500 specs per invocation. An explicitly named file does not need a recognized suffix. Each file: - is limited to 1 MiB; - must contain exactly one YAML document; - is decoded strictly, so unknown mapping fields are rejected; - must pass semantic validation before any request is sent. `exploitspec validate PATH...` parses and validates only. It does not interpolate runtime variables and does not make network requests. ## Complete shape ```yaml version: "1" id: SEC-001 name: Cross-tenant object access is denied description: Optional human context target: base_url: "${EXPLOITSPEC_BASE_URL:-http://127.0.0.1:8080}" allowed_hosts: - staging.example.com defaults: timeout: 5s max_body_bytes: 1048576 headers: Accept: application/json variables: api_prefix: /api object_path: "{{ api_prefix }}/objects" actors: owner: headers: Authorization: "Bearer ${OWNER_TOKEN}" attacker: headers: Authorization: "Bearer ${ATTACKER_TOKEN}" steps: - name: Owner creates an object actor: owner request: method: POST path: "{{ object_path }}" json: label: security-regression-canary expect: status: 201 headers: exists: [Content-Type] capture: object_id: json_path: $.id - name: Another tenant cannot read it actor: attacker request: method: GET path: "{{ object_path }}/{{ object_id }}" expect: status: [403, 404] body: not_contains: - "{{ object_id }}" json: not_exists: - $.owner_id ``` ## Top-level fields | Field | Type | Required | Meaning | | --- | --- | :---: | --- | | `version` | string | yes | Must be exactly `"1"` | | `id` | string | yes | Stable identifier for reports | | `name` | string | yes | Human-readable invariant name | | `description` | string | no | Human context; not interpreted | | `target` | object | at run time | Base target and remote-host policy | | `defaults` | object | no | Request timeout, response limit, and headers | | `variables` | map of strings | no | Values resolved before the first request | | `actors` | map | yes | At least one named HTTP identity/session | | `steps` | list | yes | At least one ordered request and expectation | `id` must contain 1–128 letters, digits, dots, underscores, or dashes and must begin with a letter or digit. Actor, variable, and capture names must match: ```text [A-Za-z_][A-Za-z0-9_]{0,63} ``` Step names must be non-empty and unique within the document. ## Target ```yaml target: base_url: https://staging.example.com allowed_hosts: - staging.example.com ``` ### `base_url` `target.base_url` must be an absolute `http` or `https` URL without embedded credentials or a fragment. It may be omitted from the file only when `run --base-url` or a calibration URL supplies it at execution time. Templated expressions in `base_url` are resolved at runtime. Validation does not resolve them, so a templated value can pass file validation and still be rejected during execution if it resolves to an invalid URL. ### `allowed_hosts` Every non-loopback target and redirect hostname must be explicitly listed in `target.allowed_hosts` or added with the repeatable CLI flag `--allow-host`. Entries are hostnames or IP addresses, without a path. Matching is case-insensitive and ignores a supplied port. `localhost`, subdomains ending in `.localhost`, and loopback IP addresses are accepted automatically. The exact metadata IPs `169.254.169.254` and `fd00:ec2::254` are always rejected, including canonical IPv4-mapped IPv6 forms. IPv6 zone identifiers are rejected. The policy compares hostnames. It does not resolve names into an IP policy and does not protect against DNS rebinding. See [Safety model](#safety-model). ## Defaults ```yaml defaults: timeout: 5s max_body_bytes: 1048576 headers: Accept: application/json ``` | Field | Default | YAML constraint | Runtime effect | | --- | ---: | --- | --- | | `timeout` | `10s` | positive Go duration, at most `5m` | Whole-request timeout and response-header timeout | | `max_body_bytes` | `1048576` | `0` or 1–16777216 | Maximum response body read into memory | | `headers` | none | valid HTTP names and newline-free values | Applied to every request | A positive `run --timeout` or `run --max-body-bytes` overrides the document value. CLI overrides use the same maximums shown above. Header precedence, from lowest to highest, is: 1. `defaults.headers`; 2. the selected actor's `headers`; 3. the current request's `headers`. JSON request bodies receive `Content-Type: application/json` before these header layers are applied, so an explicit header can replace it. Configured header names must be valid HTTP field names. Values cannot contain carriage returns or line feeds, including after interpolation. `Host`, `Content-Length`, `Transfer-Encoding`, `Connection`, and `Proxy-Authorization` are controlled by the HTTP transport and cannot be set in defaults, actors, or requests. ## Variables and interpolation ExploitSpec has two interpolation forms. ### Environment variables ```yaml Authorization: "Bearer ${OWNER_TOKEN}" base_url: "${EXPLOITSPEC_BASE_URL:-http://127.0.0.1:8080}" ``` - `${NAME}` requires `NAME` to exist in the process environment. - `${NAME:-fallback}` uses `fallback` only when `NAME` is absent. - An environment variable that exists but is empty is considered present. ExploitSpec does not read `.env` files. Export values through the shell or a secret manager before running the CLI. ### Spec and captured variables ```yaml variables: api_prefix: /api object_path: "{{ api_prefix }}/objects" # later, after object_id has been captured path: "{{ object_path }}/{{ object_id }}" ``` `{{ name }}` resolves a document variable or a value captured by an earlier successful step. Whitespace inside the braces is optional. Document variables may refer to one another; cycles and unresolved references are rejected before the first request. A later capture with the same name replaces the previous value. Interpolation is performed in: - document variable values; - the selected base URL; - request paths, raw bodies, JSON string values, JSON object keys, and headers; - body assertion strings and regular expressions; - JSON equality values; - expected response-header values. It is not performed in identifiers, names, descriptions, actor names, HTTP methods, allowed-host entries, JSON paths, capture definitions, or status codes. Interpolation is textual. A captured value inserted into a URL path is not automatically percent-encoded. ## Actors and sessions ```yaml actors: owner: headers: Authorization: "Bearer ${OWNER_TOKEN}" attacker: headers: Authorization: "Bearer ${ATTACKER_TOKEN}" ``` Every step must reference an existing actor. Each actor receives its own cookie jar, retained across that actor's steps and isolated from the other actors. Actors can have empty headers when authentication is established by a previous request or is not required. ## Steps Steps execute in list order. The runner stops the current spec at the first failed assertion or execution error. It does not execute later steps in that spec. When multiple specs were discovered, the remaining specs are still run. ```yaml steps: - name: Fetch the profile actor: user request: method: GET path: /api/profile?view=compact expect: status: 200 ``` Each step requires: - a unique, non-empty `name`; - an existing `actor`; - a `request` with `method` and `path`; - an `expect` object containing at least one assertion. `capture` is optional and runs only after every assertion in the step passes. ## Requests ```yaml request: method: POST path: /api/objects headers: X-Test-Run: exploitspec json: label: canary ``` | Field | Type | Required | Meaning | | --- | --- | :---: | --- | | `method` | string | yes | HTTP method; normalized to uppercase | | `path` | string | yes | Relative URL reference, optionally with a query | | `headers` | map of strings | no | Per-request header overrides | | `body` | string | no | Raw request body | | `json` | any YAML/JSON value | no | Encoded as JSON after interpolation | `body` and `json` are mutually exclusive. The method must begin with an uppercase letter after normalization and may then contain uppercase letters, digits, underscores, or dashes. The path must remain relative and fragment-free before and after interpolation. ExploitSpec resolves it against the base URL and rejects any result that changes the base URL's scheme or host. Fragments are rejected because user agents do not send them in HTTP requests. This prevents a spec path from selecting another target; redirects are validated separately. ExploitSpec sends the method exactly as specified after uppercase normalization. It does not distinguish read-only methods from state-changing methods. ## Expectations At least one assertion is required. When multiple assertions are present, every one must pass. ### Status Accept one status: ```yaml expect: status: 403 ``` Or any status in a list: ```yaml expect: status: [403, 404] ``` Valid status values are 100–599. ### Body ```yaml expect: body: contains: - access denied not_contains: - "{{ secret_marker }}" matches: - '(?i)forbidden|denied' not_matches: - '"is_admin"\s*:\s*true' ``` `contains` and `not_contains` use literal byte-string containment after interpolation. `matches` and `not_matches` use Go regular expressions. Static patterns are checked during validation; a pattern that becomes invalid only after interpolation fails as an assertion at runtime. ### JSON ```yaml expect: json: exists: - $.error.code not_exists: - $.owner.email equals: $.error.code: forbidden not_equals: $.tenant_id: "{{ attacker_tenant_id }}" ``` Supported JSON paths are intentionally small: ```text $ $.property $.nested.property $.items[0].id $.matrix[0][1] ``` Only dot-separated object keys and zero-based, non-negative array indexes are supported. Bracket-quoted keys, wildcards, filters, recursive descent, and keys containing dots are not supported. Behavior: - `exists` passes when the path resolves, including when its value is `null`; - `not_exists` passes only when the path does not resolve; - `equals` requires the path and strict JSON value equality; - `not_equals` passes when the path is absent or its value differs; - any JSON assertion fails when the response is not valid JSON. ### Response headers ```yaml expect: headers: exists: - X-Content-Type-Options not_exists: - X-Internal-Debug equals: Cache-Control: no-store ``` Header names use the standard case-insensitive HTTP lookup. `equals` compares the first value returned by Go's HTTP header API as an exact string. ## Captures Captures run only after the current step's expectations pass. Each capture must define exactly one source. ### JSON path ```yaml capture: object_id: json_path: $.data.id ``` Strings are preserved. Other JSON values are compactly JSON-encoded before being stored as a variable. A missing path or invalid JSON response is an execution error. ### Response header ```yaml capture: csrf_token: header: X-CSRF-Token ``` An absent or empty header is an execution error. ### Regular expression ```yaml capture: csrf_token: regex: 'name="csrf" value="([^"]+)"' group: 1 ``` The expression is matched against the response body. `group` is zero-based; `0`, the default, captures the full match. The selected group must exist. Reports expose captured variable names, not their captured values. Templated body assertion values are redacted, and JSON/header comparisons report match state rather than the compared values. Reports should still be treated as potentially sensitive because they contain metadata, source paths, assertion structure, literal non-templated body assertions, and diagnostic text. ## CLI execution behavior ### `init` ```text exploitspec init PATH.exploit.yaml ``` Creates parent directories and writes a starter spec. It refuses to overwrite an existing path. ### `validate` ```text exploitspec validate [PATH...] ``` Discovers, parses, and validates specs. With no path, discovery starts at the current directory. ### `import curl` ```text exploitspec import curl --out PATH.exploit.yaml [FLAGS] < request.txt ``` Parses one cURL command and writes a new, single-step ExploitSpec. The command is handled as input data: ExploitSpec does not execute cURL and does not invoke a shell. Standard input is limited to 1 MiB, and the output path is created with requested mode `0644` only when it does not already exist. Input can come from standard input or `--command`. The following authoring flags shape the generated invariant: | Flag | Default | Meaning | | --- | --- | --- | | `--out PATH` | required | New spec path; never overwritten | | `--id ID` | `SEC-001` | Generated spec identifier | | `--name NAME` | `Imported security invariant` | Generated invariant name | | `--actor NAME` | `attacker` | Actor for the imported request | | `--expect-status LIST` | `403,404` | Comma-separated accepted response statuses | | `--not-contains TEXT` | none | Body text that must stay absent; repeatable | | `--json-not-exists PATH` | none | JSON path that must stay absent; repeatable | | `--command TEXT` | stdin | cURL command supplied directly | The importer supports one absolute HTTP(S) URL, `-X`/`--request`, `-H`/`--header`, common `-d`/`--data*` forms, `--json`, `--url`, `-b`/`--cookie`, `-u`/`--user`, `-G`/`--get`, `-I`/`--head`, and a small set of transport flags that do not affect the generated application request. Unknown options, multiple URLs, file-backed headers or bodies, shell control operators, shell expansion, and command substitution are rejected. `--insecure` is ignored with a warning; TLS verification can only be disabled explicitly when running ExploitSpec. All imported header values except a small literal allowlist (`Accept`, `Accept-Encoding`, `Accept-Language`, `Content-Type`, `User-Agent`, `Cache-Control`, and `Pragma`) are replaced with named `EXPLOITSPEC_*` environment placeholders. All query and form values are replaced regardless of their key. Every JSON string leaf is also replaced except exact lowercase JSON Patch operation names (`add`, `remove`, `replace`, `move`, `copy`, and `test`). This intentionally trades a little convenience for a generated file that cannot copy arbitrary string secrets. Transport-controlled headers are dropped. An unrecognized opaque body is rejected rather than copied into the generated file because it cannot be redacted structurally. Such requests must be authored manually after review. The generated target uses the source URL's scheme and authority and allowlists its hostname. Query parameters remain in the relative request path. Import does not send the request, validate that the finding is real, infer multiple actors, or calibrate the invariant. ### `import har` ```text exploitspec import har --list [--file capture.har] exploitspec import har --entry N --out PATH.exploit.yaml [FLAGS] [--file capture.har] ``` HAR import is a two-step, local-only authoring flow. `--list` prints one-based entry indexes with a standard HTTP method, a path shape containing only `` markers, and a query-parameter count. It never prints or hashes a captured origin, and never prints captured path-segment, parameter, header, cookie, or body names or values. Import then requires an explicit `--entry N`; there is no implicit first-entry selection. Standard input is used unless `--file` names a local HAR file. The authoring flags `--out`, `--id`, `--name`, `--actor`, `--expect-status`, `--not-contains`, and `--json-not-exists` have the same meanings as for `import curl`. `--out` is not accepted with `--list` and is required with `--entry`. The accepted input is deliberately narrower than the full extensible HAR format: - the document must be unambiguous UTF-8 JSON with `log.version` exactly `1.2`; - the archive is limited to 8 MiB and 256 entries; - JSON nesting, token count, URL size, path segments, headers, cookies, parameters, and captured request bodies have independent upper bounds; - duplicate or case-conflicting JSON object members and duplicate HTTP header names are rejected; - object-member conflicts use Unicode simple case folding, so cycles such as ASCII `s` and long-s cannot bypass ambiguity detection; - each selected request needs one absolute HTTP(S) URL without credentials or a fragment, and its URL query must exactly match `request.queryString`; - request methods are restricted to `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, and `TRACE`; - only fixed protocol header names are imported; custom names must be added manually after reviewing the capture, and Unicode format controls are refused in query, form, and JSON object names; - `postData.text` and `postData.params` are mutually exclusive; - only unencoded JSON with one of `application/json`, `application/json-patch+json`, `application/merge-patch+json`, or `application/problem+json`, plus `application/x-www-form-urlencoded`, is accepted; - binary encodings, file parameters, multipart data, opaque media types, malformed JSON/forms, missing captured body bytes, and inconsistent media types fail closed. The importer does not execute browser scripts, open an HTTP client, resolve DNS, or replay a request. It reads only the selected local bytes. The fixed importable header-name set is `Accept`, `Accept-Encoding`, `Accept-Language`, `Authorization`, `Baggage`, `Cache-Control`, `Content-Type`, `Cookie`, `Dnt`, `If-Match`, `If-Modified-Since`, `If-None-Match`, `If-Unmodified-Since`, `Origin`, `Pragma`, `Referer`, `Sec-Ch-Ua`, `Sec-Ch-Ua-Mobile`, `Sec-Ch-Ua-Platform`, `Sec-Fetch-Dest`, `Sec-Fetch-Mode`, `Sec-Fetch-Site`, `Sec-Fetch-User`, `Traceparent`, `Tracestate`, `Upgrade-Insecure-Requests`, `User-Agent`, `X-Api-Key`, `X-Csrf-Token`, and `X-Xsrf-Token`. Header matching is case-insensitive and generated names use one canonical spelling. Transport-controlled names are dropped with a warning; content encodings are rejected. Generated HAR specs use `${EXPLOITSPEC_HAR_BASE_URL}` instead of persisting the captured origin and leave `target.allowed_hosts` empty. Every non-empty URL path segment, query/form name and value, allowlisted non-transport header value, JSON object key, and JSON string leaf is replaced with a deterministic positional `EXPLOITSPEC_HAR_*` environment placeholder; captured names are not embedded in the placeholder names. All cookie material is represented by one redacted `Cookie` header placeholder. At most 4,096 placeholders may be generated, and the resulting YAML must fit the ordinary 1 MiB ExploitSpec loader bound. Transport-controlled headers are dropped. The selected allowlisted HTTP method, a normalized supported `Content-Type`, fixed protocol header names, and exact lowercase JSON Patch operation names in `application/json-patch+json` bodies are the only imported strings preserved literally. Numeric, boolean, and null JSON values are rejected because replacing them with strings would silently change JSON types, while preserving them could retain captured state. The output is an ordinary version 1 ExploitSpec covered by `schemas/exploitspec.schema.json`; HAR import does not add fields to or fork the document schema. Before execution, set the listed environment variables and explicitly authorize a non-loopback target with `run --allow-host`. Review and calibrate the generated invariant as you would a cURL import. Path, query, and form placeholders use the format's ordinary textual interpolation, so supply percent-encoded names and values when captured text is not already URL-safe. JSON object-key interpolation fails if two supplied names resolve to the same key. ### `run` ```text exploitspec run [FLAGS] [PATH...] ``` Supported flags: | Flag | Default | Meaning | | --- | --- | --- | | `--base-url URL` | spec value | Override `target.base_url` | | `--allow-host HOST` | none | Authorize another hostname; repeatable | | `--insecure` | false | Skip TLS certificate verification | | `--format` | `text` | `text`, `json`, or `junit` | | `--output` | `-` | Report path, or `-` for stdout | | `--timeout` | document/default | Positive per-request duration, at most `5m` | | `--max-body-bytes` | document/default | Positive response limit, at most 16777216 | ### `calibrate` ```text exploitspec calibrate --vulnerable-url URL --fixed-url URL [FLAGS] SPEC ``` Calibration accepts exactly one spec. It first expects the invariant to fail on the vulnerable target (**RED**), then expects it to pass on the fixed target (**GREEN**) for `--runs` consecutive executions (**STABLE**). `--runs` defaults to 3 and must be between 1 and 20. Supported calibration flags are `--vulnerable-url`, `--fixed-url`, `--runs`, repeatable `--allow-host`, `--insecure`, `--format text|json`, and `--output`. Unlike `run`, calibration currently has no timeout or response-size override flags; document defaults apply. ### Exit codes | Code | Meaning | | ---: | --- | | `0` | All invariants passed, or calibration reached RED/GREEN/STABLE | | `1` | An invariant failed, or calibration was not accepted | | `2` | Usage, validation, safety, execution, or report-writing error | An HTTP response that violates a resolved expectation is a **failure** (`1`). A request that cannot be executed, an assertion template or runtime regex that cannot be resolved, an oversized response, or a capture that cannot be resolved is an **error** (`2`). ## Safety model The runner applies these controls: - only `http` and `https` URLs; - no credentials embedded in URLs; - explicit authorization for every non-loopback target hostname; - validation of each redirect target, with at most five redirects; - removal of credential-bearing headers on cross-origin redirects; - unconditional blocking of `169.254.169.254`, `fd00:ec2::254`, and canonical aliases; - rejection of IPv6 zone identifiers; - same-scheme and same-host enforcement for interpolated request paths; - rejection of URL fragments in targets and request paths; - a default 10-second request timeout; - a default 1 MiB response body limit; - TLS 1.2 minimum; - certificate verification unless `--insecure` is explicitly used. These controls are guardrails, not containment. In particular: - allowlisting is hostname-based and does not resolve or pin DNS results; - private and link-local addresses other than the two exact metadata IPs are not categorically blocked; - a permitted target can redirect to another explicitly permitted target; - specs can use state-changing or application-specific dangerous methods; - configured `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` behavior comes from Go's standard HTTP transport; - the runner does not create or clean up test data automatically. Use isolated environments, synthetic data, scoped credentials, explicit target authorization, and code review. ExploitSpec is not an authorization mechanism. ## Report formats - **Text** prints per-spec and per-step status with failed assertion details. - **JSON** returns counts plus structured result, step, assertion, timing, and error data. - **JUnit XML** maps each executed step to a test case and distinguishes failed invariants from execution errors. Durations in JSON are integer nanoseconds. `--output PATH` creates or truncates the file and forces mode `0600`; it refuses a path that names or resolves to an input spec. `--output -` writes to standard output. The runner intentionally does not include response bodies or captured values in reports. Templated body expectations are redacted; JSON and header equality diagnostics expose only match state; network errors omit request paths and query strings. Literal body expectations, spec metadata, source paths, JSON paths, header names, hostnames, and other diagnostic text can still appear. Store reports accordingly. ## Current limitations Version 1 currently has no: - browser or JavaScript execution; - WebSocket, GraphQL-aware, gRPC, or non-HTTP transport; - conditional steps, loops, parallelism, retries, or cleanup/finally steps; - general JSONPath implementation; - direct import from Burp project files or pentest reports (bounded cURL and selected-entry HAR 1.2 importers are available); - automatic credential acquisition or refresh; - automatic test-data generation or teardown; - response-body persistence; - spec signing, calibration attestation, or remote registry; - built-in `.env` loader. These omissions are deliberate facts about the current implementation. See the [roadmap](../ROADMAP.md) for possible future work.