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
- Go to Security Checks
- Click on the check you want to integrate with CI/CD
- Find the CI/CD Integration section
- 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.