mirror of
https://github.com/vee1e/supplicant.git
synced 2026-09-01 17:57:15 +00:00
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:
parent
0dc54bc4d0
commit
ab9f8ffe7d
4 changed files with 243 additions and 4 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
127
tests/test_sarif.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue