id: trivy-image-scan-cve-gate
namespace: company.team
description: |
Scan a container image with Trivy, count CRITICAL and HIGH CVEs, and alert
Slack — plus fail the release gate when criticals exceed your budget.
triggers:
- id: pre_deploy_webhook
type: io.kestra.plugin.core.trigger.Webhook
description: Call from the deploy pipeline right after the image is pushed — the
scan runs before anything rolls out.
key: trivy-scan
- id: nightly_registry_scan
type: io.kestra.plugin.core.trigger.Schedule
description: Nightly re-scan catches newly published CVEs against the same
image; shipped disabled.
cron: "0 3 * * *"
disabled: true
inputs:
- id: image
type: STRING
defaults: nginx:latest
description: Image reference to scan, e.g. your-registry/app:sha.
- id: max_critical
type: INT
defaults: 0
description: Critical CVEs tolerated before the gate fails.
- id: max_high
type: INT
defaults: 20
description: High CVEs tolerated before the gate fails.
tasks:
- id: run_trivy
type: io.kestra.plugin.scripts.shell.Commands
description: Pull Trivy's official scanner, scan the image as JSON, and emit CVE
counts via the stdout outputs protocol. Missing counts coerce to a safe
worst case so no scan result can silently pass.
containerImage: aquasec/trivy:0.58.1
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
commands:
- |
set -eu
trivy image --format json --quiet --severity CRITICAL,HIGH "{{ inputs.image }}" > trivy.json
node -e '
const fs = require("fs");
let report = {};
try { report = JSON.parse(fs.readFileSync("trivy.json", "utf8")); } catch (e) { report = {}; }
let critical = 0, high = 0;
for (const result of report.Results || []) {
for (const vuln of result.Vulnerabilities || []) {
if (vuln.Severity === "CRITICAL") critical++;
else if (vuln.Severity === "HIGH") high++;
}
}
console.log("critical: " + critical + ", high: " + high);
console.log("::" + JSON.stringify({ outputs: { critical, high } }) + "::");
' 2>/dev/null || python3 -c "
import json
report = json.load(open('trivy.json'))
critical = high = 0
for result in report.get('Results', []):
for vuln in result.get('Vulnerabilities', []) or []:
if vuln.get('Severity') == 'CRITICAL': critical += 1
elif vuln.get('Severity') == 'HIGH': high += 1
print('critical:', critical, 'high:', high)
print('::' + json.dumps({'outputs': {'critical': critical, 'high': high}}) + '::')
"
- id: check_gate
type: io.kestra.plugin.core.flow.If
description: One branch for the counts — any breach over either budget goes to
Slack with the numbers.
condition: "{{ outputs.run_trivy.vars.critical > inputs.max_critical or
outputs.run_trivy.vars.high > inputs.max_high }}"
then:
- id: alert_cve_breach
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post both counts next to their budgets so the message says how bad
it is, plus the image.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": ":rotating_light: Trivy gate BREACH on {{ inputs.image }} — critical {{ outputs.run_trivy.vars.critical }} (max {{ inputs.max_critical }}), high {{ outputs.run_trivy.vars.high }} (max {{ inputs.max_high }}). Block the rollout or patch the base image. Execution {{ execution.id }}."
}
else:
- id: log_clean
type: io.kestra.plugin.core.log.Log
description: Record passing counts on every scan so the execution history is
your CVE trend.
message: "Trivy within budget on {{ inputs.image }}: critical {{
outputs.run_trivy.vars.critical }}, high {{
outputs.run_trivy.vars.high }}."
errors:
- id: alert_scan_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Alert when the scan itself fails — no result must ever read as a
clean image.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "Trivy scan FAILED in flow {{ flow.id }} (execution {{ execution.id }}) for {{ inputs.image }}. Check the image reference and registry access."
}
outputs:
- id: cve_counts
type: JSON
description: 'CVE counts from this scan, e.g. {"critical": 0, "high": 4}.'
value: "{{ outputs.run_trivy.vars | toJson }}"