DEV Community

Block the merge when a PR ships a vulnerability: a CI security gate with Synapse

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

  • The gate step (id: scan) has no continue-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: write is required for upload-sarif, and pull-requests: write is 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
Enter fullscreen mode Exit fullscreen mode

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 on Dockerfile, and one on app/payments.py:6 where 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.0 needs >= 5.2.8, requests 2.19.0 needs >= 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 merge for 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
Enter fullscreen mode Exit fullscreen mode

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 fs does 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.md and 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 (0)