Emit SARIF 2.1.0 From Your Linter So Findings Show Up in GitHub Code Scanning
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 requ
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.
What SARIF actually is
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.
Declaring a rule
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:
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.
Reporting a result
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.
Only declare the rules you fired
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.
Uploading in a workflow
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.
The honest caveat
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.
Wrapping up
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. Copy the shape, swap in your rules, and let your scanner start showing up where reviewers actually look.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.