CI/CD Integration

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.

CI-triggered pentests consume credits from your workspace balance. See Billing & Usage for details on credit management and usage tracking.

Installation

Install the Pensar CI package globally:

npm install -g @pensar/ci

Or add it as a dev dependency:

npm install --save-dev @pensar/ci

View the source code and additional examples on GitHub.

Configuration

Required Environment Variables

VariableDescription
PENSAR_API_KEYYour Pensar API key

Optional Environment Variables

VariableDescriptionDefault
PENSAR_ENVIRONMENTdev, staging, or productionproduction

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

pensar pentest [options]

Options:

OptionDescription
-b, --branch <branch>Branch name to associate with the scan
-e, --environment <env>Environment: dev, staging, production
--no-waitDispatch the scan without waiting for completion

Examples:

# 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

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:

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:

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.

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

pensar status <scanId>

Example:

pensar status abc123-def456-789

CI/CD Examples

GitHub Actions

Run a pentest when commits are pushed to main branches:

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 }}

GitLab CI

Add to your .gitlab-ci.yml:

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:

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

Exit Codes

CodeMeaning
0Scan completed with no issues
1Scan found security issues or failed

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 the scan covers every application in the workspace.

{
"repoId": 123456789,
"repositoryId": "string (uuid, optional alternative to repoId)",
"branch": "string (optional)",
"scanLevel": "priority | full (optional)",
"commitSha": "string (optional)"
}

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.

Response:

{
"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:

{
"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