How to Integrate GitHub Actions with Grawlr for Automated
Security Testing?

Continuous integration pipelines commonly run unit tests, code-quality checks, dependency scans and deployment validation. These checks are valuable, but they do not necessarily show how a running web application or web-based asset behaves when it receives malicious requests.

How to Integrate GitHub Actions with Grawlr for Automated Security Testing?

Grawlr adds dynamic and adaptive application security testing to the delivery pipeline. Instead of inspecting source code, Grawlr sends security test payloads to a running application and measures whether the application successfully blocks them. By integrating Grawlr with GitHub Actions, the development team can automatically trigger a security scan after a commit or a deployment, wait for the result, enforce a security threshold and stop the pipeline when too many attacks succeed.

The integration uses ordinary authenticated HTTP requests, so no custom GitHub Action is required.

How the Integration Works?

A typical GitHub Actions integration follows these steps:

  1. The application is deployed to a reachable staging, preview, or production environment.
  2. GitHub Actions sends a request to Grawlr to start a security scan.
  3. Grawlr queues the scan and returns a run identifier.
  4. GitHub Actions polls the run until the scan finishes.
  5. The workflow reads the security score and test results.
  6. The pipeline passes or fails according to the configured security policy.

Grawlr provides two CI/CD API endpoints:

  • POST /ci/v1/runs starts an on-demand security scan.
  • GET /ci/v1/runs/{run} returns the current scan status and, once completed, the result.

The API base URL is:

https://api.grawlr.com

Prerequisites

Before creating the GitHub Actions workflow, prepare the integration in the Grawlr dashboard.

You need:

  • a Grawlr account with the CI/CD pipeline API enabled;
  • an active and verified web application or web-based asset;
  • at least 1 security test package assigned to that application;
  • a CI/CD target created in the Grawlr dashboard;
  • ... and a Grawlr CI/CD API key.

API keys and targets are created on the CI/CD section in the Grawlr dashboard.

The API key begins with:

grwl_ci_

The complete key is displayed only once when it is created. Store it immediately in a secure location.

The target is represented by a GUID, for example:

a1b2c3d4-e5f6-7890-abcd-ef1234567890

Grawlr only allows the pipeline API to start scans for targets that have already been configured and associated with an eligible verified web application or web-based asset.

Store the Grawlr Credentials in GitHub

The Grawlr API key must not be committed to the repository.

In GitHub, open:

Repository → Settings → Secrets and variables → Actions

Under Secrets, create:

GRAWLR_API_KEY

Store the complete grwl_ci_... API key as its value.

Under Variables, create:

GRAWLR_TARGET

Use the target GUID from the Grawlr CI/CD dashboard as its value.

The API key is sensitive and belongs in GitHub Secrets. The target GUID is an identifier rather than a credential, so it can normally be stored as a GitHub Actions variable.

They can then be accessed in the workflow as:

${{ secrets.GRAWLR_API_KEY }}
${{ vars.GRAWLR_TARGET }}

Authentication

Every Grawlr CI/CD API request must include the API key as a bearer token:

Authorization: Bearer grwl_ci_<your_api_key>

The CI/CD API does not use browser sessions or cookie authentication.

Understanding the Response Format

Every Grawlr CI/CD API response uses a JSON envelope.

A successful response looks like this:

{
  "status": "OK",
  "payload": {
    "...": "..."
  }
}

An unsuccessful response looks like this:

{
  "status": "NOK",
  "payload": "Description of the error"
}

The API also returns an appropriate HTTP status code, such as:

  • 400 for an invalid request;
  • 401 for a missing or invalid API key;
  • 403 when the CI/CD API is not enabled;
  • 404 when a target or run cannot be found;
  • 429 when the monthly CI/CD quota has been exceeded.

A reliable integration should check both the HTTP status code and the top-level status field.

Start a Grawlr Security Scan

To start a scan, send a POST request to:

https://api.grawlr.com/ci/v1/runs

The request body contains the target GUID:

{
  "target": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

A direct curl request looks like this:

curl --request POST \
  --url "https://api.grawlr.com/ci/v1/runs" \
  --header "Authorization: Bearer grwl_ci_<your_api_key>" \
  --header "Content-Type: application/json" \
  --header "Accept: application/json" \
  --data '{
    "target": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }'

A successful response returns immediately:

{
  "status": "OK",
  "payload": {
    "run": "f0e1d2c3-b4a5-6789-0abc-def123456789",
    "status": "PENDING",
    "target": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "queued_at": "2026-07-26 18:30:00"
  }
}

The most important value is the run identifier:

"run": "f0e1d2c3-b4a5-6789-0abc-def123456789"

GitHub Actions must save this value and use it when checking the scan status.

Create the GitHub Actions Workflow

Create the following file in your repository:

.github/workflows/grawlr-security.yml

The workflow below can initially be triggered manually:

name: Grawlr Security Scan

on:
  workflow_dispatch:

jobs:
  grawlr-security:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    env:
      GRAWLR_API_URL: https://api.grawlr.com
      GRAWLR_TARGET: ${{ vars.GRAWLR_TARGET }}

    steps:
      - name: Start Grawlr security scan
        id: start_grawlr
        shell: bash
        env:
          GRAWLR_API_KEY: ${{ secrets.GRAWLR_API_KEY }}
        run: |
          set -euo pipefail

          response_file=$(mktemp)
          trap 'rm -f "${response_file}"' EXIT

          http_code=$(curl \
            --silent \
            --show-error \
            --output "${response_file}" \
            --write-out "%{http_code}" \
            --request POST \
            --url "${GRAWLR_API_URL}/ci/v1/runs" \
            --header "Authorization: Bearer ${GRAWLR_API_KEY}" \
            --header "Content-Type: application/json" \
            --header "Accept: application/json" \
            --data "$(jq -n \
              --arg target "${GRAWLR_TARGET}" \
              '{target: $target}')")

          response=$(cat "${response_file}")

          if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
            echo "Grawlr returned HTTP ${http_code}."
            echo "${response}"
            exit 1
          fi

          envelope_status=$(jq -r '.status // empty' <<< "${response}")

          if [ "${envelope_status}" != "OK" ]; then
            error_message=$(jq -r '.payload // "Unknown Grawlr error"' <<< "${response}")
            echo "Grawlr rejected the request: ${error_message}"
            exit 1
          fi

          run_id=$(jq -r '.payload.run // empty' <<< "${response}")
          run_status=$(jq -r '.payload.status // empty' <<< "${response}")

          if [ -z "${run_id}" ]; then
            echo "Grawlr did not return a run identifier."
            exit 1
          fi

          if [ "${run_status}" != "PENDING" ]; then
            echo "Unexpected initial Grawlr status: ${run_status}"
            exit 1
          fi

          echo "run=${run_id}" >> "${GITHUB_OUTPUT}"
          echo "Grawlr scan queued successfully."
          echo "Run: ${run_id}"

Using jq -n to construct the request body avoids problems caused by manually escaping JSON inside the shell script.

Poll Until the Scan Finishes

A Grawlr scan is asynchronous. However starting a run queues it for immediate execution.

A run can have the following statuses:

  • PENDING means the scan is queued.
  • RUNNING means a worker is executing the scan.
  • COMPLETED means the scan finished and the result is available.
  • FAILED means the scan could not be executed successfully.
  • CANCELLED means the run was cancelled.

A background worker normally picks up on-demand runs approximately once per minute. The workflow should therefore wait between polling requests.

Add the following step after the scan creation step:

      - name: Wait for Grawlr scan
        id: wait_grawlr
        shell: bash
        env:
          GRAWLR_API_KEY: ${{ secrets.GRAWLR_API_KEY }}
          GRAWLR_RUN: ${{ steps.start_grawlr.outputs.run }}
        run: |
          set -euo pipefail

          max_attempts=30
          polling_interval=30

          for attempt in $(seq 1 "${max_attempts}"); do
            echo "Checking Grawlr run ${GRAWLR_RUN}."
            echo "Polling attempt ${attempt}/${max_attempts}."

            response_file=$(mktemp)

            http_code=$(curl \
              --silent \
              --show-error \
              --output "${response_file}" \
              --write-out "%{http_code}" \
              --request GET \
              --url "${GRAWLR_API_URL}/ci/v1/runs/${GRAWLR_RUN}" \
              --header "Authorization: Bearer ${GRAWLR_API_KEY}" \
              --header "Accept: application/json")

            response=$(cat "${response_file}")
            rm -f "${response_file}"

            if [ "${http_code}" -lt 200 ] || [ "${http_code}" -ge 300 ]; then
              echo "Grawlr returned HTTP ${http_code} while polling."
              echo "${response}"
              exit 1
            fi

            envelope_status=$(jq -r '.status // empty' <<< "${response}")

            if [ "${envelope_status}" != "OK" ]; then
              error_message=$(jq -r '.payload // "Unknown Grawlr error"' <<< "${response}")
              echo "Could not retrieve the Grawlr run: ${error_message}"
              exit 1
            fi

            run_status=$(jq -r '.payload.status // empty' <<< "${response}")
            echo "Current Grawlr status: ${run_status}"

            case "${run_status}" in
              PENDING|RUNNING)
                sleep "${polling_interval}"
                ;;

              COMPLETED)
                echo "${response}" > grawlr-result.json

                score=$(jq -r '.payload.score' <<< "${response}")
                tests=$(jq -r '.payload.tests' <<< "${response}")
                passed=$(jq -r '.payload.passed' <<< "${response}")
                failed=$(jq -r '.payload.failed' <<< "${response}")
                blocked=$(jq -r '.payload.blocked' <<< "${response}")

                echo "score=${score}" >> "${GITHUB_OUTPUT}"
                echo "tests=${tests}" >> "${GITHUB_OUTPUT}"
                echo "passed=${passed}" >> "${GITHUB_OUTPUT}"
                echo "failed=${failed}" >> "${GITHUB_OUTPUT}"
                echo "blocked=${blocked}" >> "${GITHUB_OUTPUT}"

                echo "Grawlr scan completed."
                echo "Score: ${score}"
                echo "Tests: ${tests}"
                echo "Blocked: ${blocked}"
                echo "Failed: ${failed}"
                exit 0
                ;;

              FAILED)
                echo "${response}" > grawlr-result.json
                error_code=$(jq -r '.payload.error // "scan_execution_failed"' <<< "${response}")

                echo "Grawlr could not execute the scan."
                echo "Execution error: ${error_code}"
                exit 1
                ;;

              CANCELLED)
                echo "${response}" > grawlr-result.json
                echo "The Grawlr scan was cancelled."
                exit 1
                ;;

              *)
                echo "Unexpected Grawlr run status: ${run_status}"
                exit 1
                ;;
            esac
          done

          echo "Grawlr did not complete within the configured polling period."
          exit 1

With 30 attempts and a 30-second interval, the workflow allows approximately 15 minutes for the scan to begin and finish.

Larger test packages may require a longer timeout.

Understanding the Completed Result

A completed scan returns a payload similar to this:

{
  "status": "OK",
  "payload": {
    "run": "f0e1d2c3-b4a5-6789-0abc-def123456789",
    "status": "COMPLETED",
    "target": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "queued_at": "2026-07-26 18:30:00",
    "started_at": "2026-07-26 18:31:05",
    "completed_at": "2026-07-26 18:34:22",
    "score": 92.5,
    "tests": 40,
    "passed": 37,
    "failed": 3,
    "blocked": 37
  }
}

The fields have the following meanings:

  • score is the overall security score from 0 to 100.
  • tests is the total number of attacks attempted.
  • passed is the number of attacks blocked by the application.
  • blocked is an alias for passed.
  • failed is the number of attacks that got through.

A higher passed or blocked value is good.

A higher failed value is bad because it indicates that more attack attempts were not stopped.

The full human-readable report and PDF are not returned through the CI/CD API. Detailed findings and reports can be opened from the Grawlr dashboard.

Gatekeeping the Pipeline

Once the run reaches COMPLETED, GitHub Actions can decide whether the pipeline should continue.

The strictest rule is:

failed == 0

This means that every tested attack must be blocked.

However, a team introducing Grawlr into an existing application may initially need a more gradual rule. For example, the pipeline may continue as long as no more than five tests fail:

failed <= 5

A minimum security score can be enforced at the same time:

score >= 80

Add the following evaluation step:

      - name: Apply Grawlr security gate
        shell: bash
        env:
          GRAWLR_SCORE: ${{ steps.wait_grawlr.outputs.score }}
          GRAWLR_FAILED: ${{ steps.wait_grawlr.outputs.failed }}
          MAX_FAILED_TESTS: "5"
          MIN_SECURITY_SCORE: "80"
        run: |
          set -euo pipefail

          echo "Applying Grawlr pipeline policy."
          echo "Security score: ${GRAWLR_SCORE}"
          echo "Failed tests: ${GRAWLR_FAILED}"
          echo "Minimum score: ${MIN_SECURITY_SCORE}"
          echo "Maximum failed tests: ${MAX_FAILED_TESTS}"

          if [ "${GRAWLR_FAILED}" -gt "${MAX_FAILED_TESTS}" ]; then
            echo "Security gate failed."
            echo "Grawlr found ${GRAWLR_FAILED} successful attacks."
            echo "The configured maximum is ${MAX_FAILED_TESTS}."
            exit 1
          fi

          if ! awk \
            -v score="${GRAWLR_SCORE}" \
            -v minimum="${MIN_SECURITY_SCORE}" \
            'BEGIN { exit !(score >= minimum) }'
          then
            echo "Security gate failed."
            echo "Grawlr score ${GRAWLR_SCORE} is below ${MIN_SECURITY_SCORE}."
            exit 1
          fi

          echo "Grawlr security gate passed."

The score comparison uses awk because Grawlr scores may contain decimal values such as 92.5. Standard Bash integer comparisons do not reliably handle decimal numbers.

Example Gatekeeping Policies

A strict production policy could use:

MAX_FAILED_TESTS: "0"
MIN_SECURITY_SCORE: "90"

This blocks deployment when any tested attack succeeds.

A progressive adoption policy could use:

MAX_FAILED_TESTS: "5"
MIN_SECURITY_SCORE: "80"

This allows a limited number of existing findings while still preventing severely insecure releases.

During the initial integration, a team may temporarily use monitoring-only mode:

      - name: Report Grawlr result
        run: |
          echo "Grawlr score: ${{ steps.wait_grawlr.outputs.score }}"
          echo "Failed tests: ${{ steps.wait_grawlr.outputs.failed }}"

Monitoring-only mode should normally be temporary. Once the baseline is understood, the team can introduce stricter thresholds.

What “No More Than Five Failed” Means?

A policy allowing no more than five failed tests means:

failed <= 5

The deployment is allowed when the result contains zero, one, two, three, four, or five failed tests.

The pipeline should be blocked when the result contains six or more failed tests.

The Bash condition is:

if [ "${failed}" -gt 5 ]; then
  exit 1
fi

This is different from requiring fewer than five failures, which would mean failed < 5.

Distinguish Security Findings from Execution Failures

A completed scan with three failed tests might look like this:

{
  "status": "COMPLETED",
  "failed": 3
}

This means the scan ran successfully, but three attack attempts got through.

An execution failure might look like this:

{
  "status": "FAILED",
  "error": "scan_execution_failed",
  "failed": 0,
  "score": null
}

This means the scan itself did not complete and these results must not be treated as equivalent.

A FAILED run is an operational or infrastructure failure. The zero in the failed field does not mean the application passed the security test. No valid security conclusion can be made because the scan did not finish.

The workflow should normally fail in both situations, but it should report a different reason

  • A security gate failure means the scan completed, but too many attacks succeeded.
  • An execution failure means Grawlr could not complete the scan.

External Notifications

Grawlr can notify external communication and incident-management channels when a scan finishes.

When external notifications are enabled, the CI/CD-triggered scan result is sent to all configured and enabled notification channels. For example, when Slack notifications are enabled, the configured Slack channel receives the result of the scan. Similarly when several integrations are enabled, all configured channels are notified according to their notification settings. This can include services such as Slack, Mattermost, Discord, PagerDuty and other supported destinations.

The GitHub Actions workflow therefore does not necessarily need its own Slack-specific or webhook-specific notification step. GitHub Actions remains responsible for deciding whether the pipeline passes or fails. Grawlr handles distributing the scan result to the configured external channels.

This provides several advantages:

  • Notification settings can be managed centrally in Grawlr.
  • The same notification policy can be used across several repositories.
  • External service credentials do not need to be duplicated in GitHub.
  • Security and operations teams can receive results without regularly checking GitHub Actions.

Add the Result to the GitHub Job Summary

GitHub Actions supports a formatted job summary.

Add the following step:

      - name: Add Grawlr result to job summary
        if: always() && steps.wait_grawlr.outputs.tests != ''
        shell: bash
        run: |
          {
            echo "## Grawlr Security Scan"
            echo ""
            echo "Score: ${{ steps.wait_grawlr.outputs.score }}"
            echo ""
            echo "Tests: ${{ steps.wait_grawlr.outputs.tests }}"
            echo ""
            echo "Blocked: ${{ steps.wait_grawlr.outputs.blocked }}"
            echo ""
            echo "Failed: ${{ steps.wait_grawlr.outputs.failed }}"
          } >> "${GITHUB_STEP_SUMMARY}"

This version avoids a Markdown table and is easier to paste into rich-text editors.

Upload the JSON Result as an Artifact

The API response can also be retained as a GitHub Actions artifact:

      - name: Upload Grawlr result
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: grawlr-security-result
          path: grawlr-result.json
          if-no-files-found: warn
          retention-days: 14

The if: always() condition allows the upload step to run even when the security gate fails.

The artifact contains the API response rather than the complete Grawlr report. Detailed findings remain available in the Grawlr dashboard.

Run Grawlr After Deployment

Grawlr requires a running application and the job should therefore normally follow a successful application deployment:

jobs:
  deploy-staging:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to staging
        run: ./scripts/deploy-staging.sh

  grawlr-security:
    needs: deploy-staging
    runs-on: ubuntu-latest

    steps:
      - name: Start Grawlr security scan
        run: |
          # Grawlr API request

The needs: deploy-staging setting prevents the security scan from starting before the application has been deployed.

For preview or staging environments, it can also be useful to wait until a health endpoint responds:

      - name: Wait for staging application
        shell: bash
        run: |
          set -euo pipefail

          for attempt in $(seq 1 30); do
            if curl \
              --silent \
              --show-error \
              --fail \
              "https://staging.example.com/health" \
              > /dev/null
            then
              echo "Staging application is ready."
              exit 0
            fi

            echo "Waiting for staging application."
            sleep 10
          done

          echo "Staging application did not become ready."
          exit 1

Common API Errors

Invalid or Missing API Key

The API may return:

{
  "status": "NOK",
  "payload": "Invalid or missing API key"
}

The HTTP status is 401.

Check that:

  • GRAWLR_API_KEY exists in GitHub Secrets;
  • the complete API key was copied;
  • the key has not been revoked;
  • and the authorization header uses Bearer.

CI/CD API Not Enabled

The API may return:

{
  "status": "NOK",
  "payload": "CI/CD pipeline API is not enabled for this account"
}

The HTTP status is 403.

The CI/CD pipeline feature must be enabled for the Grawlr account.

Target Is Missing

The API may return:

{
  "status": "NOK",
  "payload": "target is required"
}

Check that GRAWLR_TARGET exists and is available to the workflow.

Target Not Found

The API may return:

{
  "status": "NOK",
  "payload": "Target not found"
}

The target may have been deleted, copied incorrectly, or created under another customer account.

Web Application Is Not Active or Verified

The API may return:

{
  "status": "NOK",
  "payload": "Target website is not active/verified"
}

Open the Grawlr dashboard and verify that the application is active and eligible for testing.

Package Is Not Assigned

The API may return:

{
  "status": "NOK",
  "payload": "Package is not assigned to this website"
}

The application must have a security test package assigned before the CI/CD API can queue a run.

Monthly Quota Exceeded

The API may return:

{
  "status": "NOK",
  "payload": "Monthly CI/CD scan quota exceeded"
}

The HTTP status is 429.

The account has reached its monthly on-demand CI/CD scan allowance.

Recommended Rollout Strategy

A practical rollout can happen in three stages.

Stage 1: Visibility

Run Grawlr after staging deployments and report the result without blocking releases.

This establishes the current baseline and helps the team understand which findings are already present.

Stage 2: Limited Gatekeeping

Start with a policy such as:

failed <= 5
score >= 80

This prevents severe regressions while giving the team time to address existing findings.

Stage 3: Strict Enforcement

Move toward a stricter policy such as:

failed == 0
score >= 90

The appropriate score threshold depends on the application, the assigned security test package, and the organization’s release policy.

Conclusion

Integrating Grawlr with GitHub Actions adds dynamic and adaptive security testing directly to the CI/CD process.

The workflow starts an on-demand scan with:

POST /ci/v1/runs

It then polls:

GET /ci/v1/runs/{run}

until the run is completed or fails.

Once the scan is completed, GitHub Actions can use the result to enforce deployment rules. A strict pipeline can require zero failed tests, while a progressive policy can require a minimum security score. When external notifications are enabled in Grawlr, the scan result is also sent to all configured notification channels, such as Slack. This keeps engineering, security, and operations teams informed without requiring every GitHub repository to maintain separate notification integrations.

The end result is an automated and repeatable security validation that can stop a deployment when the application no longer meets its required security standard.

← Volver al blog