Skip to content

Commit 9491a13

Browse files
authored
fix: reconcile vulnerability issues safely (#1505)
1 parent 61c9302 commit 9491a13

6 files changed

Lines changed: 357 additions & 41 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
def finding_id($prefix):
2+
if ((.title // "") | startswith($prefix + " ")) then
3+
(.title | ltrimstr($prefix + " ") | split(": "))
4+
| if length > 1 then .[0] else "" end
5+
else
6+
""
7+
end;
8+
9+
($findings[0].cves | map(.cveId)) as $currentIds
10+
| map(
11+
. + { findingId: finding_id($prefix) }
12+
| .findingId as $findingId
13+
| if .findingId == "" then
14+
. + { action: "ignore" }
15+
elif ($currentIds | index($findingId)) != null then
16+
. + { action: "keep" }
17+
else
18+
. + { action: "close" }
19+
end
20+
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
def finding_id($prefix):
2+
if ((.title // "") | startswith($prefix + " ")) then
3+
(.title | ltrimstr($prefix + " ") | split(": "))
4+
| if length > 1 then .[0] else "" end
5+
else
6+
""
7+
end;
8+
9+
[
10+
.data.issues.nodes[]?
11+
| select(finding_id($prefix) == $findingId)
12+
] as $matches
13+
| ($matches | map(select(.state.type != "completed" and .state.type != "canceled" and .state.type != "duplicate")) | .[0]) as $open
14+
| ($matches | map(select(.state.type == "completed" or .state.type == "canceled")) | .[0]) as $reopenable
15+
| ($open // $reopenable // $matches[0]) as $chosen
16+
| if $chosen == null then
17+
{
18+
linearIssueExists: false,
19+
linearIssueId: "",
20+
linearIssueIdentifier: "",
21+
linearIssueUrl: "",
22+
linearIssueClosed: false
23+
}
24+
else
25+
{
26+
linearIssueExists: true,
27+
linearIssueId: $chosen.id,
28+
linearIssueIdentifier: $chosen.identifier,
29+
linearIssueUrl: $chosen.url,
30+
linearIssueClosed: (($chosen.state.type == "completed") or ($chosen.state.type == "canceled"))
31+
}
32+
end
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+
MATCH_FILTER="$SCRIPT_DIR/match-vulnerability-issue.jq"
7+
CLASSIFY_FILTER="$SCRIPT_DIR/classify-vulnerability-issues.jq"
8+
PREFIX="[sourcebot-dev/example]"
9+
10+
assert_json() {
11+
local description="$1"
12+
local actual="$2"
13+
local expected="$3"
14+
15+
if ! jq -e --argjson expected "$expected" '. == $expected' <<<"$actual" >/dev/null; then
16+
echo "FAIL: $description"
17+
echo "Expected: $expected"
18+
echo "Actual: $actual"
19+
exit 1
20+
fi
21+
}
22+
23+
match() {
24+
local finding_id="$1"
25+
jq -c --arg prefix "$PREFIX" --arg findingId "$finding_id" -f "$MATCH_FILTER"
26+
}
27+
28+
EMPTY_MATCH='{"linearIssueExists":false,"linearIssueId":"","linearIssueIdentifier":"","linearIssueUrl":"","linearIssueClosed":false}'
29+
OPEN_MATCH='{"linearIssueExists":true,"linearIssueId":"open-id","linearIssueIdentifier":"SOU-2","linearIssueUrl":"https://linear.app/SOU-2","linearIssueClosed":false}'
30+
CLOSED_MATCH='{"linearIssueExists":true,"linearIssueId":"closed-id","linearIssueIdentifier":"SOU-1","linearIssueUrl":"https://linear.app/SOU-1","linearIssueClosed":true}'
31+
32+
NO_ISSUES='{"data":{"issues":{"nodes":[]}}}'
33+
assert_json "creates when no Linear issue exists" "$(match CVE-2026-123 <<<"$NO_ISSUES")" "$EMPTY_MATCH"
34+
35+
ISSUES='{
36+
"data": {
37+
"issues": {
38+
"nodes": [
39+
{
40+
"id": "closed-id",
41+
"identifier": "SOU-1",
42+
"url": "https://linear.app/SOU-1",
43+
"title": "[sourcebot-dev/example] CVE-2026-123: old finding",
44+
"state": {"type": "completed"}
45+
},
46+
{
47+
"id": "open-id",
48+
"identifier": "SOU-2",
49+
"url": "https://linear.app/SOU-2",
50+
"title": "[sourcebot-dev/example] CVE-2026-123: current finding",
51+
"state": {"type": "started"}
52+
},
53+
{
54+
"id": "prefix-collision",
55+
"identifier": "SOU-3",
56+
"url": "https://linear.app/SOU-3",
57+
"title": "[sourcebot-dev/example] CVE-2026-1234: different finding",
58+
"state": {"type": "started"}
59+
},
60+
{
61+
"id": "other-repo",
62+
"identifier": "SOU-4",
63+
"url": "https://linear.app/SOU-4",
64+
"title": "[sourcebot-dev/other] CVE-2026-123: different repository",
65+
"state": {"type": "started"}
66+
},
67+
{
68+
"id": "duplicate-id",
69+
"identifier": "SOU-5",
70+
"url": "https://linear.app/SOU-5",
71+
"title": "[sourcebot-dev/example] CVE-2026-123: duplicate marker",
72+
"state": {"type": "duplicate"}
73+
}
74+
]
75+
}
76+
}
77+
}'
78+
assert_json "deduplicates by exact finding id and prefers an open issue" "$(match CVE-2026-123 <<<"$ISSUES")" "$OPEN_MATCH"
79+
assert_json "does not confuse an id with a longer id" "$(match CVE-2026-12 <<<"$ISSUES")" "$EMPTY_MATCH"
80+
81+
CLOSED_ONLY="$(jq '.data.issues.nodes |= map(select(.id == "closed-id"))' <<<"$ISSUES")"
82+
assert_json "reopens a closed issue when the finding returns" "$(match CVE-2026-123 <<<"$CLOSED_ONLY")" "$CLOSED_MATCH"
83+
84+
DUPLICATE_ONLY="$(jq '.data.issues.nodes |= map(select(.id == "duplicate-id"))' <<<"$ISSUES")"
85+
DUPLICATE_MATCH='{"linearIssueExists":true,"linearIssueId":"duplicate-id","linearIssueIdentifier":"SOU-5","linearIssueUrl":"https://linear.app/SOU-5","linearIssueClosed":false}'
86+
assert_json "does not reopen a duplicate marker" "$(match CVE-2026-123 <<<"$DUPLICATE_ONLY")" "$DUPLICATE_MATCH"
87+
88+
FINDINGS='{
89+
"cves": [
90+
{"cveId": "CVE-2026-123"},
91+
{"cveId": "GHSA-abcd-efgh-ijkl"},
92+
{"cveId": "codeql:js/example-rule"}
93+
]
94+
}'
95+
OPEN_ISSUES='[
96+
{
97+
"id": "keep-cve",
98+
"title": "[sourcebot-dev/example] CVE-2026-123: still present"
99+
},
100+
{
101+
"id": "keep-codeql",
102+
"title": "[sourcebot-dev/example] codeql:js/example-rule: still present"
103+
},
104+
{
105+
"id": "close-cve",
106+
"title": "[sourcebot-dev/example] CVE-2025-999: resolved"
107+
},
108+
{
109+
"id": "close-prefix-collision",
110+
"title": "[sourcebot-dev/example] CVE-2026-12: resolved"
111+
},
112+
{
113+
"id": "ignore-other-repo",
114+
"title": "[sourcebot-dev/other] CVE-2025-999: unrelated"
115+
},
116+
{
117+
"id": "ignore-unmanaged",
118+
"title": "[sourcebot-dev/example] maintenance without a finding delimiter"
119+
}
120+
]'
121+
122+
CLASSIFIED="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
123+
assert_json "keeps every exact current finding" "$(jq -c '[.[] | select(.action == "keep") | .id]' <<<"$CLASSIFIED")" '["keep-cve","keep-codeql"]'
124+
assert_json "closes only resolved managed findings" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED")" '["close-cve","close-prefix-collision"]'
125+
assert_json "ignores unrelated titles and repositories" "$(jq -c '[.[] | select(.action == "ignore") | .id]' <<<"$CLASSIFIED")" '["ignore-other-repo","ignore-unmanaged"]'
126+
127+
EMPTY_FINDINGS='{"cves":[]}'
128+
CLASSIFIED_EMPTY="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
129+
assert_json "closes all managed issues when a complete scan is clean" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED_EMPTY")" '["keep-cve","keep-codeql","close-cve","close-prefix-collision"]'
130+
131+
echo "All vulnerability triage reconciliation tests passed."

.github/workflows/test.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ on:
66

77

88
jobs:
9+
vulnerability-triage:
10+
name: Vulnerability triage reconciliation
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
- name: Test reconciliation behavior
18+
run: .github/scripts/test-vulnerability-triage.sh
19+
920
test:
1021
runs-on: ubuntu-latest
1122
permissions:

0 commit comments

Comments
 (0)