FlawPilot
CI/CD

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:

  1. POST /v1/scans/trigger queues the scan and returns a scanJobId.
  2. GET /v1/scans/:id/status is polled until status reaches COMPLETE, PARTIAL or FAILED.
  3. 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.
Use the REST API, not MCP. The MCP tools ask for anything you leave out instead of assuming a default. That is right in an editor and wrong in a runner: a missing argument returns a prompt rather than a scan, so the job would pass having scanned nothing.

Before you start

  • An API key, stored as a secret in your CI provider. Never commit it.
  • Your projectId - the shortId from GET /v1/projects, which looks like PRJ-3TV1T.
  • curl, jq and bc on 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.

.flawpilot/scan.sh
#!/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.

.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.sh

GitLab CI

Add FLAWPILOT_API_KEY under Settings → CI/CD → Variables, marked both masked and protected, then add this job to .gitlab-ci.yml.

.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_BRANCH

Bitbucket Pipelines

Add FLAWPILOT_API_KEY as a secured variable under Repository settings → Repository variables, then add this to bitbucket-pipelines.yml.

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.sh

Choosing 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 min expression with .categories[] | select(.pillar=="SECURITY") | .score to 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.
Starting out? Delete the 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.

.flawpilot/code-scan.sh
#!/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.

CODE_QUALITY tops out at HIGH. Only SAST, SCA and SECRETS produce CRITICAL findings, so a zero from the quality dimension is expected rather than suspicious. Raise 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.

Watch the rate limit here. Site Health allows 8 triggers per hour, not per minute - the only hourly limit on the API. It is built for a nightly or per-release job, not for every push. Run it on a schedule and keep the quick scan on your deploy workflow.
.flawpilot/site-health.sh
#!/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
fi

A 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.
  • PARTIAL is terminal. It means some pillars finished and some did not. The script scores what completed and warns; make it an exit 1 if 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-After in seconds. Treat it as a retry, not a build failure.