Add SARIF 2.1.0 export for scan and diff

- render_sarif() maps each Signal to a SARIF result, rules deduped by kind
- Severity -> level: critical/high = error, medium = warning, low/info = note
- Evidence file/line become physicalLocation so GitHub code scanning
  renders inline source links
- --format sarif on scan and diff subcommands
This commit is contained in:
vee1e 2026-08-29 21:05:30 +05:30
parent 0dc54bc4d0
commit ab9f8ffe7d
No known key found for this signature in database
4 changed files with 243 additions and 4 deletions

View file

@ -59,6 +59,7 @@ supplicant diff lodash@4.17.20 lodash@4.17.21
|---|---|
| `text` | scan, diff (default) |
| `json` | scan, diff, graph |
| `sarif` | scan, diff (SARIF 2.1.0, for GitHub code scanning) |
| `dot` | scan, graph (default for graph) |
| `graph` | scan |

View file

@ -56,13 +56,15 @@ def _load_report(ctx, kind: str, arg: str, max_depth: int) -> ScanReport:
def _run_scan(args: argparse.Namespace) -> int:
from . import pipeline
from .graph import build_graph, to_dot, to_json
from .report.render import render_json, render_text
from .report.render import render_json, render_sarif, render_text
ctx = pipeline.build_context(args.cache_dir, config={"osv": not args.no_osv})
report = _load_report(ctx, args.kind, args.target, args.max_depth)
if args.format == "text":
print(render_text(report, min_severity=args.min_severity, color=not args.no_color))
elif args.format == "sarif":
print(render_sarif(report))
else:
if args.format == "dot":
print(to_dot(build_graph(report.artifacts, report.edges, report.signals)))
@ -106,7 +108,7 @@ def _parse_diff_pair(a: str, b: str):
def _run_diff(args: argparse.Namespace) -> int:
from . import pipeline
from .report.render import render_diff_text, render_json
from .report.render import render_diff_text, render_json, render_sarif
try:
ecosystem, name, v_a, v_b = _parse_diff_pair(args.version_a, args.version_b)
@ -129,6 +131,13 @@ def _run_diff(args: argparse.Namespace) -> int:
errors=list(ctx.config.get("_errors", [])),
)
print(render_json(report))
elif args.format == "sarif":
report = ScanReport(
artifacts=[outcome.artifact],
diffs=[outcome],
errors=list(ctx.config.get("_errors", [])),
)
print(render_sarif(report))
else:
print(render_diff_text(outcome, min_severity=args.min_severity, color=not args.no_color))
@ -177,14 +186,14 @@ def build_parser() -> argparse.ArgumentParser:
scan.add_argument("--kind", choices=["target", "lockfile", "manifest"], default=None,
help="force input interpretation (default: auto-detect from name)")
scan.add_argument("--max-depth", type=int, default=10)
scan.add_argument("--format", choices=["text", "json", "dot", "graph"], default="text")
scan.add_argument("--format", choices=["text", "json", "dot", "graph", "sarif"], default="text")
_add_common(scan)
scan.set_defaults(func=_run_scan)
diff = sub.add_parser("diff", help="flagship: security-scored diff of two versions")
diff.add_argument("version_a", help="e.g. keyv@5.2.0 or npm:keyv@5.2.0")
diff.add_argument("version_b", help="e.g. keyv@6.0.0")
diff.add_argument("--format", choices=["text", "json"], default="text")
diff.add_argument("--format", choices=["text", "json", "sarif"], default="text")
_add_common(diff)
diff.set_defaults(func=_run_diff)

View file

@ -11,6 +11,7 @@ from typing import Any
from ..graph import serialize_signal
from ..models import DiffOutcome, ScanReport, severity_rank
from .. import __version__ as _supplicant_version
RESET = "\033[0m"
@ -288,3 +289,104 @@ def render_json(report: ScanReport) -> str:
"errors": list(report.errors),
}
return json.dumps(payload, indent=2)
_SARIF_LEVEL = {
"critical": "error",
"high": "error",
"medium": "warning",
"low": "note",
"info": "note",
}
_SARIF_RULE_SEVERITY = {
"critical": "error",
"high": "error",
"medium": "warning",
"low": "note",
"info": "note",
}
SARIF_SCHEMA = "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json"
SARIF_VERSION = "2.1.0"
TOOL_NAME = "supplicant"
TOOL_INFORMATION_URI = "https://github.com/vee1e/supplicant"
def _sarif_rule(kind: str) -> dict:
"""One reportingDescriptor per detector kind. SARIF dedupes results by ruleId."""
return {
"id": kind,
"name": kind,
"shortDescription": {"text": kind.replace("_", " ")},
"defaultConfiguration": {"level": "warning"},
}
def _sarif_result(signal) -> dict:
"""Map a Signal to a SARIF result. One result per signal."""
rule = {"id": signal.kind}
props = {
"severity": signal.severity,
"confidence": signal.confidence,
"reportedBy": signal.reported_by,
"signalId": signal.id,
}
if signal.artifact is not None:
props["artifact"] = signal.artifact.key
result = {
"ruleId": signal.kind,
"rule": rule,
"level": _SARIF_LEVEL.get(signal.severity, "warning"),
"message": {"text": signal.explanation or signal.kind},
"properties": props,
}
file = signal.evidence.get("file") if signal.evidence else None
if file:
location = {
"physicalLocation": {
"artifactLocation": {"uri": file, "uriBaseId": "%SRCROOT%"},
}
}
line = signal.evidence.get("line") if signal.evidence else None
if isinstance(line, int) and line > 0:
location["physicalLocation"]["region"] = {"startLine": line}
result["locations"] = [location]
return result
def render_sarif(report: ScanReport) -> str:
"""SARIF 2.1.0 report. One result per Signal, rules deduped by detector kind."""
rules: dict[str, dict] = {}
results: list[dict] = []
for signal in report.signals:
rules.setdefault(signal.kind, _sarif_rule(signal.kind))
results.append(_sarif_result(signal))
for outcome in report.diffs:
for signal in outcome.signals:
rules.setdefault(signal.kind, _sarif_rule(signal.kind))
results.append(_sarif_result(signal))
sarif = {
"$schema": SARIF_SCHEMA,
"version": SARIF_VERSION,
"runs": [
{
"tool": {
"driver": {
"name": TOOL_NAME,
"informationUri": TOOL_INFORMATION_URI,
"version": _supplicant_version,
"rules": sorted(rules.values(), key=lambda r: r["id"]),
}
},
"results": results,
}
],
}
return json.dumps(sarif, indent=2)

127
tests/test_sarif.py Normal file
View file

@ -0,0 +1,127 @@
"""Tests for the SARIF 2.1.0 renderer."""
from __future__ import annotations
import json
from supplicant.models import Artifact, DiffOutcome, ScanReport, Signal
from supplicant.report.render import render_sarif
def _signal(kind, severity, artifact=None, file=None, line=None, explanation="x"):
evidence = {}
if file is not None:
evidence["file"] = file
if line is not None:
evidence["line"] = line
return Signal(
kind=kind,
artifact=artifact,
severity=severity,
confidence=0.9,
evidence=evidence,
explanation=explanation,
)
def test_sarif_envelope():
report = ScanReport(artifacts=[Artifact("npm", "x", "1.0.0")])
out = json.loads(render_sarif(report))
assert out["version"] == "2.1.0"
assert out["$schema"].endswith("sarif-2.1.0.json")
run = out["runs"][0]
assert run["tool"]["driver"]["name"] == "supplicant"
assert run["tool"]["driver"]["informationUri"].startswith("https://")
assert run["results"] == []
def test_sarif_maps_signal_to_result_with_rule_and_location():
art = Artifact("npm", "keyv", "6.0.0")
sig = _signal(
"install_script_network",
"high",
artifact=art,
file="setup.mjs",
line=3,
explanation="curl | sh in install hook",
)
report = ScanReport(artifacts=[art], signals=[sig])
out = json.loads(render_sarif(report))
result = out["runs"][0]["results"][0]
assert result["ruleId"] == "install_script_network"
assert result["level"] == "error"
assert result["message"]["text"] == "curl | sh in install hook"
loc = result["locations"][0]["physicalLocation"]
assert loc["artifactLocation"]["uri"] == "setup.mjs"
assert loc["region"]["startLine"] == 3
rule = out["runs"][0]["tool"]["driver"]["rules"][0]
assert rule["id"] == "install_script_network"
def test_sarif_dedupes_rules_by_kind():
art = Artifact("npm", "x", "1.0.0")
sigs = [
_signal("obfuscation", "low", artifact=art, file="a.js"),
_signal("obfuscation", "low", artifact=art, file="b.js"),
_signal("native_binary", "medium", artifact=art, file="lib.so"),
]
report = ScanReport(artifacts=[art], signals=sigs)
out = json.loads(render_sarif(report))
rules = out["runs"][0]["tool"]["driver"]["rules"]
assert {r["id"] for r in rules} == {"obfuscation", "native_binary"}
assert len(out["runs"][0]["results"]) == 3
def test_sarif_severity_to_level_mapping():
art = Artifact("npm", "x", "1.0.0")
sigs = [
_signal("k", "critical", artifact=art, explanation="c"),
_signal("k", "high", artifact=art, explanation="h"),
_signal("k", "medium", artifact=art, explanation="m"),
_signal("k", "low", artifact=art, explanation="l"),
_signal("k", "info", artifact=art, explanation="i"),
]
report = ScanReport(artifacts=[art], signals=sigs)
out = json.loads(render_sarif(report))
levels = [r["level"] for r in out["runs"][0]["results"]]
assert levels == ["error", "error", "warning", "note", "note"]
def test_sarif_includes_diff_signals():
art = Artifact("npm", "keyv", "6.0.0")
diff_sig = _signal(
"baseline_deviation",
"critical",
artifact=art,
file="setup.mjs",
explanation="new install hook in patch bump",
)
diff = DiffOutcome(
artifact=art,
from_version="5.2.0",
to_version="6.0.0",
signals=[diff_sig],
)
report = ScanReport(artifacts=[art], diffs=[diff])
out = json.loads(render_sarif(report))
result = out["runs"][0]["results"][0]
assert result["ruleId"] == "baseline_deviation"
assert result["level"] == "error"
assert out["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] == "setup.mjs"
def test_sarif_handles_missing_evidence_file():
sig = _signal("obfuscation", "medium", artifact=Artifact("npm", "x", "1.0.0"), explanation="eval found")
report = ScanReport(artifacts=[sig.artifact], signals=[sig])
out = json.loads(render_sarif(report))
result = out["runs"][0]["results"][0]
assert "locations" not in result or result.get("locations") == []
def test_sarif_output_is_valid_json():
sig = _signal("obfuscation", "low", artifact=Artifact("npm", "x", "1.0.0"), file="a.js")
report = ScanReport(artifacts=[sig.artifact], signals=[sig])
out = render_sarif(report)
parsed = json.loads(out)
assert isinstance(parsed["runs"], list)
assert isinstance(parsed["runs"][0]["results"], list)