blob: 61e9e73163d4fcfc0045f4c088b1d1cc52710087 [file]
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors
# SPDX-License-Identifier: Apache-2.0
"""Generate a GitHub Actions step summary from a SARIF file.
Reads the SARIF file produced by the scan tools and writes a Markdown
table to $GITHUB_STEP_SUMMARY. Also emits one ::notice:: workflow
annotation per triggered rule so each rule appears in the Actions log
with its violation count and description.
Usage:
python3 scripts/ci/sarif_summary.py [results.sarif]
"""
import argparse
import collections
import json
import os
import sys
def _tag_order(tag):
return {"mandatory": 0, "required": 1, "advisory": 2}.get(tag, 3)
def parse_sarif(path):
"""Return (counts, rule_meta, file_counts) from *path*.
counts -- Counter mapping ruleId -> total violation count
rule_meta -- dict mapping ruleId -> {desc, tag}
file_counts -- Counter mapping file URI -> total violation count
"""
with open(path, encoding="utf-8") as fh:
sarif = json.load(fh)
counts = collections.Counter()
rule_meta = {}
file_counts = collections.Counter()
for run in sarif.get("runs", []):
for rule in run.get("tool", {}).get("driver", {}).get("rules", []):
rid = rule.get("id", "")
tags = rule.get("properties", {}).get("tags", [])
# Pick the most restrictive classification tag present.
tag = ""
for candidate in ("mandatory", "required", "advisory"):
if candidate in tags:
tag = candidate
break
rule_meta[rid] = {
"desc": rule.get("fullDescription", {}).get("text", rid),
"tag": tag,
}
for result in run.get("results", []):
rid = result.get("ruleId", "unknown")
counts[rid] += 1
for loc in result.get("locations", []):
uri = loc.get("physicalLocation", {}).get("artifactLocation", {}).get("uri", "")
if uri:
file_counts[uri] += 1
return counts, rule_meta, file_counts
def write_file_summary(file_counts, out):
"""Write a Markdown table of violation counts per file."""
n_files = len(file_counts)
out.write("## :file_folder: Violations by File\n\n")
out.write(f"> **Files with violations:** {n_files:,}\n\n")
out.write("| # | File | Violations |\n")
out.write("|--:|------|----------:|\n")
for idx, (uri, cnt) in enumerate(sorted(file_counts.items(), key=lambda kv: -kv[1]), 1):
out.write(f"| {idx} | `{uri}` | {cnt:,} |\n")
def write_summary(counts, rule_meta, file_counts, out):
total = sum(counts.values())
n_rules = len(counts)
out.write("## :mag: Scan Results\n\n")
out.write(f"> **Total violations:** {total:,}  |  **Rules triggered:** {n_rules}\n\n")
# Sort by tag priority then by count (descending within same tag).
sorted_rules = sorted(
counts.items(),
key=lambda kv: (_tag_order(rule_meta.get(kv[0], {}).get("tag", "")), -kv[1]),
)
out.write("| # | Rule ID | Classification | Violations | Description |\n")
out.write("|--:|---------|---------------|----------:|-------------|\n")
for idx, (rid, cnt) in enumerate(sorted_rules, 1):
m = rule_meta.get(rid, {})
tag = m.get("tag", "")
desc = m.get("desc", "")
out.write(f"| {idx} | `{rid}` | {tag} | {cnt:,} | {desc} |\n")
out.write("\n")
write_file_summary(file_counts, out)
def _escape_data(value):
"""Escape a workflow command message so it cannot break the format."""
return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
def _escape_property(value):
"""Escape a workflow command property value (also escapes ':' and ',')."""
return _escape_data(value).replace(":", "%3A").replace(",", "%2C")
def emit_annotations(counts, rule_meta):
"""Emit one ``::notice::`` annotation per rule for the Actions log."""
for rid, cnt in sorted(counts.items(), key=lambda kv: -kv[1]):
m = rule_meta.get(rid, {})
desc = m.get("desc", rid)
tag = m.get("tag", "")
prefix = f"[{tag}] " if tag else ""
title = _escape_property(rid)
message = _escape_data(f"{prefix}{desc} - {cnt} violation(s)")
print(f"::notice title={title}::{message}")
def main():
ap = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
ap.add_argument(
"sarif",
nargs="?",
default="results.sarif",
help="Path to the SARIF file (default: results.sarif)",
)
ap.add_argument(
"--annotations",
action=argparse.BooleanOptionalAction,
default=True,
help="Emit ::notice:: workflow command annotations",
)
args = ap.parse_args()
counts, rule_meta, file_counts = parse_sarif(args.sarif)
summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
if summary_file:
with open(summary_file, "a", encoding="utf-8") as fh:
write_summary(counts, rule_meta, file_counts, fh)
else:
write_summary(counts, rule_meta, file_counts, sys.stdout)
if args.annotations:
emit_annotations(counts, rule_meta)
if __name__ == "__main__":
main()