CI recipes
Gate a deploy on a passive scan, and run pentests on a schedule, from GitHub Actions, GitLab CI or any shell.
Two patterns cover most pipelines:
- A passive scan on every deploy. Free, done in minutes, and it can fail the build.
- A pentest on a schedule or a release. It takes hours and costs credits, so start it and move on, and read the results in a later job instead of keeping a runner waiting.
Every recipe needs a key stored as a CI secret named BARRION_API_KEY. Give it only the permissions it needs, and a budget that fits how often it runs. See API keys.
Fail a deploy on a passive scan
This script starts a scan, waits for it, and fails if it found anything at the severities you choose. It needs curl and jq, which the standard GitHub and GitLab runners have.
#!/usr/bin/env bash
# Usage: barrion-scan.sh <url> [severities] e.g. barrion-scan.sh https://staging.example.com critical,high
set -euo pipefail
URL="$1"
FAIL_ON="${2:-critical,high}"
API="https://api.barrion.io/v1"
AUTH="Authorization: Bearer $BARRION_API_KEY"
# The same key on a retried step, a new one when the pipeline is re-run.
IDEMPOTENCY_KEY="${IDEMPOTENCY_KEY:-scan-$(date +%s)}"
scan_id=$(curl -sS --fail-with-body -X POST "$API/scans" \
-H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d "{\"url\": \"$URL\"}" | jq -r .id)
echo "Scan $scan_id started"
for _ in $(seq 1 40); do
status=$(curl -sS --fail-with-body "$API/scans/$scan_id" -H "$AUTH" | jq -r .status)
[ "$status" = "COMPLETED" ] && break
[ "$status" = "FAILED" ] && { echo "The scan failed"; exit 1; }
sleep 15
done
[ "$status" = "COMPLETED" ] || { echo "The scan did not finish in 10 minutes"; exit 1; }
result=$(curl -sS --fail-with-body "$API/scans/$scan_id/findings?severity=$FAIL_ON" -H "$AUTH")
count=$(echo "$result" | jq .total)
echo "$result" | jq -r '.findings[] | "\(.severity)\t\(.name)"'
if [ "$count" -gt 0 ]; then
echo "$count finding(s) at $FAIL_ON"
exit 1
fi
echo "No findings at $FAIL_ON"GitHub Actions
name: Barrion scan
on:
deployment_status:
jobs:
scan:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Passive scan
env:
BARRION_API_KEY: ${{ secrets.BARRION_API_KEY }}
IDEMPOTENCY_KEY: scan-${{ github.run_id }}-${{ github.run_attempt }}
run: bash barrion-scan.sh "${{ github.event.deployment_status.environment_url }}" critical,highGitLab CI
barrion-scan:
stage: test
image: alpine:3.20
before_script:
- apk add --no-cache bash curl jq
variables:
# Stable when the job is retried, new when the pipeline runs again.
IDEMPOTENCY_KEY: scan-$CI_PIPELINE_ID-$CI_JOB_NAME
script:
- bash barrion-scan.sh https://staging.example.com critical,highAdd BARRION_API_KEY as a masked CI/CD variable.
Run a pentest on a schedule
A pentest takes hours, so no job should wait for one. One scheduled workflow starts it and saves its id. Another, running later, checks that same run and fails if its released findings include anything you care about.
#!/usr/bin/env bash
# Starts a pentest and prints its id. Needs TARGET_URL, VERIFICATION_ID and IDEMPOTENCY_KEY.
set -euo pipefail
response=$(curl -sS --fail-with-body -X POST "https://api.barrion.io/v1/pentests" \
-H "Authorization: Bearer $BARRION_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d "{\"targetUrl\": \"$TARGET_URL\", \"verificationId\": \"$VERIFICATION_ID\", \"depth\": \"LEAD\"}")
id=$(jq -r .id <<<"$response")
if [ -z "$id" ] || [ "$id" = "null" ]; then
echo "No pentest id in the response: $response" >&2
exit 1
fi
echo "$id"The check looks at one run, by id, and answers with one of three exit codes, so a run that has not finished yet is never mistaken for a clean one:
#!/usr/bin/env bash
# Usage: barrion-pentest-check.sh <pentest id> [severities]
# Exit 0: finished, nothing at those severities.
# Exit 1: findings at those severities, or the run failed or was cancelled.
# Exit 2: not finished yet, or its report is still being reviewed.
set -euo pipefail
ID="$1"
FAIL_ON="${2:-critical,high}"
API="https://api.barrion.io/v1"
AUTH="Authorization: Bearer $BARRION_API_KEY"
run=$(curl -sS --fail-with-body "$API/pentests/$ID" -H "$AUTH")
status=$(jq -r .status <<<"$run")
released=$(jq -r .reportReleased <<<"$run")
case "$status" in
COMPLETED) ;;
FAILED | CANCELLED) echo "Pentest $ID ended $status, so nothing was tested"; exit 1 ;;
*) echo "Pentest $ID is still $status"; exit 2 ;;
esac
if [ "$released" != "true" ]; then
echo "Pentest $ID is complete; its report is still being reviewed"
exit 2
fi
findings=$(curl -sS --fail-with-body "$API/pentests/$ID/findings?severity=$FAIL_ON&limit=100" -H "$AUTH")
total=$(jq .total <<<"$findings")
# A report can be taken back between the two requests: judge only a released
# report with a real count, and treat anything else as not finished.
if [ "$(jq -r .reportReleased <<<"$findings")" != "true" ] || ! [[ "$total" =~ ^[0-9]+$ ]]; then
echo "Pentest $ID's report is not released any more; checking again later"
exit 2
fi
jq -r '.findings[] | "\(.severity)\t\(.title)"' <<<"$findings"
if [ "$total" -gt 0 ]; then
echo "$total finding(s) at $FAIL_ON"
exit 1
fi
echo "No findings at $FAIL_ON"A Light run's findings are released while it is still running, and a Standard or Deep run's only after review, so the check waits for both a COMPLETED status and a released report. See Read the findings.
GitHub Actions
The start workflow saves the run's id as an artifact:
name: Barrion pentest
on:
schedule:
- cron: "0 2 * * 1" # Mondays at 02:00 UTC
workflow_dispatch:
jobs:
start:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Start pentest
env:
BARRION_API_KEY: ${{ secrets.BARRION_API_KEY }}
# run_id alone: re-running this run gets the same pentest back instead of paying for a second.
IDEMPOTENCY_KEY: pentest-${{ github.run_id }}
TARGET_URL: https://staging.example.com
VERIFICATION_ID: ${{ vars.BARRION_VERIFICATION_ID }}
run: bash barrion-pentest-start.sh > pentest-id.txt && cat pentest-id.txt
- uses: actions/upload-artifact@v4
with:
name: barrion-pentest
path: pentest-id.txt
retention-days: 14The check workflow fetches that id from the newest successful start, and checks that run:
name: Barrion pentest check
on:
schedule:
- cron: "0 6 * * *" # daily at 06:00 UTC
workflow_dispatch:
permissions:
actions: read
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Find the pentest the start workflow saved
env:
GH_TOKEN: ${{ github.token }}
run: |
run_id=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow barrion-pentest.yml \
--status success --limit 1 --json databaseId -q '.[0].databaseId')
gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name barrion-pentest
echo "PENTEST_ID=$(cat pentest-id.txt)" >> "$GITHUB_ENV"
- name: Check it
env:
BARRION_API_KEY: ${{ secrets.BARRION_API_KEY }}
run: |
code=0
bash barrion-pentest-check.sh "$PENTEST_ID" critical,high || code=$?
if [ "$code" -eq 2 ]; then
echo "::notice::The pentest has not finished yet. The next scheduled check will look again."
exit 0
fi
exit "$code"The same two scripts work in any CI that can run a schedule and keep a file between runs, GitLab included.
Stopping a run when a job is cancelled
This only applies to a job that starts a pentest and then waits for it, for example a short Light run on a release. If that job is cancelled, stop the run too, using the id the start step saved:
- name: Start pentest
run: |
id=$(bash barrion-pentest-start.sh)
echo "PENTEST_ID=$id" >> "$GITHUB_ENV"
# ... steps that wait for the run ...
- name: Stop the pentest
if: cancelled() && env.PENTEST_ID != ''
run: |
curl -sS -X POST "https://api.barrion.io/v1/pentests/$PENTEST_ID/cancel" \
-H "Authorization: Bearer ${{ secrets.BARRION_API_KEY }}" \
-d ''Use cancelled(), not always(): always() also runs when everything went well, and would stop the run you just started. Do not add this step to the start-only workflow above, where the job ends as soon as the run has started. A cancelled run is charged only for the work it got through.