Block the Merge When a PR Ships a Vulnerability: A CI Security Gate with Synapse

I have a simple rule: if a vulnerability makes it into main, the process already failed upstream. The right place to catch it is on the pull request, before anyone clicks merge. This post shows how to build that gate with Synapse, an open-source security scanner written in Go.

The 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, results show up in the GitHub Code Scanning tab, and the merge button stays locked until the issue is dealt with.

Why Another Tool?

Fair question. Trivy, Grype, and OSV-Scanner are all good, and I still use them. What's different about Synapse is not "it finds more CVEs". Detection is basically a commodity now. 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 AI-assisted parts, and a report path with no LLM in it. It's built for the case where you have to prove a result, not just print one.

But this post is not about philosophy. It's about one concrete job: blocking the merge.

One thing I like: synapse-cli runs with no database and no server. It's 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's the same footprint you already know from running Trivy in CI.

What You Need

Three binaries:

  • synapse-cli (built from source)
  • 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 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
      - 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
      - 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 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

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), and 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

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.

  • Drop a star if the project looks worth following.
  • Run it in your own pipeline and open an issue about what breaks or what is missing.
  • Pick up an issue labeled good first issue, or add a lockfile parser, a SAST or misconfig rule, or a new ecosystem for SCA.

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.