diff --git a/.github/scripts/sarif_summary.py b/.github/scripts/sarif_summary.py new file mode 100644 index 00000000..a741a429 --- /dev/null +++ b/.github/scripts/sarif_summary.py @@ -0,0 +1,150 @@ +"""Summarizes SARIF static analysis results as Markdown and, given the base's results, fails on new findings.""" +import argparse +import collections +import json +import pathlib +import re +import sys +import urllib.parse +import urllib.request + +MAX_LISTED = 50 +GATED_LEVELS = ("error", "warning") +Finding = collections.namedtuple("Finding", "rule level description path line column message") + + +def sarif_files(arguments): + """Returns the SARIF files named by the arguments, expanding directories.""" + files = [] + for argument in arguments: + path = pathlib.Path(argument) + files.extend(sorted(path.rglob("*.sarif")) if path.is_dir() else [path] if path.is_file() else []) + return files + + +def relative_path(uri, root): + """Returns the path of a SARIF artifact URI, relative to root when it lies inside it.""" + parsed = urllib.parse.urlparse(uri) + path = urllib.request.url2pathname(parsed.path) if parsed.scheme == "file" else urllib.parse.unquote(uri) + try: + return (root / path).resolve().relative_to(root.resolve()).as_posix() + except (ValueError, OSError): + return pathlib.PurePath(path).as_posix() + + +def artifact_uri(location, base_ids): + """Returns the artifact URI of a SARIF location, resolved against its uriBaseId when the run defines it.""" + uri = location.get("uri", "") + base = base_ids.get(location.get("uriBaseId"), {}).get("uri") + return urllib.parse.urljoin(base, uri) if base else uri + + +def findings(files, root): + """Yields every distinct finding in the SARIF files, with paths relative to root.""" + seen = set() + for file in files: + for run in json.loads(file.read_text(encoding="utf-8-sig")).get("runs", []): + rules = {rule.get("id"): rule for rule in run.get("tool", {}).get("driver", {}).get("rules", [])} + base_ids = run.get("originalUriBaseIds", {}) + for result in run.get("results", []): + rule_id = result.get("ruleId") or result.get("rule", {}).get("id", "unknown") + rule = rules.get(rule_id, {}) + physical = (result.get("locations") or [{}])[0].get("physicalLocation", {}) + finding = Finding( + rule_id, + result.get("level") or rule.get("defaultConfiguration", {}).get("level", "warning"), + rule.get("shortDescription", {}).get("text") + or rule.get("fullDescription", {}).get("text") or rule.get("helpUri", ""), + relative_path(artifact_uri(physical.get("artifactLocation", {}), base_ids), root), + physical.get("region", {}).get("startLine"), + physical.get("region", {}).get("startColumn"), + (result.get("message", {}).get("text") or "").split("\n")[0], + ) + # The same finding in a shared header is reported once per source file that includes it. + if finding not in seen: + seen.add(finding) + yield finding + + +def fingerprint(finding): + """Returns what identifies a finding across commits: rule, file and message, with numbers masked.""" + return finding.rule, finding.path, re.sub(r"\d+", "#", finding.message) + + +def cell(text): + """Escapes text for a Markdown table cell.""" + return str(text).replace("|", "\\|").replace("\n", " ")[:160] + + +def where(finding): + """Returns 'path:line', or the path alone when the finding has no line.""" + return f"{finding.path}:{finding.line}" if finding.line else finding.path + + +def print_summary(title, rows): + """Prints the findings per rule and level, then the first findings with their location.""" + print(f"### {title}: {len(rows)} finding{'s' if len(rows) != 1 else ''}\n") + if not rows: + return + counts = collections.Counter((row.rule, row.level, row.description) for row in rows) + print("| Rule | Level | Count | Description |\n|---|---|---|---|") + for (rule, level, description), count in sorted(counts.items(), key=lambda item: (-item[1], item[0])): + print(f"| {cell(rule)} | {level} | {count} | {cell(description)} |") + print(f"\n
First {min(MAX_LISTED, len(rows))} findings\n") + print("| Location | Rule | Message |\n|---|---|---|") + for row in sorted(rows, key=lambda row: (row.path, row.line or 0))[:MAX_LISTED]: + print(f"| {cell(where(row))} | {cell(row.rule)} | {cell(row.message)} |") + print("\n
\n") + + +def print_new(title, rows, base_rows): + """Prints the findings that outnumber the base's findings with the same fingerprint; returns the gated count.""" + base_counts = collections.Counter(fingerprint(row) for row in base_rows) + groups = collections.defaultdict(list) + for row in rows: + groups[fingerprint(row)].append(row) + new = {key: group for key, group in groups.items() if len(group) > base_counts[key]} + added = sum(len(group) - base_counts[key] for key, group in new.items()) + gated = sum(len(group) - base_counts[key] for key, group in new.items() if group[0].level in GATED_LEVELS) + print(f"### New compared with the base branch: {added}\n") + if not new: + print("No finding that the base branch does not have.\n") + return 0 + print("| Location | Rule | Level | Message | Base | Head |\n|---|---|---|---|---|---|") + for key, group in sorted(new.items()): + locations = ", ".join(where(row) for row in group[:5]) + (", ..." if len(group) > 5 else "") + print(f"| {cell(locations)} | {cell(key[0])} | {group[0].level} | {cell(group[0].message)} " + f"| {base_counts[key]} | {len(group)} |") + print() + if gated: + print(f"::error title={title}::{gated} new finding{'s' if gated != 1 else ''} at " + f"{' or '.join(GATED_LEVELS)} level: fix or suppress them, the job summary lists them.", file=sys.stderr) + return gated + + +def load(title, paths, root): + """Returns the findings of the SARIF files under paths, failing when there is no SARIF at all.""" + files = sarif_files(paths) + if not files: + # An analyzer that silently wrote nothing must not read as a clean result. + sys.exit(f"::error title={title}::No SARIF file found in {' '.join(paths)}") + return list(findings(files, root)) + + +def main(): + """Prints the summary; with --base, also the new findings, and exits with 1 when any is gated.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("title", help="the analyzer's name, used as the heading") + parser.add_argument("sarif", nargs="+", help="SARIF files or directories of the working directory's analysis") + parser.add_argument("--base", nargs="+", help="SARIF files or directories of the pull request base's analysis") + parser.add_argument("--base-root", default=".", help="the base checkout, which the base's paths are relative to") + arguments = parser.parse_args() + rows = load(arguments.title, arguments.sarif, pathlib.Path.cwd()) + print_summary(arguments.title, rows) + if arguments.base and print_new(arguments.title, rows, + load(arguments.title, arguments.base, pathlib.Path(arguments.base_root))): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/spotbugs.init.gradle b/.github/scripts/spotbugs.init.gradle new file mode 100644 index 00000000..1b653223 --- /dev/null +++ b/.github/scripts/spotbugs.init.gradle @@ -0,0 +1,43 @@ +// Applies SpotBugs to the modules listed in -DspotbugsModules (for example :sdk-java,:sdk-java-ui) and writes each +// analysis as SARIF to <-DspotbugsDir>/-.sarif. Findings never fail the build. +// Only .github/workflows/static_analysis.yml applies it, through --init-script, so no build file changes. +def spotbugsModules = (System.getProperty('spotbugsModules') ?: '').tokenize(',') as Set +def spotbugsDir = System.getProperty('spotbugsDir') + +rootProject { + buildscript { + repositories { + gradlePluginPortal { + content { + includeGroup 'com.github.spotbugs.snom' + } + } + } + dependencies { + classpath 'com.github.spotbugs.snom:spotbugs-gradle-plugin:6.5.13' + } + } +} + +allprojects { project -> + if (!spotbugsModules.contains(project.path)) { + return + } + if (!spotbugsDir) { + throw new org.gradle.api.GradleException('Pass -DspotbugsDir= to choose where the reports go.') + } + project.pluginManager.withPlugin('java') { + project.pluginManager.apply('com.github.spotbugs') + project.spotbugs { + ignoreFailures.set(true) + } + project.tasks.matching { it.name.startsWith('spotbugs') }.configureEach { task -> + task.reports { + create('sarif') { + required.set(true) + outputLocation.set(new File(spotbugsDir, "${project.name}-${task.name}.sarif")) + } + } + } + } +} diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 00000000..4f33d6aa --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,64 @@ +name: Static analysis + +on: + pull_request: + push: + branches: + - master + - staging + workflow_dispatch: + +permissions: + contents: read + +jobs: + spotbugs: + name: SpotBugs + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + persist-credentials: false + + # JDK 17 rather than the SDK's Java 8 floor: settings.gradle only includes :sdk-java-ui on Java 11+. + - name: Set up JDK 17 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + java-version: '17' + distribution: 'corretto' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + + # On pull requests the base branch is analyzed too, so that only findings it lacks can fail the job. + - name: Analyze the published modules + run: | + # Runs SpotBugs on the published modules that the checkout in $1 has, writing SARIF to $2. + analyze() { + modules=() + for module in :sdk-java :sdk-java-ui; do + if [ -d "$1/${module#:}" ]; then + modules+=("$module") + fi + done + (cd "$1" && ./gradlew --no-configuration-cache --init-script "$GITHUB_WORKSPACE/.github/scripts/spotbugs.init.gradle" \ + -DspotbugsModules="$(IFS=,; echo "${modules[*]}")" "-DspotbugsDir=$2" "${modules[@]/%/:spotbugsMain}") + } + analyze "$GITHUB_WORKSPACE" "$RUNNER_TEMP/sarif" + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + # The first parent of the pull request's merge commit is the base branch it merges into. + git worktree add "$RUNNER_TEMP/base" HEAD^1 + analyze "$RUNNER_TEMP/base" "$RUNNER_TEMP/sarif-base" + fi + + # Every finding goes to the job summary; a new one at warning or error level fails the job. + - name: Summarize the findings + run: | + base=() + if [ -d "$RUNNER_TEMP/sarif-base" ]; then + base=(--base "$RUNNER_TEMP/sarif-base" --base-root "$RUNNER_TEMP/base") + fi + python3 .github/scripts/sarif_summary.py SpotBugs "$RUNNER_TEMP/sarif" "${base[@]}" | tee -a "$GITHUB_STEP_SUMMARY"