I have a simple rule when I work with a team: if a vulnerability makes it into main, the process already failed somewhere upstream. The right place to catch it is on the pull request, before anyone clicks merge.
This post is how I built exactly that gate with Synapse, an open source security scanner written in Go. The end result: open a PR that introduces a vulnerability and CI goes red, a comment lands on the PR telling you what to fix and how, the results show up in the GitHub Code scanning tab, and the merge button stays locked until the issue is dealt with.
Everything below actually runs. There is a public demo repo linked at the end so you can see it, not just read about it.
Why another tool
Fair question. Trivy, Grype and OSV-Scanner are all good and I still use them.
What is different about Synapse is not "it finds more CVEs". Detection is basically a commodity now, since every tool pulls from the same handful of data sources. The difference is the governance layer around a finding: a hash chained chain of custody so results cannot be edited quietly, a separation between who proposes and who confirms for the AI assisted parts, and a report path with no LLM in it. In short, it is built for the case where you have to prove a result, not just print one.
But this post is not about philosophy. It is about one concrete job: blocking the merge.
One thing I like: synapse-cli runs with no database and no server. It is a single static Go binary. It shells out to syft to build the SBOM and to grype as one detection source, and both of those are single static binaries too. That is the same footprint you already know from running Trivy in CI.
What you need
Three binaries:
-
synapse-cli(built from source, see the workflow below) -
syft(builds the SBOM) -
grype(one offline detection source)
No Postgres, no external service. If the runner has network access it also uses OSV.dev as a second source. If you are air gapped, add the --offline flag.
The gate itself is one flag:
synapse-cli scan . --fail-on high
That returns a non zero exit code if there is any finding at or above high, and the important part is that it counts across every kind: dependency vulnerabilities (SCA), issues in your own source code (SAST), hardcoded secrets, and misconfigurations in Dockerfiles, Kubernetes, Helm and Terraform. A leaked API key in the code blocks the merge just like a CVE does.
Add --sarif to emit a SARIF 2.1.0 report for the GitHub Code scanning tab:
synapse-cli scan . --sarif --fail-on high > synapse.sarif
SARIF is written to stdout before the gate sets the exit code, so the file is complete even when the command returns 1. A single run both annotates the PR and blocks the merge.
The full workflow
Here is the .github/workflows/synapse.yml I use in the demo repo. It runs the scan, gates on high, uploads SARIF, and posts a remediation report on the PR.
name: Synapse Security Scan
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
security-events: write # to upload SARIF
pull-requests: write # to comment on the PR
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.4'
- name: Install syft + grype
run: |
mkdir -p "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b "$HOME/.local/bin"
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b "$HOME/.local/bin"
- name: Build synapse-cli
env:
GOTOOLCHAIN: auto
run: |
git clone --depth 1 https://github.com/KKloudTarus/synapse-ce.git /tmp/synapse-ce
cd /tmp/synapse-ce
go build -o "$HOME/.local/bin/synapse-cli" ./cmd/synapse-cli
# The gate. The JSON result feeds both the exit code and the PR report.
- name: Synapse scan (gate on high)
id: scan
env:
SYNAPSE_MISCONFIG_ENABLED: "true"
SYNAPSE_SAST_ENABLED: "true"
SYNAPSE_SECRET_SCAN_ENABLED: "true"
run: synapse-cli scan . --json --fail-on high > synapse.json
# SARIF for the Code scanning tab. The gate already ran, so ignore the exit here.
- name: Synapse scan (SARIF for code scanning)
if: always()
env:
SYNAPSE_MISCONFIG_ENABLED: "true"
SYNAPSE_SAST_ENABLED: "true"
SYNAPSE_SECRET_SCAN_ENABLED: "true"
run: synapse-cli scan . --sarif > synapse.sarif || true
- name: Upload SARIF to code scanning
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: synapse.sarif
- name: Post report on the PR
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
GATE: ${{ steps.scan.outcome }}
run: |
crit=$(jq '[.findings[]|select(.Severity=="critical")]|length' synapse.json)
high=$(jq '[.findings[]|select(.Severity=="high")]|length' synapse.json)
med=$(jq '[.findings[]|select(.Severity=="medium")]|length' synapse.json)
low=$(jq '[.findings[]|select(.Severity=="low" or .Severity=="info")]|length' synapse.json)
{
echo "## Synapse security scan"
echo
if [ "$GATE" = "failure" ]; then
echo "**Result: FAILED.** Findings at or above high severity. The merge is blocked until they are resolved."
else
echo "**Result: PASSED.** No findings at or above high severity."
fi
echo
echo "| Severity | Count |"
echo "|---|---|"
echo "| Critical | $crit |"
echo "| High | $high |"
echo "| Medium | $med |"
echo "| Low | $low |"
echo
if [ "$(jq '[.vulnerabilities[].Component]|unique|length' synapse.json)" -gt 0 ]; then
echo "### Dependencies to upgrade"
echo
echo "Edit the manifest and bump each package to at least the version in the \"Upgrade to\" column."
echo
echo "| Package | Current | Upgrade to | CVEs | Top severity |"
echo "|---|---|---|---|---|"
jq -r '
.vulnerabilities | group_by(.Component)
| map({comp:.[0].Component, ver:.[0].Version,
fix:((map(.FixedVersion)|map(select(.!=null and .!=""))|max) // "no fix yet"),
n:length,
sev:(if any(.[];.Severity=="critical") then "critical"
elif any(.[];.Severity=="high") then "high"
elif any(.[];.Severity=="medium") then "medium" else "low" end)})
| sort_by(.n) | reverse
| .[] | "| \(.comp) | \(.ver) | >= \(.fix) | \(.n) | \(.sev) |"' synapse.json
echo
fi
if [ "$(jq '[.findings[]|select(.Kind!="sca")]|length' synapse.json)" -gt 0 ]; then
echo "### Code and config"
echo
jq -r '.findings[] | select(.Kind != "sca")
| "- **\(.Title)** [\(.Severity)] `\(.DedupKey|split(":")[1])`\n \(.Description|split("\n")[0])"' synapse.json
echo
fi
echo "_Per line annotations live in the repository Code scanning tab._"
} > comment.md
gh pr comment "$PR" --edit-last --body-file comment.md || gh pr comment "$PR" --body-file comment.md
A few things worth calling out:
- The gate step (
id: scan) has nocontinue-on-error, so when it fails the whole job fails. That is what turns CI red. - The upload and comment steps use
if: always(), so they still run after the gate fails. That is how you get both the merge block and the annotations plus the comment. -
security-events: writeis required forupload-sarif, andpull-requests: writeis required to post the comment. - The report is built from
synapse-cli scan --json, which carries the fixed version, EPSS, CVSS and a remediation description per finding. That is what lets the comment say "upgrade to X" and "here is how to fix it", not just "there is a problem".
Actually blocking the merge
The CLI only produces a pass or fail signal. Blocking the merge is GitHub branch protection using that check as a required status.
# require the "scan" check to pass before merging into main
# enforce_admins=true so even an admin cannot bypass it
gh api -X PUT repos/OWNER/REPO/branches/main/protection --input - <<'JSON'
{
"required_status_checks": { "strict": false, "contexts": ["scan"] },
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null
}
JSON
From here, any PR where the scan job fails cannot be merged.
What it looks like in practice
I set up a demo repo: a clean main (CI green), then a PR that adds a "payments service" with three deliberate problems. A Dockerfile that runs as root and pulls a script from an external URL, a function that uses MD5 to build a token, and a requirements.txt pinned to old Django, PyYAML and requests full of CVEs.
That PR produced:
- Gate FAILED: findings at and above high, so the job went red.
-
51 alerts in the Code scanning tab, each with a file location: most on
requirements.txt, several onDockerfile, and one onapp/payments.py:6where the MD5 call is. - A single PR comment that reads like a real report:
- a severity summary table
- a "Dependencies to upgrade" table (for example
django 2.2.0needs>= 5.2.8,requests 2.19.0needs>= 2.33.0), with a collapsible list of every CVE and its fixed version sorted by severity and EPSS - a "Code and config" section with each issue as
file:line, its rule id, and a one line remediation ("MD5 is broken, use SHA-256 or a KDF", "add a non-root USER before the entrypoint", and so on)
- The merge blocked. I ran
gh pr mergefor real and GitHub answered:the base branch policy prohibits the merge.
One run caught all four kinds: vulnerable dependencies, weak source code, and an unsafe Docker config, all rolled into a single pass or fail decision, with the fix spelled out on the PR.
The SARIF gotcha worth knowing
This is the part I most wanted to share, because it only showed up when I ran the real thing.
On the first run the SARIF upload failed with:
Code Scanning could not process the submitted SARIF file:
expected a physical location
It turns out GitHub code scanning rejects the entire file if any result has a location that carries only a logicalLocation and no physicalLocation. The SCA findings were pointing at a "module" (something like django@2.2.0) with no concrete file, so they were all rejected.
The correct fix, which is also what Trivy does, is to point each SCA finding at the manifest that declares it, for example requirements.txt. If the manifest is unknown, leave the result with no location at all, since GitHub still accepts that as a repo level alert. Never emit a location that is logical only.
The lesson if you generate SARIF yourself: every result you want GitHub to annotate on a line needs a physicalLocation, and artifactLocation.uri must be a repo relative path with no leading slash. This is handled inside synapse-cli now, but if you write your own exporter, keep it in mind.
How it compares to Trivy
I ran both side by side on the same large real repo:
- Unique CVEs: Synapse 261, Trivy 239, with 235 in common. The four CVEs only Trivy reported were all one package where the two tools resolved a different version, not a database gap.
- License detection: Synapse attached a license to 1443 packages, Trivy to 1394.
- Misconfig: Trivy has more low severity rules in raw count (270 to 208), but both now cover Dockerfile, Kubernetes, Helm and Terraform.
- Plus SAST and secret scanning, which
trivy fsdoes not do.
The short version: detection is at parity with a few areas ahead, and Synapse adds the governance layer that the plain scanners do not have.
Come and contribute
Synapse is open source under Apache-2.0 at github.com/KKloudTarus/synapse-ce.
If you find it useful, I would love for you to jump in:
- Drop a star if the project looks worth following. It helps other people find it.
- Run it in your own pipeline and open an issue about what breaks or what is missing. Feedback from a real CI run is the most valuable kind.
- Pick up an issue labeled
good first issue, or add a lockfile parser, a SAST or misconfig rule, or a new ecosystem for SCA. The architecture is cleanly layered, so adding a tool does not touch the core. - There is a
CONTRIBUTING.mdand a full docs set to get you started.
You can fork the demo repo and play with it right away: github.com/nghiadaulau/synapse-ci-demo (open PR #1 to see the blocked merge and the report).
If you wire this gate into your own project, drop a comment below and tell me what it caught on the first run. My guess is there will be a surprise.
Top comments (18)
Putting the security decision in CI is important because it makes the gate part of the delivery path, not a best-effort review habit. The useful detail is what happens after the block: clear evidence, owner routing, and a fix path. A gate without a repair loop just teaches teams to work around it.
Totally agree. The repair loop is the part most "just add a gate" posts skip, and it's the part that decides whether the gate survives.
Two of your three I already lean on. Evidence: every finding is hash-chained and keeps its file:line, so it's auditable rather than a screenshot. Routing: pushing SARIF into Code scanning lands the alert on the exact line and pulls code owners into the PR. The fix-path / SLA loop is my honest gap right now, and it's what I'm building next, not something I'd pretend is done.
If it's useful, I also wrote a follow-up on a sneakier reason teams route around a gate: false positives quietly eroding trust until people just hit "merge anyway." dev.to/aws-builders/let-an-ai-clea...
That fix-path point is important. A CI gate earns trust only if the blocked developer can see the evidence, the owner, the expected repair path, and the SLA for getting unblocked. Otherwise a technically correct gate turns into a queue that people learn to route around.
Blocking the merge is the right place for this class of control. Security findings are much cheaper when they interrupt the change before it becomes shared state. The key is keeping the gate explainable: what failed, why it matters, what evidence was used, and what exact change would make it pass.
Exactly right, and "explainable" is the part most gates get wrong. In Synapse a blocking finding carries four things by construction: the rule id and CWE (what failed), a short rationale (why it matters), the evidence it used, and a remediation with a compliant example (the exact change that makes it pass). For SCA the evidence is the scanner plus advisory-DB version; for SAST it is the matched pattern or dataflow at a specific file and line. All of it is hash-chained, so a finding cannot be quietly edited after the fact and still pass the report gate. The goal is that a developer never has to guess why the gate fired or what to do about it.
That four-part finding shape is strong: what failed, why it matters, what evidence was used, and the exact compliant change. The hash-chain detail matters too, because otherwise the report can become another editable narrative. Once developers trust that the finding is stable and explainable, the gate feels less like a black box.
A hard gate on merge is the right instinct, especially now that a good chunk of PRs are AI generated and read clean at a glance. I've caught vulnerable patterns in agent output that passed every unit test because the test suite was also written by the same agent and inherited the same blind spot. The place I'd push on this is false positives. A gate that blocks too eagerly trains people to bypass or silence it, which is worse than not having a gate at all. How is Synapse handling the tradeoff between catching real issues and not becoming the thing everyone routes around?
This is the right thing to push on, and it is the tradeoff we treat as non-negotiable. A few ways we handle it:
Precision is a release gate for the rules themselves, not just for your code. The deterministic SAST findings publish with no AI triage step, so a false-positive-prone rule is a bug in the tool. Concretely: we just blocked our own contributor PR twice over two rules before merging it. One flagged in saved notebook output (which fires on every Plotly or Bokeh chart), and one flagged local paths in tracebacks (which fires on every site-packages frame). Both had to be narrowed to a real injection or real-project-path signal, with regression tests, before it went in. The bar is: if a rule cries wolf on ordinary code, it does not ship.</p></li> <li><p>AI-derived claims go through a separate propose, verify, confirm path. An LLM opinion cannot block anything until a distinct verifier seals a verdict over a threshold, so a confident-but-wrong model output never becomes a merge blocker.</p></li> <li><p>For dependency findings, reachability and taint decide whether a vulnerable symbol is actually reachable before it escalates, so "it is in the tree somewhere" is not automatically a hard stop.</p></li> </ol> <p>On your test-suite point: agreed, that is the real danger with agent-written code, and it is exactly why the gate is pattern and dataflow based and independent of the test suite rather than derived from it. If the tests inherited the blind spot, the gate still does not.</p>
Interesting approach to integrating security gates into the CI/CD pipeline. In my work with GPU-accelerated builds, especially with frameworks like VoltageGPU, ensuring that dependencies are secure before merging is critical—especially when dealing with low-level libraries that interact with hardware. Have you considered integrating SBOM generation as part of the build process to make post-merge audits easier?
Yes, that is built in. Every scan generates an SBOM first, using owned lockfile parsers plus Syft, and that SBOM is exactly what the vulnerability matching runs against. You can export it as CycloneDX or SPDX from the same run, so wiring it into the build for post-merge audits is a one-flag step.
Interesting approach to integrating security gates in CI! I've worked on systems where we used similar patterns with Dependabot and custom scripts to fail builds on known CVEs. It's crucial to catch these early, especially when dealing with GPU-driven workloads where dependencies can be complex and hard to audit manually.
Interesting approach to enforcing security gates in CI! I've seen similar patterns with pre-submit checks for GPU-driven workloads, where we also integrate static analysis tools to catch unsafe memory patterns before merge. On VoltageGPU, we sometimes add hardware-specific constraints to these checks to prevent insecure kernel launches.
Thanks. That hardware-constraint angle is the same idea in a different domain: encode the unsafe pattern as a deterministic rule and gate on it before merge. Synapse's SAST rules are first-party Go, so a domain-specific check like an unsafe kernel launch would slot in the same way. Curious how you model the hardware constraints.
Interesting approach to integrating security gates into the CI/CD pipeline. I've worked on similar setups where we used static analysis tools to block merges that introduced insecure patterns, especially in systems code. It's reassuring to see more tooling like Synapse helping enforce this kind of discipline—especially in larger teams where code review fatigue can be a real issue.
Review fatigue is the exact failure mode we designed around. The thing that makes a merge gate survivable at team scale is that it has to be reproducible and arguable: same input, same finding, every time, with no model in the finding or report path. Synapse keeps the deterministic detectors (SCA, pattern SAST, taint, misconfig, secrets) LLM-free precisely so the gate is auditable rather than a black box a reviewer has to litigate. AI is allowed to propose, never to decide what blocks a merge.
That SARIF gotcha near the end is worth more than the rest of the post to anyone writing their own exporter. GitHub rejecting the entire file because a few SCA findings carried only a logicalLocation is exactly the kind of thing you lose an afternoon to, and "point the finding at the manifest that declares it, or emit no location at all" is the fix nobody writes down. One thing I'd flag on the workflow itself:
strict: falseon the required check means a PR can go green against an older base, so a vulnerability introduced on main between branch and merge can still slip in behind a passing gate. Was leaving it non-strict a deliberate call to avoid constant rebases, or just the default?Good catch, and you are right that this is a real gap, not pedantry.
Honest answer: strict was left off on purpose, but for the boring reason. Turning on "require branches to be up to date" serializes merges in a busy repo, since every PR has to rebase and re-run before it can land, so only one can be first in line. I did not want the demo to preach a workflow that fights people. It is the pragmatic default, not a claim that it is safe.
The window you describe is real though. The cleaner fix than flipping strict on is GitHub's merge queue: it re-runs the required checks against the actual base-plus-PR merge result before the PR lands, so you close the window without forcing everyone to rebase by hand.
One layer deeper, since we are here: even strict cannot save you from an advisory published after merge for a dependency you never touched. Point-in-time PR gating is necessary but not sufficient. The complement is a scheduled scan of the default branch, a nightly synapse-cli scan on main, so a CVE disclosed against code already on main still trips an alert. I am going to add that to the demo and call the trade-off out in the post, because you are right that it is the part nobody writes down.
If this is your area, I would genuinely welcome the input. Synapse is open source (Apache-2.0) and I have been filing scoped good-first and help-wanted issues. A star helps others find it, and there is a pinned roadmap if you want to see where it is heading: github.com/KKloudTarus/synapse-ce. Even a review of the branch-protection guidance in the docs would be a solid first contribution.
Really practical project. Instead of finding vulnerabilities only after deployment, it brings security checks directly into the PR workflow and stops risky changes before they reach production. I especially like the control dashboard and governance layer, which make findings easier to track, review, and audit. Compared with Trivy’s more scanner-focused workflow, Synapse feels more complete for teams that need both vulnerability detection and visibility across the remediation process.