> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.pensar.dev/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pensar.dev/_mcp/server.

# CI/CD Integration

> Integrate Pensar pentesting into your CI/CD pipeline

## Overview

Pensar provides a CLI tool and API for integrating automated pentesting directly into your CI/CD pipeline. Run pentests on every commit or deployment to catch vulnerabilities before they reach production.

> **Info**
>
> CI-triggered pentests consume credits from your workspace balance. See [Billing & Usage](/features/billing) for details on credit management and usage tracking.

## Installation

Install the Pensar CI package globally:

```bash
npm install -g @pensar/ci
```

Or add it as a dev dependency:

```bash
npm install --save-dev @pensar/ci
```

> **Note**
>
> View the source code and additional examples on [GitHub](https://github.com/pensarai/ci).

## Configuration

### Required Environment Variables

| Variable         | Description         |
| ---------------- | ------------------- |
| `PENSAR_API_KEY` | Your Pensar API key |

### Optional Environment Variables

| Variable             | Description                       | Default      |
| -------------------- | --------------------------------- | ------------ |
| `PENSAR_ENVIRONMENT` | `dev`, `staging`, or `production` | `production` |

> **Info**
>
> You can configure CI/CD integration directly from the Pensar Console. Navigate to **Settings → Automation → CI/CD** in your workspace for guided setup and one-click workflow creation.

## CLI Usage

### Run a Pentest

```bash
pensar pentest [options]
```

**Options:**

| Option                    | Description                                                      |
| ------------------------- | ---------------------------------------------------------------- |
| `-b, --branch <branch>`   | Branch name to associate with the scan                           |
| `-e, --environment <env>` | Environment: `dev`, `staging`, `production`                      |
| `--commit <sha>`          | Exact commit to test; pass the deployed SHA for post-deploy runs |
| `--no-wait`               | Dispatch the scan without waiting for completion                 |

**Examples:**

```bash
# Basic pentest using environment variables
pensar pentest

# Pentest a specific branch
pensar pentest --branch main

# Pentest without waiting for completion
pensar pentest --no-wait

# Pentest with explicit environment
pensar pentest --branch develop --environment staging
```

### How the CI diff is calculated

By default:

* **Compare:** Your requested commit (`--commit`) is compared with the latest qualifying completed pentest for the same repository in your workspace. This baseline is shared across branches and environments; it is not the previous deployment.
* **Filter:** Files such as `package.json` and lockfiles are excluded. If no changes remain, testing is skipped; large changes can trigger broader coverage.
* **Test:** The agent uses the diff to create security objectives for the affected applications.
* **Results:** A launched scan returns an ID and status, with findings after testing completes. Buffered runs can defer launch; a queued scan is not a passing result.

Without a baseline, or if a force-push removed it, Pensar runs without diff narrowing. Other comparison errors fail the run. For an explicit comparison, the API accepts `baseSha` with `commitSha`; both commits must exist.

### Compare two explicit commits

To test exactly what changed between two deployed versions, pass the previously deployed commit as `--base` (or `PENSAR_BASE_SHA`) along with the new commit. Requires `@pensar/ci` 2.7.0 or later.

```bash
pensar pentest \
  --commit 2222222222222222222222222222222222222222 \
  --base 1111111111111111111111111111111111111111
```

In GitHub Actions the repository is detected automatically; elsewhere, pass `--repo-id`. The CLI sends these as `baseSha` and `commitSha` to [`POST /ci/dispatch`](#dispatch-scan), which you can also call directly.

* Both values must be full 40-character SHAs, and the repository must be known (`--repo-id`, or `repoId` / `repositoryId` on the API).
* Both commits must exist in that repository. A missing commit returns `400`; Pensar does not fall back to the last pentest or a full scan.
* The two commit trees are compared directly, so rollbacks and diverged histories work. The commit buffer is bypassed.
* If the two commits are the same, there is nothing to test and the run is skipped.
* The range is kept if the scan is queued, deduplicated, or retried.

### Run Only on Labelled Changes

When only some changes are worth pentesting, label the pull request and gate the
workflow on that label. This is done in the workflow, not in the CLI.

On a pull-request-triggered workflow the labels are in the event payload, so a
job-level condition is all you need and nothing extra runs:

```yaml
jobs:
  pentest:
    if: contains(github.event.pull_request.labels.*.name, 'pentest')
    runs-on: ubuntu-latest
    # ...
```

On a push or a post-deploy run there is no pull request in the payload, so the
pull request the commit came from has to be looked up. Add a small job that does
the lookup and make the pentest job depend on it:

```yaml
permissions:
  contents: read
  pull-requests: read # lets the gate read the label off the pull request

jobs:
  gate:
    runs-on: ubuntu-latest
    outputs:
      labelled: ${{ steps.check.outputs.labelled }}
    steps:
      - id: check
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          LABEL: "pentest"
        run: |
          if gh api "repos/${{ github.repository }}/commits/${{ github.sha }}/pulls" \
               --jq '.[].labels[].name' | grep -Fqx -- "$LABEL"; then
            echo 'labelled=true' >> "$GITHUB_OUTPUT"
          else
            echo "No $LABEL label on the pull request for this commit. Skipping."
            echo 'labelled=false' >> "$GITHUB_OUTPUT"
          fi

  pentest:
    needs: gate
    if: needs.gate.outputs.labelled == 'true'
    runs-on: ubuntu-latest
    # ...
```

Because the pentest job depends on the gate, an unlabelled change never starts
it: no runner, no checkout, no install.

Keep the label in `LABEL` rather than inlining it into the command. A label is
free text, so an apostrophe would break the quoting and a `.` or `*` would make
`grep` match a different label; `-F` compares it literally.

> **Warning**
>
> After a deploy, use `${{ github.event.workflow_run.head_sha }}` rather than
> `${{ github.sha }}`. On a `workflow_run` event `github.sha` is the tip of the
> default branch, not the commit that was deployed, so the gate would check
> whichever change landed most recently.

The Console generates both of these for you: enter a label under **Settings →
CI/CD** and the workflow snippets on that page include the gate, with the right
SHA for each trigger.

### Check Scan Status

```bash
pensar status <scanId>
```

**Example:**

```bash
pensar status abc123-def456-789
```

## CI/CD Examples

### GitHub Actions

#### On Push

Run a pentest when commits are pushed to main branches:

```yaml
name: Pensar Pentest

on:
  push:
    branches: [main, master, develop]

jobs:
  pentest:
    name: Pensar Pentest
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install Pensar CI
        run: npm install -g @pensar/ci

      - name: Run Pensar Pentest
        env:
          PENSAR_API_KEY: ${{ secrets.PENSAR_API_KEY }}
        run: pensar pentest --branch ${{ github.ref_name }}
```

#### After Deployment

Run a pentest after a successful deployment workflow. This example assumes that workflow deployed its `head_sha`; if it deploys a different artifact, pass the actual artifact commit to both checkout and `--commit`. The comparison base remains the last qualifying pentest, not the preceding deployment:

```yaml
name: Pensar Pentest (Post-Deploy)

on:
  workflow_run:
    workflows: ['Deploy']
    types: [completed]
    branches: [main, staging]

jobs:
  pentest:
    name: Pensar Pentest
    runs-on: ubuntu-latest
    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.workflow_run.head_sha }}

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install Pensar CI
        run: npm install -g @pensar/ci

      - name: Run Pensar Pentest
        env:
          PENSAR_API_KEY: ${{ secrets.PENSAR_API_KEY }}
        run: |
          pensar pentest \
            --branch ${{ github.event.workflow_run.head_branch || github.ref_name }} \
            --commit ${{ github.event.workflow_run.head_sha }} \
            --environment staging
```

#### Compare With Last Deploy

Test only what changed since the previous successful deployment. It uses `--base` ([compare two explicit commits](#compare-two-explicit-commits)), which requires `@pensar/ci` 2.7.0 or later.

It assumes your deploy job records a [GitHub deployment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) (for example by setting `environment: production` on the job), so the workflow runs on each successful deployment and the last deploy is the most recent earlier deployment to that environment that ever reached `success` (GitHub marks it `inactive` once a newer deploy succeeds). If there is no earlier deployment, it runs without `--base` and the default baseline applies.

```yaml
name: Pensar Pentest (Since Last Deploy)

on:
  deployment_status:

permissions:
  deployments: read

jobs:
  pentest:
    if: github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'production'
    runs-on: ubuntu-latest
    steps:
      - name: Find the previous successful deploy
        id: base
        env:
          GH_TOKEN: ${{ github.token }}
          ENVIRONMENT: ${{ github.event.deployment.environment }}
          CURRENT_ID: ${{ github.event.deployment.id }}
        run: |
          base=""
          for row in $(gh api "repos/${{ github.repository }}/deployments?environment=$ENVIRONMENT&per_page=50" \
              --jq ".[] | select(.id < $CURRENT_ID) | \"\\(.id),\\(.sha)\""); do
            ok=$(gh api "repos/${{ github.repository }}/deployments/${row%%,*}/statuses?per_page=100" --jq 'any(.[]; .state == "success")')
            if [ "$ok" = "true" ]; then base="${row#*,}"; break; fi
          done
          echo "sha=$base" >> "$GITHUB_OUTPUT"

      - uses: actions/setup-node@v4
        with:
          node-version: '22'

      - name: Install Pensar CI
        run: npm install -g @pensar/ci@^2.7.0

      - name: Run Pensar Pentest
        env:
          PENSAR_API_KEY: ${{ secrets.PENSAR_API_KEY }}
          PENSAR_BASE_SHA: ${{ steps.base.outputs.sha }}
        run: |
          [ -n "$PENSAR_BASE_SHA" ] || unset PENSAR_BASE_SHA
          pensar pentest --commit ${{ github.event.deployment.sha }}
```

### GitLab CI

Add to your `.gitlab-ci.yml`:

```yaml
stages:
  - security

pensar-pentest:
  stage: security
  image: node:22
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  before_script:
    - npm install -g @pensar/ci
  script:
    - pensar pentest --branch $CI_COMMIT_REF_NAME
  variables:
    PENSAR_API_KEY: $PENSAR_API_KEY
```

### Bitbucket Pipelines

Add to your `bitbucket-pipelines.yml`:

```yaml
pipelines:
  branches:
    main:
      - step:
          name: Pensar Pentest
          image: node:22
          script:
            - npm install -g @pensar/ci
            - pensar pentest --branch main
```

## Exit Codes

| Code | Meaning                              |
| ---- | ------------------------------------ |
| 0    | Scan completed with no issues        |
| 1    | Scan found security issues or failed |

> **Warning**
>
> By default, the CLI will exit with code 1 if any security issues are found. Use this to block deployments when vulnerabilities are detected.

## API Reference

### Dispatch Scan

```
POST /ci/dispatch
```

**Headers:**

* `Authorization: Bearer <api_key>` or `x-api-key: <api_key>`
* `Content-Type: application/json`

**Request Body:**

Provide a repository so the pentest is scoped to that repository's
applications. Supply either `repoId` (GitHub's numeric repository id — the
CLI auto-detects this from `GITHUB_REPOSITORY_ID`) or `repositoryId` (the
internal repository UUID). When neither is supplied, application scope remains
workspace-wide. Exactly one connected repository still supplies attribution and
a baseline; multiple repositories leave the scan unattributed without diff narrowing.

```json
{
  "repoId": 123456789,
  "repositoryId": "string (uuid, optional alternative to repoId)",
  "branch": "string (optional)",
  "scanLevel": "priority | full (optional)",
  "commitSha": "string (optional)",
  "baseSha": "string (optional; requires commitSha and a selected repository)",
  "testType": "custom | endpoint-selection (optional, defaults to custom)"
}
```

The repository must already be connected to the workspace. If it isn't found,
the API responds `404`; if it belongs to another workspace, `403`; if it has
no discovered applications yet (run reconnaissance first), `422`.

With `baseSha`, CI compares exactly that commit to `commitSha` in the selected repository and bypasses the commit buffer. Both commits must exist there; a missing commit returns `400` instead of recovering to a full scan. Omit `baseSha` to keep the default last-pentest baseline.

`testType: custom` derives application-level objectives from the diff;
`endpoint-selection` selects affected discovered endpoints. Both use the same
repository baseline and recovery rules above. A supplied branch must match the
repository's configured target branch; it does not select a different branch for one run.

**Response:**

```json
{
  "scanId": "string",
  "label": "string",
  "status": "queued"
}
```

### Get Scan Status

```
GET /ci/status/{scanId}
```

**Headers:**

* `Authorization: Bearer <api_key>` or `x-api-key: <api_key>`

**Response:**

```json
{
  "scanId": "string",
  "label": "string",
  "status": "queued | running | completed | failed | paused",
  "startedAt": "string | null",
  "completedAt": "string | null",
  "errorMessage": "string | null",
  "issuesCount": 0,
  "issueCountsBySeverity": {
    "critical": 0,
    "high": 0,
    "medium": 0,
    "low": 0
  },
  "reportReady": false
}
```

## Next Steps

#### [Hybrid Testing](/console/features/hybrid-testing)

Learn about Pensar's hybrid testing methodology.

#### [Auto-Remediation](/console/features/auto-remediation)

Automatically fix vulnerabilities found during scans.

#### [Pentest Reports](/console/features/pentest-reports)

Generate detailed security reports from your scans.

#### [GitHub Integration](/console/integrations/github)

Set up event-based scanning with GitHub integration.