Skip to content

Run a pentest in CI

A pentest in CI has a failure mode a scan does not: the saved session expires, the run probes the app logged out, and every real finding comes back refuted. CI mode turns that silent miss into a hard failure. This guide restores a session from a secret, verifies it, and runs the pipeline so a dead session stops the run before it wastes any AI budget.

  • cxg and the pentest orchestrator installed. See the web-application pentest.
  • A captured session exported once, locally, as a Playwright storage_state. That artifact is what CI restores; nobody logs in during the run.

Restore the session into an isolated directory

Section titled “Restore the session into an isolated directory”

A saved session is a credential. Keep it out of the operator’s home store and in a directory the run owns, mode 0700. cxg reads the state from a file, from stdin, or from a base64 environment variable. The last is what a CI secret store gives you.

  1. Create the auth directory with tight permissions.

    Terminal window
    mkdir -p ci-auth && chmod 700 ci-auth
  2. Import the session. In CI, pipe the secret straight in rather than writing it to disk in the clear.

    Terminal window
    printf '%s' "$SESSION_STATE" | cxg pentest auth import \
    --profile live --target https://staging.example.com \
    --storage-state - --auth-dir ./ci-auth --ci

    --storage-state - reads stdin. --ci makes import refuse a world-readable destination rather than write a credential a fellow user could read:

    ✗ auth dir ci-auth is world-accessible (mode 0o755); a saved session is a credential. Run `chmod 700 ci-auth` and retry, or run without CI mode to override.

    That refusal exits 2. It comes from auth import, not from run, so guard it on the import step rather than expecting the run to report it.

auth verify is a cheap liveness gate: exit 0 if the session is alive, non-zero if it is dead. Run it first and skip the expensive pipeline when it fails.

Terminal window
cxg pentest auth verify --profile live --auth-dir ./ci-auth
✓ session for 'live' is ALIVE against https://staging.example.com
· landed at https://staging.example.com/ (no login redirect)

A dead session prints ✗ session for live expired with the landing page it hit, and exits 1, so the guard is:

Terminal window
cxg pentest auth verify --profile live --auth-dir ./ci-auth \
|| { echo "session expired, re-import"; exit 1; }

Pass --ci to run, or set CXG_CI=1 for pipelines that cannot change the invocation. Now a dead session at pre-flight is a hard failure instead of a warn-and-continue:

Terminal window
cxg pentest run \
--codebase ./app --target https://staging.example.com \
--auth live --auth-dir ./ci-auth \
--ai --ai-provider claude \
--attestation "$CI_ENGAGEMENT_ATTESTATION" \
--ci

Against a dead session the run stops at the identity check:

[1b] inspecting 1 captured auth profiles…
· live (DEAD: status=401)
· landed at https://staging.example.com/login which looks like a login/SSO page
· /api/me returned 401

and exits 5, before generating or sending anything. Without --ci the same situation only warns and probes on, logged out.

--ci only helps if the pre-flight check can tell a dead session from a live one. Two things defeat it, and both are silent.

The run’s exit code is the whole verdict. These are every code cxg pentest run returns:

Exit Meaning CI action
0 Clean. No confirmed findings. Pass.
1 No templates were available to run. Investigate. Usually a missing findings.sarif.
2 Confirmed findings present. Fail the build, publish report.json.
3 Hard-killed: a 5xx streak, a scope violation, or under --no-restart a desktop target that died mid-scan. Investigate the target.
5 CI mode: the session was dead at pre-flight. Fail loud, re-capture the session.

There is no exit 4. The point of --ci is that 5 exists at all: without it, an expired session passes quietly as a 0.

  • Record authorization. --attestation writes your engagement ID and operator into the audit-log header, the dispute-ready record that you had permission. Strongly recommended for any non-local target.
  • Bound the AI cost. --max-templates caps how many probes the ranker may select, and --generation-timeout caps each AI call.