Scan in your CI/CD pipeline
Run a FlawPilot scan on every deploy and fail the build when a pillar score drops below the bar you set. The pattern is the same everywhere: trigger a scan, poll until it finishes, then read the score and exit accordingly. Copy a workflow file below and change three values.
Every endpoint used here is documented in full in the API reference.
How it works
Three calls, in order:
POST /v1/scans/triggerqueues the scan and returns ascanJobId.GET /v1/scans/:id/statusis polled untilstatusreachesCOMPLETE,PARTIALorFAILED.- The terminal response carries
categories[]- one entry per pillar with its own score. Compare those against your threshold and exit non-zero to fail the build.
Before you start
- An API key, stored as a secret in your CI provider. Never commit it.
- Your
projectId- the shortId fromGET /v1/projects, which looks likePRJ-3TV1T. curl,jqandbcon the runner. The GitHub image has all three; the Alpine examples install them.- Headroom against the 5 scans per minute trigger limit. A matrix build fanning out across several URLs will hit it - stagger those jobs or scan one representative URL per run.
Save the script below as .flawpilot/scan.sh in your repository. All three workflows call it, so the logic lives in one file.
#!/usr/bin/env bash
set -euo pipefail
# Three values to change:
API="https://api.flawpilot.com/v1"
PROJECT_ID="PRJ-XXXXX"
TARGET_URL="https://example.com"
# Fail the build when any pillar scores below this.
THRESHOLD=70
# 1. Trigger the scan.
SCAN_ID=$(curl -sS -X POST "$API/scans/trigger" \
-H "Authorization: Bearer $FLAWPILOT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"projectId\":\"$PROJECT_ID\",\"url\":\"$TARGET_URL\"}" \
| jq -r '.scanJobId')
if [ -z "$SCAN_ID" ] || [ "$SCAN_ID" = "null" ]; then
echo "::error::Could not start the scan."
exit 1
fi
echo "Scan $SCAN_ID started."
# 2. Poll until terminal, with a bound so a stuck scan cannot hang the runner.
# 60 attempts x 10s = 10 minutes.
for i in $(seq 1 60); do
BODY=$(curl -sS "$API/scans/$SCAN_ID/status" \
-H "Authorization: Bearer $FLAWPILOT_API_KEY")
STATUS=$(echo "$BODY" | jq -r '.status')
echo " [$i/60] $STATUS"
case "$STATUS" in
COMPLETE|PARTIAL|FAILED) break ;;
esac
sleep 10
done
if [ "$STATUS" = "FAILED" ]; then
echo "::error::The scan failed to complete."
exit 1
fi
if [ "$STATUS" != "COMPLETE" ] && [ "$STATUS" != "PARTIAL" ]; then
echo "::error::Timed out after 10 minutes (last status: $STATUS)."
exit 1
fi
# PARTIAL means some pillars finished and some did not. Scoring what did
# finish is usually right; exit 1 here instead if you want it treated as a
# failure.
if [ "$STATUS" = "PARTIAL" ]; then
echo "::warning::Scan finished partially - scoring the pillars that completed."
fi
# 3. Gate on the lowest pillar score. score_total is not returned by the
# public API, and a per-pillar floor is the better test anyway: a strong
# SEO score should not be able to mask a weak security one.
echo "$BODY" | jq -r '.categories[] | " \(.pillar): \(.score)"'
LOWEST=$(echo "$BODY" | jq '[.categories[].score] | min')
echo "Lowest pillar score: $LOWEST (threshold $THRESHOLD)"
if [ "$(echo "$LOWEST < $THRESHOLD" | bc -l)" -eq 1 ]; then
echo "::error::A pillar scored below $THRESHOLD."
exit 1 # remove this line to warn without failing the build
fi
echo "All pillars at or above $THRESHOLD."GitHub Actions
Add the key under Settings → Secrets and variables → Actions as FLAWPILOT_API_KEY, then commit this as .github/workflows/flawpilot.yml.
name: FlawPilot scan
on:
push:
branches: [main]
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Scan and gate
env:
FLAWPILOT_API_KEY: ${{ secrets.FLAWPILOT_API_KEY }}
run: bash .flawpilot/scan.shGitLab CI
Add FLAWPILOT_API_KEY under Settings → CI/CD → Variables, marked both masked and protected, then add this job to .gitlab-ci.yml.
flawpilot-scan:
stage: test
image: alpine:3.20
before_script:
- apk add --no-cache bash curl jq bc
script:
- bash .flawpilot/scan.sh
variables:
# Set FLAWPILOT_API_KEY as a masked, protected CI/CD variable in
# Settings > CI/CD > Variables. Never commit the key.
GIT_DEPTH: "1"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHBitbucket Pipelines
Add FLAWPILOT_API_KEY as a secured variable under Repository settings → Repository variables, then add this to bitbucket-pipelines.yml.
pipelines:
branches:
main:
- step:
name: FlawPilot scan
image: alpine:3.20
script:
- apk add --no-cache bash curl jq bc
# Set FLAWPILOT_API_KEY as a secured repository variable in
# Repository settings > Repository variables.
- bash .flawpilot/scan.shChoosing a threshold
The script gates on the lowest pillar score. That is deliberate: score_total is not returned by the public API, and a floor per pillar is the better test regardless - a strong SEO score should not be able to offset a weak security one.
Three ways to tune it:
- Raise or lower the number. Start at 70 and move it once you have seen a few real runs.
- Gate on one pillar. Replace the
minexpression with.categories[] | select(.pillar=="SECURITY") | .scoreto fail only on security. - Use the band instead of the number. Each pillar also carries a
band, which is a coarser signal that moves less between runs.
exit 1 on the threshold check. The scan still runs and still prints every score, but a low score annotates the build instead of breaking it - useful while you find the right bar for your codebase.Code scans and Site Health
The quick scan above is one URL. Both other scan types follow the same shape - trigger, poll, gate - against their own endpoints.
Code scan: the repository
To scan the repository rather than the deployed site, use the code-scan endpoints. The repo must already be connected and assigned to a project; the scan reads it through that connection rather than from the runner's checkout.
#!/usr/bin/env bash
set -euo pipefail
API="https://api.flawpilot.com/v1"
REPO_ID="your-integration-repo-id" # from GET /v1/integration-repos
# Fail the build when more than this many CRITICAL findings are found.
MAX_CRITICAL=0
POLL_ATTEMPTS=60
POLL_INTERVAL=10
# Every call goes through this: it checks the HTTP status, surfaces the body
# on a 4xx/5xx instead of feeding it to jq, and backs off on a 429. Without
# it an error page reaches jq, parses to null, and the gate silently passes.
api() {
local method="$1" path="$2" data="${3:-}" attempt=1 out code
while :; do
if [ -n "$data" ]; then
out=$(curl -sS -w '\n%{http_code}' -X "$method" "$API$path" \
-H "Authorization: Bearer $FLAWPILOT_API_KEY" \
-H "Content-Type: application/json" -d "$data")
else
out=$(curl -sS -w '\n%{http_code}' -X "$method" "$API$path" \
-H "Authorization: Bearer $FLAWPILOT_API_KEY")
fi
code=${out##*$'\n'}
body=${out%$'\n'*}
case "$code" in
2*) printf '%s' "$body"; return 0 ;;
429)
if [ "$attempt" -ge 5 ]; then
echo "::error::Rate limited by $path after $attempt attempts." >&2
return 1
fi
sleep $((attempt * 10)); attempt=$((attempt + 1)) ;;
*)
echo "::error::$method $path returned $code: $body" >&2
return 1 ;;
esac
done
}
# Trigger every dimension. One job per dimension, all sharing one run id.
TRIGGER=$(api POST /code-scans/trigger \
"{\"integrationRepoId\":\"$REPO_ID\",\"dimensions\":[]}")
RUN_ID=$(printf '%s' "$TRIGGER" | jq -r '.[0].scanRunId // empty')
if [ -z "$RUN_ID" ]; then
echo "::error::Could not start the code scan."
exit 1
fi
echo "Run $RUN_ID started."
# Poll until every dimension is terminal. PENDING starts non-zero so a loop
# that never runs cannot fall through to the gate as if it had passed.
PENDING=1
for i in $(seq 1 $POLL_ATTEMPTS); do
BODY=$(api GET "/code-scans/runs/$RUN_ID")
PENDING=$(printf '%s' "$BODY" | jq '
[.dimensions[] | select(.status != "COMPLETED" and .status != "FAILED")] | length')
echo " [$i/$POLL_ATTEMPTS] $PENDING dimension(s) still running"
[ "$PENDING" -eq 0 ] && break
sleep $POLL_INTERVAL
done
if [ "$PENDING" -ne 0 ]; then
echo "::error::Timed out after $((POLL_ATTEMPTS * POLL_INTERVAL))s with $PENDING dimension(s) unfinished."
exit 1
fi
printf '%s' "$BODY" | jq -r '.dimensions[]
| " \(.dimension): \(.status) score=\(.score // "n/a") findings=\(.findingsCount)"'
# A dimension that failed scanned nothing, so its zero findings prove nothing.
FAILED=$(printf '%s' "$BODY" | jq -r '
[.dimensions[] | select(.status == "FAILED") | .dimension] | join(", ")')
if [ -n "$FAILED" ]; then
echo "::error::Dimension(s) failed to scan: $FAILED"
exit 1
fi
# Count CRITICAL findings from the findings API. findingsCount is the TOTAL
# at every severity, so gating on it would fail a build over informational
# results - and miss the distinction entirely.
CRITICAL=0
for JOB_ID in $(printf '%s' "$BODY" | jq -r '.dimensions[].id'); do
N=$(api GET "/code-scans/jobs/$JOB_ID/findings?severity=CRITICAL&limit=1" \
| jq '.total // 0')
CRITICAL=$((CRITICAL + N))
done
echo "CRITICAL findings: $CRITICAL (max $MAX_CRITICAL)"
if [ "$CRITICAL" -gt "$MAX_CRITICAL" ]; then
echo "::error::$CRITICAL critical finding(s) exceed the limit of $MAX_CRITICAL."
exit 1 # remove this line to warn without failing the build
fi
echo "Code scan passed."This one triggers 10 per minute rather than 5, and dimensions: [] runs all four. Narrow it to ["SAST", "SECRETS"] for a faster gate.
The gate counts CRITICAL findings from the findings endpoint rather than reading findingsCount, which is the total at every severity - a build should not break over informational results. It also fails when any dimension reports FAILED: a dimension that never scanned reports zero findings, and treating that as a pass is exactly the false negative a gate exists to prevent.
MAX_CRITICAL above 0 to allow a known backlog while still blocking anything new.Site Health: every page, not just one
A quick scan measures the URL you give it. Site Health crawls everything reachable from that URL and scores each page with Lighthouse, so a slow template buried three clicks deep cannot hide behind a healthy landing page.
#!/usr/bin/env bash
set -euo pipefail
API="https://api.flawpilot.com/v1"
PROJECT_ID="PRJ-XXXXX"
TARGET_URL="https://example.com"
# Lighthouse categories are scored 0-100. 90+ is Google's "good" band.
THRESHOLD=80
# Trigger the crawl. mode is DESKTOP unless you pass MOBILE.
SH_ID=$(curl -sS -X POST "$API/site-health/trigger" \
-H "Authorization: Bearer $FLAWPILOT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"projectId\":\"$PROJECT_ID\",\"url\":\"$TARGET_URL\",\"mode\":\"DESKTOP\"}" \
| jq -r '.id')
echo "Site Health check $SH_ID started."
# A full-site crawl takes minutes, not seconds - poll less often and for
# longer than a single-URL scan. 60 attempts x 30s = 30 minutes.
for i in $(seq 1 60); do
BODY=$(curl -sS "$API/site-health/$SH_ID" \
-H "Authorization: Bearer $FLAWPILOT_API_KEY")
STATUS=$(echo "$BODY" | jq -r '.status')
echo " [$i/60] $STATUS"
case "$STATUS" in
COMPLETED|FAILED) break ;;
esac
sleep 30
done
if [ "$STATUS" != "COMPLETED" ]; then
echo "::error::Crawl did not complete (last status: $STATUS)."
echo "$BODY" | jq -r '.errorMessage // empty'
exit 1
fi
echo "Crawled $(echo "$BODY" | jq -r '.pageCount') page(s)."
echo "$BODY" | jq -r '
" performance: \(.scorePerformance)",
" accessibility: \(.scoreAccessibility)",
" best practices: \(.scoreBestPractices)",
" seo: \(.scoreSeo)",
" overall: \(.scoreOverall)"'
# Gate on the lowest category, same idea as the quick scan.
LOWEST=$(echo "$BODY" | jq '[.scorePerformance, .scoreAccessibility,
.scoreBestPractices, .scoreSeo] | min')
echo "Lowest category: $LOWEST (threshold $THRESHOLD)"
if [ "$(echo "$LOWEST < $THRESHOLD" | bc -l)" -eq 1 ]; then
echo "::error::A Lighthouse category scored below $THRESHOLD."
exit 1 # remove this line to warn without failing the build
fiA crawl takes minutes rather than seconds, so this script polls every 30 seconds for up to 30 minutes. Scores are Lighthouse's own, which makes them directly comparable with PageSpeed Insights.
Gotchas
- Always bound the poll. The examples stop after 60 attempts and exit non-zero. An unbounded loop hangs the runner until the job's own limit kills it, which looks like a FlawPilot failure rather than a stuck scan.
- Poll about every 10 seconds. Status responses are cached for a few seconds, so polling faster returns the same body and burns rate limit.
PARTIALis terminal. It means some pillars finished and some did not. The script scores what completed and warns; make it anexit 1if a partial result should block a deploy.- Scan after deploy, not before. The scan fetches a live URL, so it measures whatever is currently published - point it at a preview or staging URL if you want to gate before production.
- A 429 means back off. The response carries
Retry-Afterin seconds. Treat it as a retry, not a build failure.