blob: 658def625890a866cfb1417bbac52725e14a0275 [file]
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors
# SPDX-License-Identifier: Apache-2.0
"""Merge the SARIF files produced by the GCC static analyzer into one upload.
``-fdiagnostics-format=sarif-file`` makes GCC write one SARIF file per
translation unit, named after the input file and dropped in the compiler's
working directory (the CMake build directory). A twister run therefore leaves
thousands of these scattered across the output tree, the vast majority holding
no results at all. GitHub wants a single file, so they have to be merged.
The merge is not just concatenation; four things have to be fixed up before
code scanning will take the result:
* GCC records **absolute** paths. GitHub resolves result locations against the
repository root and silently drops anything it cannot map, so paths are
rewritten relative to the source root and results outside it (SDK headers,
west modules) are discarded rather than uploaded as unmappable noise.
* Each run embeds the full text of every artifact it touched under
``artifacts[].contents``, which is where nearly all of the file size comes
from -- a single translation unit reporting two leaks weighs 66 KB. GitHub
rejects oversized uploads, so the embedded contents are dropped.
* The tool name varies with the input language (``GNU C17``, ``GNU C++17``).
Code scanning keys alert identity partly on the tool, so the driver name is
normalised to keep alerts stable across the tree and across runs.
* Shared code is recompiled by every build in the matrix, so the same finding
reappears once per build. Results are deduplicated on
(rule, file, line, column, message).
The CWE taxonomy GCC already emits is preserved and merged: it is what makes
the alerts land in the dashboard with a CWE attached.
Usage:
python3 scripts/ci/gcc_sca_sarif.py twister-out-gcc-sca -o results.sarif
"""
import argparse
import collections
import json
import os
import pathlib
import sys
import sarif_utils
# Conservative ceiling. GitHub imposes its own per-upload limits and rejects
# the whole file when they are exceeded; losing every result to a hard reject
# is worse than uploading the first N and saying so. Truncation is always
# reported, never silent.
DEFAULT_MAX_RESULTS = 5000
DRIVER_NAME = "GCC Static Analyzer"
DRIVER_URI = "https://gcc.gnu.org/onlinedocs/gcc/Static-Analyzer-Options.html"
# GCC's rule metadata is just an id and a help URI. GitHub files an alert as a
# security alert only when its rule carries the "security" tag, and ranks it by
# "security-severity", so forwarding GCC's rules verbatim would land a
# use-after-free here as an ordinary quality alert while the identical finding
# from the sanitizer scan lands as a critical security one. The severities
# below match sanitizer_sarif.py so that the two scans rank the same defect
# classes the same way.
SEVERITY = {"corruption": "9.0", "undefined": "7.0", "leak": "5.0"}
# Memory corruption: a reachable write or read outside the object's lifetime
# or bounds.
_CORRUPTION = (
"-Wanalyzer-allocation-size",
"-Wanalyzer-double-free",
"-Wanalyzer-free-of-non-heap",
"-Wanalyzer-mismatching-deallocation",
"-Wanalyzer-out-of-bounds",
"-Wanalyzer-overlapping-buffers",
"-Wanalyzer-putenv-of-auto-var",
"-Wanalyzer-stale-setjmp-buffer",
"-Wanalyzer-tainted-allocation-size",
"-Wanalyzer-tainted-array-index",
"-Wanalyzer-tainted-offset",
"-Wanalyzer-tainted-size",
"-Wanalyzer-use-after-free",
"-Wanalyzer-use-of-pointer-in-stale-stack-frame",
"-Wanalyzer-write-to-const",
"-Wanalyzer-write-to-string-literal",
# The front end finds the same defect classes without the analyzer, and
# ships them through the same SARIF file.
"-Warray-bounds",
"-Wdangling-pointer",
"-Wfree-nonheap-object",
"-Wreturn-local-addr",
"-Wstringop-overflow",
"-Wstringop-overread",
"-Wuse-after-free",
)
# Undefined behaviour, and untrusted input reaching an operation that trusts it.
_UNDEFINED = (
"-Wanalyzer-deref-before-check",
"-Wanalyzer-double-fclose",
"-Wanalyzer-exposure-through-output-file",
"-Wanalyzer-exposure-through-uninit-copy",
"-Wanalyzer-fd-access-mode-mismatch",
"-Wanalyzer-fd-double-close",
"-Wanalyzer-fd-phase-mismatch",
"-Wanalyzer-fd-type-mismatch",
"-Wanalyzer-fd-use-after-close",
"-Wanalyzer-fd-use-without-check",
"-Wanalyzer-infinite-recursion",
"-Wanalyzer-jump-through-null",
"-Wanalyzer-null-argument",
"-Wanalyzer-null-dereference",
"-Wanalyzer-possible-null-argument",
"-Wanalyzer-possible-null-dereference",
"-Wanalyzer-shift-count-negative",
"-Wanalyzer-shift-count-overflow",
"-Wanalyzer-tainted-assertion",
"-Wanalyzer-tainted-divisor",
"-Wanalyzer-undefined-behavior-strtok",
"-Wanalyzer-unsafe-call-within-signal-handler",
"-Wanalyzer-use-of-uninitialized-value",
"-Wanalyzer-va-arg-type-mismatch",
"-Wanalyzer-va-list-exhausted",
"-Wanalyzer-va-list-use-after-va-end",
"-Wmaybe-uninitialized",
"-Wnonnull",
"-Wuninitialized",
)
# Resources the code allocates and never gives back.
_LEAK = (
"-Wanalyzer-fd-leak",
"-Wanalyzer-file-leak",
"-Wanalyzer-malloc-leak",
"-Wanalyzer-va-list-leak",
)
RULE_CLASS = {
rule: severity_class
for severity_class, rules in (
("corruption", _CORRUPTION),
("undefined", _UNDEFINED),
("leak", _LEAK),
)
for rule in rules
}
def annotate_rule(rule):
"""Tag *rule* as a security rule when it describes a security defect.
Anything not in the table keeps GCC's metadata untouched and surfaces as an
ordinary alert: -Wanalyzer-too-complex and friends report on the analyzer
giving up, not on a defect in the code, and tagging those as security would
only dilute the dashboard.
"""
# Warnings that take a level spell their rule id with the trailing "=",
# as in "-Warray-bounds=".
severity_class = RULE_CLASS.get(rule["id"].rstrip("="))
if severity_class is None:
return rule
properties = dict(rule.get("properties", {}))
properties["tags"] = sorted({*properties.get("tags", []), "security"})
properties["security-severity"] = SEVERITY[severity_class]
return dict(rule, properties=properties)
def result_key(result):
"""Identity used for deduplication across builds, and for sorting.
Every field here is optional in SARIF -- GCC emits diagnostics with no
ruleId, and regions without a column -- so the key is stringified to stay
totally ordered. Comparing a missing ruleId against a present one would
otherwise raise partway through the sort.
"""
location = (result.get("locations") or [{}])[0]
physical = location.get("physicalLocation", {})
region = physical.get("region", {})
return sarif_utils.stable_key(
result.get("ruleId"),
physical.get("artifactLocation", {}).get("uri"),
region.get("startLine"),
region.get("startColumn"),
result.get("message", {}).get("text"),
)
def collect(paths, source_root):
"""Merge every SARIF file under *paths*.
Returns (results, rules, taxonomies, stats).
"""
results = {}
rules = {}
taxonomies = {}
stats = collections.Counter()
for sarif_path in sorted(paths):
stats["files"] += 1
try:
with open(sarif_path, encoding="utf-8") as handle:
document = json.load(handle)
except (OSError, json.JSONDecodeError) as err:
# A build killed mid-write leaves a truncated file. One unreadable
# translation unit must not sink the whole report.
print(f"warning: skipping {sarif_path}: {err}", file=sys.stderr)
stats["unreadable"] += 1
continue
for run in document.get("runs", []):
driver = run.get("tool", {}).get("driver", {})
for rule in driver.get("rules", []):
rules.setdefault(rule["id"], rule)
for taxonomy in run.get("taxonomies", []):
merged = taxonomies.setdefault(taxonomy.get("name"), dict(taxonomy, taxa=[]))
known = {taxon["id"] for taxon in merged["taxa"]}
merged["taxa"].extend(
taxon for taxon in taxonomy.get("taxa", []) if taxon["id"] not in known
)
for result in run.get("results", []):
stats["raw"] += 1
locations = result.get("locations") or []
if not locations:
stats["unlocated"] += 1
continue
if not sarif_utils.rewrite_location(locations[0], source_root):
stats["external"] += 1
continue
# Secondary locations are kept only when they survive the
# rewrite. Dropping them is not cosmetic: a related location
# with no physical location of its own, which GCC emits for
# notes like "argument 1 of 'x' must be non-null", makes code
# scanning reject the entire upload.
result["locations"] = [locations[0]] + [
extra
for extra in locations[1:]
if sarif_utils.rewrite_location(extra, source_root)
]
related = [
extra
for extra in (result.get("relatedLocations") or [])
if sarif_utils.rewrite_location(extra, source_root)
]
if related:
result["relatedLocations"] = related
else:
result.pop("relatedLocations", None)
sarif_utils.prune_code_flows(result, source_root)
key = result_key(result)
if key in results:
stats["duplicate"] += 1
continue
results[key] = result
return list(results.values()), rules, taxonomies, stats
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("search_root", help="directory to scan for *.sarif files")
parser.add_argument("-o", "--output", default="results.sarif")
parser.add_argument(
"--source-root",
default=os.environ.get("ZEPHYR_BASE", os.getcwd()),
help="repository root that result paths are made relative to",
)
parser.add_argument("--max-results", type=int, default=DEFAULT_MAX_RESULTS)
args = parser.parse_args()
source_root = pathlib.Path(args.source_root).resolve()
sarif_files = list(pathlib.Path(args.search_root).rglob("*.sarif"))
results, rules, taxonomies, stats = collect(sarif_files, source_root)
# Report the worst first so that truncation, if it happens, keeps the
# findings most likely to matter.
severity = {"error": 0, "warning": 1, "note": 2}
results.sort(key=lambda r: (severity.get(r.get("level"), 3), result_key(r)))
results, dropped = sarif_utils.cap_results(results, args.max_results)
document = sarif_utils.build_document(
DRIVER_NAME,
DRIVER_URI,
[annotate_rule(rule) for rule in sorted(rules.values(), key=lambda r: r["id"])],
results,
list(taxonomies.values()),
)
sarif_utils.write_report(
document,
args.output,
dropped,
args.max_results,
f"scanned {stats['files']} SARIF files: {stats['raw']} raw results, "
f"{stats['duplicate']} duplicates, {stats['external']} outside the source root, "
f"{stats['unlocated']} without a location, {stats['unreadable']} unreadable files",
)
return 0
if __name__ == "__main__":
sys.exit(main())