#!/usr/bin/env python3 """Build a compact Semgrep gate report from Semgrep JSON output.""" from __future__ import annotations import argparse import json from pathlib import Path from typing import Any BLOCKING_SEVERITIES = {"ERROR"} SEVERITIES = ("ERROR", "WARNING", "INFO", "UNKNOWN") SONAR_SEVERITY_BY_SEMGREP = { "ERROR": "CRITICAL", "WARNING": "MAJOR", "INFO": "MINOR", "UNKNOWN": "INFO", } def _read_json(path: Path) -> dict[str, Any]: """Read a JSON object from disk, returning an error-shaped payload on failure.""" if not path.exists(): return {"errors": [{"message": f"report missing: {path}"}], "results": []} try: payload = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: return {"errors": [{"message": f"invalid JSON: {exc}"}], "results": []} if not isinstance(payload, dict): return {"errors": [{"message": "report payload is not an object"}], "results": []} return payload def _finding_severity(finding: dict[str, Any]) -> str: """Return a normalized Semgrep finding severity.""" extra = finding.get("extra") severity = extra.get("severity") if isinstance(extra, dict) else None normalized = str(severity or "UNKNOWN").strip().upper() return normalized if normalized in SEVERITIES else "UNKNOWN" def build_report( semgrep_payload: dict[str, Any], *, semgrep_exit_code: int, blocking_severities: set[str] | None = None, ) -> dict[str, Any]: """Summarize Semgrep evidence into the quality-gate report contract.""" blocking = blocking_severities or BLOCKING_SEVERITIES raw_results = semgrep_payload.get("results", []) raw_errors = semgrep_payload.get("errors", []) results = [item for item in raw_results if isinstance(item, dict)] if isinstance(raw_results, list) else [] errors = [item for item in raw_errors if isinstance(item, dict)] if isinstance(raw_errors, list) else [] severity_counts = {severity: 0 for severity in SEVERITIES} blocking_findings = 0 for finding in results: severity = _finding_severity(finding) severity_counts[severity] += 1 if severity in blocking: blocking_findings += 1 engine_error = semgrep_exit_code not in {0, 1} or bool(errors) status = "failed" if engine_error or blocking_findings else "ok" return { "status": status, "scanner": "semgrep", "semgrep_rc": semgrep_exit_code, "findings_total": len(results), "blocking_findings": blocking_findings, "errors_total": len(errors), "severity_counts": severity_counts, "blocking_severities": sorted(blocking), } def _line_number(value: Any, default: int = 1) -> int: """Return a Sonar-compatible one-indexed line number.""" try: line = int(value) except (TypeError, ValueError): return default return max(line, default) def _sonar_issue_from_finding(finding: dict[str, Any]) -> dict[str, Any] | None: """Convert one Semgrep finding into SonarQube generic issue format.""" path = str(finding.get("path") or "").strip() if not path: return None extra = finding.get("extra") if isinstance(finding.get("extra"), dict) else {} severity = _finding_severity(finding) start = finding.get("start") if isinstance(finding.get("start"), dict) else {} end = finding.get("end") if isinstance(finding.get("end"), dict) else {} start_line = _line_number(start.get("line") if isinstance(start, dict) else None) end_line = _line_number(end.get("line") if isinstance(end, dict) else None, start_line) if end_line < start_line: end_line = start_line return { "engineId": "semgrep", "ruleId": str(finding.get("check_id") or "semgrep.unknown"), "type": "VULNERABILITY" if severity == "ERROR" else "CODE_SMELL", "severity": SONAR_SEVERITY_BY_SEMGREP[severity], "primaryLocation": { "message": str(extra.get("message") or finding.get("check_id") or "Semgrep finding"), "filePath": path, "textRange": { "startLine": start_line, "endLine": end_line, }, }, } def build_sonar_issues(semgrep_payload: dict[str, Any]) -> dict[str, list[dict[str, Any]]]: """Build SonarQube generic external issues from Semgrep JSON output.""" raw_results = semgrep_payload.get("results", []) results = [item for item in raw_results if isinstance(item, dict)] if isinstance(raw_results, list) else [] issues = [issue for finding in results if (issue := _sonar_issue_from_finding(finding))] return {"issues": issues} def main(argv: list[str] | None = None) -> int: """CLI entrypoint used by Jenkins after Semgrep finishes.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--semgrep-json", required=True) parser.add_argument("--exit-code", type=int, default=0) parser.add_argument("--output", required=True) parser.add_argument("--sonar-issues-output") parser.add_argument( "--blocking-severity", action="append", default=[], help="Severity that should mark the report failed. Defaults to ERROR.", ) args = parser.parse_args(argv) blocking = {item.strip().upper() for item in args.blocking_severity if item.strip()} payload = _read_json(Path(args.semgrep_json)) report = build_report( payload, semgrep_exit_code=args.exit_code, blocking_severities=blocking or BLOCKING_SEVERITIES, ) output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") if args.sonar_issues_output: sonar_output = Path(args.sonar_issues_output) sonar_output.parent.mkdir(parents=True, exist_ok=True) sonar_output.write_text( json.dumps(build_sonar_issues(payload), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) return 0 if __name__ == "__main__": # pragma: no cover raise SystemExit(main())