sarif-parsing
Parses and processes SARIF files from static analysis tools like CodeQL, Semgrep, or other scanners. Triggers on "parse sarif", "read scan results", "aggregate findings", "deduplicate alerts", or "process sarif output". Handles filtering, deduplication, format conversion, and CI/
Install
npx skills add https://github.com/trailofbits/skills/tree/main/plugins/static-analysis/skills/sarif-parsing
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install trailofbits-skills@llmmart
git clone https://github.com/trailofbits/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole trailofbits/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SARIF Parsing Best Practices
You are a SARIF parsing expert. Your role is to help users effectively read, analyze, and process SARIF files from static analysis tools.
When to Use
Use this skill when:
- Reading or interpreting static analysis scan results in SARIF format
- Aggregating findings from multiple security tools
- Deduplicating or filtering security alerts
- Extracting specific vulnerabilities from SARIF files
- Integrating SARIF data into CI/CD pipelines
- Converting SARIF output to other formats
When NOT to Use
Do NOT use this skill for:
- Running static analysis scans (use CodeQL or Semgrep skills instead)
- Writing CodeQL or Semgrep rules (use their respective skills)
- Analyzing source code directly (SARIF is for processing existing scan results)
- Triaging findings without SARIF input (use variant-analysis or audit skills)
SARIF Structure Overview
SARIF 2.1.0 is the current OASIS standard. Every SARIF file has this hierarchical structure:
sarifLog
├── version: "2.1.0"
├── $schema: (optional, enables IDE validation)
└── runs[] (array of analysis runs)
├── tool
│ ├── driver
│ │ ├── name (required)
│ │ ├── version
│ │ └── rules[] (rule definitions)
│ └── extensions[] (plugins)
├── results[] (findings)
│ ├── ruleId
│ ├── ruleIndex (index into tool.driver.rules[])
│ ├── level (OPTIONAL, inherited from the rule when absent)
│ ├── message.text
│ ├── locations[]
│ │ └── physicalLocation
│ │ ├── artifactLocation.uri
│ │ └── region (startLine, startColumn, etc.)
│ ├── fingerprints{}
│ └── partialFingerprints{}
└── artifacts[] (scanned files metadata)
Severity Is Not Always on the Result
result.level is optional. CodeQL omits it on every result and records severity on the
rule as defaultConfiguration.level, which the result inherits. Read result.level
directly and a CodeQL run scores as clean however many errors it found, which is how a
severity gate ends up exiting 0 on a failing repo.
Resolve severity in this order (SARIF 2.1.0 section 3.27.10):
kindother than"fail"(a pass/notApplicable record), so"none"result.level, when present- the matched rule's
defaultConfiguration.level, joiningruleIndexintoruns[].tool.driver.rules[], or matchingruleIdagainstrules[].idwhen the tool omitsruleIndex "warning", the SARIF default
Every severity query in this skill starts from that resolution. In jq it is the
LEVEL_FN definition in /resources/jq-queries.md;
in Python it is resolve_level(result, run) in
/resources/sarif_helpers.py.
Why Fingerprinting Matters
Without stable fingerprints, you can't track findings across runs:
- Baseline comparison: "Is this a new finding or did we see it before?"
- Regression detection: "Did this PR introduce new vulnerabilities?"
- Suppression: "Ignore this known false positive in future runs"
Tools report different paths (/path/to/project/ vs /github/workspace/), so path-based matching fails. Fingerprints hash the content (code snippet, rule ID, relative location) to create stable identifiers regardless of environment.
Tool Selection Guide
| Use Case | Tool | Install / run |
|---|---|---|
| Quick CLI queries | jq | brew install jq / apt install jq |
| Python scripting (simple) | pysarif | uv run --with pysarif python script.py |
| Python scripting (advanced) | sarif-tools | uv run --with sarif-tools python script.py |
| .NET applications | SARIF SDK | NuGet package |
| JavaScript/Node.js | sarif-js | npm package |
| Go applications | garif | go get github.com/chavacava/garif |
| Validation | SARIF Validator | sarifweb.azurewebsites.net |
Strategy 1: Quick Analysis with jq
For rapid exploration and one-off queries:
# Pretty print the file
jq '.' results.sarif
# Count total findings
jq '[.runs[].results[]] | length' results.sarif
# List all rule IDs triggered
jq '[.runs[].results[].ruleId] | unique' results.sarif
# Severity resolution, needed by every query below that filters on level.
# See resources/jq-queries.md for the annotated version.
LEVEL_FN='
def rule($run):
. as $r
| ($run.tool.driver.rules // []) as $rules
| (if ($r.ruleIndex | type) == "number" and $r.ruleIndex >= 0
then $rules[$r.ruleIndex] else null end)
// first($rules[] | select(.id == $r.ruleId))
// null;
def level($run):
. as $r
| if ($r.kind // "fail") != "fail" then "none"
else ($r.level // rule($run).defaultConfiguration.level // "warning") end;
'
# Extract errors only
jq "$LEVEL_FN"'.runs[] as $run | $run.results[] | select(level($run) == "error")' results.sarif
# Get findings with file locations
jq '.runs[].results[] | {
rule: .ruleId,
message: .message.text,
file: .locations[0].physicalLocation.artifactLocation.uri,
line: .locations[0].physicalLocation.region.startLine
}' results.sarif
# Filter by severity and get count per rule
jq "$LEVEL_FN"'[.runs[] as $run | $run.results[] | select(level($run) == "error")] | group_by(.ruleId) | map({rule: .[0].ruleId, count: length})' results.sarif
# Extract findings for a specific file
jq --arg file "src/auth.py" '.runs[].results[] | select(.locations[].physicalLocation.artifactLocation.uri | contains($file))' results.sarif
Strategy 2: Python with pysarif
For programmatic access with full object model:
from pysarif import load_from_file, save_to_file
# Load SARIF file
sarif = load_from_file("results.sarif")
# Iterate through runs and results
for run in sarif.runs:
tool_name = run.tool.driver.name
print(f"Tool: {tool_name}")
for result in run.results:
# pysarif fills a missing result.level with "warning", so .level here is NOT the
# rule-inherited severity: a CodeQL error (no level on the result, severity on the
# rule) reads as "warning". Gate severity with Strategy 1's level() or with
# resolve_level() in resources/sarif_helpers.py, which resolve it from the rule.
print(f" {result.rule_id}: {result.message.text}")
if result.locations:
loc = result.locations[0].physical_location
if loc and loc.artifact_location:
print(f" File: {loc.artifact_location.uri}")
if loc.region:
print(f" Line: {loc.region.start_line}")
# Save modified SARIF
save_to_file(sarif, "modified.sarif")
Strategy 3: Python with sarif-tools
For aggregation, reporting, and CI/CD integration:
from sarif import loader
# Load single file
sarif_data = loader.load_sarif_file("results.sarif")
# Or load multiple files
sarif_set = loader.load_sarif_files(["tool1.sarif", "tool2.sarif"])
# Get summary report
report = sarif_data.get_report()
# Get histogram by severity
errors = report.get_issue_type_histogram_for_severity("error")
warnings = report.get_issue_type_histogram_for_severity("warning")
# Filter by severity. sarif-tools hands back raw result dicts, and a result's level may
# live on its rule, so resolve it against the run instead of reading r["level"].
from sarif_helpers import extract_findings, filter_by_level, load_sarif
high_severity = filter_by_level(extract_findings(load_sarif("results.sarif")), "error")
sarif-tools CLI commands:
# Summary of findings
sarif summary results.sarif
# List all results with details
sarif ls results.sarif
# Get results by severity
sarif ls --level error results.sarif
# Diff two SARIF files (find new/fixed issues)
sarif diff baseline.sarif current.sarif
# Convert to other formats
sarif csv results.sarif > results.csv
sarif html results.sarif > report.html
Strategy 4: Aggregating Multiple SARIF Files
When combining results from multiple tools:
import json
from sarif_helpers import deduplicate, extract_findings
def aggregate_sarif_files(sarif_paths: list[str]) -> dict:
"""Combine multiple SARIF files into one."""
aggregated = {
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": []
}
for path in sarif_paths:
with open(path) as f:
sarif = json.load(f)
aggregated["runs"].extend(sarif.get("runs", []))
return aggregated
unique = deduplicate(extract_findings(aggregate_sarif_files(["tool1.sarif", "tool2.sarif"])))
deduplicate() prefers whatever fingerprints or partialFingerprints the tool supplied
and falls back to hashing rule ID, the whole normalized path, line, and message. Keep the
directory in that key: the same rule at the same line in auth/login.py and
admin/login.py is two findings, and a basename-only key throws one of them away.
Strategy 5: Extracting Actionable Data
resources/sarif_helpers.py covers this with the standard library alone.
extract_findings() returns Finding objects whose severity is already resolved, and
filter_by_level(), sort_by_severity(), deduplicate() and diff_findings() consume
those:
from sarif_helpers import extract_findings, filter_by_level, load_sarif, sort_by_severity
findings = sort_by_severity(extract_findings(load_sarif("results.sarif")))
for f in filter_by_level(findings, "error"):
print(f"{f.file_path}:{f.start_line} [{f.level}] {f.rule_id}: {f.message}")
Writing your own extractor, severity is the part that goes wrong silently:
def resolve_level(result: dict, run: dict) -> str:
"""Severity of a result: its own level, else its rule's default, else "warning"."""
if result.get("kind", "fail") != "fail":
return "none"
if result.get("level"):
return result["level"]
rules = run.get("tool", {}).get("driver", {}).get("rules", [])
index = result.get("ruleIndex")
rule = rules[index] if isinstance(index, int) and 0 <= index < len(rules) else next(
(r for r in rules if r.get("id") == result.get("ruleId")), {}
)
return rule.get("defaultConfiguration", {}).get("level") or "warning"
Results carry ruleIndex on some tools and only ruleId on others, so a resolver that
joins one way alone silently returns the default for every result the other kind of tool
produces.
Common Pitfalls and Solutions
1. Path Normalization Issues
Different tools report paths differently (absolute, relative, URI-encoded), so
file:///src/a%20b.py and src/a b.py can be the same file. Strip the file:// scheme,
percent-decode, resolve against a base path, and normalize separators before comparing or
hashing anything: normalize_path() in resources/sarif_helpers.py does all four.
2. Fingerprint Mismatch Across Runs
Fingerprints may not match if:
- File paths differ between environments
- Tool versions changed fingerprinting algorithm
- Code was reformatted (changing line numbers)
Solution: Use multiple fingerprint strategies:
def compute_stable_fingerprint(result: dict, file_content: str = None) -> str:
"""Compute environment-independent fingerprint."""
import hashlib
components = [
result.get("ruleId", ""),
result.get("message", {}).get("text", "")[:100], # First 100 chars
]
# Add code snippet if available
if file_content and result.get("locations"):
region = result["locations"][0].get("physicalLocation", {}).get("region", {})
if region.get("startLine"):
lines = file_content.split("\n")
line_idx = region["startLine"] - 1
if 0 <= line_idx < len(lines):
# Normalize whitespace
components.append(lines[line_idx].strip())
return hashlib.sha256("".join(components).encode()).hexdigest()[:16]
3. Missing or Incomplete Data
SARIF allows many optional fields. Always use defensive access:
def safe_get_location(result: dict) -> tuple[str, int]:
"""Safely extract file and line from result."""
try:
loc = result.get("locations", [{}])[0]
phys = loc.get("physicalLocation", {})
file_path = phys.get("artifactLocation", {}).get("uri", "unknown")
line = phys.get("region", {}).get("startLine", 0)
return file_path, line
except (IndexError, KeyError, TypeError):
return "unknown", 0
4. Large File Performance
For very large SARIF files (100MB+):
import ijson # run via: uv run --with ijson
def stream_results(sarif_path: str):
"""Stream results without loading entire file."""
with open(sarif_path, "rb") as f:
# Stream through results arrays
for result in ijson.items(f, "runs.item.results.item"):
yield result
5. Schema Validation
Validate before processing to catch malformed files:
# Using ajv-cli
npm install -g ajv-cli
ajv validate -s sarif-schema-2.1.0.json -d results.sarif
# Using Python jsonschema
uv run --with jsonschema python your_script.py # e.g. the function below
from jsonschema import validate, ValidationError
import json
def validate_sarif(sarif_path: str, schema_path: str) -> bool:
"""Validate SARIF file against schema."""
with open(sarif_path) as f:
sarif = json.load(f)
with open(schema_path) as f:
schema = json.load(f)
try:
validate(sarif, schema)
return True
except ValidationError as e:
print(f"Validation error: {e.message}")
return False
CI/CD Integration Patterns
GitHub Actions
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
- name: Check for high severity
run: |
# select(.level == "error") counts zero on CodeQL output, which records severity on
# the rule instead. Resolve the level or the gate passes on a repo full of errors.
HIGH_COUNT=$(jq '
def rule($run):
. as $r
| ($run.tool.driver.rules // []) as $rules
| (if ($r.ruleIndex | type) == "number" and $r.ruleIndex >= 0
then $rules[$r.ruleIndex] else null end)
// first($rules[] | select(.id == $r.ruleId))
// null;
def level($run):
. as $r
| if ($r.kind // "fail") != "fail" then "none"
else ($r.level // rule($run).defaultConfiguration.level // "warning") end;
[.runs[] as $run | $run.results[] | select(level($run) == "error")] | length
' results.sarif)
if [ "$HIGH_COUNT" -gt 0 ]; then
echo "Found $HIGH_COUNT high severity issues"
exit 1
fi
Fail on New Issues
from sarif import loader
def check_for_regressions(baseline: str, current: str) -> int:
"""Return count of new issues not in baseline."""
baseline_data = loader.load_sarif_file(baseline)
current_data = loader.load_sarif_file(current)
baseline_fps = {get_fingerprint(r) for r in baseline_data.get_results()}
new_issues = [r for r in current_data.get_results()
if get_fingerprint(r) not in baseline_fps]
return len(new_issues)
Key Principles
- Validate first: Check SARIF structure before processing
- Resolve severity, never read
result.level: it is optional, and CodeQL always omits it - Handle optionals: Many fields are optional; use defensive access
- Normalize paths: Tools report paths differently; normalize early
- Fingerprint wisely: Combine multiple strategies for stable deduplication
- Stream large files: Use ijson or similar for 100MB+ files
- Aggregate thoughtfully: Preserve tool metadata when combining files
Skill Resources
For ready-to-use query templates, see /resources/jq-queries.md:
- 40+ jq queries for common SARIF operations
LEVEL_FN- the severity resolution every filtering query starts from- Severity filtering, rule extraction, aggregation patterns
For Python utilities, see /resources/sarif_helpers.py:
resolve_level()- Severity from the result or the rule it inherits fromnormalize_path()- Handle tool-specific path formatscompute_fingerprint()- Rule, normalized path, line, and messagededuplicate()- Remove duplicates across runs
Two SARIF fixtures live in /resources/fixtures, one with severity on the rules only and one with severity on the results. Each contains exactly one error, so a gate can be checked against a known answer before it is trusted.
Reference Links
Files (skills)
-
agents
-
openai.yaml 231 B
interface: display_name: "SARIF Parsing" short_description: "Parse and process SARIF static analysis results" icon_small: "assets/trail-of-bits-mark.svg" icon_large: "assets/trail-of-bits-mark.svg" brand_color: "#D83A34"
-
-
assets
-
trail-of-bits-mark.svg 3 KB · in bundle
-
-
resources
-
fixtures
-
codeql-no-level.sarif 2.2 KB · in bundle
-
levels-on-results.sarif 1.7 KB · in bundle
-
-
jq-queries.md 7.7 KB
# SARIF jq Query Reference Ready-to-use jq queries for common SARIF parsing tasks. ## Severity resolution (read this first) `result.level` is optional in SARIF 2.1.0. CodeQL omits it on every result and stores severity on the rule instead, as `defaultConfiguration.level`; the result inherits it. So `select(.level == "error")` matches nothing on a CodeQL run no matter how many errors it found, and a CI gate written that way exits 0 on a failing repo. Resolve it instead: `result.level` when present, otherwise the matched rule's `defaultConfiguration.level`, otherwise `"warning"` (the SARIF default). Join the rule on `ruleIndex` when the tool populates it, on `ruleId` when it does not. ```bash # Paste once per shell. `jq "$LEVEL_FN"'<query>'` concatenates the two into one filter. LEVEL_FN=' def rule($run): . as $r | ($run.tool.driver.rules // []) as $rules | (if ($r.ruleIndex | type) == "number" and $r.ruleIndex >= 0 then $rules[$r.ruleIndex] else null end) // first($rules[] | select(.id == $r.ruleId)) // null; def level($run): . as $r | if ($r.kind // "fail") != "fail" then "none" else ($r.level // rule($run).defaultConfiguration.level // "warning") end; ' # Severity of every result, whichever way the tool recorded it jq -r "$LEVEL_FN"'.runs[] as $run | $run.results[] | "\(level($run))\t\(.ruleId)"' results.sarif # The CI gate: count of error-level findings, however the tool recorded severity jq "$LEVEL_FN"'[.runs[] as $run | $run.results[] | select(level($run) == "error")] | length' results.sarif ``` `kind` other than `"fail"` marks a pass/notApplicable record rather than a finding, and resolves to `"none"`. Without that clause a compliance tool's passing checks inherit their rule's `error` and fail the build. The `>= 0` guard matters as much: SARIF writes `ruleIndex: -1` for "no rule", and `$rules[-1]` in jq is the *last* rule, so dropping it labels those results with whatever severity the final rule in the array happens to have. Two fixtures under `fixtures/` exercise both shapes: `codeql-no-level.sarif` (severity on the rules only) and `levels-on-results.sarif` (severity on the results). Each holds exactly one error, so any query below can be checked against a known answer. ## Basic Exploration ```bash # Pretty print jq '.' results.sarif # Get SARIF version jq '.version' results.sarif # List tool names from all runs jq '.runs[].tool.driver.name' results.sarif # Count runs jq '.runs | length' results.sarif ``` ## Result Queries ```bash # Total result count jq '[.runs[].results[]] | length' results.sarif # Count by severity level jq "$LEVEL_FN"'reduce (.runs[] as $run | $run.results[] | level($run)) as $l ({}; .[$l] += 1)' results.sarif # List unique rule IDs jq '[.runs[].results[].ruleId] | unique | sort' results.sarif # Count per rule jq '[.runs[].results[]] | group_by(.ruleId) | map({rule: .[0].ruleId, count: length}) | sort_by(-.count)' results.sarif ``` ## Filtering Results ```bash # Only errors jq "$LEVEL_FN"'.runs[] as $run | $run.results[] | select(level($run) == "error")' results.sarif # Only warnings jq "$LEVEL_FN"'.runs[] as $run | $run.results[] | select(level($run) == "warning")' results.sarif # By specific rule ID jq --arg rule "SQL_INJECTION" '.runs[].results[] | select(.ruleId == $rule)' results.sarif # By file path (contains) jq --arg file "auth" '.runs[].results[] | select(.locations[].physicalLocation.artifactLocation.uri | contains($file))' results.sarif # By file extension jq '.runs[].results[] | select(.locations[].physicalLocation.artifactLocation.uri | test("\\.py$"))' results.sarif # Multiple conditions jq "$LEVEL_FN"'.runs[] as $run | $run.results[] | select(level($run) == "error" and (.ruleId | startswith("SEC")))' results.sarif ``` ## Extracting Locations ```bash # File and line for each result jq '.runs[].results[] | { rule: .ruleId, file: .locations[0].physicalLocation.artifactLocation.uri, line: .locations[0].physicalLocation.region.startLine }' results.sarif # Unique affected files jq '[.runs[].results[].locations[].physicalLocation.artifactLocation.uri] | unique | sort' results.sarif # Results grouped by file jq '[.runs[].results[] | {file: .locations[0].physicalLocation.artifactLocation.uri, result: .}] | group_by(.file) | map({file: .[0].file, count: length})' results.sarif ``` ## Rule Information ```bash # List all rules with severity jq '.runs[].tool.driver.rules[] | {id: .id, name: .name, level: .defaultConfiguration.level}' results.sarif # Get rule description by ID jq --arg id "RULE001" '.runs[].tool.driver.rules[] | select(.id == $id)' results.sarif # Rules with help URLs jq '.runs[].tool.driver.rules[] | select(.helpUri) | {id: .id, help: .helpUri}' results.sarif ``` ## Fingerprints ```bash # Results with fingerprints jq '.runs[].results[] | select(.fingerprints or .partialFingerprints) | {rule: .ruleId, fp: (.fingerprints // .partialFingerprints)}' results.sarif # Extract all partial fingerprints jq '[.runs[].results[].partialFingerprints] | add' results.sarif ``` ## Aggregation and Reporting ```bash # Summary by severity and rule jq "$LEVEL_FN"'[.runs[] as $run | $run.results[] | {level: level($run), ruleId}] | group_by(.level) | map({level: .[0].level, rules: (group_by(.ruleId) | map({rule: .[0].ruleId, count: length}))})' results.sarif # Top 10 most frequent rules jq '[.runs[].results[]] | group_by(.ruleId) | map({rule: .[0].ruleId, count: length}) | sort_by(-.count) | .[0:10]' results.sarif # Files with most issues jq '[.runs[].results[] | .locations[0].physicalLocation.artifactLocation.uri] | group_by(.) | map({file: .[0], count: length}) | sort_by(-.count) | .[0:10]' results.sarif ``` ## Output Formatting ```bash # CSV-like output jq -r "$LEVEL_FN"'.runs[] as $run | $run.results[] | [.ruleId, level($run), .locations[0].physicalLocation.artifactLocation.uri, .locations[0].physicalLocation.region.startLine, .message.text] | @csv' results.sarif # Tab-separated jq -r "$LEVEL_FN"'.runs[] as $run | $run.results[] | [.ruleId, level($run), .locations[0].physicalLocation.artifactLocation.uri // "N/A"] | @tsv' results.sarif # Markdown table echo "| Rule | Level | File | Line |" echo "|------|-------|------|------|" jq -r "$LEVEL_FN"'.runs[] as $run | $run.results[] | "| \(.ruleId) | \(level($run)) | \(.locations[0].physicalLocation.artifactLocation.uri // "N/A") | \(.locations[0].physicalLocation.region.startLine // "N/A") |"' results.sarif ``` ## Comparison and Diff ```bash # Find rules in file1 not in file2 comm -23 <(jq -r '[.runs[].results[].ruleId] | unique | sort[]' file1.sarif) <(jq -r '[.runs[].results[].ruleId] | unique | sort[]' file2.sarif) # Compare result counts echo "File 1: $(jq '[.runs[].results[]] | length' file1.sarif)" echo "File 2: $(jq '[.runs[].results[]] | length' file2.sarif)" ``` ## Transformation ```bash # Extract minimal SARIF (results only) jq '{version: .version, runs: [.runs[] | {tool: {driver: {name: .tool.driver.name}}, results: .results}]}' results.sarif # Filter and create new SARIF with only errors, per run: the rules a result # inherits from live in its own run, so runs must not be flattened together jq "$LEVEL_FN"'.runs |= map(. as $run | .results = [.results[] | select(level($run) == "error")])' results.sarif > errors-only.sarif # Merge multiple SARIF files jq -s '{version: "2.1.0", runs: [.[].runs[]]}' file1.sarif file2.sarif > merged.sarif ``` ## Validation Checks ```bash # Check if version is 2.1.0 jq -e '.version == "2.1.0"' results.sarif && echo "Valid version" || echo "Invalid version" # Check for empty results jq -e '[.runs[].results[]] | length > 0' results.sarif && echo "Has results" || echo "No results" # Verify all results have locations jq '[.runs[].results[] | select(.locations | length == 0)] | length' results.sarif ``` -
sarif_helpers.py 12.7 KB
""" SARIF Parsing Helper Functions Reusable utilities for working with SARIF files. No external dependencies beyond standard library. """ import hashlib import json from collections import defaultdict from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path from typing import Any from urllib.parse import unquote # What a result's severity is when neither the result nor its rule states one. SARIF_DEFAULT_LEVEL = "warning" @dataclass class Finding: """Structured representation of a SARIF result.""" rule_id: str level: str # resolved by resolve_level(), not read from result.level message: str file_path: str | None = None start_line: int | None = None end_line: int | None = None start_column: int | None = None end_column: int | None = None fingerprint: str | None = None tool_name: str | None = None rule_name: str | None = None raw: dict = field(default_factory=dict, repr=False) def load_sarif(path: str | Path) -> dict: """Load and parse a SARIF file.""" with open(path) as f: return json.load(f) def save_sarif(sarif: dict, path: str | Path, indent: int = 2) -> None: """Save SARIF data to file.""" with open(path, "w") as f: json.dump(sarif, f, indent=indent) def validate_version(sarif: dict) -> bool: """Check if SARIF version is 2.1.0.""" return sarif.get("version") == "2.1.0" def normalize_path(uri: str, base_path: str = "") -> str: """Normalize SARIF artifact URI to consistent path.""" if not uri: return "" # Remove file:// prefix if uri.startswith("file://"): uri = uri[7:] # URL decode uri = unquote(uri) # Handle relative paths if base_path and not Path(uri).is_absolute(): uri = str(Path(base_path) / uri) return str(Path(uri)) def safe_get(data: dict, *keys, default: Any = None) -> Any: """Safely navigate nested dict structure.""" for key in keys: if isinstance(data, dict): data = data.get(key, {}) elif isinstance(data, list) and isinstance(key, int): data = data[key] if 0 <= key < len(data) else {} else: return default return data if data != {} else default def find_rule(result: dict, run: dict) -> dict | None: """Find the rule definition a result was produced by. Joins on `ruleIndex` first (the cheap, unambiguous key CodeQL populates) and falls back to matching `ruleId` against `runs[].tool.driver.rules[].id`, which is all Semgrep and most other tools give you. """ rules = safe_get(run, "tool", "driver", "rules", default=[]) or [] index = result.get("ruleIndex") if isinstance(index, int) and not isinstance(index, bool) and 0 <= index < len(rules): return rules[index] rule_id = result.get("ruleId") if rule_id: for rule in rules: if rule.get("id") == rule_id: return rule return None def resolve_level(result: dict, run: dict) -> str: """Resolve a result's effective severity, per SARIF 2.1.0 section 3.27.10. `result.level` is optional, and CodeQL routinely omits it: severity lives on the rule as `defaultConfiguration.level`, and the result inherits it. Reading `result.level` directly therefore scores an entire CodeQL run as clean, which is why a severity gate written that way exits 0 on a repo full of errors. Resolution order: 1. `kind` other than "fail" (a pass/informational/notApplicable record) is "none" 2. `result.level` when present 3. the matched rule's `defaultConfiguration.level` 4. "warning", the SARIF default `invocations[].ruleConfigurationOverrides` can outrank the rule default; no tool in common use emits it, so this does not read it. """ if (result.get("kind") or "fail") != "fail": return "none" level = result.get("level") if level: return level rule = find_rule(result, run) if rule: rule_level = safe_get(rule, "defaultConfiguration", "level") if rule_level: return rule_level return SARIF_DEFAULT_LEVEL def extract_location(result: dict) -> tuple[str | None, int | None, int | None]: """Extract file path, start line, and end line from result.""" loc = safe_get(result, "locations", 0, default={}) phys = loc.get("physicalLocation", {}) region = phys.get("region", {}) file_path = safe_get(phys, "artifactLocation", "uri") start_line = region.get("startLine") end_line = region.get("endLine") return file_path, start_line, end_line def iter_results(sarif: dict) -> Iterator[tuple[dict, dict]]: """Iterate over all results with their run context.""" for run in sarif.get("runs", []): for result in run.get("results", []): yield result, run def extract_findings(sarif: dict) -> list[Finding]: """Extract all findings as structured objects.""" findings = [] for result, run in iter_results(sarif): tool_name = safe_get(run, "tool", "driver", "name") file_path, start_line, end_line = extract_location(result) loc = safe_get(result, "locations", 0, default={}) phys = loc.get("physicalLocation", {}) region = phys.get("region", {}) rule = find_rule(result, run) # Get fingerprint fp = None if result.get("partialFingerprints"): fp = next(iter(result["partialFingerprints"].values()), None) elif result.get("fingerprints"): fp = next(iter(result["fingerprints"].values()), None) findings.append( Finding( rule_id=result.get("ruleId", "unknown"), level=resolve_level(result, run), message=safe_get(result, "message", "text", default=""), file_path=file_path, start_line=start_line, end_line=end_line, start_column=region.get("startColumn"), end_column=region.get("endColumn"), fingerprint=fp, tool_name=tool_name, rule_name=rule.get("name") if rule else None, raw=result, ) ) return findings def filter_by_level(findings: list[Finding], *levels: str) -> list[Finding]: """Filter findings by severity level(s).""" return [f for f in findings if f.level in levels] def filter_by_file(findings: list[Finding], pattern: str) -> list[Finding]: """Filter findings by file path pattern (substring match).""" return [f for f in findings if f.file_path and pattern in f.file_path] def filter_by_rule(findings: list[Finding], *rule_ids: str) -> list[Finding]: """Filter findings by rule ID(s).""" return [f for f in findings if f.rule_id in rule_ids] def sort_by_severity(findings: list[Finding], reverse: bool = False) -> list[Finding]: """Sort findings by severity (error > warning > note > none).""" severity_order = {"error": 0, "warning": 1, "note": 2, "none": 3} return sorted(findings, key=lambda f: severity_order.get(f.level, 99), reverse=reverse) def group_by_file(findings: list[Finding]) -> dict[str, list[Finding]]: """Group findings by file path.""" grouped = defaultdict(list) for f in findings: key = f.file_path or "unknown" grouped[key].append(f) return dict(grouped) def group_by_rule(findings: list[Finding]) -> dict[str, list[Finding]]: """Group findings by rule ID.""" grouped = defaultdict(list) for f in findings: grouped[f.rule_id].append(f) return dict(grouped) def count_by_level(findings: list[Finding]) -> dict[str, int]: """Count findings by severity level.""" counts = defaultdict(int) for f in findings: counts[f.level] += 1 return dict(counts) def count_by_rule(findings: list[Finding]) -> dict[str, int]: """Count findings by rule ID.""" counts = defaultdict(int) for f in findings: counts[f.rule_id] += 1 return dict(counts) def compute_fingerprint(result: dict, include_message: bool = True) -> str: """Compute stable fingerprint from result data. The whole normalized path goes into the hash, directory included. Hashing the basename alone gave `src/auth/login.py:42` and `src/admin/login.py:42` one fingerprint under the same rule, so `deduplicate()` and `diff_findings()` threw away the second finding and called the file fixed. The cost is that runs reporting different absolute prefixes for the same file (`/github/workspace/src/a.py` vs `/builds/proj/src/a.py`) no longer match. Make the URIs repo-relative before fingerprinting when comparing across environments; a collision that hides a finding is the worse failure. """ components = [result.get("ruleId", "")] file_path, start_line, _ = extract_location(result) if file_path: # POSIX separators so a fingerprint computed on Windows matches one from CI. components.append(Path(normalize_path(file_path)).as_posix()) if start_line: components.append(str(start_line)) if include_message: msg = safe_get(result, "message", "text", default="") # First 50 chars of message for stability components.append(msg[:50]) return hashlib.sha256("|".join(components).encode()).hexdigest()[:16] def deduplicate(findings: list[Finding]) -> list[Finding]: """Remove duplicate findings based on fingerprints.""" seen = set() unique = [] for f in findings: key = f.fingerprint or compute_fingerprint(f.raw) if key not in seen: seen.add(key) unique.append(f) return unique def merge_sarif_files(*paths: str | Path) -> dict: """Merge multiple SARIF files into one.""" merged = { "version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": [], } for path in paths: sarif = load_sarif(path) merged["runs"].extend(sarif.get("runs", [])) return merged def diff_findings( baseline: list[Finding], current: list[Finding] ) -> tuple[list[Finding], list[Finding], list[Finding]]: """ Compare two sets of findings. Returns: - new: findings in current but not baseline - fixed: findings in baseline but not current - unchanged: findings in both """ baseline_fps = {f.fingerprint or compute_fingerprint(f.raw) for f in baseline} current_fps = {f.fingerprint or compute_fingerprint(f.raw) for f in current} new = [f for f in current if (f.fingerprint or compute_fingerprint(f.raw)) not in baseline_fps] fixed = [ f for f in baseline if (f.fingerprint or compute_fingerprint(f.raw)) not in current_fps ] unchanged = [ f for f in current if (f.fingerprint or compute_fingerprint(f.raw)) in baseline_fps ] return new, fixed, unchanged def get_rules(sarif: dict) -> dict[str, dict]: """Extract rule definitions from SARIF file.""" rules = {} for run in sarif.get("runs", []): for rule in safe_get(run, "tool", "driver", "rules", default=[]): rules[rule.get("id", "")] = rule return rules def to_csv_rows(findings: list[Finding]) -> list[list[str]]: """Convert findings to CSV-ready rows.""" rows = [["rule_id", "level", "file", "line", "message"]] for f in findings: rows.append( [ f.rule_id, f.level, f.file_path or "", str(f.start_line or ""), f.message.replace("\n", " ")[:200], ] ) return rows def summary(findings: list[Finding]) -> dict: """Generate summary statistics for findings.""" return { "total": len(findings), "by_level": count_by_level(findings), "by_rule": count_by_rule(findings), "files_affected": len(set(f.file_path for f in findings if f.file_path)), "rules_triggered": len(set(f.rule_id for f in findings)), } # Example usage if __name__ == "__main__": import sys if len(sys.argv) < 2: print("Usage: uv run --no-project sarif_helpers.py <sarif_file>") sys.exit(1) sarif = load_sarif(sys.argv[1]) if not validate_version(sarif): print("Warning: SARIF version is not 2.1.0") findings = extract_findings(sarif) findings = sort_by_severity(findings) print("\nSummary:") stats = summary(findings) print(f" Total findings: {stats['total']}") print(f" Files affected: {stats['files_affected']}") print(f" Rules triggered: {stats['rules_triggered']}") print("\nBy severity:") for level, count in stats["by_level"].items(): print(f" {level}: {count}") print("\nTop 5 rules:") for rule, count in sorted(stats["by_rule"].items(), key=lambda x: -x[1])[:5]: print(f" {rule}: {count}") -
test_sarif_helpers.py 13.7 KB
# /// script # requires-python = ">=3.11" # dependencies = ["pytest>=8"] # /// """Tests for sarif_helpers.py, with the weight on severity resolution. `result.level` is optional in SARIF 2.1.0, and CodeQL never emits it: severity lives on the rule as `defaultConfiguration.level` and the result inherits it. Every helper here used to read `result.level` directly, so `filter_by_level(findings, "error")` returned nothing for a CodeQL run and the documented CI gate exited 0 on a repo full of errors. fixtures/codeql-no-level.sarif is that shape, and test_naive_level_read_finds_nothing pins it: if someone "fixes" the fixture by adding levels to its results, that test fails rather than the suite quietly losing the only case it exists to cover. fixtures/levels-on-results.sarif is the ordinary shape, and it is here so a resolver cannot pass by ignoring `result.level` altogether. The jq tests run the documented commands themselves, extracted from the markdown. A severity gate that is correct in sarif_helpers.py and wrong in the docs is still a severity gate that passes on a failing repo. """ from __future__ import annotations import json import re import shutil import subprocess from pathlib import Path import pytest from sarif_helpers import ( compute_fingerprint, deduplicate, extract_findings, filter_by_level, find_rule, load_sarif, resolve_level, ) RESOURCES = Path(__file__).resolve().parent SKILL = RESOURCES.parent / "SKILL.md" JQ_QUERIES = RESOURCES / "jq-queries.md" NO_LEVEL = RESOURCES / "fixtures/codeql-no-level.sarif" WITH_LEVEL = RESOURCES / "fixtures/levels-on-results.sarif" # The gate every CI pipeline built from this skill runs, in its jq form. ERROR_GATE = '[.runs[] as $run | $run.results[] | select(level($run) == "error")] | length' NAIVE_GATE = '[.runs[].results[] | select(.level == "error")] | length' FENCE = re.compile(r"^```[a-z]*\n(.*?)^```", re.MULTILINE | re.DOTALL) LEVEL_FN = re.compile(r"LEVEL_FN='\n(.*?)'\n", re.DOTALL) # The GitHub Actions gate, which inlines its own copy of the resolver. CI_GATE = re.compile(r"HIGH_COUNT=\$\(jq '\n(.*?)\n\s*' results\.sarif\)", re.DOTALL) def results_of(sarif: dict) -> list[dict]: return [r for run in sarif["runs"] for r in run["results"]] def jq(program: str, path: Path) -> str: """Run jq, failing rather than skipping when it is missing. A skip reads as a clean run while the only check that can catch a broken documented gate did not execute. jq is this skill's primary tool and CI installs it. """ if not shutil.which("jq"): raise AssertionError( "jq is not installed, so the documented severity gate went unverified. " "Install it (brew install jq); this suite must not pass without it." ) out = subprocess.run(["jq", program, str(path)], capture_output=True, text=True, check=True) return out.stdout.strip() def documented_level_fn(path: Path) -> str: """The LEVEL_FN jq definition as the docs publish it.""" match = LEVEL_FN.search(path.read_text()) assert match, f"{path.name} no longer defines LEVEL_FN; the jq tests below test nothing" return match.group(1) # --- the fixtures themselves, so the suite cannot go vacuous ------------------------ def test_codeql_fixture_omits_every_result_level(): """The bug only exists on results with no level of their own.""" results = results_of(load_sarif(NO_LEVEL)) assert results, "fixture has no results" assert all("level" not in r for r in results) rules = load_sarif(NO_LEVEL)["runs"][0]["tool"]["driver"]["rules"] assert any(r.get("defaultConfiguration", {}).get("level") == "error" for r in rules) def test_codeql_fixture_covers_both_join_directions(): """One result joins by ruleIndex, another only by ruleId.""" results = results_of(load_sarif(NO_LEVEL)) assert any("ruleIndex" in r for r in results) assert any("ruleIndex" not in r for r in results) def test_naive_level_read_finds_nothing_on_codeql_output(): """The bug, stated: reading result.level scores a CodeQL run as clean.""" results = results_of(load_sarif(NO_LEVEL)) assert [r for r in results if r.get("level") == "error"] == [] assert len(filter_by_level(extract_findings(load_sarif(NO_LEVEL)), "error")) == 1 # --- resolution --------------------------------------------------------------------- def test_severity_inherited_from_rule_defaults(): levels = {f.rule_id: f.level for f in extract_findings(load_sarif(NO_LEVEL))} assert levels == { "py/sql-injection": "error", # defaultConfiguration.level, via ruleIndex "py/clear-text-logging-sensitive-data": "warning", # via ruleId, no ruleIndex "py/unused-import": "warning", # rule has no defaultConfiguration at all } def test_result_level_outranks_rule_default(): """Both fixtures hold exactly one error; a resolver that ignored result.level would report zero here, which is how you notice the fix was an inversion.""" findings = extract_findings(load_sarif(WITH_LEVEL)) assert {f.rule_id: f.level for f in findings} == { "python.lang.security.audit.dangerous-subprocess-use": "error", "python.django.security.audit.avoid-mark-safe": "warning", } rules = load_sarif(WITH_LEVEL)["runs"][0]["tool"]["driver"]["rules"] assert all(r["defaultConfiguration"]["level"] == "warning" for r in rules) assert len(filter_by_level(findings, "error")) == 1 @pytest.mark.parametrize("kind", ["pass", "notApplicable", "informational"]) def test_kind_other_than_fail_is_none(kind): """A passing compliance check must not inherit its rule's error level.""" run = {"tool": {"driver": {"rules": [{"id": "r", "defaultConfiguration": {"level": "error"}}]}}} assert resolve_level({"ruleId": "r", "kind": kind}, run) == "none" assert resolve_level({"ruleId": "r", "kind": "fail"}, run) == "error" @pytest.mark.parametrize("kind", [None, "fail"]) def test_null_or_fail_kind_resolves_from_the_rule(kind): """SARIF's `kind` defaults to "fail", and the jq gate coalesces null with `// "fail"`. `.get("kind", "fail")` would instead read an explicit null and hide the error as "none", so the resolver must coalesce null to "fail" to agree with the jq gate.""" run = {"tool": {"driver": {"rules": [{"id": "r", "defaultConfiguration": {"level": "error"}}]}}} assert resolve_level({"ruleId": "r", "kind": kind}, run) == "error" def test_unmatched_rule_falls_back_to_sarif_default(): run = {"tool": {"driver": {"name": "tool-with-no-rule-metadata"}}} assert resolve_level({"ruleId": "r"}, run) == "warning" assert find_rule({"ruleId": "r"}, run) is None @pytest.mark.parametrize("index", [9, -1]) def test_rule_index_outside_the_array_falls_back_to_rule_id(index): """-1 is SARIF's "no rule", and it is the dangerous one: `$rules[-1]` in jq is the last rule, so an unguarded index labels the result with that rule's severity.""" run = { "tool": { "driver": { "rules": [ {"id": "r", "defaultConfiguration": {"level": "note"}}, {"id": "last", "defaultConfiguration": {"level": "error"}}, ] } } } assert resolve_level({"ruleId": "r", "ruleIndex": index}, run) == "note" # --- the documented jq gate --------------------------------------------------------- def test_documented_jq_gate_reports_errors_from_rule_defaults(): """Before and after, against the same bytes: the naive gate misses the error the documented one finds, and neither over-reports on ordinary SARIF.""" level_fn = documented_level_fn(JQ_QUERIES) assert jq(NAIVE_GATE, NO_LEVEL) == "0" assert jq(level_fn + ERROR_GATE, NO_LEVEL) == "1" assert jq(NAIVE_GATE, WITH_LEVEL) == "1" assert jq(level_fn + ERROR_GATE, WITH_LEVEL) == "1" def test_jq_and_python_resolution_agree(): level_fn = documented_level_fn(JQ_QUERIES) program = level_fn + r'[.runs[] as $run | $run.results[] | level($run)] | join(",")' for fixture in (NO_LEVEL, WITH_LEVEL): from_jq = json.loads(jq(program, fixture)).split(",") assert from_jq == [f.level for f in extract_findings(load_sarif(fixture))] def test_skill_and_reference_publish_the_same_resolution(): assert documented_level_fn(SKILL) == documented_level_fn(JQ_QUERIES) def test_the_github_actions_gate_runs_and_counts_the_inherited_error(): """The gate from issue #262, run as published. It inlines its own copy of the resolver because a workflow step has no shell variable to paste into, so comparing the LEVEL_FN blocks does not cover it.""" match = CI_GATE.search(SKILL.read_text()) assert match, "the GitHub Actions gate is no longer where this test reads it" program = match.group(1) assert jq(program, NO_LEVEL) == "1" assert jq(program, WITH_LEVEL) == "1" def test_documented_jq_does_not_read_the_last_rule_for_rule_index_minus_one(tmp_path): """`$rules[-1]` is the last element in jq, so the documented guard has to reject it.""" sarif = { "version": "2.1.0", "runs": [ { "tool": { "driver": { "name": "t", "rules": [ {"id": "r", "defaultConfiguration": {"level": "note"}}, {"id": "last", "defaultConfiguration": {"level": "error"}}, ], } }, "results": [{"ruleId": "r", "ruleIndex": -1, "message": {"text": "m"}}], } ], } path = tmp_path / "minus-one.sarif" path.write_text(json.dumps(sarif)) program = documented_level_fn(JQ_QUERIES) + ".runs[] as $run | $run.results[] | level($run)" assert json.loads(jq(program, path)) == "note" assert jq(documented_level_fn(JQ_QUERIES) + ERROR_GATE, path) == "0" def unresolved_severity(markdown: str) -> tuple[list[str], int]: """Documented jq lines that read a result's `.level` without resolving it, and the number of jq blocks scanned. Line by line, not block by block: one query reverted to `select(.level == "error")` hides inside a block whose other queries still resolve, which is how five of these survived review the first time. `.defaultConfiguration.level` is the rule's own field, read on purpose by the queries that list rule metadata, and `$r.level` is the resolver's own first branch. """ offenders, blocks = [], 0 for block in FENCE.findall(markdown): if "jq " not in block: continue blocks += 1 for raw in block.splitlines(): line = raw.strip() if line.startswith("#"): continue for benign in ("defaultConfiguration.level", "$r.level"): line = line.replace(benign, "") if ".level" in line and "level($run)" not in line: offenders.append(raw.strip()) return offenders, blocks def test_no_documented_jq_command_filters_on_bare_result_level(): """No unresolved read anywhere, and the resolved queries did not vanish either. A doc with every severity query deleted would satisfy the first assertion, so the second one counts the resolutions that must still be there. """ scanned, resolutions = 0, 0 for doc in (SKILL, JQ_QUERIES): text = doc.read_text() offenders, blocks = unresolved_severity(text) assert offenders == [], f"{doc.name}: unresolved severity in {offenders}" scanned += blocks resolutions += text.count("level($run)") assert scanned >= 10, f"only {scanned} jq blocks found; block discovery is broken" assert resolutions >= 15, f"only {resolutions} resolved queries left in the docs" def test_the_severity_guard_still_detects_a_regression(): """The guard above reports nothing by design, so prove it can still report.""" regressed = """```bash # select(.level == "error") is what this used to say jq '.runs[].results[] | select(.level == "error")' results.sarif jq '.runs[].tool.driver.rules[] | {id: .id, level: .defaultConfiguration.level}' results.sarif jq "$LEVEL_FN"'.runs[] as $run | $run.results[] | select(level($run) == "error")' out.sarif ``` """ offenders, blocks = unresolved_severity(regressed) assert blocks == 1 assert offenders == ["""jq '.runs[].results[] | select(.level == "error")' results.sarif"""] # --- fingerprints ------------------------------------------------------------------- def result_at(uri: str) -> dict: return { "ruleId": "py/sql-injection", "message": {"text": "This query depends on a user-provided value."}, "locations": [ {"physicalLocation": {"artifactLocation": {"uri": uri}, "region": {"startLine": 42}}} ], } def test_fingerprint_separates_identical_findings_in_different_directories(): """Hashing the basename alone made these one finding, so the second was discarded.""" assert compute_fingerprint(result_at("app/auth/login.py")) != compute_fingerprint( result_at("app/admin/login.py") ) def test_deduplicate_keeps_a_finding_from_each_directory(): sarif = { "version": "2.1.0", "runs": [ { "tool": {"driver": {"name": "CodeQL"}}, "results": [result_at("app/auth/login.py"), result_at("app/admin/login.py")], } ], } assert len(deduplicate(extract_findings(sarif))) == 2 def test_fingerprint_ignores_uri_scheme_and_percent_encoding(): assert compute_fingerprint(result_at("file:///src/a%20b.py")) == compute_fingerprint( result_at("/src/a b.py") ) def test_fingerprint_still_matches_the_same_finding_twice(): assert compute_fingerprint(result_at("app/auth/login.py")) == compute_fingerprint( result_at("app/auth/login.py") )
-
-
SKILL.md 17.5 KB
--- name: sarif-parsing description: >- Parses and processes SARIF files from static analysis tools like CodeQL, Semgrep, or other scanners. Triggers on "parse sarif", "read scan results", "aggregate findings", "deduplicate alerts", or "process sarif output". Handles filtering, deduplication, format conversion, and CI/CD integration of SARIF data. Does NOT run scans — use the Semgrep or CodeQL skills for that. allowed-tools: Bash Read Glob Grep --- # SARIF Parsing Best Practices You are a SARIF parsing expert. Your role is to help users effectively read, analyze, and process SARIF files from static analysis tools. ## When to Use Use this skill when: - Reading or interpreting static analysis scan results in SARIF format - Aggregating findings from multiple security tools - Deduplicating or filtering security alerts - Extracting specific vulnerabilities from SARIF files - Integrating SARIF data into CI/CD pipelines - Converting SARIF output to other formats ## When NOT to Use Do NOT use this skill for: - Running static analysis scans (use CodeQL or Semgrep skills instead) - Writing CodeQL or Semgrep rules (use their respective skills) - Analyzing source code directly (SARIF is for processing existing scan results) - Triaging findings without SARIF input (use variant-analysis or audit skills) ## SARIF Structure Overview SARIF 2.1.0 is the current OASIS standard. Every SARIF file has this hierarchical structure: ``` sarifLog ├── version: "2.1.0" ├── $schema: (optional, enables IDE validation) └── runs[] (array of analysis runs) ├── tool │ ├── driver │ │ ├── name (required) │ │ ├── version │ │ └── rules[] (rule definitions) │ └── extensions[] (plugins) ├── results[] (findings) │ ├── ruleId │ ├── ruleIndex (index into tool.driver.rules[]) │ ├── level (OPTIONAL, inherited from the rule when absent) │ ├── message.text │ ├── locations[] │ │ └── physicalLocation │ │ ├── artifactLocation.uri │ │ └── region (startLine, startColumn, etc.) │ ├── fingerprints{} │ └── partialFingerprints{} └── artifacts[] (scanned files metadata) ``` ### Severity Is Not Always on the Result `result.level` is optional. CodeQL omits it on every result and records severity on the rule as `defaultConfiguration.level`, which the result inherits. Read `result.level` directly and a CodeQL run scores as clean however many errors it found, which is how a severity gate ends up exiting 0 on a failing repo. Resolve severity in this order (SARIF 2.1.0 section 3.27.10): 1. `kind` other than `"fail"` (a pass/notApplicable record), so `"none"` 2. `result.level`, when present 3. the matched rule's `defaultConfiguration.level`, joining `ruleIndex` into `runs[].tool.driver.rules[]`, or matching `ruleId` against `rules[].id` when the tool omits `ruleIndex` 4. `"warning"`, the SARIF default Every severity query in this skill starts from that resolution. In jq it is the `LEVEL_FN` definition in [{baseDir}/resources/jq-queries.md]({baseDir}/resources/jq-queries.md); in Python it is `resolve_level(result, run)` in [{baseDir}/resources/sarif_helpers.py]({baseDir}/resources/sarif_helpers.py). ### Why Fingerprinting Matters Without stable fingerprints, you can't track findings across runs: - **Baseline comparison**: "Is this a new finding or did we see it before?" - **Regression detection**: "Did this PR introduce new vulnerabilities?" - **Suppression**: "Ignore this known false positive in future runs" Tools report different paths (`/path/to/project/` vs `/github/workspace/`), so path-based matching fails. Fingerprints hash the *content* (code snippet, rule ID, relative location) to create stable identifiers regardless of environment. ## Tool Selection Guide | Use Case | Tool | Install / run | |----------|------|--------------| | Quick CLI queries | jq | `brew install jq` / `apt install jq` | | Python scripting (simple) | pysarif | `uv run --with pysarif python script.py` | | Python scripting (advanced) | sarif-tools | `uv run --with sarif-tools python script.py` | | .NET applications | SARIF SDK | NuGet package | | JavaScript/Node.js | sarif-js | npm package | | Go applications | garif | `go get github.com/chavacava/garif` | | Validation | SARIF Validator | sarifweb.azurewebsites.net | ## Strategy 1: Quick Analysis with jq For rapid exploration and one-off queries: ```bash # Pretty print the file jq '.' results.sarif # Count total findings jq '[.runs[].results[]] | length' results.sarif # List all rule IDs triggered jq '[.runs[].results[].ruleId] | unique' results.sarif # Severity resolution, needed by every query below that filters on level. # See resources/jq-queries.md for the annotated version. LEVEL_FN=' def rule($run): . as $r | ($run.tool.driver.rules // []) as $rules | (if ($r.ruleIndex | type) == "number" and $r.ruleIndex >= 0 then $rules[$r.ruleIndex] else null end) // first($rules[] | select(.id == $r.ruleId)) // null; def level($run): . as $r | if ($r.kind // "fail") != "fail" then "none" else ($r.level // rule($run).defaultConfiguration.level // "warning") end; ' # Extract errors only jq "$LEVEL_FN"'.runs[] as $run | $run.results[] | select(level($run) == "error")' results.sarif # Get findings with file locations jq '.runs[].results[] | { rule: .ruleId, message: .message.text, file: .locations[0].physicalLocation.artifactLocation.uri, line: .locations[0].physicalLocation.region.startLine }' results.sarif # Filter by severity and get count per rule jq "$LEVEL_FN"'[.runs[] as $run | $run.results[] | select(level($run) == "error")] | group_by(.ruleId) | map({rule: .[0].ruleId, count: length})' results.sarif # Extract findings for a specific file jq --arg file "src/auth.py" '.runs[].results[] | select(.locations[].physicalLocation.artifactLocation.uri | contains($file))' results.sarif ``` ## Strategy 2: Python with pysarif For programmatic access with full object model: ```python from pysarif import load_from_file, save_to_file # Load SARIF file sarif = load_from_file("results.sarif") # Iterate through runs and results for run in sarif.runs: tool_name = run.tool.driver.name print(f"Tool: {tool_name}") for result in run.results: # pysarif fills a missing result.level with "warning", so .level here is NOT the # rule-inherited severity: a CodeQL error (no level on the result, severity on the # rule) reads as "warning". Gate severity with Strategy 1's level() or with # resolve_level() in resources/sarif_helpers.py, which resolve it from the rule. print(f" {result.rule_id}: {result.message.text}") if result.locations: loc = result.locations[0].physical_location if loc and loc.artifact_location: print(f" File: {loc.artifact_location.uri}") if loc.region: print(f" Line: {loc.region.start_line}") # Save modified SARIF save_to_file(sarif, "modified.sarif") ``` ## Strategy 3: Python with sarif-tools For aggregation, reporting, and CI/CD integration: ```python from sarif import loader # Load single file sarif_data = loader.load_sarif_file("results.sarif") # Or load multiple files sarif_set = loader.load_sarif_files(["tool1.sarif", "tool2.sarif"]) # Get summary report report = sarif_data.get_report() # Get histogram by severity errors = report.get_issue_type_histogram_for_severity("error") warnings = report.get_issue_type_histogram_for_severity("warning") # Filter by severity. sarif-tools hands back raw result dicts, and a result's level may # live on its rule, so resolve it against the run instead of reading r["level"]. from sarif_helpers import extract_findings, filter_by_level, load_sarif high_severity = filter_by_level(extract_findings(load_sarif("results.sarif")), "error") ``` **sarif-tools CLI commands:** ```bash # Summary of findings sarif summary results.sarif # List all results with details sarif ls results.sarif # Get results by severity sarif ls --level error results.sarif # Diff two SARIF files (find new/fixed issues) sarif diff baseline.sarif current.sarif # Convert to other formats sarif csv results.sarif > results.csv sarif html results.sarif > report.html ``` ## Strategy 4: Aggregating Multiple SARIF Files When combining results from multiple tools: ```python import json from sarif_helpers import deduplicate, extract_findings def aggregate_sarif_files(sarif_paths: list[str]) -> dict: """Combine multiple SARIF files into one.""" aggregated = { "version": "2.1.0", "$schema": "https://json.schemastore.org/sarif-2.1.0.json", "runs": [] } for path in sarif_paths: with open(path) as f: sarif = json.load(f) aggregated["runs"].extend(sarif.get("runs", [])) return aggregated unique = deduplicate(extract_findings(aggregate_sarif_files(["tool1.sarif", "tool2.sarif"]))) ``` `deduplicate()` prefers whatever `fingerprints` or `partialFingerprints` the tool supplied and falls back to hashing rule ID, the whole normalized path, line, and message. Keep the directory in that key: the same rule at the same line in `auth/login.py` and `admin/login.py` is two findings, and a basename-only key throws one of them away. ## Strategy 5: Extracting Actionable Data `resources/sarif_helpers.py` covers this with the standard library alone. `extract_findings()` returns `Finding` objects whose severity is already resolved, and `filter_by_level()`, `sort_by_severity()`, `deduplicate()` and `diff_findings()` consume those: ```python from sarif_helpers import extract_findings, filter_by_level, load_sarif, sort_by_severity findings = sort_by_severity(extract_findings(load_sarif("results.sarif"))) for f in filter_by_level(findings, "error"): print(f"{f.file_path}:{f.start_line} [{f.level}] {f.rule_id}: {f.message}") ``` Writing your own extractor, severity is the part that goes wrong silently: ```python def resolve_level(result: dict, run: dict) -> str: """Severity of a result: its own level, else its rule's default, else "warning".""" if result.get("kind", "fail") != "fail": return "none" if result.get("level"): return result["level"] rules = run.get("tool", {}).get("driver", {}).get("rules", []) index = result.get("ruleIndex") rule = rules[index] if isinstance(index, int) and 0 <= index < len(rules) else next( (r for r in rules if r.get("id") == result.get("ruleId")), {} ) return rule.get("defaultConfiguration", {}).get("level") or "warning" ``` Results carry `ruleIndex` on some tools and only `ruleId` on others, so a resolver that joins one way alone silently returns the default for every result the other kind of tool produces. ## Common Pitfalls and Solutions ### 1. Path Normalization Issues Different tools report paths differently (absolute, relative, URI-encoded), so `file:///src/a%20b.py` and `src/a b.py` can be the same file. Strip the `file://` scheme, percent-decode, resolve against a base path, and normalize separators before comparing or hashing anything: `normalize_path()` in `resources/sarif_helpers.py` does all four. ### 2. Fingerprint Mismatch Across Runs Fingerprints may not match if: - File paths differ between environments - Tool versions changed fingerprinting algorithm - Code was reformatted (changing line numbers) **Solution:** Use multiple fingerprint strategies: ```python def compute_stable_fingerprint(result: dict, file_content: str = None) -> str: """Compute environment-independent fingerprint.""" import hashlib components = [ result.get("ruleId", ""), result.get("message", {}).get("text", "")[:100], # First 100 chars ] # Add code snippet if available if file_content and result.get("locations"): region = result["locations"][0].get("physicalLocation", {}).get("region", {}) if region.get("startLine"): lines = file_content.split("\n") line_idx = region["startLine"] - 1 if 0 <= line_idx < len(lines): # Normalize whitespace components.append(lines[line_idx].strip()) return hashlib.sha256("".join(components).encode()).hexdigest()[:16] ``` ### 3. Missing or Incomplete Data SARIF allows many optional fields. Always use defensive access: ```python def safe_get_location(result: dict) -> tuple[str, int]: """Safely extract file and line from result.""" try: loc = result.get("locations", [{}])[0] phys = loc.get("physicalLocation", {}) file_path = phys.get("artifactLocation", {}).get("uri", "unknown") line = phys.get("region", {}).get("startLine", 0) return file_path, line except (IndexError, KeyError, TypeError): return "unknown", 0 ``` ### 4. Large File Performance For very large SARIF files (100MB+): ```python import ijson # run via: uv run --with ijson def stream_results(sarif_path: str): """Stream results without loading entire file.""" with open(sarif_path, "rb") as f: # Stream through results arrays for result in ijson.items(f, "runs.item.results.item"): yield result ``` ### 5. Schema Validation Validate before processing to catch malformed files: ```bash # Using ajv-cli npm install -g ajv-cli ajv validate -s sarif-schema-2.1.0.json -d results.sarif # Using Python jsonschema uv run --with jsonschema python your_script.py # e.g. the function below ``` ```python from jsonschema import validate, ValidationError import json def validate_sarif(sarif_path: str, schema_path: str) -> bool: """Validate SARIF file against schema.""" with open(sarif_path) as f: sarif = json.load(f) with open(schema_path) as f: schema = json.load(f) try: validate(sarif, schema) return True except ValidationError as e: print(f"Validation error: {e.message}") return False ``` ## CI/CD Integration Patterns ### GitHub Actions ```yaml - name: Upload SARIF uses: github/codeql-action/upload-sarif@v3 with: sarif_file: results.sarif - name: Check for high severity run: | # select(.level == "error") counts zero on CodeQL output, which records severity on # the rule instead. Resolve the level or the gate passes on a repo full of errors. HIGH_COUNT=$(jq ' def rule($run): . as $r | ($run.tool.driver.rules // []) as $rules | (if ($r.ruleIndex | type) == "number" and $r.ruleIndex >= 0 then $rules[$r.ruleIndex] else null end) // first($rules[] | select(.id == $r.ruleId)) // null; def level($run): . as $r | if ($r.kind // "fail") != "fail" then "none" else ($r.level // rule($run).defaultConfiguration.level // "warning") end; [.runs[] as $run | $run.results[] | select(level($run) == "error")] | length ' results.sarif) if [ "$HIGH_COUNT" -gt 0 ]; then echo "Found $HIGH_COUNT high severity issues" exit 1 fi ``` ### Fail on New Issues ```python from sarif import loader def check_for_regressions(baseline: str, current: str) -> int: """Return count of new issues not in baseline.""" baseline_data = loader.load_sarif_file(baseline) current_data = loader.load_sarif_file(current) baseline_fps = {get_fingerprint(r) for r in baseline_data.get_results()} new_issues = [r for r in current_data.get_results() if get_fingerprint(r) not in baseline_fps] return len(new_issues) ``` ## Key Principles 1. **Validate first**: Check SARIF structure before processing 2. **Resolve severity, never read `result.level`**: it is optional, and CodeQL always omits it 3. **Handle optionals**: Many fields are optional; use defensive access 4. **Normalize paths**: Tools report paths differently; normalize early 5. **Fingerprint wisely**: Combine multiple strategies for stable deduplication 6. **Stream large files**: Use ijson or similar for 100MB+ files 7. **Aggregate thoughtfully**: Preserve tool metadata when combining files ## Skill Resources For ready-to-use query templates, see [{baseDir}/resources/jq-queries.md]({baseDir}/resources/jq-queries.md): - 40+ jq queries for common SARIF operations - `LEVEL_FN` - the severity resolution every filtering query starts from - Severity filtering, rule extraction, aggregation patterns For Python utilities, see [{baseDir}/resources/sarif_helpers.py]({baseDir}/resources/sarif_helpers.py): - `resolve_level()` - Severity from the result or the rule it inherits from - `normalize_path()` - Handle tool-specific path formats - `compute_fingerprint()` - Rule, normalized path, line, and message - `deduplicate()` - Remove duplicates across runs Two SARIF fixtures live in [{baseDir}/resources/fixtures]({baseDir}/resources/fixtures), one with severity on the rules only and one with severity on the results. Each contains exactly one error, so a gate can be checked against a known answer before it is trusted. ## Reference Links - [OASIS SARIF 2.1.0 Specification](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) - [Microsoft SARIF Tutorials](https://github.com/microsoft/sarif-tutorials) - [SARIF SDK (.NET)](https://github.com/microsoft/sarif-sdk) - [sarif-tools (Python)](https://github.com/microsoft/sarif-tools) - [pysarif (Python)](https://github.com/Kjeld-P/pysarif) - [GitHub SARIF Support](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning) - [SARIF Validator](https://sarifweb.azurewebsites.net/)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.