Two files, same toolchain:
security-scan.yml report-only Semgrep · Trivy · SBOM+Grype · ZAP baseline
security-gate.yml blocking secrets · diff-aware SAST · SCA · IaC
The split is the design. Everything else is configuration.
Why not one
A single blocking pipeline sounds stricter and is worse, because of what "blocking" means when the input is a moving target.
A dependency scanner reads a vulnerability database that changes without you. Somebody discloses a CVE overnight in a transitive dependency you have never heard of, and your build goes red on a pull request that changed a CSS file. Nothing you did caused it and nothing in your change can fix it.
Do that twice and people learn the workaround: re-run it, merge past it, or add a blanket ignore. The scanner has trained everyone to route around it, and the finding that actually mattered arrives into a process that no longer reads findings.
So visibility is unconditional and blocking is narrow.
What blocks
Secrets — any, zero tolerance. The one threshold that should never be relaxed. This repository's whole credential design rests on secrets living in GitHub and reaching the server only through a forced command; a key committed to the tree routes around all of it.
SAST, diff-aware. Only findings this pull request introduces fail. The existing backlog is surfaced by the report-only run instead. A gate that blocks every change until a legacy backlog is cleared is a gate that gets disabled in week two.
SCA — HIGH and CRITICAL, fixable only. --ignore-unfixed, because blocking
on a vulnerability with no available patch is blocking on something nobody can
act on. That is the difference between a gate and a wall.
IaC — HIGH and above. Dockerfiles and the deployment manifest.
What only reports
Everything above with broader rulesets, plus a CycloneDX SBOM with Grype over it, and an OWASP ZAP baseline against the live hosts.
ZAP is schedule and dispatch only. A baseline scan is passive — it crawls and reads responses rather than attacking — but it is still traffic against production, and running it per pull request would mean scanning the live site because somebody edited a comment.
Waivers carry a reason
.trivyignore starts empty, with the rule at the top:
a waiver carries a reason, an owner and a review date. A bare id with no note is indistinguishable from someone silencing a scanner to get a merge through, and in six months nobody can tell which it was.
That is the whole difference between a waiver file and a suppression file. Same syntax, opposite meaning, and the only thing separating them is a convention somebody has to hold.
The first run found something
KSV-0014 — the router container had no readOnlyRootFilesystem.
Fixed rather than waived, because nothing that process writes belongs in the
image: the decision log already goes to a mounted volume, so the root filesystem
can be immutable. allowPrivilegeEscalation: false and dropping all
capabilities came along with it.
Worth saying that a clean first run would have been the worrying outcome. A scanner that finds nothing on a codebase that has never been scanned is usually a scanner that is not looking at it.
The adaptation worth noting
This is KibiPay's pipeline, ported so a finding means the same thing in both repositories. Three things changed, each stated rather than silently dropped.
The licence gate is absent. KibiPay fails on the GPL/AGPL family, which follows
from a proprietary distribution model. This repository has no LICENSE and has
not declared one, so the same gate would enforce a policy nobody has decided —
and the first AGPL dependency would block a merge with a rule that cannot be
pointed at. The scan inventories licences instead, so one arriving is visible.
Copying a security policy without copying the decision behind it is how a team ends up defending a rule nobody chose.