Skip to content

Commit e9550c3

Browse files
chore(ci): auto-close resolved vulnerability issues in triage pipeline
The triage pipeline created/reopened Linear issues for vulnerabilities but never closed them once resolved, so a fixed CVE could linger as an open, SLA-breaching issue. Reconcile open pipeline-created issues against the current findings each run and move any whose vulnerability is no longer reported to Done. Run triage on the canonical repo even when scans are clean so reconciliation happens, and guard the close step on successful scans to avoid mass-closing on scan failure. Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com>
1 parent 08cf01d commit e9550c3

1 file changed

Lines changed: 136 additions & 2 deletions

File tree

.github/workflows/vulnerability-triage.yml

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,11 +242,17 @@ jobs:
242242
triage:
243243
name: Linear Triage
244244
needs: [scan, check-alerts]
245+
# Run whenever there is scan/alert data to triage, OR force_analysis is set. We
246+
# also run on the canonical repo even when the scans come back clean: the triage
247+
# job reconciles existing Linear issues against the current findings and closes
248+
# any whose vulnerability is no longer present, which only happens if the job runs.
245249
if: >-
246250
always() && !cancelled() && (
247251
needs.scan.outputs.has_vulnerabilities == 'true' ||
248252
needs.check-alerts.outputs.has_alerts == 'true' ||
249-
inputs.force_analysis == true
253+
inputs.force_analysis == true ||
254+
github.repository == 'sourcebot-dev/sourcebot' ||
255+
inputs.image != ''
250256
)
251257
runs-on: ubuntu-latest
252258
steps:
@@ -545,7 +551,7 @@ jobs:
545551
set -euo pipefail
546552
# Resolve team UUID + the "CVE" label, "Triage" state, and API key owner once,
547553
# and expose them as outputs so the issue-creation step can reuse them.
548-
METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { id } } } viewer { id } }'
554+
METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { id } } doneStates: states(filter: { type: { eq: "completed" } }) { nodes { id position } } } viewer { id } }'
549555
METADATA_PAYLOAD=$(jq -n --arg query "$METADATA_QUERY" --arg teamId "$LINEAR_TEAM_ID" \
550556
'{query: $query, variables: {teamId: $teamId}}')
551557
METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
@@ -556,6 +562,9 @@ jobs:
556562
TEAM_UUID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.id // empty')
557563
LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty')
558564
STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty')
565+
# Lowest-position completed state is the team's canonical "Done"; used to
566+
# auto-close issues whose vulnerability is no longer reported.
567+
DONE_STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.doneStates.nodes | sort_by(.position) | .[0].id // empty')
559568
VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty')
560569
561570
if [ -z "$TEAM_UUID" ]; then
@@ -594,6 +603,7 @@ jobs:
594603
echo "label_id=$LABEL_ID"
595604
echo "repo_label_id=$REPO_LABEL_ID"
596605
echo "state_id=$STATE_ID"
606+
echo "done_state_id=$DONE_STATE_ID"
597607
echo "viewer_id=$VIEWER_ID"
598608
} >> "$GITHUB_OUTPUT"
599609
@@ -827,4 +837,128 @@ jobs:
827837
if [ "$FAILED_COUNT" -gt 0 ]; then
828838
echo "::error::Failed to create $FAILED_COUNT Linear issue(s)"
829839
exit 1
840+
fi
841+
842+
- name: Close resolved Linear issues
843+
# Only reconcile when both scanners succeeded — a failed scan produces an empty
844+
# findings set, which would otherwise look like "everything is resolved" and
845+
# close every open issue. Skipped/failed scans leave existing issues untouched.
846+
if: inputs.dry_run != true && needs.scan.result == 'success' && needs.check-alerts.result == 'success'
847+
env:
848+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
849+
REPOSITORY: ${{ github.repository }}
850+
DONE_STATE_ID: ${{ steps.match.outputs.done_state_id }}
851+
run: |
852+
set -uo pipefail
853+
# Auto-close pipeline-created issues whose vulnerability is no longer reported by
854+
# any scanner. Issues created by this workflow all carry a "[<repository>]" title
855+
# prefix and embed their finding id in the title, so we fetch every open issue with
856+
# that prefix and close any whose id is absent from the current findings set. This
857+
# keeps resolved vulnerabilities (e.g. a CVE fixed by a merged upgrade) from
858+
# lingering as open, SLA-breaching issues.
859+
if [ -z "$DONE_STATE_ID" ]; then
860+
echo "::warning::Could not resolve a completed (Done) workflow state. Skipping auto-close."
861+
echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY"
862+
echo "" >> "$GITHUB_STEP_SUMMARY"
863+
echo "Skipped — no completed state found in the Linear team." >> "$GITHUB_STEP_SUMMARY"
864+
exit 0
865+
fi
866+
867+
# Current finding ids. An open issue is considered resolved when none of these
868+
# ids appears in its title (mirrors how the match step locates issues by id).
869+
jq -r '.cves[].cveId' findings.json > /tmp/current-ids.txt
870+
CURRENT_COUNT=$(wc -l < /tmp/current-ids.txt | tr -d ' ')
871+
echo "Reconciling against $CURRENT_COUNT current finding id(s)."
872+
873+
PREFIX="[$REPOSITORY]"
874+
SEARCH_QUERY='query($prefix: String!, $after: String) { issues(first: 100, after: $after, filter: { title: { startsWith: $prefix }, state: { type: { nin: ["completed", "canceled"] } } }) { nodes { id identifier url title } pageInfo { hasNextPage endCursor } } }'
875+
876+
echo '[]' > /tmp/open-issues.json
877+
AFTER=""
878+
while true; do
879+
if [ -z "$AFTER" ]; then
880+
VARS=$(jq -n --arg prefix "$PREFIX" '{prefix: $prefix}')
881+
else
882+
VARS=$(jq -n --arg prefix "$PREFIX" --arg after "$AFTER" '{prefix: $prefix, after: $after}')
883+
fi
884+
PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}')
885+
RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
886+
-H "Content-Type: application/json" \
887+
-H "Authorization: $LINEAR_API_KEY" \
888+
-d "$PAYLOAD")
889+
890+
if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then
891+
echo "::warning::Failed to fetch open Linear issues: $(echo "$RESPONSE" | jq -c '.errors // .'). Skipping auto-close."
892+
exit 0
893+
fi
894+
895+
PAGE=$(echo "$RESPONSE" | jq '.data.issues.nodes')
896+
jq -s '.[0] + .[1]' /tmp/open-issues.json <(echo "$PAGE") > /tmp/open-issues.tmp && mv /tmp/open-issues.tmp /tmp/open-issues.json
897+
898+
HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.hasNextPage')
899+
if [ "$HAS_NEXT" != "true" ]; then
900+
break
901+
fi
902+
AFTER=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.endCursor')
903+
done
904+
905+
OPEN_COUNT=$(jq 'length' /tmp/open-issues.json)
906+
echo "Found $OPEN_COUNT open issue(s) with prefix '$PREFIX'."
907+
908+
echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY"
909+
echo "" >> "$GITHUB_STEP_SUMMARY"
910+
911+
CLOSE_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }'
912+
913+
CLOSED_COUNT=0
914+
FAILED_COUNT=0
915+
916+
jq -c '.[]' /tmp/open-issues.json > /tmp/open-issues.jsonl
917+
while IFS= read -r issue; do
918+
ISSUE_ID=$(echo "$issue" | jq -r '.id')
919+
ISSUE_IDENTIFIER=$(echo "$issue" | jq -r '.identifier')
920+
ISSUE_URL=$(echo "$issue" | jq -r '.url')
921+
ISSUE_TITLE=$(echo "$issue" | jq -r '.title')
922+
923+
# Keep the issue open if any current finding id is present in its title.
924+
STILL_PRESENT=false
925+
while IFS= read -r id; do
926+
[ -n "$id" ] || continue
927+
case "$ISSUE_TITLE" in
928+
*"$id"*) STILL_PRESENT=true; break ;;
929+
esac
930+
done < /tmp/current-ids.txt
931+
932+
if [ "$STILL_PRESENT" = "true" ]; then
933+
continue
934+
fi
935+
936+
echo "Closing $ISSUE_IDENTIFIER — vulnerability no longer reported ($ISSUE_URL)"
937+
CLOSE_VARS=$(jq -n --arg issueId "$ISSUE_ID" --arg stateId "$DONE_STATE_ID" '{issueId: $issueId, stateId: $stateId}')
938+
CLOSE_PAYLOAD=$(jq -n --arg query "$CLOSE_MUTATION" --argjson vars "$CLOSE_VARS" '{query: $query, variables: $vars}')
939+
CLOSE_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
940+
-H "Content-Type: application/json" \
941+
-H "Authorization: $LINEAR_API_KEY" \
942+
-d "$CLOSE_PAYLOAD")
943+
944+
if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then
945+
echo "- Closed [$ISSUE_IDENTIFIER]($ISSUE_URL) — vulnerability no longer reported" >> "$GITHUB_STEP_SUMMARY"
946+
CLOSED_COUNT=$((CLOSED_COUNT + 1))
947+
else
948+
echo "::error::Failed to close $ISSUE_IDENTIFIER"
949+
echo "$CLOSE_RESPONSE" | jq .
950+
echo "- **FAILED** to close [$ISSUE_IDENTIFIER]($ISSUE_URL)" >> "$GITHUB_STEP_SUMMARY"
951+
FAILED_COUNT=$((FAILED_COUNT + 1))
952+
fi
953+
done < /tmp/open-issues.jsonl
954+
955+
echo "" >> "$GITHUB_STEP_SUMMARY"
956+
echo "**Summary:** Closed $CLOSED_COUNT resolved issue(s), failed $FAILED_COUNT." >> "$GITHUB_STEP_SUMMARY"
957+
if [ "$CLOSED_COUNT" -eq 0 ] && [ "$FAILED_COUNT" -eq 0 ]; then
958+
echo "No resolved issues to close." >> "$GITHUB_STEP_SUMMARY"
959+
fi
960+
961+
if [ "$FAILED_COUNT" -gt 0 ]; then
962+
echo "::error::Failed to close $FAILED_COUNT Linear issue(s)"
963+
exit 1
830964
fi

0 commit comments

Comments
 (0)