| #!/usr/bin/env python3 | |
| # SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors | |
| # SPDX-License-Identifier: Apache-2.0 | |
| """Shared helpers for turning scanner output into a SARIF upload. | |
| Every scanner wired into the code scanning dashboard hits the same handful of | |
| problems, so they are solved once here rather than per tool: | |
| * Findings carry absolute paths. GitHub resolves result locations against the | |
| repository root and silently drops what it cannot map, so paths have to be | |
| made relative and results outside the checkout discarded. | |
| * Stack traces and analyzer execution paths run through code that is not in | |
| this repository (toolchain headers, sanitizer runtimes, libc). Those steps | |
| have to go, without throwing away the frames that are in the tree. | |
| * The same finding is reported once per build, because a matrix recompiles or | |
| re-executes shared code many times. | |
| Used by gcc_sca_sarif.py and sanitizer_sarif.py. | |
| """ | |
| import json | |
| import os | |
| import pathlib | |
| import sys | |
| CWE_HELP = "https://cwe.mitre.org/data/definitions/{}.html" | |
| def relativize(uri, source_root): | |
| """Return *uri* relative to *source_root*, or None if it falls outside. | |
| Handles the plain absolute paths scanners emit as well as ``file://`` | |
| URIs. Symlinks are resolved on both sides so a build under a symlinked | |
| path still maps onto the checkout. A path that is already relative is | |
| assumed to be relative to the source root and returned unchanged, which | |
| is what makes a second merge pass over already-processed files a no-op. | |
| """ | |
| if not uri: | |
| return None | |
| path = uri[len("file://") :] if uri.startswith("file://") else uri | |
| if not os.path.isabs(path): | |
| # Sanitizer runtimes report their own frames with paths relative to | |
| # wherever the toolchain was built, e.g. | |
| # "../../../../../src/libsanitizer/asan/asan_malloc_linux.cpp". | |
| # Those escape the source root and must not be mistaken for in-tree | |
| # paths just because they are not absolute. | |
| if os.path.normpath(path).startswith(".."): | |
| return None | |
| return path | |
| try: | |
| resolved = pathlib.Path(path).resolve() | |
| except OSError: | |
| return None | |
| try: | |
| return resolved.relative_to(source_root).as_posix() | |
| except ValueError: | |
| return None | |
| def rewrite_location(location, source_root): | |
| """Rewrite one SARIF location in place. | |
| Returns False when the location cannot be reported, either because it | |
| lies outside the source root or because it has no physical location at | |
| all. Callers must drop what this rejects. | |
| The second case is not hypothetical: GCC annotates a diagnostic with | |
| message-only related locations ("argument 1 of '__builtin_strlen' must be | |
| non-null") and with code flow steps carrying nothing but a logical | |
| location ("looping back..." against a function name). Both are valid | |
| SARIF, and both make code scanning reject the entire upload with | |
| "expected physical location". | |
| """ | |
| artifact = location.get("physicalLocation", {}).get("artifactLocation") | |
| if artifact is None: | |
| return False | |
| rel = relativize(artifact.get("uri"), source_root) | |
| if rel is None: | |
| return False | |
| artifact["uri"] = rel | |
| # An absolute uriBaseId would send GitHub looking outside the repository. | |
| artifact.pop("uriBaseId", None) | |
| return True | |
| def prune_code_flows(result, source_root): | |
| """Rewrite code flow steps, dropping those outside the source root. | |
| The execution path is the most useful part of both an analyzer report and | |
| a sanitizer stack trace, so it is kept, but steps through code GitHub | |
| cannot render are removed. A flow left with no steps is dropped rather | |
| than uploaded empty, which SARIF forbids. | |
| """ | |
| kept_flows = [] | |
| for flow in result.get("codeFlows", []): | |
| kept_threads = [] | |
| for thread in flow.get("threadFlows", []): | |
| steps = [ | |
| step | |
| for step in thread.get("locations", []) | |
| if rewrite_location(step.get("location", {}), source_root) | |
| ] | |
| if steps: | |
| thread["locations"] = steps | |
| kept_threads.append(thread) | |
| if kept_threads: | |
| flow["threadFlows"] = kept_threads | |
| kept_flows.append(flow) | |
| if kept_flows: | |
| result["codeFlows"] = kept_flows | |
| else: | |
| result.pop("codeFlows", None) | |
| def stable_key(*values): | |
| """Build a totally ordered key from optional SARIF fields. | |
| Almost everything in SARIF is optional, so a key assembled from raw field | |
| values can mix strings with None and blow up the moment two results are | |
| compared -- a rule without a ruleId next to one with it, for instance. | |
| Stringifying everything keeps the key usable both for sorting and as a | |
| deduplication identity. | |
| """ | |
| return tuple("" if value is None else str(value) for value in values) | |
| def count_unreportable(document): | |
| """Return how many locations in *document* lack a physical location. | |
| Code scanning rejects the whole upload when it finds one, so this is | |
| checked before writing rather than discovered after a job has already | |
| spent an hour building. | |
| """ | |
| bad = 0 | |
| def check(location): | |
| nonlocal bad | |
| if "physicalLocation" not in location: | |
| bad += 1 | |
| for run in document.get("runs", []): | |
| for result in run.get("results", []): | |
| for location in result.get("locations", []): | |
| check(location) | |
| for location in result.get("relatedLocations", []): | |
| check(location) | |
| for flow in result.get("codeFlows", []): | |
| for thread in flow.get("threadFlows", []): | |
| for step in thread.get("locations", []): | |
| check(step.get("location", {})) | |
| return bad | |
| def make_cwe_taxonomy(cwe_ids): | |
| """Build the CWE taxonomy block for the given set of CWE ids.""" | |
| if not cwe_ids: | |
| return [] | |
| return [ | |
| { | |
| "name": "CWE", | |
| "organization": "MITRE", | |
| "shortDescription": {"text": "The MITRE Common Weakness Enumeration"}, | |
| "taxa": [{"id": cwe, "helpUri": CWE_HELP.format(cwe)} for cwe in sorted(cwe_ids)], | |
| } | |
| ] | |
| def build_document(driver_name, driver_uri, rules, results, taxonomies): | |
| """Assemble a single-run SARIF 2.1.0 document.""" | |
| return { | |
| "$schema": "https://json.schemastore.org/sarif-2.1.0.json", | |
| "version": "2.1.0", | |
| "runs": [ | |
| { | |
| "tool": { | |
| "driver": { | |
| "name": driver_name, | |
| "informationUri": driver_uri, | |
| "rules": rules, | |
| } | |
| }, | |
| "taxonomies": taxonomies, | |
| "results": results, | |
| } | |
| ], | |
| } | |
| def cap_results(results, max_results): | |
| """Trim *results* to *max_results*, returning (results, dropped). | |
| Callers sort by severity first so that what survives is what matters most. | |
| """ | |
| if len(results) <= max_results: | |
| return results, 0 | |
| return results[:max_results], len(results) - max_results | |
| def write_report(document, output, dropped, max_results, stats_line): | |
| """Write *document* to *output* and report what happened.""" | |
| unreportable = count_unreportable(document) | |
| if unreportable: | |
| # Fail here rather than let the upload be rejected wholesale later. | |
| print( | |
| f"::error title=Malformed SARIF::{unreportable} locations have no " | |
| "physicalLocation; code scanning would reject this upload", | |
| file=sys.stderr, | |
| ) | |
| raise SystemExit(1) | |
| with open(output, "w", encoding="utf-8") as handle: | |
| json.dump(document, handle) | |
| run = document["runs"][0] | |
| print(stats_line) | |
| print( | |
| f"wrote {len(run['results'])} results and " | |
| f"{len(run['tool']['driver']['rules'])} rules to {output}" | |
| ) | |
| if dropped: | |
| # Surface truncation in the Actions log; a capped report that looks | |
| # complete is worse than one that says what it dropped. | |
| print( | |
| f"::warning title=Results truncated::dropped {dropped} results over the " | |
| f"{max_results} cap; the uploaded report is incomplete", | |
| file=sys.stderr, | |
| ) |