Skip to content

Commit aba3a36

Browse files
authored
Fix Snyk SARIF uploads so alerts track per-image categories (#775)
Merge Snyk's per-target SARIF runs into a single run and strip automationDetails so GitHub uses our stable per-image categories again, mark the kitchen-sink scan continue-on-error (Snyk exits 1 on findings, so it failed nightly and never uploaded), and add a cleanup job that dismisses alerts in categories no longer produced by the scan matrix.
1 parent eef3902 commit aba3a36

4 files changed

Lines changed: 245 additions & 138 deletions

File tree

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/python
2+
# Dismiss open code scanning alerts whose analysis category is not produced by
3+
# the current scan matrix.
4+
#
5+
# GitHub only auto-closes an alert when a newer analysis is uploaded to the
6+
# *same* category. When an image is retired from the scan matrix, its
7+
# categories stop receiving uploads and their alerts stay open forever. This
8+
# script dismisses those orphaned alerts.
9+
#
10+
# The set of expected categories is derived from the same versions.py that
11+
# generates the scan matrix, so retiring an image automatically retires its
12+
# alerts on the next scheduled run.
13+
#
14+
# Pass --dry-run to only print what would be dismissed.
15+
16+
import json
17+
import os
18+
import sys
19+
import urllib.request
20+
21+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "matrix"))
22+
import versions
23+
24+
DRY_RUN = "--dry-run" in sys.argv
25+
REPO = os.environ.get("GITHUB_REPOSITORY", "pulumi/pulumi-docker-containers")
26+
TOKEN = os.environ["GH_TOKEN"]
27+
ARCHS = ["amd64", "arm64"]
28+
29+
DISMISS_COMMENT = (
30+
"This alert belongs to an analysis category that is not produced by the "
31+
"Snyk scan workflow (retired image or renamed category), so it can never "
32+
"be closed automatically. Current results are tracked under the per-image "
33+
"categories."
34+
)
35+
36+
37+
def expected_categories():
38+
expected = set()
39+
for suffix in ["", "-nonroot"]:
40+
for arch in ARCHS:
41+
expected.add(f"pulumi{suffix}-{arch}")
42+
for arch in ARCHS:
43+
expected.add(f"pulumi-provider-build-environment-{arch}")
44+
for base_os in ["debian", "ubi"]:
45+
for arch in ARCHS:
46+
expected.add(f"pulumi-base-{base_os}-{arch}")
47+
for sdk in versions.unversioned:
48+
for arch in ARCHS:
49+
expected.add(f"pulumi-{sdk}-debian-{arch}")
50+
for sdk, info in versions.versioned.items():
51+
for version in [info["default"]] + info["additional"]:
52+
for arch in ARCHS:
53+
expected.add(f"pulumi-{sdk}-{version}-debian-{arch}")
54+
for sdk in ["nodejs", "python", "dotnet", "go"]:
55+
expected.add(f"pulumi-{sdk}-ubi")
56+
return expected
57+
58+
59+
def api(path, method="GET", body=None):
60+
request = urllib.request.Request(
61+
f"https://api.github.com{path}",
62+
method=method,
63+
data=json.dumps(body).encode() if body is not None else None,
64+
headers={
65+
"Authorization": f"Bearer {TOKEN}",
66+
"Accept": "application/vnd.github+json",
67+
"X-GitHub-Api-Version": "2022-11-28",
68+
},
69+
)
70+
with urllib.request.urlopen(request) as response:
71+
return json.load(response)
72+
73+
74+
def open_alerts():
75+
alerts = []
76+
page = 1
77+
while True:
78+
batch = api(f"/repos/{REPO}/code-scanning/alerts?state=open&per_page=100&page={page}")
79+
alerts.extend(batch)
80+
if len(batch) < 100:
81+
return alerts
82+
page += 1
83+
84+
85+
expected = expected_categories()
86+
stale = [
87+
alert
88+
for alert in open_alerts()
89+
if alert.get("most_recent_instance", {}).get("category") not in expected
90+
]
91+
print(f"Found {len(stale)} open alerts in stale categories.")
92+
93+
for alert in stale:
94+
category = alert["most_recent_instance"]["category"]
95+
label = f"alert #{alert['number']} ({alert['rule']['id']}) in category '{category}'"
96+
if DRY_RUN:
97+
print(f"Would dismiss {label}")
98+
continue
99+
api(
100+
f"/repos/{REPO}/code-scanning/alerts/{alert['number']}",
101+
method="PATCH",
102+
body={
103+
"state": "dismissed",
104+
"dismissed_reason": "won't fix",
105+
"dismissed_comment": DISMISS_COMMENT,
106+
},
107+
)
108+
print(f"Dismissed {label}")

.github/scripts/filter-sarif.py

Lines changed: 0 additions & 64 deletions
This file was deleted.

.github/scripts/merge-sarif.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#!/usr/bin/python
2+
# Merge all runs in Snyk's snyk.sarif into a single run and write it to
3+
# out.sarif.
4+
#
5+
# Snyk emits one SARIF run per target it detects in the image (OS packages,
6+
# npm projects, go binaries, ...). GitHub code scanning treats every run as a
7+
# separate analysis whose category is derived from the run's
8+
# `automationDetails.id`, ignoring the `category` input of the upload-sarif
9+
# action:
10+
# https://github.blog/changelog/2025-07-21-code-scanning-will-stop-combining-multiple-sarif-runs-uploaded-in-the-same-sarif-file/
11+
#
12+
# Uploading a single run without `automationDetails` makes GitHub use the
13+
# per-image category the workflow passes to upload-sarif. A stable category
14+
# per image lets GitHub track alerts across scans and automatically close
15+
# alerts that no longer appear in the image's latest scan.
16+
#
17+
# snyk.sarif is deleted after a successful merge so that sequential scans
18+
# within the same job can never accidentally re-upload a previous scan's
19+
# results. If snyk.sarif is missing the script fails, failing the job without
20+
# uploading anything, so a broken scan leaves existing alerts untouched.
21+
22+
import json
23+
import os
24+
import sys
25+
26+
if not os.path.exists("snyk.sarif"):
27+
print(
28+
"error: snyk.sarif not found — the Snyk scan failed to produce output.",
29+
file=sys.stderr,
30+
)
31+
sys.exit(1)
32+
33+
with open("snyk.sarif") as f:
34+
sarif = json.load(f)
35+
36+
runs = sarif.get("runs", [])
37+
if len(runs) == 0:
38+
print("error: snyk.sarif contains no runs", file=sys.stderr)
39+
sys.exit(1)
40+
41+
# Merge the rules of all runs, deduplicating by rule id, and remember each
42+
# rule's index in the merged rules array so results can be re-pointed at it.
43+
merged_rules = []
44+
rule_index_by_id = {}
45+
for run in runs:
46+
for rule in run["tool"]["driver"].get("rules", []):
47+
if rule["id"] not in rule_index_by_id:
48+
rule_index_by_id[rule["id"]] = len(merged_rules)
49+
merged_rules.append(rule)
50+
51+
# Merge the results of all runs, dropping exact duplicates (the same vuln
52+
# reported at the same location for multiple targets collapses into a single
53+
# alert in GitHub anyway).
54+
merged_results = []
55+
seen_results = set()
56+
for run in runs:
57+
for result in run.get("results", []):
58+
if "ruleId" in result:
59+
result["ruleIndex"] = rule_index_by_id[result["ruleId"]]
60+
key = json.dumps(result, sort_keys=True)
61+
if key not in seen_results:
62+
seen_results.add(key)
63+
merged_results.append(result)
64+
65+
merged_driver = {**runs[0]["tool"]["driver"], "name": "Snyk Container", "rules": merged_rules}
66+
merged_run = {**runs[0], "tool": {"driver": merged_driver}, "results": merged_results}
67+
merged_run.pop("automationDetails", None)
68+
69+
with open("out.sarif", "w") as out:
70+
json.dump({**sarif, "runs": [merged_run]}, out, indent=2)
71+
72+
os.remove("snyk.sarif")
73+
print(f"Merged {len(runs)} runs into one: {len(merged_results)} results, {len(merged_rules)} rules.")

0 commit comments

Comments
 (0)