[CI_CD]

How to Add a GitHub Actions Security Scan to Your CI/CD Pipeline

The short answer: add a job to your GitHub Actions workflow that runs after your deploy step, have it call the SteelSuit scan API against your staging or preview URL, wait for the result, and exit non-zero if there are high or critical findings. That turns a security check into an automatic gate that runs on every deploy — no scanner to install, just curl and an API key.

This guide gives you a complete, copy-pasteable workflow for a GitHub Actions security scan, explains each step, and shows how to turn it into a security check before deploy that can fail the build.

Why run a security scan in CI/CD at all?

Manual security reviews don't keep up with continuous delivery. If you ship multiple times a day, the only way to catch regressions — a header that got dropped, a debug endpoint that got exposed, a TLS misconfiguration on a new subdomain — is to make the check part of the pipeline. That is the whole idea behind shifting security left, and the OWASP DevSecOps Guideline describes building exactly these automated controls into delivery.

SteelSuit is an external, black-box scanner: it takes a deployed URL, runs a chain of recon and vulnerability checks against it from the outside, and returns deduped findings with a severity, an A–F (and 0–100) score, and a signed report link. There's no agent to install and no source-code access — which is precisely what makes it easy to drop into automated security testing in CI. You point it at the URL you just deployed.

What you need before you start

RequirementDetail
A SteelSuit API keyFormat sk_..., created in Dashboard → API keys. API access is a paid-plan feature; the free plan has no API.
The key stored as a secretAdd it as a repository or environment encrypted secret named STEELSUIT_API_KEY. Never hard-code it in the workflow.
A staging/preview URLThe deploy step should expose the URL you want to scan (a Vercel/Netlify preview, a staging host, etc.).
A delivery channelThe API requires at least one delivery channel (webhook, email, or slack). The simplest is email to your own account address.

The flow: start, poll, gate

The API is intentionally small. Three calls cover the whole job:

  1. Start the scanPOST /api/v1/integrations/scans with the target URL, a pipeline, and the required delivery array. It returns 202 with a scan_id, a signed result_url (valid 7 days), and the approximate queue depth.
  2. Poll for completionGET /api/v1/scans/{scan_id} returns a status. Wait until it reaches a terminal state: completed, failed, or partial.
  3. Gate on findingsGET /api/v1/scans/{scan_id}/findings?severity=critical,high returns only the severities you ask for. If the list is non-empty, fail the build.

Auth is the same on every call: an Authorization: Bearer sk_... header.

The workflow YAML

Drop this into .github/workflows/security-scan.yml. It runs after a deploy job (referenced here as deploy) and reads the preview URL from that job's output.

name: Security scan

on:
  push:
    branches: [main]

jobs:
  # ... your existing deploy job that publishes a preview/staging URL ...
  # It must expose the URL as an output named `preview_url`.

  security-scan:
    needs: deploy
    runs-on: ubuntu-latest
    env:
      STEELSUIT_API_KEY: ${{ secrets.STEELSUIT_API_KEY }}
      TARGET_URL: ${{ needs.deploy.outputs.preview_url }}
    steps:
      - name: Start scan, poll, and gate on high/critical findings
        run: |
          set -euo pipefail

          # 1. Start the scan against the deployed preview/staging URL.
          #    `delivery` is REQUIRED — email to your own account is simplest.
          #    `pipeline` is fast_scan (~26s) or deep_scan (~5-6 min).
          scan_id=$(curl -fsS -X POST \
            https://steelsuit.com/api/v1/integrations/scans \
            -H "Authorization: Bearer $STEELSUIT_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{\"target\":\"$TARGET_URL\",\"pipeline\":\"fast_scan\",\"delivery\":[{\"type\":\"email\"}]}" \
            | jq -r .scan_id)
          echo "Started scan $scan_id"

          # 2. Poll until the scan reaches a terminal state.
          for _ in $(seq 1 60); do
            status=$(curl -fsS \
              -H "Authorization: Bearer $STEELSUIT_API_KEY" \
              "https://steelsuit.com/api/v1/scans/$scan_id" | jq -r .status)
            echo "status: $status"
            case "$status" in
              completed|failed|partial) break ;;
            esac
            sleep 5
          done

          # 3. Fail the build on any high or critical finding.
          n=$(curl -fsS \
            -H "Authorization: Bearer $STEELSUIT_API_KEY" \
            "https://steelsuit.com/api/v1/scans/$scan_id/findings?severity=critical,high" \
            | jq 'length')
          echo "high/critical findings: $n"
          if [ "$n" -ne 0 ]; then
            echo "::error::SteelSuit found $n high/critical issue(s)"
            exit 1
          fi

A few notes on why it's written this way:

  • set -euo pipefail makes the step fail loudly if any curl fails, rather than silently continuing.
  • curl -fsS fails on HTTP errors (-f), stays quiet on progress (-s), but still prints errors (-S).
  • The poll loop is bounded (60 iterations × 5s). A fast_scan is usually done in under a minute; the cap stops a hung job from running forever.
  • The gate uses ?severity=critical,high. Change it to just critical if you only want to block on the most serious issues, or add medium to be stricter.

For a runnable variant and notes on other runners, see the internal CI integration docs.

Webhook instead of polling

If your CI can receive an HTTP callback — or you'd rather not hold a runner open while the scan runs — swap the email delivery for a webhook and drop the poll loop entirely. SteelSuit POSTs a result envelope (score, finding counts, and the signed result_url) to your endpoint when the scan finishes, signed with your shared secret in the X-SteelSuit-Signature header so you can verify it:

{ "type": "webhook", "target": "https://example.com/hooks/steelsuit", "secret": "your-shared-secret" }

You can list up to five delivery channels in one request, so it's fine to send a webhook and an email at the same time.

Which pipeline should CI use?

  • fast_scan (~26 seconds) is the default for CI. It's quick enough to run inline on every push without slowing the pipeline noticeably.
  • deep_scan (~5–6 minutes) is more thorough but requires DNS-TXT domain-ownership verification for the target, so it's best for scheduled runs or release branches rather than every pull request.
  • pentest (active scanning) is not available over the API — it's deliberately gated, so don't reference it in a workflow.

A practical setup: run fast_scan on every push to a feature branch, and reserve deep_scan for merges into main or a nightly schedule. Each scan consumes your plan's per-period quota, so if you're scanning every pull request, scope the job to the branches that matter.

Reading the result

When the gate fails, the same result_url from the start response opens the full report so a human can triage. The findings endpoint also returns JSON your tooling can parse, and a ?format=llm variant produces a fix-oriented prompt if you want to feed it to an assistant. To understand the severity levels and score, see how to read a vulnerability scan report.

If you're standing up CI security from scratch, pair this with a baseline review using the web app security checklist, and — if you're shipping a lot of AI-generated code — the patterns in keeping vibe-coded apps secure are worth a read before you trust the gate.

Common pitfalls

  • Scan the preview, not production. Always point the job at the staging/preview deploy, not your live site.
  • One scan per domain at a time. Starting a second scan for a domain that already has one queued or running returns 409. Let the previous one finish, or scope CI to main.
  • Keep the key out of logs. GitHub redacts registered secrets in logs automatically, but don't echo the key yourself. Store it as an encrypted secret and reference it only via ${{ secrets.STEELSUIT_API_KEY }}.
  • Make the job a required check. A failing job only blocks a merge if it's marked required in your branch protection rules — see the GitHub Actions documentation.

That's the whole pattern: deploy to a preview, scan it, and let the result decide whether the build moves forward. A few lines of YAML turn an external security scan into a standing CI/CD security scan that runs on every change.

Frequently asked

How do I run a security scan in GitHub Actions?

Add a workflow step that calls a scanning API with curl. With SteelSuit, store your sk_ API key as an encrypted secret, then POST to /api/v1/integrations/scans with your staging URL, a pipeline (fast_scan or deep_scan), and a required delivery channel. The response gives you a scan_id you poll for the result. No tool needs to be installed on the runner — it's plain HTTP, so any runner with curl and jq works.

Can I fail a CI build on security findings?

Yes. After the scan reaches a terminal state, fetch the findings filtered to the severities you care about — for example GET /api/v1/scans/{id}/findings?severity=critical,high — and exit the step with a non-zero code if any are returned. GitHub Actions marks the job as failed, which blocks the merge or deploy if the job is a required check.

Should I scan production or staging in CI?

Scan your staging or preview deployment, not production. In a CI/CD pipeline you typically deploy to a preview environment first, so point the scan at that URL. SteelSuit's fast_scan and deep_scan are passive external recon, but scanning the preview keeps load and noise off production while still testing the exact build you are about to ship.

Do I need to install anything on my GitHub Actions runner?

No. The scan runs server-side via the SteelSuit API. Your workflow only needs curl and jq, which are already present on GitHub-hosted runners, plus an API key stored as an encrypted secret. There is no agent, no scanner binary, and no source-code access involved — it is a black-box external scan of your deployed URL.

How long does a scan take in a CI pipeline?

A fast_scan typically finishes in around 26 seconds, which is short enough to run inline on every deploy. A deep_scan takes roughly five to six minutes and requires DNS-TXT domain-ownership verification, so it suits scheduled or release-branch runs rather than every pull request.