From ab9f8ffe7de7585bcc6da3fdccb06b4e93c28d9c Mon Sep 17 00:00:00 2001 From: vee1e Date: Sat, 29 Aug 2026 21:05:30 +0530 Subject: [PATCH] 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 --- README.md | 1 + supplicant/cli.py | 17 +++-- supplicant/report/render.py | 102 +++++++++++++++++++++++++++++ tests/test_sarif.py | 127 ++++++++++++++++++++++++++++++++++++ 4 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 tests/test_sarif.py diff --git a/README.md b/README.md index 50cb119..e473fb6 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/supplicant/cli.py b/supplicant/cli.py index 0ca244f..bcf64e9 100644 --- a/supplicant/cli.py +++ b/supplicant/cli.py @@ -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) diff --git a/supplicant/report/render.py b/supplicant/report/render.py index da836d9..933bad4 100644 --- a/supplicant/report/render.py +++ b/supplicant/report/render.py @@ -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) diff --git a/tests/test_sarif.py b/tests/test_sarif.py new file mode 100644 index 0000000..2ad1b4f --- /dev/null +++ b/tests/test_sarif.py @@ -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)