Browse the wiki
Product

Run a security scan in GitHub Actions

Store a Sentrint API key as a GitHub Actions secret, add a workflow that submits your repository to the Sentrint API on each push, wait for the scan to finish, then read the report. The job fails when the scan finds Critical issues, or High ones too if you choose, and each finding appears as an annotation on the commit.

Updated 2026-10-03 For developers

This guide adds a security gate to a GitHub repository. On every push to main, and on pull requests opened from branches in the same repository, the workflow asks Sentrint to scan the branch, waits for the result and fails the job if the scan finds a Critical issue. You can raise the bar to High as well.

The scripts below use only the Sentrint API, curl and jq, or the standard library of Python or Node.js. Pick one. They behave the same way.

What you need

  • A Sentrint account with the repository connected. Private repositories need your GitHub account linked with access to that repository.
  • An API key with the default permissions. Create one under API → API keys. The defaults include scans:create, scans:read and findings:read, which is everything this guide uses.
  • The ID of the workspace the repository belongs to.

To find the workspace ID, run this once from your own machine:

curl -s https://sentrint.com/api/v1/workspaces \
  -H "Authorization: Bearer $SENTRINT_API_KEY" | jq '.data[] | {id, name}'

Store the key and workspace in GitHub

In your repository, open Settings → Secrets and variables → Actions.

  1. Under Secrets, add SENTRINT_API_KEY with the full snt_... key. Keep it a secret: anyone holding it can start scans on your account.
  2. Under Variables, add SENTRINT_WORKSPACE_ID with the workspace ID. It is not sensitive.

Keys expire after 90 days by default, and one year at most. Put the expiry date in your calendar and replace the secret before then, or the workflow starts failing with invalid_api_key.

Add the workflow

Create .github/workflows/sentrint.yml:

name: Security scan

on:
  push:
    branches: [main]
  pull_request:

# One scan runs per workspace at a time, so queue runs instead of overlapping them.
concurrency:
  group: sentrint-scan
  cancel-in-progress: false

jobs:
  scan:
    # Pull requests from forks get no secrets, so skip them.
    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 75
    env:
      SENTRINT_API_KEY: ${{ secrets.SENTRINT_API_KEY }}
      SENTRINT_WORKSPACE_ID: ${{ vars.SENTRINT_WORKSPACE_ID }}
      SCAN_REPO: https://github.com/${{ github.repository }}
      SCAN_BRANCH: ${{ github.head_ref || github.ref_name }}
      IDEMPOTENCY_KEY: gh-${{ github.run_id }}-${{ github.run_attempt }}
      FAIL_ON: critical  # or "high" to also fail on High findings
    steps:
      - uses: actions/checkout@v4
      - run: bash scripts/sentrint-scan.sh

The checkout step is only there to get the script into the runner. Sentrint fetches the code from GitHub itself.

IDEMPOTENCY_KEY ties each submission to one workflow attempt. If a network error makes the script send the same request twice, Sentrint returns the original submission instead of starting a second scan. Re-running the job gets a new attempt number, so a re-run starts fresh.

The script in Bash

Save as scripts/sentrint-scan.sh. GitHub's Ubuntu runners include curl and jq.

#!/usr/bin/env bash
set -euo pipefail

BASE=${SENTRINT_URL:-https://sentrint.com}
DEADLINE=$((SECONDS + 70 * 60))
FAIL_ON=${FAIL_ON:-critical}

# Call the API. Waits and retries on rate limits; stops on any other error.
api() {
  local method=$1 path=$2 data=${3:-} code
  while :; do
    code=$(curl -sS -X "$method" "$BASE$path" \
      -H "Authorization: Bearer $SENTRINT_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
      ${data:+--data "$data"} \
      -D /tmp/sentrint.headers -o /tmp/sentrint.body -w '%{http_code}')
    if [[ $code == 429 || $code == 503 ]] && [[ $(jq -r .code /tmp/sentrint.body) != quota_exhausted ]]; then
      wait=$(awk 'tolower($1) == "retry-after:" { print $2 + 0 }' /tmp/sentrint.headers)
      sleep "${wait:-30}"
      continue
    fi
    if (( code >= 400 )); then
      # stderr, because api() runs inside $(...) and stdout is captured there.
      echo "::error::Sentrint API $code $(jq -r '"\(.code): \(.detail) (request \(.request_id))"' /tmp/sentrint.body)" >&2
      exit 1
    fi
    cat /tmp/sentrint.body
    return
  done
}

# 1. Submit. Returns at once; GitHub validation happens on Sentrint's side.
body=$(jq -n --arg repo "$SCAN_REPO" --arg branch "$SCAN_BRANCH" '{repo_url: $repo, branch: $branch}')
submission=$(api POST "/api/v1/workspaces/$SENTRINT_WORKSPACE_ID/submissions" "$body")
status_url=$(jq -r .status_url <<<"$submission")

# 2. Wait for the submission to become a scan.
while :; do
  submission=$(api GET "$status_url")
  case $(jq -r .status <<<"$submission") in
    scan_ready) break ;;
    failed) echo "::error::Submission failed: $(jq -r '"\(.error_code): \(.error_detail)"' <<<"$submission")"; exit 1 ;;
  esac
  (( SECONDS < DEADLINE )) || { echo "::error::Timed out waiting for validation"; exit 1; }
  sleep 5
done
scan_id=$(jq -r .scan_id <<<"$submission")
echo "Scan $scan_id ($(jq -r 'if .reused then "reused" else "new" end' <<<"$submission"))"

# 3. Wait for the scan to finish.
while :; do
  status=$(api GET "/api/v1/scans/$scan_id" | jq -r .status)
  case $status in
    done|completed|archived) break ;;
    pending|running) ;;
    *) echo "::error::Scan ended with status $status"; exit 1 ;;
  esac
  (( SECONDS < DEADLINE )) || { echo "::error::Timed out waiting for the scan"; exit 1; }
  sleep 15
done

# 4. Read the report and annotate findings on the commit.
report=$(api GET "/api/v1/scans/$scan_id/report")
jq -r '"Grade \(.grade), score \(.score): \(.critical) critical, \(.high) high, \(.medium) medium, \(.low) low"' <<<"$report"

severities=(CRITICAL)
[[ $FAIL_ON == high ]] && severities+=(HIGH)
# Annotation text must escape %, CR and LF; property values also escape : and ,
annotate='def esc: gsub("%"; "%25") | gsub("\r"; "%0D") | gsub("\n"; "%0A");
  def prop: esc | gsub(":"; "%3A") | gsub(","; "%2C");
  .data[] | "::error file=\(.file_path | prop),line=\(.line // 1),title=\("\(.severity) \(.rule_id)" | prop)::\(.message | esc)"'
for severity in "${severities[@]}"; do
  api GET "/api/v1/findings?scan_id=$scan_id&severity=$severity&limit=100" | jq -r "$annotate"
done

blocking=$(jq --arg fail_on "$FAIL_ON" 'if $fail_on == "high" then .critical + .high else .critical end' <<<"$report")
if (( blocking > 0 )); then
  echo "::error::$blocking blocking finding(s). Full report: $BASE/dashboard/scan/$scan_id"
  exit 1
fi
echo "No blocking findings."

The script in Python

Use this instead of the Bash version if your team prefers Python. It needs Python 3.10 or newer and no packages; ubuntu-latest ships a recent Python 3. Save as scripts/sentrint_scan.py and change the workflow's last step to run: python3 scripts/sentrint_scan.py.

"""Submit this repository to Sentrint, wait for the scan, fail on blocking findings."""

import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

BASE = os.environ.get("SENTRINT_URL", "https://sentrint.com")
FAIL_ON = os.environ.get("FAIL_ON", "critical")
DEADLINE = time.monotonic() + 70 * 60


def fail(message: str) -> None:
    print(f"::error::{message}")
    sys.exit(1)


def api(method: str, path: str, body: dict | None = None) -> dict:
    """Call the API. Waits and retries on rate limits; stops on any other error."""
    while True:
        request = urllib.request.Request(
            BASE + path,
            method=method,
            data=json.dumps(body).encode() if body is not None else None,
            headers={
                "Authorization": f"Bearer {os.environ['SENTRINT_API_KEY']}",
                "Content-Type": "application/json",
                "Idempotency-Key": os.environ["IDEMPOTENCY_KEY"],
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=60) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            problem = json.loads(error.read() or b"{}")
            if error.code in (429, 503) and problem.get("code") != "quota_exhausted":
                time.sleep(int(error.headers.get("Retry-After", "30")))
                continue
            fail(
                f"Sentrint API {error.code} {problem.get('code')}: {problem.get('detail')} "
                f"(request {problem.get('request_id')})"
            )


def esc(text: str) -> str:
    """Escape annotation text: %, CR and LF."""
    return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")


def prop(text: str) -> str:
    """Escape an annotation property value, which also cannot hold : or ,."""
    return esc(text).replace(":", "%3A").replace(",", "%2C")


def wait(what: str, seconds: int) -> None:
    if time.monotonic() > DEADLINE:
        fail(f"Timed out waiting for {what}")
    time.sleep(seconds)


# 1. Submit. Returns at once; GitHub validation happens on Sentrint's side.
workspace = os.environ["SENTRINT_WORKSPACE_ID"]
submission = api(
    "POST",
    f"/api/v1/workspaces/{workspace}/submissions",
    {"repo_url": os.environ["SCAN_REPO"], "branch": os.environ["SCAN_BRANCH"]},
)

# 2. Wait for the submission to become a scan.
while submission["status"] != "scan_ready":
    if submission["status"] == "failed":
        fail(f"Submission failed: {submission['error_code']}: {submission['error_detail']}")
    wait("validation", 5)
    submission = api("GET", submission["status_url"])
scan_id = submission["scan_id"]
print(f"Scan {scan_id} ({'reused' if submission['reused'] else 'new'})")

# 3. Wait for the scan to finish.
while True:
    status = api("GET", f"/api/v1/scans/{scan_id}")["status"]
    if status in ("done", "completed", "archived"):
        break
    if status not in ("pending", "running"):
        fail(f"Scan ended with status {status}")
    wait("the scan", 15)

# 4. Read the report and annotate findings on the commit.
report = api("GET", f"/api/v1/scans/{scan_id}/report")
print(
    f"Grade {report['grade']}, score {report['score']}: {report['critical']} critical, "
    f"{report['high']} high, {report['medium']} medium, {report['low']} low"
)
severities = ["CRITICAL", "HIGH"] if FAIL_ON == "high" else ["CRITICAL"]
for severity in severities:
    query = urllib.parse.urlencode({"scan_id": scan_id, "severity": severity, "limit": 100})
    for finding in api("GET", f"/api/v1/findings?{query}")["data"]:
        title = prop(f"{finding['severity']} {finding['rule_id']}")
        print(
            f"::error file={prop(finding['file_path'])},line={finding['line'] or 1},"
            f"title={title}::{esc(finding['message'])}"
        )

blocking = report["critical"] + (report["high"] if FAIL_ON == "high" else 0)
if blocking:
    fail(f"{blocking} blocking finding(s). Full report: {BASE}/dashboard/scan/{scan_id}")
print("No blocking findings.")

The script in JavaScript

For Node.js 20 or newer, with no packages. Save as scripts/sentrint-scan.mjs and change the workflow's last step to run: node scripts/sentrint-scan.mjs. The ubuntu-latest runner already has Node.js; add actions/setup-node if you want to pin a version.

// Submit this repository to Sentrint, wait for the scan, fail on blocking findings.
const BASE = process.env.SENTRINT_URL ?? "https://sentrint.com";
const FAIL_ON = process.env.FAIL_ON ?? "critical";
const DEADLINE = Date.now() + 70 * 60 * 1000;
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

function fail(message) {
  console.log(`::error::${message}`);
  process.exit(1);
}

// Call the API. Waits and retries on rate limits; stops on any other error.
async function api(method, path, body) {
  for (;;) {
    const response = await fetch(BASE + path, {
      method,
      body: body === undefined ? undefined : JSON.stringify(body),
      headers: {
        Authorization: `Bearer ${process.env.SENTRINT_API_KEY}`,
        "Content-Type": "application/json",
        "Idempotency-Key": process.env.IDEMPOTENCY_KEY,
      },
      signal: AbortSignal.timeout(60_000),
    });
    if (response.ok) return response.json();
    const problem = await response.json().catch(() => ({}));
    if ([429, 503].includes(response.status) && problem.code !== "quota_exhausted") {
      await sleep(Number(response.headers.get("retry-after") ?? 30));
      continue;
    }
    fail(`Sentrint API ${response.status} ${problem.code}: ${problem.detail} (request ${problem.request_id})`);
  }
}

// Annotation text escapes %, CR and LF; property values also escape : and ,
const esc = (text) => text.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
const prop = (text) => esc(text).replaceAll(":", "%3A").replaceAll(",", "%2C");

async function wait(what, seconds) {
  if (Date.now() > DEADLINE) fail(`Timed out waiting for ${what}`);
  await sleep(seconds);
}

// 1. Submit. Returns at once; GitHub validation happens on Sentrint's side.
let submission = await api("POST", `/api/v1/workspaces/${process.env.SENTRINT_WORKSPACE_ID}/submissions`, {
  repo_url: process.env.SCAN_REPO,
  branch: process.env.SCAN_BRANCH,
});

// 2. Wait for the submission to become a scan.
while (submission.status !== "scan_ready") {
  if (submission.status === "failed") {
    fail(`Submission failed: ${submission.error_code}: ${submission.error_detail}`);
  }
  await wait("validation", 5);
  submission = await api("GET", submission.status_url);
}
const scanId = submission.scan_id;
console.log(`Scan ${scanId} (${submission.reused ? "reused" : "new"})`);

// 3. Wait for the scan to finish.
for (;;) {
  const { status } = await api("GET", `/api/v1/scans/${scanId}`);
  if (["done", "completed", "archived"].includes(status)) break;
  if (!["pending", "running"].includes(status)) fail(`Scan ended with status ${status}`);
  await wait("the scan", 15);
}

// 4. Read the report and annotate findings on the commit.
const report = await api("GET", `/api/v1/scans/${scanId}/report`);
console.log(
  `Grade ${report.grade}, score ${report.score}: ${report.critical} critical, ` +
    `${report.high} high, ${report.medium} medium, ${report.low} low`,
);
const severities = FAIL_ON === "high" ? ["CRITICAL", "HIGH"] : ["CRITICAL"];
for (const severity of severities) {
  const query = new URLSearchParams({ scan_id: scanId, severity, limit: "100" });
  const { data } = await api("GET", `/api/v1/findings?${query}`);
  for (const finding of data) {
    console.log(
      `::error file=${prop(finding.file_path)},line=${finding.line ?? 1},` +
        `title=${prop(`${finding.severity} ${finding.rule_id}`)}::${esc(finding.message)}`,
    );
  }
}

const blocking = report.critical + (FAIL_ON === "high" ? report.high : 0);
if (blocking > 0) fail(`${blocking} blocking finding(s). Full report: ${BASE}/dashboard/scan/${scanId}`);
console.log("No blocking findings.");

What a failing run looks like

Each blocking finding appears as an annotation on the file and line, in the run summary and on the pull request's Files changed tab. The log ends like this:

Scan 3f9c2a1e-7b4d-4c1a-9e2f-5d8b6a0c4e17 (new)
Grade C, score 61: 1 critical, 3 high, 7 medium, 2 low
Error: 1 blocking finding(s). Full report: https://sentrint.com/dashboard/scan/3f9c2a1e-7b4d-4c1a-9e2f-5d8b6a0c4e17

Open the report link to see each finding in full.

Does every push use a scan from my allowance?

Every new commit uses one scan. If nothing changed since the last scan of that commit, Sentrint returns the earlier result for free and the log says reused. A submission that fails before the scan starts, for example because the branch does not exist, costs nothing. To keep usage down, trigger the workflow only on main and pull requests, as the example does, rather than on every branch push.

Which commit gets scanned?

Sentrint scans the latest commit on the branch at the moment it validates the submission, usually a few seconds after the push. If someone pushes again in that window, the newer commit is scanned. The concurrency group then queues the next run, which scans the newest commit again.

Why are pull requests from forks skipped?

GitHub does not pass repository secrets to workflows triggered by pull requests from forks, so the job has no API key to send. The if: line skips those runs instead of letting them fail. Scan the change after you merge it, or from a branch in your own repository.

How do I scan a private repository?

Link your GitHub account to Sentrint with access to the repository. The API uses that link to fetch the code. Never put a GitHub token in the workflow or the request body. The API has no field for one and rejects a request that includes it.

What happens when Sentrint or GitHub is busy?

The scripts read the Retry-After header on a 429 or 503 response, wait that long and try again. If GitHub rate-limits Sentrint while it checks your repository, the submission stays in validating and Sentrint retries on its own, up to three attempts. The one 429 the scripts do not retry is quota_exhausted, which means the account has no scans left this period. Waiting will not fix that within a CI run, so the job fails with that code.

Can I fail the build on High findings too?

Yes. Set FAIL_ON: high in the workflow. The job then fails when the scan finds any Critical or High issue, and annotates both. Medium and Low findings never fail the job; they stay in the report as backlog.

The API quickstart covers keys, limits and errors in more depth. The API reference lists every endpoint, filter and response field.