Skip to main content

SARIF in practice: getting scanner results into GitHub code scanning without duplicate alerts

SARIF is a JSON schema, and the JSON shape is the easy part. Any scanner can emit a runs[].results[] array with a ruleId, a message and a physicalLocation. The upload succeeds, alerts appear on the pull request, and for a week everything looks right.

Then the alert count starts climbing. The same finding appears twice, once per analysis, because something in the fingerprint changed. A rule was renamed and every open alert closed and reopened under the new name. A large monorepo scan hit a limit nobody had read about and the top of the results list was kept while the rest were silently dropped. None of these are schema errors. They are the operational rules of the consumer, and GitHub documents all of them on one page that is worth reading end to end.

This post is the version of that page you need when wiring a scanner into pull requests: the limits, the one fingerprint that matters, the severity mapping, and how Vulkro's output is shaped to stay inside the lines.

The limits you will hit first

GitHub code scanning accepts SARIF 2.1.0 only. The numbers to know:

  • 10 MB maximum per file, measured gzip-compressed.
  • 20 runs per file.
  • 25,000 results per run. When a run exceeds that, GitHub keeps the top 5,000 by severity and drops the rest.
  • 25,000 rules per run.
  • 1,000 locations per result, of which the first 100 are shown.

The 25,000 result cap is the dangerous one, because it does not fail the upload. A full scan of a large repository with historical debt can exceed it, and the consequence is that the lower-severity findings disappear without an error. If your scanner reports every finding in the tree on every push, you will eventually hit this and not know.

The practical answer is to upload fewer results per run, and the cleanest way to do that is to upload only what is new. More on that below.

The one fingerprint GitHub uses

Deduplication across analyses is what turns a stream of uploads into a stable set of alerts, and it rests on one field. GitHub uses partialFingerprints.primaryLocationLineHash and nothing else from the partialFingerprints object. If the scanner does not supply it, GitHub computes one from the location, which works until a line moves.

Two other things have to stay constant or the alerts will not match up. The ruleId must be stable across analyses: rename a rule and every open alert under the old id closes while a new one opens under the new id, which looks like a regression on the PR. File paths must be consistent across runs. A scan that runs from /home/runner/work/repo/repo one day and from ./ the next produces two different sets of alerts for the same code.

{
  "ruleId": "sql-injection",
  "level": "error",
  "message": { "text": "Request parameter reaches a SQL query by string concatenation" },
  "locations": [{
    "physicalLocation": {
      "artifactLocation": { "uri": "src/api/users.ts" },
      "region": { "startLine": 42 }
    }
  }],
  "partialFingerprints": { "primaryLocationLineHash": "a3f9c2e1d0b4:1" }
}

The uri is relative to the repository root, the ruleId is a stable slug rather than a display name, and the fingerprint hashes the line content rather than its number. Those three choices are most of what keeps an alert set clean.

Severity: the string GitHub reads

SARIF level has four values (error, warning, note, none) and GitHub shows them, but the severity filter in the UI reads a different field. The rule's properties.security-severity is a string holding a number, and GitHub maps it as: over 9.0 critical, 7.0 to 8.9 high, 4.0 to 6.9 medium, 0.1 to 3.9 low.

{
  "id": "sql-injection",
  "name": "SQL injection",
  "shortDescription": { "text": "Untrusted input reaches a SQL query" },
  "properties": { "security-severity": "9.1" }
}

If this field is missing, every alert lands in the same bucket and the "critical only" filter that your branch protection rule depends on matches nothing. Check that the scanner emits it per rule before you rely on it.

Upload only new findings

Here is where the limits and the fingerprints meet. The pull request lane does not need to know about the 1,400 findings that already existed on main. It needs to know about the three the branch introduced.

Vulkro's gate command compares the full finding set of the base ref with the full set of the head and reports the difference. vulkro gate --base origin/main --format sarif writes a SARIF file containing the new findings only. Pre-existing findings are deliberately left out, because the consumer is a PR gate and they were already in the base. Documentation: vulkro gate.

- name: Gate new findings
  run: vulkro gate --base origin/main --format sarif > vulkro.sarif
  env:
    VULKRO_ACCOUNT_TOKEN: ${{ secrets.VULKRO_ACCOUNT_TOKEN }}

Follow it with GitHub's own SARIF upload step, run with if: always() so the file is uploaded whether or not the gate exited 1.

A few consequences follow. The upload is small, so the 10 MB and 25,000 result caps stop being a concern on the PR lane. The exit code is meaningful: gate exits 0 when nothing new was found, 1 when a new finding exists, 2 on an error such as a bad ref or a tree that is not a git repository. And the alerts that appear on the PR are the ones the author can do something about.

There is a cheaper cousin. vulkro scan --gate-vs <ref> checks changed line ranges only. It is faster, and it is blind to one case: a new caller that makes an existing sink reachable without touching the sink's line. gate --base catches that because it compares whole finding sets. Use --gate-vs for a quick pre-commit check and gate for the merge decision.

For the full inventory, run the full scan on main after merge and upload that. vulkro scan ci . --format sarif > vulkro.sarif is the documented form, with VULKRO_ACCOUNT_TOKEN as the CI key. Documentation: CI/CD setup. One upload per push to main keeps the default branch's alert set current without the PR lane carrying the history.

When SARIF is not the right shape

Code scanning alerts are one way to surface a finding on a pull request. They are not always the best one. Vulkro also emits gh-pr, gh-pr-inline-comments and github-annotations for GitHub, gitlab-mr, bitbucket-pr and azure-pr for the other platforms, and junit for anything that reads test reports. Documentation: output formats. Inline comments put the finding on the diff line with the fix next to it. Annotations show up in the checks tab without needing the code scanning feature enabled on the repository.

The other direction is also useful. If another scanner already uploads SARIF and you want to know which of its results are backed by a traced data flow, the Vulkro MCP server exposes verify_sarif, which checks another tool's SARIF results against Vulkro's proof and marks each one proven, not provable or not checked. Documentation: MCP server. "Not provable" is a statement about what Vulkro could establish from the source, not a verdict that the other tool was wrong.

What to do this week

  • Open your current SARIF output and check three fields: partialFingerprints.primaryLocationLineHash on each result, properties.security-severity on each rule, and relative uri paths.
  • Count the results in the last upload. If it is in the thousands, move the PR lane to new findings only.
  • Switch the PR job to vulkro gate --base origin/main --format sarif and keep the full vulkro scan ci . --format sarif for pushes to main.
  • Read the GitHub SARIF support page once, in full, before the next rule rename.

The CI/CD docs have the complete workflow file. Vulkro Core comes with a 14-day trial; start it here and wire the gate into one repository.