ブログに戻る
DevSecOps

Automating Penetration Tests with CI/CD

The real CyberSec Pro API contract for a GitHub Actions pipeline — start a scan, poll it, gate the build on severity — and why a noisy gate is worse than no gate.

Semih Kilic January 2, 2026 5 min read

The case for automating the boring half

Manual penetration testing is where the real findings come from — the access-control flaw a human notices, the business-logic abuse no scanner has a signature for. But a lot of security work is not that. Checking that no dependency has a known CVE, that a scan of the staging site still comes back clean, that no secret got committed — this is repetitive, mechanical, and exactly the kind of thing that gets skipped under deadline pressure precisely because it is repetitive. Automating it in the pipeline means it runs on every change whether anyone remembers or not, and it catches the regression in development, where a fix is cheap, rather than in production, where it is not.

The goal is not to replace the pentest. It is to let the pentester spend their time on the half that needs a human, by making the machine handle the half that does not.

Where each check belongs in the pipeline

Security testing is not one step; different checks belong at different stages, because they need different things to be true.

  • Pre-commit / commit: secret scanning and a dependency audit. These need only the source, run in seconds, and stop the two most common own-goals — a committed credential, a pulled-in package with a known CVE — before they land.
  • Build: static analysis (SAST) and container image scanning. These need the code compiled and the image built, but not deployed.
  • Post-deploy to staging: dynamic analysis (DAST) against the running application. This is the stage that needs a live URL, and it is where a tool that actually exercises the app — like a CyberSec Pro scan — fits.
  • Scheduled, out of band: a fuller scan weekly, independent of any single change, to catch the slow drift a per-commit scan is too narrow to see.
  • The mistake is trying to do everything at every stage. A DAST scan on every commit is too slow and needs a deployment that may not exist yet; a secret scan post-deploy is too late. Match the check to the stage that can actually support it.

    Triggering a scan from GitHub Actions

    Here is the real integration against the CyberSec Pro API. Note the exact shape — the endpoint, the authentication header, and the request body are what the API actually accepts:

    .github/workflows/security-scan.yml

    name: Security Scan on: push: branches: [main, develop] pull_request: branches: [main]

    jobs: dast: runs-on: ubuntu-latest steps: - name: Start a scan of the staging deployment id: scan run: | RESPONSE=$(curl -sS -X POST https://api.cyber-sec-pro.com/api/v1/scans \ -H "X-API-Key: ${{ secrets.CYBERSEC_API_KEY }}" \ -H "Content-Type: application/json" \ -d '{ "tool": "nuclei", "target": "https://staging.example.com", "parameters": { "severity": "critical,high" } }') echo "scan_id=$(echo "$RESPONSE" | jq -r .scan_id)" >> "$GITHUB_OUTPUT"

    Three things are worth calling out because they are the parts people get wrong. The path is /api/v1/scans — the /api prefix is part of it. Authentication is an API key in the X-API-Key header (CyberSec Pro keys start with csp_; the same key also works as Authorization: Bearer csp_...), not a user password. And the body's fields are tool, target and parameters — you pass the tool by name or as tool_id, and parameters is the same options object the tool's form builds in the dashboard.

    A successful call returns 201 with a body like:

    { "success": true, "scan_id": "3f2a…", "status": "running", "engine": "rust-axum" }
    

    That scan_id is what you poll.

    Waiting for the result and acting on it

    Starting a scan is asynchronous — the call returns immediately with a scan_id, and the scan runs server-side. A useful pipeline waits for it to finish and then decides whether to fail the build:

          - name: Wait for the scan and gate on severity
            run: |
              SCAN_ID="${{ steps.scan.outputs.scan_id }}"
              for i in $(seq 1 60); do
                RESULT=$(curl -sS https://api.cyber-sec-pro.com/api/v1/scans/$SCAN_ID \
                  -H "X-API-Key: ${{ secrets.CYBERSEC_API_KEY }}")
                STATUS=$(echo "$RESULT" | jq -r '.scan.status')
                [ "$STATUS" = "completed" ] && break
                [ "$STATUS" = "failed" ] && { echo "scan failed"; exit 1; }
                sleep 10
              done

    CRIT=$(echo "$RESULT" | jq -r '.scan.findings_summary.critical // 0') HIGH=$(echo "$RESULT" | jq -r '.scan.findings_summary.high // 0') echo "Critical: $CRIT High: $HIGH" if [ "$CRIT" -gt 0 ]; then echo "::error::Critical findings — failing the build." exit 1 fi

    GET /api/v1/scans/{scan_id} returns the scan with its status and, once complete, a findings_summary broken down by severity — the same structure the dashboard shows. Polling it until completed and reading those counts is the whole mechanism.

    The gate is a policy decision, not a technical one

    The most important line in that script is the one that decides what fails the build, and it is a judgement call, not a default.

  • Gate on Critical, and maybe High. These are the findings worth stopping a deploy for.
  • Never gate on informational or low findings. A pipeline that fails the build over a missing header teaches developers to ignore the security step — and once they are routing around it, it protects nothing. A noisy gate is worse than no gate.
  • Report everything, block on little. Send the full result to a dashboard or a ticket queue so nothing is lost, but let only genuinely serious findings stop the line.
  • The failure mode of DevSecOps is not too little scanning; it is scanning that cries wolf until everyone stops listening. Tune the gate so that a red build always means something a developer should actually stop and fix.

    Start small, then widen

    You do not roll all of this out at once. The order that works:

  • Secret scanning in pre-commit. Highest value, lowest friction, catches the worst mistakes.
  • Dependency audit in CI. Nearly free, and "Vulnerable and Outdated Components" is a standing OWASP risk.
  • A DAST scan against staging, gated on Critical only, once the first two are trusted.
  • A scheduled weekly full scan, independent of commits, once the per-commit scan is stable.

Each step earns trust before the next is added. A pipeline that starts by blocking every merge on a hundred low-severity findings gets disabled within a week; one that starts by quietly catching committed secrets earns the room to grow.

Doing it with CyberSec Pro

The API above is real and is how the automation works: issue an API key in your account settings, store it as a CI secret, and the same POST /api/v1/scans you saw drives every stage. Because the scan runs server-side in a dedicated container, your CI runner does not need the tools installed — it just makes an HTTPS call and reads the result — and the findings land in the same dashboard as your manual scans, so the pipeline's output and a tester's output live in one place. Automate the mechanical half here; keep the human on the half that needs judgement.

#CI/CD#automation#DevSecOps#GitHub-Actions