blob: 47e515a2eba9c0d7e874a57520c856d0dd98625a [file]
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright The Zephyr Project Contributors
# SPDX-License-Identifier: Apache-2.0
"""Turn sanitizer findings from a twister run into a SARIF report.
Twister has no SARIF writer, and sanitizer output is not part of its report
model: a run that trips AddressSanitizer is recorded as ``rc=1`` and nothing
more. The diagnostics themselves survive only in the per-instance logs, so
this walks the twister output tree and reconstructs them.
Two logs matter, because the sanitizers do not agree on where to write:
handler.log ASan and LSan (twister sets ASAN_OPTIONS=log_path=stdout)
handler_stderr.log UBSan, which ignores that and writes to stderr
Three report formats are parsed:
ASan ==PID==ERROR: AddressSanitizer: <type> on address 0x...
followed by one or more " #N 0xaddr in <func> <file>:<line>"
stacks and a SUMMARY line.
LSan ==PID==ERROR: LeakSanitizer: detected memory leaks
followed by one "Direct leak"/"Indirect leak" block per leak, each
with its own allocation stack.
UBSan <file>:<line>:<col>: runtime error: <description>
a single line, with no stack unless print_stacktrace is enabled.
Unlike a static analyzer, these findings are proven reachable -- the code
actually executed and misbehaved -- so they are reported at "error" level.
Coverage is bounded by what the tests exercise, which is the opposite
trade-off to the static scans.
Usage:
python3 scripts/ci/sanitizer_sarif.py twister-out-asan -o results.sarif
"""
import argparse
import collections
import os
import pathlib
import re
import sys
import sarif_utils
DRIVER_NAME = "Sanitizers"
DRIVER_URI = "https://github.com/google/sanitizers"
DEFAULT_MAX_RESULTS = 5000
# A stack frame with source information, e.g.
# #1 0x8049a1f in sanleak_test_leak /path/to/main.c:8
# Frames without a resolved source file (bare addresses into libc) are left
# unmatched on purpose: they carry nothing GitHub could render.
FRAME_RE = re.compile(
r"^\s*#(?P<depth>\d+)\s+0x[0-9a-fA-F]+\s+in\s+(?P<func>\S+)\s+"
r"(?P<file>[^\s]+?):(?P<line>\d+)(?::(?P<col>\d+))?\s*$"
)
ASAN_ERROR_RE = re.compile(r"^==\d+==ERROR:\s+AddressSanitizer:\s+(?P<kind>[a-zA-Z0-9_-]+)")
LSAN_ERROR_RE = re.compile(r"^==\d+==ERROR:\s+LeakSanitizer:\s+(?P<desc>.+?)\s*$")
LEAK_BLOCK_RE = re.compile(
r"^(?P<kind>Direct|Indirect) leak of (?P<bytes>\d+) byte\(s\) in "
r"(?P<objects>\d+) object\(s\) allocated from:"
)
UBSAN_RE = re.compile(
r"^(?P<file>[^\s]+?):(?P<line>\d+):(?P<col>\d+):\s+runtime error:\s+(?P<desc>.+?)\s*$"
)
# Hex addresses differ between runs, so they are masked out of the text used
# for deduplication. Without this the same defect looks new on every run and
# the dashboard fills with duplicates that never close.
ADDR_RE = re.compile(r"0x[0-9a-fA-F]+")
# Only mappings that are unambiguous. A wrong CWE is worse than none, so
# checks whose classification is arguable are left untagged.
ASAN_CWE = {
"heap-buffer-overflow": "122",
"stack-buffer-overflow": "121",
"stack-buffer-underflow": "124",
"global-buffer-overflow": "787",
"heap-use-after-free": "416",
"stack-use-after-return": "562",
"stack-use-after-scope": "562",
"double-free": "415",
"alloc-dealloc-mismatch": "762",
"SEGV": "476",
}
LSAN_CWE = "401"
UBSAN_CWE = (
("signed integer overflow", "190"),
("unsigned integer overflow", "190"),
("division by zero", "369"),
("null pointer", "476"),
("out of bounds", "125"),
("shift exponent", "1335"),
)
# GitHub ranks alerts by security-severity and only files them as security
# alerts when the rule is tagged as such. Memory corruption outranks a leak.
SEVERITY = {"corruption": "9.0", "leak": "5.0", "undefined": "7.0"}
def classify_ubsan(description):
"""Return (rule suffix, cwe) for a UBSan description."""
lowered = description.lower()
for needle, cwe in UBSAN_CWE:
if needle in lowered:
return needle.replace(" ", "-"), cwe
return "runtime-error", None
def _frame_location(match, message=None):
"""Build a SARIF location from a stack frame match."""
region = {"startLine": int(match.group("line"))}
if match.group("col"):
region["startColumn"] = int(match.group("col"))
location = {
"physicalLocation": {
"artifactLocation": {"uri": match.group("file")},
"region": region,
}
}
if message:
location["message"] = {"text": message}
return location
def parse_stack(lines, start):
"""Collect consecutive stack frames beginning at *start*.
Returns (frames, next_index). Frames that carry no source location end the
run of interest but do not stop collection, so a stack that dips through
libc and comes back is not truncated.
"""
frames = []
index = start
while index < len(lines):
line = lines[index]
match = FRAME_RE.match(line)
if match:
frames.append(match)
index += 1
continue
# A bare "#N 0xaddr (...)" frame has no source info; skip it but stay
# in the stack.
if re.match(r"^\s*#\d+\s+0x[0-9a-fA-F]+", line):
index += 1
continue
break
return frames, index
def _make_result(rule_id, level, text, frames, cwe, properties, source_root):
"""Build a SARIF result from a message and its stack frames.
The primary location is the shallowest frame that lies inside the
repository, not simply the shallowest frame: an ASan or LSan stack almost
always begins inside the sanitizer runtime's own interceptor (malloc,
free, memcpy), and anchoring an alert there would point every leak at
libsanitizer instead of at the code that leaked. The full stack is kept as
a code flow; sarif_utils.prune_code_flows strips the external frames from
it afterwards.
Returns None when no frame is in the repository, which is how findings
entirely inside the toolchain get dropped.
"""
primary = next(
(
frame
for frame in frames
if sarif_utils.relativize(frame.group("file"), source_root) is not None
),
None,
)
if primary is None:
return None
result = {
"ruleId": rule_id,
"level": level,
"message": {"text": text},
"locations": [_frame_location(primary)],
"properties": properties,
}
if len(frames) > 1:
result["codeFlows"] = [
{
"threadFlows": [
{
"locations": [
{
"location": _frame_location(
frame, f"#{frame.group('depth')} {frame.group('func')}"
)
}
for frame in frames
]
}
]
}
]
if cwe:
result["taxa"] = [{"id": cwe, "toolComponent": {"name": "CWE"}}]
return result
def parse_log(text, instance, source_root):
"""Yield (result, cwe, severity_class) for every finding in *text*."""
lines = text.splitlines()
index = 0
while index < len(lines):
line = lines[index]
asan = ASAN_ERROR_RE.match(line)
if asan:
kind = asan.group("kind")
# The access line ("WRITE of size 1 at ...") sits between the
# error banner and the stack; skip ahead to the frames.
frames, index = parse_stack(lines, index + 1)
if not frames:
frames, index = parse_stack(lines, index + 1)
cwe = ASAN_CWE.get(kind)
result = _make_result(
f"asan-{kind}",
"error",
f"AddressSanitizer: {kind}",
frames,
cwe,
{"sanitizer": "address", "testInstance": instance},
source_root,
)
if result:
yield result, cwe, "corruption"
continue
if LSAN_ERROR_RE.match(line):
index += 1
continue
leak = LEAK_BLOCK_RE.match(line)
if leak:
frames, index = parse_stack(lines, index + 1)
kind = leak.group("kind").lower()
result = _make_result(
f"lsan-{kind}-leak",
"error",
f"LeakSanitizer: {leak.group('kind').lower()} leak of "
f"{leak.group('bytes')} byte(s) in {leak.group('objects')} object(s)",
frames,
LSAN_CWE,
{"sanitizer": "leak", "testInstance": instance},
source_root,
)
if result:
yield result, LSAN_CWE, "leak"
continue
ubsan = UBSAN_RE.match(line)
if ubsan:
suffix, cwe = classify_ubsan(ubsan.group("desc"))
frames, index = parse_stack(lines, index + 1)
result = {
"ruleId": f"ubsan-{suffix}",
"level": "error",
"message": {"text": f"UndefinedBehaviorSanitizer: {ubsan.group('desc')}"},
"locations": [_frame_location(ubsan)],
"properties": {"sanitizer": "undefined", "testInstance": instance},
}
if cwe:
result["taxa"] = [{"id": cwe, "toolComponent": {"name": "CWE"}}]
if frames:
result["codeFlows"] = [
{
"threadFlows": [
{
"locations": [
{"location": _frame_location(frame)} for frame in frames
]
}
]
}
]
yield result, cwe, "undefined"
continue
index += 1
def result_key(result):
"""Identity used for deduplication across test instances.
Deliberately excludes the test instance and any hex address: the same
defect reached by twenty tests is one defect, and its addresses differ on
every run.
"""
physical = result["locations"][0]["physicalLocation"]
region = physical.get("region", {})
return (
result["ruleId"],
physical["artifactLocation"]["uri"],
region.get("startLine"),
ADDR_RE.sub("0xADDR", result["message"]["text"]),
)
def collect(outdir, source_root):
"""Parse every sanitizer log under *outdir*."""
results = {}
cwes = set()
rule_class = {}
stats = collections.Counter()
logs = sorted(pathlib.Path(outdir).rglob("handler.log")) + sorted(
pathlib.Path(outdir).rglob("handler_stderr.log")
)
for log_path in logs:
stats["logs"] += 1
try:
text = log_path.read_text(encoding="utf-8", errors="replace")
except OSError as err:
print(f"warning: skipping {log_path}: {err}", file=sys.stderr)
stats["unreadable"] += 1
continue
if not text:
continue
# The build directory name is the twister instance name, which is what
# tells a maintainer how to reproduce the finding.
instance = log_path.parent.name
for result, cwe, severity_class in parse_log(text, instance, source_root):
stats["raw"] += 1
if not sarif_utils.rewrite_location(result["locations"][0], source_root):
stats["external"] += 1
continue
sarif_utils.prune_code_flows(result, source_root)
key = result_key(result)
if key in results:
stats["duplicate"] += 1
continue
results[key] = result
rule_class[result["ruleId"]] = severity_class
if cwe:
cwes.add(cwe)
return list(results.values()), cwes, rule_class, stats
def build_rules(rule_class):
"""Build rule metadata, tagged so GitHub files these as security alerts."""
return [
{
"id": rule_id,
"name": rule_id,
"shortDescription": {"text": rule_id},
"properties": {
"tags": ["security"],
"security-severity": SEVERITY.get(severity_class, "5.0"),
},
}
for rule_id, severity_class in sorted(rule_class.items())
]
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("outdir", help="twister output directory to scan")
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()
results, cwes, rule_class, stats = collect(args.outdir, source_root)
results.sort(key=result_key)
results, dropped = sarif_utils.cap_results(results, args.max_results)
document = sarif_utils.build_document(
DRIVER_NAME,
DRIVER_URI,
build_rules(rule_class),
results,
sarif_utils.make_cwe_taxonomy(cwes),
)
sarif_utils.write_report(
document,
args.output,
dropped,
args.max_results,
f"scanned {stats['logs']} logs: {stats['raw']} raw findings, "
f"{stats['duplicate']} duplicates, {stats['external']} outside the source root, "
f"{stats['unreadable']} unreadable logs",
)
return 0
if __name__ == "__main__":
sys.exit(main())