diff --git a/utils/ci/parse_asan_reports.py b/utils/ci/parse_asan_reports.py new file mode 100644 index 00000000000..3e8ea366c73 --- /dev/null +++ b/utils/ci/parse_asan_reports.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" + Copyright 2025-2026 Hewlett Packard Enterprise Development LP + All rights reserved. + + SPDX-License-Identifier: BSD-2-Clause-Patent + + Parse AddressSanitizer log files produced during CI unit test runs. + + Outputs: + • SARIF 2.1.0 file → uploaded to the Security / Code scanning tab + • Markdown summary → appended to $GITHUB_STEP_SUMMARY + + Invocation (from .github/workflows/unit-test-template.yml, called by unit-testing.yml): + + python3 utils/ci/parse_asan_reports.py \ + --report-dir sanitizer-logs \ + --source-root "$(pwd)" \ + --sarif-out test-results/asan.sarif \ + --summary-out test-results/asan_summary.md + + ASan must be started with: + ASAN_OPTIONS="log_path=/asan:exitcode=42:symbolize=1" + so that each process writes its own asan. file into the shared + sanitizer-logs/ directory and the stack frames already contain resolved + function / file / line information. +""" + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from sanitizer_report_base import ADDR_FRAME_RE as _RE_FRAME +from sanitizer_report_base import StackFrame, build_sarif_doc, build_summary_md +from sanitizer_report_base import collect_reports as _collect_reports +from sanitizer_report_base import get_args, main_runner, report_heading, resolve_paths_frames +from sanitizer_report_base import sarif_location as _sarif_location_base + +# ── Data structures ─────────────────────────────────────────────────────────── + + +@dataclass +class AsanReport: # pylint: disable=too-many-instance-attributes + """One ASan error report parsed from a single asan. log file.""" + + pid: str + error_type: str # e.g. "stack-buffer-overflow" + error_summary: str # the first ==PID==ERROR: … line + access_type: str # "READ" | "WRITE" | "unknown" + access_size: Optional[int] + frames: list = field(default_factory=list) # list[StackFrame] + raw: str = "" + test_name: str = "" # suite name, filled in by collect_reports() + + +# ── Parsing ─────────────────────────────────────────────────────────────────── + +_RE_ERROR_HEADER = re.compile( + r"==\d+==ERROR: AddressSanitizer: (?P\S+(?:\s+\S+)*?) on address" +) +_RE_ACCESS = re.compile(r"(?PREAD|WRITE) of size (?P\d+)") + + +def parse_report_file(path: Path) -> Optional[AsanReport]: + """Parse one ASan log file. Returns None when it contains no error.""" + text = path.read_text(errors="replace") + if "AddressSanitizer" not in text: + return None + + pid = re.sub(r"^.*\.", "", path.name) # asan.12345 → "12345" + error_type = "unknown" + error_summary = "" + access_type = "unknown" + access_size = None + frames = [] + + for line in text.splitlines(): + m = _RE_ERROR_HEADER.search(line) + if m: + error_type = m.group("type").strip() + error_summary = line.strip() + continue + + m = _RE_ACCESS.search(line) + if m and access_type == "unknown": + access_type = m.group("access") + access_size = int(m.group("size")) + continue + + m = _RE_FRAME.match(line) + if m: + frames.append(StackFrame( + index=int(m.group("idx")), + function=m.group("func"), + file=m.group("file"), + line=int(m.group("line")) if m.group("line") else None, + column=int(m.group("col")) if m.group("col") else None, + rel_file=None, + )) + + if error_type == "unknown" and not frames: + return None + + return AsanReport( + pid=pid, + error_type=error_type, + error_summary=error_summary, + access_type=access_type, + access_size=access_size, + frames=frames, + raw=text, + ) + + +def collect_reports(report_dir: Path) -> list: + """Return parsed AsanReport objects for every asan. file found.""" + return _collect_reports(report_dir, "asan", parse_report_file) + + +# ── SARIF 2.1.0 ─────────────────────────────────────────────────────────────── + +_TOOL_URI = "https://clang.llvm.org/docs/AddressSanitizer.html" + + +def _sarif_location(frame: StackFrame) -> dict: + """Build a SARIF location with optional logicalLocations for function name.""" + loc = _sarif_location_base(frame.rel_file, frame.line, frame.column) + if loc and frame.function: + loc["logicalLocations"] = [{"name": frame.function, "kind": "function"}] + # Fallback: use raw file path if rel_file not resolved + if not loc and frame.file: + loc = { + "physicalLocation": { + "artifactLocation": {"uri": frame.file, "uriBaseId": "%SRCROOT%"} + } + } + return loc or {} + + +def build_sarif(reports: list) -> dict: + """Build a SARIF 2.1.0 document from the parsed ASan reports.""" + rule_ids = sorted({r.error_type for r in reports}) + rules = [ + { + "id": rid, + "name": rid.replace("-", " ").title().replace(" ", ""), + "shortDescription": {"text": f"AddressSanitizer: {rid}"}, + "helpUri": _TOOL_URI, + "properties": {"tags": ["security", "correctness", "memory"]}, + } + for rid in rule_ids + ] + + results = [] + for report in reports: + in_project = [f for f in report.frames if f.rel_file and f.line] + all_framed = [f for f in report.frames if f.file] + primary = (in_project or all_framed or [None])[0] + + if primary is None: + # No usable frame — skip: SARIF requires a physicalLocation. + continue + + primary_loc = _sarif_location(primary) + if not primary_loc: + # Frame has neither a project-relative path nor a raw file path. + continue + + thread_flow_locs = [ + { + "location": _sarif_location(f), + "nestingLevel": f.index, + "executionOrder": f.index, + } + for f in report.frames if f.file + ] + + code_flows = [] + if thread_flow_locs: + code_flows = [{ + "message": {"text": f"ASan call stack (pid {report.pid})"}, + "threadFlows": [{"locations": thread_flow_locs}], + }] + + msg = ( + f"{report.access_type} of size {report.access_size} bytes" + f" detected by AddressSanitizer ({report.error_type}).\n\n" + f"```\n{report.error_summary}\n```" + ) + result: dict = { + "ruleId": report.error_type, + "level": "error", + "message": {"text": msg}, + "locations": [primary_loc], + } + if code_flows: + result["codeFlows"] = code_flows + results.append(result) + + return build_sarif_doc("AddressSanitizer", _TOOL_URI, rules, results) + + +# ── Markdown job summary ────────────────────────────────────────────────────── + +def _asan_row(i: int, report) -> list: + """Return summary table cell values for one ASan report.""" + in_proj = [f for f in report.frames if f.rel_file and f.line] + if in_proj: + p = in_proj[0] + loc = f"`{p.rel_file}:{p.line}`" + func = f"`{p.function}()`" + else: + loc, func = "_(no source)_", "_(unknown)_" + test_name = report.test_name or "_(unknown)_" + return [ + str(i), + test_name, + f"`{report.error_type}`", + report.access_type, + f"{report.access_size or '?'} B", + loc, + func, + ] + + +def build_summary(reports: list) -> str: + """Return a GitHub-flavoured Markdown summary of all ASan findings.""" + return build_summary_md( + tool_name="AddressSanitizer", + emoji="❌", + items=reports, + headers=["#", "Test", "Error type", "Access", "Size", "Primary location", "Function"], + row_fn=_asan_row, + details_fn=report_heading, + no_items_msg="#### ✅ AddressSanitizer — No memory-safety issues detected", + ) + +# ── CLI ─────────────────────────────────────────────────────────────────────── + + +def main() -> int: + """Entry point.""" + return main_runner( + get_args("Parse ASan logs → SARIF + summary"), + collect_fn=collect_reports, + resolve_fn=resolve_paths_frames, + build_sarif_fn=build_sarif, + build_summary_fn=build_summary, + tool_label="ASan", + ) + + +if __name__ == "__main__": + sys.exit(main())