CI security check¶
Kerkov runs non-interactively, which makes it a natural fit for a CI security
step: one --message call, tool approvals auto-accepted, and a reasoned
pass/fail verdict at the end. This example drives two defensive scanners —
vulnerability_scan
(Grype) against a container image and sast_scan
(OpenGrep) against source — and applies a build policy to the results. Kerkov
produces the verdict; a small wrapper turns it into the build-failing exit
status CI acts on (see Failing the build).
This is defensive tooling
Nothing here attacks a live target. Both tools read artifacts — an image's package catalogue and source files — and report known issues. See Non-interactive use for the CI shape in general.
Prerequisites¶
- Kerkov installed with a working provider.
- Grype on your
PATH(installed separately — Kerkov never downloads it). - OpenGrep installed via
kerkov setup opengrep. -
A target image pulled locally and a source tree to scan. This run used the public
wordpress:php8.2-apacheimage and a small deliberately-vulnerable Python sample. Recreate the demo tree below to reproduce the run exactly, or point the command at your own image, source directory, and rule file.ci-demo/app/payment.py— the code under test (line numbers match the captured findings below):import os import subprocess def run_report(user_input): # builds a shell command from user input os.system("generate_report " + user_input) def calc(expr): return eval(expr) def fetch(host): subprocess.call("ping -c1 " + host, shell=True)sast_scantakes local rule files (remote rulesets are unsupported), so the run used a three-rule OpenGrep ruleset matching those patterns.ci-demo/rules/python-ci.yml:rules: - id: os-system-command-injection patterns: - pattern: os.system(...) message: "Command executed via os.system(); concatenated input is OS command injection." languages: [python] severity: ERROR - id: subprocess-shell-true patterns: - pattern: subprocess.$F(..., shell=True) message: "subprocess call with shell=True invites command injection." languages: [python] severity: ERROR - id: dynamic-eval patterns: - pattern: eval(...) message: "eval() on dynamic input allows arbitrary code execution." languages: [python] severity: WARNING
The command¶
uv run kerkov --mode audit --no-welcome --verbosity standard \
--message "Act as a CI security gate for a Python service. Run two scans and \
then decide pass/fail. (1) Run vulnerability_scan on the container image \
wordpress:php8.2-apache and report the vulnerability counts by severity. (2) Run \
sast_scan on the source directory /home/kali/code/ci-demo/app using the rule \
file /home/kali/code/ci-demo/rules/python-ci.yml. Then give a combined summary \
and state whether the build should FAIL under this policy: fail on any Critical \
or High image vulnerability, or any ERROR-severity SAST finding."
The paths above are this run's absolute paths; substitute your own image, source
directory, and rule file. sast_scan validates that each rule path exists and
rejects a missing file, so point it at a real ruleset — the python-ci.yml
above, or your own.
What it did¶
Output is real, captured from the run above.
✓ Connected to gpt-5.6-sol
Workspace: /home/kali/.kerkov/workspaces/2026-10-07-8d47c1bc
I'll run the image vulnerability scan and Python SAST scan in parallel, then
apply the stated gate exactly: fail for any Critical/High image vulnerability or
any ERROR SAST finding.
→ Executing 2 tool(s): vulnerability_scan, sast_scan
vulnerability_scan target=wordpress:php8.2-apache source_type=image
{
"engine": "grype",
"status": "completed_unsummarized",
"reason": "scanner report is too large to summarize safely (18309307 bytes;
limit 16777216 bytes); the complete report remains at
.../vulnerability-scans/grype-call_<id>.json",
"artifacts": { "report": ".../grype-call_<id>.json", "log": ".../grype-call_<id>.log" }
}
sast_scan target=/home/kali/code/ci-demo/app configs=[python-ci.yml]
{
"engine": "opengrep", "engine_version": "1.27.1",
"finding_count": 3,
"counts_by_severity": { "ERROR": 2, "WARNING": 1 },
"findings": [
{ "severity": "ERROR", "path": "payment.py", "start": {"line": 7},
"message": "Command executed via os.system(); concatenated input is OS command injection." },
{ "severity": "ERROR", "path": "payment.py", "start": {"line": 15},
"message": "subprocess call with shell=True invites command injection." },
{ "severity": "WARNING", "path": "payment.py", "start": {"line": 11},
"message": "eval() on dynamic input allows arbitrary code execution." }
]
}
The image report was too large for the scanner's inline summary, so I'll extract
severity counts from the saved Grype JSON without rerunning the scan.
→ Executing 1 tool(s): exec
exec jq ... grype-call_<id>.json
✗ Error: bash: line 1: jq: command not found
→ Executing 1 tool(s): write
write → scripts/ci-gate/count_grype_severities.py (a 20-line severity counter)
→ Executing 1 tool(s): exec
exec python3 scripts/ci-gate/count_grype_severities.py .../grype-call_<id>.json
{
"total": 1277,
"counts_by_severity": {
"Critical": 34, "High": 224, "Medium": 200,
"Low": 49, "Negligible": 764, "Unknown": 6
}
}
→ Executing 2 tool(s): ledger, ledger
Recorded to findings ledger as entry #1. (image: 34 Critical, 224 High — violates policy)
Recorded to findings ledger as entry #2. (SAST: 2 ERROR command-injection findings)
# CI Security Gate: FAIL
## 1. Container vulnerability scan
Image: wordpress:php8.2-apache Scanner: Grype Total matches: 1,277
| Severity | Count |
| Critical | 34 |
| High | 224 |
| Medium | 200 |
| Low | 49 |
| Negligible | 764 |
| Unknown | 6 |
Image gate: FAIL — policy fails on any Critical or High.
## 2. Python SAST scan
Source: /home/kali/code/ci-demo/app Scanner: OpenGrep 1.27.1 Findings: 3 (2 ERROR, 1 WARNING)
- ERROR — OS command injection via os.system() at payment.py:7
- ERROR — command injection from subprocess shell=True at payment.py:15
- WARNING — arbitrary code execution via dynamic eval() at payment.py:11
SAST gate: FAIL — policy fails on any ERROR finding.
## Combined decision
Image contains Critical/High vulnerabilities: Yes
SAST contains ERROR findings: Yes
# BUILD SHOULD FAIL
Failing the build¶
kerkov --message prints the verdict but the process exits 0 regardless
of it — the gate decision lives in the output and the
findings ledger, not the exit status. It exits
0 even when the run goes wrong: a provider error mid-turn is printed, not
raised. The printed BUILD SHOULD FAIL above does not, on its own, fail the
pipeline, and a clean exit does not mean the scans ran.
To turn the verdict into a build-failing exit status, ask the model to end its reply with one machine-readable line, capture the output, and allow the build only when that line is an explicit pass:
#!/usr/bin/env bash
set -euo pipefail
status=0
output=$(uv run kerkov --mode audit --no-welcome --verbosity standard \
--message "...the scan prompt above... End your reply with exactly one line: \
GATE: PASS or GATE: FAIL.") || status=$?
printf '%s\n' "$output"
if [ "$status" -ne 0 ]; then
echo "Security gate incomplete: kerkov exited with status $status." >&2
exit 2
fi
verdict=$(printf '%s\n' "$output" | grep -F 'GATE:' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' || true)
case "$verdict" in
'GATE: PASS')
echo "Security gate passed." ;;
'GATE: FAIL')
echo "Security gate failed." >&2
exit 1 ;;
*)
echo "Security gate incomplete: expected exactly one GATE: PASS or GATE: FAIL line." >&2
exit 2 ;;
esac
The wrapper fails closed. Every line containing GATE: is collected and trimmed
of surrounding whitespace (with STREAM_OUTPUT=false the reply is rendered as
Markdown, which pads lines with trailing spaces). The build passes only when
there is exactly one such line and it reads GATE: PASS. A policy failure exits 1. Anything else exits 2: a nonzero
Kerkov exit, no verdict (a provider error, an empty reply), duplicate or
conflicting verdicts, or a formatted one such as **GATE: PASS**. The scans
still run inside Kerkov; the wrapper only translates the decision the model
made into the status CI consumes.
The captured run above predates the sentinel: its reply ends in
BUILD SHOULD FAIL with no GATE: line, so this wrapper would fail that build
with 2 (no verdict), not with the 1 the policy violation calls for.
What to take away¶
- One call, two scanners, a verdict. A single non-interactive invocation ran both scans and reconciled their output against an explicit policy to produce a clear FAIL — the raw material a pipeline step turns into a pass/fail exit status (see Failing the build).
- Large reports stay on disk, not in the model's context. Grype's full report
was 18 MB — over the summary limit — so
vulnerability_scanreturnedcompleted_unsummarizedwith the artifact path instead of flooding the conversation. The model read the saved JSON to get the counts it needed. - It adapts around a missing tool. With
jqabsent, the model wrote a 20-line Python counter into the workspace and ran that — the kind of recovery the exec and process tools make routine. sast_scanuses local rules only. It runs an existing OpenGrep binary over host-local targets and local rule files; it never fetches remote rulesets or installs software during a scan.