blob: eeca22fade8531181374d50e44a68cf9e7b0046b [file]
#!/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,
)