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.
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:readandfindings: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.
- Under Secrets, add
SENTRINT_API_KEYwith the fullsnt_...key. Keep it a secret: anyone holding it can start scans on your account. - Under Variables, add
SENTRINT_WORKSPACE_IDwith 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.
Related reading
The API quickstart covers keys, limits and errors in more depth. The API reference lists every endpoint, filter and response field.