CI/CD Integration

Automatically trigger security scans from your deployment pipeline and fail builds on critical issues.

How CI/CD integration works

StackShield provides hash-authenticated URLs that you can call from any CI/CD pipeline to trigger scans and check results. These URLs don't require an API key — authentication is baked into the URL itself using an HMAC hash of your team ID.

This means you can add security scanning to your pipeline without storing secrets in your CI/CD configuration.

Getting your CI/CD URLs

  1. Go to Security Checks
  2. Click on the check you want to integrate with CI/CD
  3. Find the CI/CD Integration section
  4. Copy the Trigger URL and Status URL template

URL format

The URLs follow this pattern:

# Trigger a scan
POST https://stackshield.io/go/scan/{'{'}check_id{'}'}/{'{'}hash{'}'}/trigger

# Check scan status
GET https://stackshield.io/go/scan/{'{'}scan_id{'}'}/{'{'}hash{'}'}/status

The {'{'}hash{'}'} is unique to your team and is pre-filled in the URLs shown on the check page.

GitHub Actions

Add this workflow to trigger a scan after every deployment:

name: Security Scan

on:
  workflow_run:
    workflows: ["Deploy"]
    types: [completed]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - name: Trigger StackShield scan
        id: trigger
        run: |
          RESPONSE=$(curl -s -X POST "YOUR_TRIGGER_URL_HERE")
          SCAN_ID=$(echo $RESPONSE | jq -r '.scan_id')
          echo "scan_id=$SCAN_ID" >> $GITHUB_OUTPUT
          echo "Scan triggered: $SCAN_ID"

      - name: Wait for scan to complete
        run: |
          STATUS="in_progress"
          while [ "$STATUS" = "in_progress" ] || [ "$STATUS" = "pending" ]; do
            sleep 30
            RESPONSE=$(curl -s "YOUR_STATUS_URL_HERE/${{ steps.trigger.outputs.scan_id }}")
            STATUS=$(echo $RESPONSE | jq -r '.status')
            echo "Scan status: $STATUS"
          done

          FAILED=$(echo $RESPONSE | jq -r '.failed_tests')
          echo "Failed tests: $FAILED"

          if [ "$FAILED" -gt 0 ]; then
            echo "::warning::Security scan found $FAILED failing tests"
          fi

Tip: use secrets for the URL

While CI/CD URLs are hash-authenticated (not secret-based), you may still want to store them as GitHub Actions secrets to keep your check IDs private. Use ${{'{'}secrets.STACKSHIELD_TRIGGER_URL{'}'}{'}'} instead of hardcoding the URL.

GitLab CI

security_scan:
  stage: post-deploy
  script:
    - RESPONSE=$(curl -s -X POST "$STACKSHIELD_TRIGGER_URL")
    - SCAN_ID=$(echo $RESPONSE | jq -r '.scan_id')
    - echo "Scan triggered - ID $SCAN_ID"
    - |
      STATUS="pending"
      while [ "$STATUS" = "in_progress" ] || [ "$STATUS" = "pending" ]; do
        sleep 30
        RESPONSE=$(curl -s "${STACKSHIELD_STATUS_URL}/${SCAN_ID}")
        STATUS=$(echo $RESPONSE | jq -r '.status')
        echo "Status: $STATUS"
      done
    - FAILED=$(echo $RESPONSE | jq -r '.failed_tests')
    - if [ "$FAILED" -gt 0 ]; then echo "WARNING - $FAILED security tests failed"; fi
  only:
    - main

Generic script (any CI/CD)

For Bitbucket Pipelines, CircleCI, Jenkins, or any other CI/CD platform, use this shell script:

#!/bin/bash
set -e

TRIGGER_URL="YOUR_TRIGGER_URL_HERE"
STATUS_BASE_URL="YOUR_STATUS_URL_HERE"

# Trigger the scan
echo "Triggering StackShield security scan..."
RESPONSE=$(curl -s -X POST "$TRIGGER_URL")
SCAN_ID=$(echo $RESPONSE | jq -r '.scan_id')

if [ "$SCAN_ID" = "null" ] || [ -z "$SCAN_ID" ]; then
  echo "Error: Failed to trigger scan"
  echo "$RESPONSE"
  exit 1
fi

echo "Scan triggered successfully (ID: $SCAN_ID)"

# Poll for completion
echo "Waiting for scan to complete..."
while true; do
  sleep 30
  RESPONSE=$(curl -s "${STATUS_BASE_URL}/${SCAN_ID}")
  STATUS=$(echo $RESPONSE | jq -r '.status')

  case $STATUS in
    "completed")
      PASSED=$(echo $RESPONSE | jq -r '.passed_tests')
      FAILED=$(echo $RESPONSE | jq -r '.failed_tests')
      WARNINGS=$(echo $RESPONSE | jq -r '.warning_tests')
      echo "Scan complete: $PASSED passed, $FAILED failed, $WARNINGS warnings"

      if [ "$FAILED" -gt 0 ]; then
        echo "WARNING: Security scan detected $FAILED failing tests"
        # Uncomment to fail the build on security issues:
        # exit 1
      fi
      break
      ;;
    "failed")
      echo "Error: Scan failed"
      exit 1
      ;;
    *)
      echo "Status: $STATUS - waiting..."
      ;;
  esac
done

API response format

Trigger response

{
  "message": "Scan triggered successfully",
  "scan_id": "9e8f7d6c-5b4a-3c2d-1e0f-a1b2c3d4e5f6",
  "status": "pending",
  "check_id": "1a2b3c4d-5e6f-7a8b-9c0d-e1f2a3b4c5d6",
  "domain": "example.com"
}

Status response

{
  "scan_id": "9e8f7d6c-5b4a-3c2d-1e0f-a1b2c3d4e5f6",
  "status": "completed",
  "domain": "example.com",
  "passed_tests": 18,
  "failed_tests": 2,
  "warning_tests": 1,
  "created_at": "2026-03-07T12:00:00Z",
  "completed_at": "2026-03-07T12:02:30Z"
}

Best practices

  • Run after deployment — trigger scans after your deploy step completes, not before. You want to test what's actually live.
  • Don't block on warnings — fail builds on critical/high issues only. Warnings are informational.
  • Set a timeout — scans typically complete in 1-3 minutes. Set a 10-minute timeout as a safety net.
  • Poll every 30 seconds — more frequent polling doesn't speed up the scan and adds unnecessary load.
  • Combine with scheduled scans — CI/CD scans catch deployment regressions, while scheduled scans catch time-based issues like certificate expirations.

Troubleshooting

403 Forbidden

The hash in the URL is invalid. Regenerate the CI/CD URLs from the check page — the hash is derived from your team ID and is stable, but make sure you're using the exact URL shown.

404 Not Found

The check ID or scan ID in the URL doesn't exist. Double-check you're using the correct IDs.

Scan stays in "pending" forever

The scan queue may be backed up. If a scan hasn't started within 5 minutes, check the StackShield dashboard for any system notices. You can trigger a new scan if needed.