Skip to content

Commit e4650ea

Browse files
authored
fix vulnerability triage for reusable callers (#1515)
* fix vulnerability triage for reusable callers * docs add vulnerability triage changelog * docs classify vulnerability triage fix * fix: retry transient Linear triage responses * fix: retry only Linear queries * fix: preserve CodeQL issues when scans are unavailable * fix: report Linear mutation transport failures * fix: default skipped CodeQL state explicitly
1 parent 9491a13 commit e4650ea

5 files changed

Lines changed: 274 additions & 73 deletions

File tree

.github/scripts/classify-vulnerability-issues.jq

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ def finding_id($prefix):
1212
| .findingId as $findingId
1313
| if .findingId == "" then
1414
. + { action: "ignore" }
15+
elif ($skipCodeql and (.findingId | startswith("codeql:"))) then
16+
. + { action: "ignore" }
1517
elif ($currentIds | index($findingId)) != null then
1618
. + { action: "keep" }
1719
else
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
if [[ -z "${LINEAR_API_KEY:-}" ]]; then
5+
echo "LINEAR_API_KEY is required" >&2
6+
exit 1
7+
fi
8+
9+
payload=$(cat)
10+
configured_attempts="${LINEAR_GRAPHQL_ATTEMPTS:-4}"
11+
retry_delay="${LINEAR_GRAPHQL_RETRY_DELAY_SECONDS:-2}"
12+
endpoint="${LINEAR_GRAPHQL_ENDPOINT:-https://api.linear.app/graphql}"
13+
is_mutation=$(jq -r '(.query // "") | test("^\\s*mutation(?:\\s|\\(|\\{)")' <<<"$payload")
14+
15+
# Retrying a mutation after an ambiguous transport failure can replay a write
16+
# that Linear already committed. Queries are safe to retry; mutations fail
17+
# visibly after one attempt and rely on the workflow's reconciliation pass.
18+
if [[ "$is_mutation" == "true" ]]; then
19+
attempts=1
20+
else
21+
attempts="$configured_attempts"
22+
fi
23+
24+
for ((attempt = 1; attempt <= attempts; attempt++)); do
25+
response_file=$(mktemp)
26+
http_code=""
27+
28+
if http_code=$(curl \
29+
--silent \
30+
--show-error \
31+
--output "$response_file" \
32+
--write-out '%{http_code}' \
33+
--connect-timeout 10 \
34+
--max-time 45 \
35+
-X POST "$endpoint" \
36+
-H "Content-Type: application/json" \
37+
-H "Authorization: $LINEAR_API_KEY" \
38+
-d "$payload"); then
39+
response=$(<"$response_file")
40+
rm -f "$response_file"
41+
42+
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]] && jq -e . >/dev/null 2>&1 <<<"$response"; then
43+
printf '%s' "$response"
44+
exit 0
45+
fi
46+
47+
if [[ "$http_code" =~ ^(408|429|5[0-9][0-9])$ ]]; then
48+
echo "Linear GraphQL returned transient HTTP $http_code (attempt $attempt/$attempts)." >&2
49+
elif jq -e . >/dev/null 2>&1 <<<"$response"; then
50+
# Preserve structured non-retryable errors so the workflow can report
51+
# the GraphQL response rather than replacing it with a transport error.
52+
printf '%s' "$response"
53+
exit 0
54+
else
55+
echo "Linear GraphQL returned a non-JSON response (HTTP $http_code, attempt $attempt/$attempts)." >&2
56+
fi
57+
else
58+
curl_status=$?
59+
response=$(<"$response_file")
60+
rm -f "$response_file"
61+
echo "Linear GraphQL request failed (curl $curl_status, HTTP ${http_code:-unknown}, attempt $attempt/$attempts)." >&2
62+
fi
63+
64+
if ((attempt < attempts)); then
65+
sleep "$retry_delay"
66+
fi
67+
done
68+
69+
echo "Linear GraphQL did not return a valid JSON response after $attempts attempts." >&2
70+
exit 1

.github/scripts/test-vulnerability-triage.sh

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ set -euo pipefail
55
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
66
MATCH_FILTER="$SCRIPT_DIR/match-vulnerability-issue.jq"
77
CLASSIFY_FILTER="$SCRIPT_DIR/classify-vulnerability-issues.jq"
8+
LINEAR_REQUEST="$SCRIPT_DIR/linear-graphql-request.sh"
9+
WORKFLOW_FILE="$SCRIPT_DIR/../workflows/vulnerability-triage.yml"
810
PREFIX="[sourcebot-dev/example]"
911

1012
assert_json() {
@@ -20,6 +22,86 @@ assert_json() {
2022
fi
2123
}
2224

25+
assert_workflow_contains() {
26+
local description="$1"
27+
local expected="$2"
28+
29+
if ! grep -Fq -- "$expected" "$WORKFLOW_FILE"; then
30+
echo "FAIL: $description"
31+
echo "Expected workflow to contain: $expected"
32+
exit 1
33+
fi
34+
}
35+
36+
assert_workflow_contains "checks out assets from the called workflow repository" 'repository: ${{ job.workflow_repository }}'
37+
assert_workflow_contains "pins assets to the called workflow revision" 'ref: ${{ job.workflow_sha }}'
38+
assert_workflow_contains "uses the shared match filter" '-f .vulnerability-triage-workflow/.github/scripts/match-vulnerability-issue.jq'
39+
assert_workflow_contains "uses the shared classification filter" '-f .vulnerability-triage-workflow/.github/scripts/classify-vulnerability-issues.jq'
40+
assert_workflow_contains "uses the retrying Linear GraphQL client" '.vulnerability-triage-workflow/.github/scripts/linear-graphql-request.sh'
41+
42+
FAKE_CURL_DIR=$(mktemp -d)
43+
FAKE_CURL_COUNT=$(mktemp)
44+
trap 'rm -rf "$FAKE_CURL_DIR"; rm -f "$FAKE_CURL_COUNT"' EXIT
45+
printf '0\n' > "$FAKE_CURL_COUNT"
46+
47+
cat > "$FAKE_CURL_DIR/curl" <<'EOF'
48+
#!/usr/bin/env bash
49+
set -euo pipefail
50+
51+
output_file=""
52+
while (($# > 0)); do
53+
case "$1" in
54+
--output)
55+
output_file="$2"
56+
shift 2
57+
;;
58+
*)
59+
shift
60+
;;
61+
esac
62+
done
63+
64+
count=$(( $(<"$FAKE_CURL_COUNT") + 1 ))
65+
printf '%s\n' "$count" > "$FAKE_CURL_COUNT"
66+
if ((count < 3)); then
67+
printf 'Bad Gateway' > "$output_file"
68+
printf '502'
69+
else
70+
printf '{"data":{"ok":true}}' > "$output_file"
71+
printf '200'
72+
fi
73+
EOF
74+
chmod +x "$FAKE_CURL_DIR/curl"
75+
76+
LINEAR_RESPONSE=$(
77+
PATH="$FAKE_CURL_DIR:$PATH" \
78+
FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \
79+
LINEAR_API_KEY="test-key" \
80+
LINEAR_GRAPHQL_ATTEMPTS=3 \
81+
LINEAR_GRAPHQL_RETRY_DELAY_SECONDS=0 \
82+
"$LINEAR_REQUEST" <<<'{"query":"query { viewer { id } }"}'
83+
)
84+
assert_json "retries non-JSON Linear responses" "$LINEAR_RESPONSE" '{"data":{"ok":true}}'
85+
if [[ "$(<"$FAKE_CURL_COUNT")" != "3" ]]; then
86+
echo "FAIL: expected Linear request helper to retry twice"
87+
exit 1
88+
fi
89+
90+
printf '0\n' > "$FAKE_CURL_COUNT"
91+
if PATH="$FAKE_CURL_DIR:$PATH" \
92+
FAKE_CURL_COUNT="$FAKE_CURL_COUNT" \
93+
LINEAR_API_KEY="test-key" \
94+
LINEAR_GRAPHQL_ATTEMPTS=3 \
95+
LINEAR_GRAPHQL_RETRY_DELAY_SECONDS=0 \
96+
"$LINEAR_REQUEST" <<<'{"query":"mutation { issueCreate(input: {}) { success } }"}' >/dev/null 2>&1; then
97+
echo "FAIL: ambiguous Linear mutations must not be retried"
98+
exit 1
99+
fi
100+
if [[ "$(<"$FAKE_CURL_COUNT")" != "1" ]]; then
101+
echo "FAIL: expected exactly one Linear mutation attempt"
102+
exit 1
103+
fi
104+
23105
match() {
24106
local finding_id="$1"
25107
jq -c --arg prefix "$PREFIX" --arg findingId "$finding_id" -f "$MATCH_FILTER"
@@ -119,13 +201,17 @@ OPEN_ISSUES='[
119201
}
120202
]'
121203

122-
CLASSIFIED="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
204+
CLASSIFIED="$(jq -c --arg prefix "$PREFIX" --argjson skipCodeql false --slurpfile findings <(printf '%s\n' "$FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
123205
assert_json "keeps every exact current finding" "$(jq -c '[.[] | select(.action == "keep") | .id]' <<<"$CLASSIFIED")" '["keep-cve","keep-codeql"]'
124206
assert_json "closes only resolved managed findings" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED")" '["close-cve","close-prefix-collision"]'
125207
assert_json "ignores unrelated titles and repositories" "$(jq -c '[.[] | select(.action == "ignore") | .id]' <<<"$CLASSIFIED")" '["ignore-other-repo","ignore-unmanaged"]'
126208

127209
EMPTY_FINDINGS='{"cves":[]}'
128-
CLASSIFIED_EMPTY="$(jq -c --arg prefix "$PREFIX" --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
210+
CLASSIFIED_EMPTY="$(jq -c --arg prefix "$PREFIX" --argjson skipCodeql false --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
129211
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"]'
130212

213+
CLASSIFIED_WITH_CODEQL_SKIPPED="$(jq -c --arg prefix "$PREFIX" --argjson skipCodeql true --slurpfile findings <(printf '%s\n' "$EMPTY_FINDINGS") -f "$CLASSIFY_FILTER" <<<"$OPEN_ISSUES")"
214+
assert_json "preserves CodeQL issues when CodeQL is unavailable" "$(jq -c '[.[] | select(.action == "ignore") | .id]' <<<"$CLASSIFIED_WITH_CODEQL_SKIPPED")" '["keep-codeql","ignore-other-repo","ignore-unmanaged"]'
215+
assert_json "still closes resolved non-CodeQL issues when CodeQL is unavailable" "$(jq -c '[.[] | select(.action == "close") | .id]' <<<"$CLASSIFIED_WITH_CODEQL_SKIPPED")" '["keep-cve","close-cve","close-prefix-collision"]'
216+
131217
echo "All vulnerability triage reconciliation tests passed."

0 commit comments

Comments
 (0)