id: nuclei-post-deploy-security-gate
namespace: company.team
description: |
After a staging deploy, scan the running application with ProjectDiscovery
Nuclei, compare the findings with the accepted-risk baseline kept in the KV
store, and stop the release for a security sign-off when new findings at the
chosen severities appear. Approving the sign-off accepts them into the
baseline; rejecting it fails the release.
inputs:
- id: target_url
type: STRING
defaults: "https://staging.example.com"
displayName: "Target URL"
description: "Base URL of the deployed application. Only scan systems you own or
are authorized to test, never production without approval."
- id: tags
type: STRING
defaults: "misconfig,exposure,headers,cve,default-login"
displayName: "Template Tags"
description: "Comma-separated Nuclei template tags to run."
- id: block_on
type: STRING
defaults: "critical,high,medium"
displayName: "Blocking Severities"
description: "New findings at these severities (critical, high, medium, low,
info) require a security sign-off before the release continues."
- id: app
type: STRING
defaults: "shop_api"
displayName: "Application Key"
description: "Lowercase key for the accepted-risk baseline in the KV store
(`nuclei_baseline_<app>`)."
triggers:
- id: after_staging_deploy
type: io.kestra.plugin.core.trigger.Flow
description: Scan every time the staging deploy flow succeeds. Shipped disabled;
point it at your own deploy flow and enable it after a manual run.
disabled: true
dependsOn:
- namespace: company.team
flowId: deploy_staging
states: [ SUCCESS ]
tasks:
- id: scan
type: io.kestra.plugin.scripts.shell.Commands
description: Run Nuclei 3.11.1 (official image) against the target with the
chosen tags, rate-limited, writing one JSON line per finding. The exit
code is captured so the findings are always kept.
containerImage: projectdiscovery/nuclei:v3.11.1
commands:
- >-
rc=0; nuclei -u '{{ inputs.target_url }}' -tags '{{ inputs.tags }}'
-severity info,low,medium,high,critical -jsonl -o findings.jsonl
-rate-limit 50 -timeout 10 -silent -no-color || rc=$?; touch
findings.jsonl; echo nuclei exit code $rc
outputFiles:
- findings.jsonl
timeout: PT45M
- id: compare_with_baseline
type: io.kestra.plugin.scripts.python.Script
description: Turn every finding into a stable fingerprint (template, matcher,
matched URL), split them into accepted (already in the KV baseline) and
new, count by severity, and decide whether the new ones include a blocking
severity.
containerImage: python:3.12-slim
inputFiles:
findings.jsonl: "{{ outputs.scan.outputFiles['findings.jsonl'] }}"
baseline.json: "{{ kv('nuclei_baseline_' ~ inputs.app, errorOnMissing=false) ?? '[]' }}"
outputFiles:
- fingerprints.json
script: |
import collections, json
blocking_levels = {s.strip().lower() for s in "{{ inputs.block_on }}".split(",") if s.strip()}
accepted = set(json.load(open("baseline.json", encoding="utf-8")) or [])
findings = [json.loads(line) for line in open("findings.jsonl", encoding="utf-8") if line.strip()]
def fingerprint(f):
return f"{f.get('template-id')}|{f.get('matcher-name', '')}|{f.get('matched-at')}"
by_sev, new, lines = collections.Counter(), [], []
for f in findings:
sev = f.get("info", {}).get("severity", "unknown")
by_sev[sev] += 1
fp = fingerprint(f)
if fp not in accepted:
new.append((sev, fp, f))
order = ["critical", "high", "medium", "low", "info", "unknown"]
new.sort(key=lambda n: order.index(n[0]) if n[0] in order else 9)
blocking = [n for n in new if n[0] in blocking_levels]
for sev, fp, f in new[:25]:
name = f.get("info", {}).get("name", f.get("template-id"))
matcher = f" ({f['matcher-name']})" if f.get("matcher-name") else ""
lines.append(f"[{sev}] {name}{matcher} at {f.get('matched-at')}")
json.dump(sorted(accepted | {fingerprint(f) for f in findings}), open("fingerprints.json", "w", encoding="utf-8"))
print("::" + json.dumps({"outputs": {
"total": len(findings), "by_severity": dict(by_sev), "accepted": len(findings) - len(new),
"new": len(new), "blocking": len(blocking), "new_lines": lines,
}}) + "::")
- id: log_findings
type: io.kestra.plugin.core.log.Log
description: Log the totals and every new finding, most severe first.
message: |
Nuclei gate for {{ inputs.target_url }}: {{ outputs.compare_with_baseline.vars.total }} finding(s) {{ outputs.compare_with_baseline.vars.by_severity }}, {{ outputs.compare_with_baseline.vars.accepted }} already accepted, {{ outputs.compare_with_baseline.vars.new }} new, {{ outputs.compare_with_baseline.vars.blocking }} new at {{ inputs.block_on }}.
{% for line in outputs.compare_with_baseline.vars.new_lines %}- {{ line }}
{% endfor %}
- id: gate
type: io.kestra.plugin.core.flow.If
description: When new findings reach a blocking severity, pause the release for
a security sign-off; otherwise continue.
condition: "{{ outputs.compare_with_baseline.vars.blocking > 0 }}"
then:
- id: security_signoff
type: io.kestra.plugin.core.flow.Pause
description: Wait up to 24 hours for a security reviewer. If nobody answers, the
release fails (deny by default).
pauseDuration: PT24H
behavior: FAIL
onResume:
- id: accept
type: BOOL
defaults: false
description: Accept these findings as known risk (they are added to the
baseline) and let the release continue?
- id: reason
type: STRING
defaults: "not reviewed"
description: Why the findings are accepted or rejected (kept in the execution
history).
- id: apply_decision
type: io.kestra.plugin.core.flow.If
condition: "{{ outputs.security_signoff.onResume.accept }}"
then:
- id: accept_into_baseline
type: io.kestra.plugin.core.kv.Set
description: Record every current finding as accepted, so the next scans only
flag what is new.
key: "nuclei_baseline_{{ inputs.app }}"
value: "{{ read(outputs.compare_with_baseline.outputFiles['fingerprints.json'])
}}"
kvType: JSON
- id: log_accepted
type: io.kestra.plugin.core.log.Log
message: "Security sign-off: {{ outputs.compare_with_baseline.vars.blocking }}
new finding(s) accepted ({{
outputs.security_signoff.onResume.reason }}); baseline updated,
release continues."
else:
- id: block_release
type: io.kestra.plugin.core.execution.Fail
errorMessage: "Release blocked by security review: {{
outputs.compare_with_baseline.vars.blocking }} new finding(s) at
{{ inputs.block_on }} on {{ inputs.target_url }} ({{
outputs.security_signoff.onResume.reason }})."
else:
- id: log_clear
type: io.kestra.plugin.core.log.Log
message: "Nuclei gate clear for {{ inputs.target_url }}: no new finding at {{
inputs.block_on }}."
outputs:
- id: new_findings
type: INT
value: "{{ outputs.compare_with_baseline.vars.new }}"
- id: findings
type: FILE
value: "{{ outputs.scan.outputFiles['findings.jsonl'] }}"