# Emit SARIF 2.1.0 From Your Linter So Findings Show Up in GitHub Code Scanning

> Source: <https://dev.to/royalpinto007/emit-sarif-210-from-your-linter-so-findings-show-up-in-github-code-scanning-301f>
> Published: 2026-10-03 09:30:29+00:00

I build small security scanners, and for a long time each one printed findings to a terminal that nobody read twice. The moment I taught them to emit SARIF, the same findings started appearing as annotations in pull requests and as alerts in the GitHub Security tab. Nothing about the scanning logic changed. I just changed the output format.

This post teaches that technique. I will use my own tool, mcp-audit, as the concrete reference, but the goal is that you can wire SARIF into whatever linter or scanner you already have.

SARIF (Static Analysis Results Interchange Format) is a JSON schema that OASIS standardizes and GitHub consumes. Version 2.1.0 is the one code scanning ingests. At its core a SARIF file has one idea: a **tool** declares its **rules**, and then reports **results** that each point back to a rule by id. Get that relationship right and most of the work is done.

The smallest useful shape looks like this:

```
{
  "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
  "version": "2.1.0",
  "runs": [
    {
      "tool": {
        "driver": {
          "name": "mcp-audit",
          "informationUri": "https://github.com/AgentPostmortem/mcp-audit",
          "version": "0.1.0",
          "rules": []
        }
      },
      "results": []
    }
  ]
}
```

Everything else is filling in `rules` and `results`.

Each rule your scanner knows about becomes a **rule descriptor** under `tool.driver.rules`. The two properties GitHub cares about most are `defaultConfiguration.level` and the `security-severity` score. The level drives whether an alert renders as error, warning, or note. The `security-severity` string (a number from 0 to 10, as text) drives the Low/Medium/High/Critical label in the Security tab.

Here is one descriptor:

```
{
  "id": "MCP001",
  "name": "UnauthenticatedToolInvocation",
  "shortDescription": { "text": "Unauthenticated tool invocation" },
  "fullDescription": { "text": "The server exposes a tool that can be called without any authentication check." },
  "defaultConfiguration": { "level": "error" },
  "properties": {
    "category": "auth",
    "security-severity": "8.0",
    "tags": ["security", "mcp", "auth"]
  }
}
```

A detail worth internalizing: your internal severity vocabulary is almost never SARIF's vocabulary. In mcp-audit I have `critical`, `high`, `medium`, `low`. SARIF has three levels. So I map. Critical and high both become `error`; medium and low become `warning`; anything else becomes `note`. Separately I translate each severity to a numeric score so the Security tab still distinguishes critical from high:

``` php
critical -> level "error", security-severity "9.5"
high     -> level "error", security-severity "8.0"
medium   -> level "warning", security-severity "5.5"
low      -> level "warning", security-severity "3.0"
```

That two-track mapping (a coarse `level` plus a fine `security-severity`) is the single most useful trick I learned. Without the score, every error-level finding collapses into one bucket in the UI.

A result references a rule by `ruleId`, carries its own `level`, a human message, and a location:

```
{
  "ruleId": "MCP001",
  "level": "error",
  "message": { "text": "Tool 'run_shell' is exposed without auth. Remediation: require a signed session token before dispatch." },
  "properties": { "security-severity": "8.0" },
  "locations": [
    {
      "logicalLocations": [
        {
          "name": "server.tools.run_shell",
          "fullyQualifiedName": "server.tools.run_shell"
        }
      ]
    }
  ]
}
```

Notice I used a **logical location** rather than a physical file and line. Code scanning strongly prefers `physicalLocation` with an `artifactLocation.uri` and a `region` so it can annotate the exact line in a diff. Use that whenever your scanner knows the file and line:

```
"locations": [
  {
    "physicalLocation": {
      "artifactLocation": { "uri": "src/server.ts" },
      "region": { "startLine": 42, "startColumn": 3 }
    }
  }
]
```

mcp-audit often audits a running server or a manifest rather than a source line, so it falls back to logical locations. That is legal SARIF and it uploads fine, which brings me to the honest caveat below.

One design choice that keeps the file clean: I only emit rule descriptors for rules that actually produced a finding. I collect the set of rule ids present in the results, then filter the full rule list down to that set before writing descriptors. A SARIF file with 80 declared rules and 2 results is valid, but it clutters the tool inventory GitHub shows. Declaring only what you used keeps the report honest and small.

GitHub ships a first-party action, `github/codeql-action/upload-sarif`, that works for any SARIF file regardless of who generated it. You do not need CodeQL itself. Here is a complete job:

```
name: security-scan
on:
  push:
    branches: [main]
  pull_request:

permissions:
  security-events: write
  contents: read

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx mcp-audit ./manifest.json --format sarif --output results.sarif
      - uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: results.sarif
```

The two things people miss: the `security-events: write` permission is required or the upload silently fails, and the upload step should run even when the scan finds problems. If your scanner exits non-zero on findings, add `if: always()` to the upload step so results still reach the Security tab.

Logical locations do not annotate pull request diffs. When a finding has no file and line, GitHub still creates a Security tab alert, but it cannot draw the inline comment on the changed code, so reviewers may never notice it. If your findings map to real source positions, spend the effort to emit `physicalLocation` with a `region`. The difference between an alert nobody opens and an annotation on the exact offending line is entirely in that one field.

The whole technique is three moves: declare rules, report results that point at them, and map your severities onto SARIF's `level` plus a numeric `security-severity`. Once your tool speaks SARIF, GitHub code scanning is a free distribution channel for everything it already knows.

If you want a full working reference, the SARIF reporter in mcp-audit is a single readable file that does exactly what this post describes, severity mapping and all. It lives at [github.com/AgentPostmortem/mcp-audit](https://github.com/AgentPostmortem/mcp-audit). Copy the shape, swap in your rules, and let your scanner start showing up where reviewers actually look.
