From e9550c33c096e0dc2aa494c208eaa5757cfc91b0 Mon Sep 17 00:00:00 2001 From: Brendan Kellam <10233483+brendan-kellam@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:13:25 +0000 Subject: [PATCH 1/2] 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> --- .github/workflows/vulnerability-triage.yml | 138 ++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 9360216b0..aac2e443f 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -242,11 +242,17 @@ jobs: triage: name: Linear Triage needs: [scan, check-alerts] + # Run whenever there is scan/alert data to triage, OR force_analysis is set. We + # also run on the canonical repo even when the scans come back clean: the triage + # job reconciles existing Linear issues against the current findings and closes + # any whose vulnerability is no longer present, which only happens if the job runs. if: >- always() && !cancelled() && ( needs.scan.outputs.has_vulnerabilities == 'true' || needs.check-alerts.outputs.has_alerts == 'true' || - inputs.force_analysis == true + inputs.force_analysis == true || + github.repository == 'sourcebot-dev/sourcebot' || + inputs.image != '' ) runs-on: ubuntu-latest steps: @@ -545,7 +551,7 @@ jobs: set -euo pipefail # Resolve team UUID + the "CVE" label, "Triage" state, and API key owner once, # and expose them as outputs so the issue-creation step can reuse them. - 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 } }' + 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 } }' METADATA_PAYLOAD=$(jq -n --arg query "$METADATA_QUERY" --arg teamId "$LINEAR_TEAM_ID" \ '{query: $query, variables: {teamId: $teamId}}') METADATA_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ @@ -556,6 +562,9 @@ jobs: TEAM_UUID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.id // empty') LABEL_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.labels.nodes[0].id // empty') STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.states.nodes[0].id // empty') + # Lowest-position completed state is the team's canonical "Done"; used to + # auto-close issues whose vulnerability is no longer reported. + DONE_STATE_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.team.doneStates.nodes | sort_by(.position) | .[0].id // empty') VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty') if [ -z "$TEAM_UUID" ]; then @@ -594,6 +603,7 @@ jobs: echo "label_id=$LABEL_ID" echo "repo_label_id=$REPO_LABEL_ID" echo "state_id=$STATE_ID" + echo "done_state_id=$DONE_STATE_ID" echo "viewer_id=$VIEWER_ID" } >> "$GITHUB_OUTPUT" @@ -827,4 +837,128 @@ jobs: if [ "$FAILED_COUNT" -gt 0 ]; then echo "::error::Failed to create $FAILED_COUNT Linear issue(s)" exit 1 + fi + + - name: Close resolved Linear issues + # Only reconcile when both scanners succeeded — a failed scan produces an empty + # findings set, which would otherwise look like "everything is resolved" and + # close every open issue. Skipped/failed scans leave existing issues untouched. + if: inputs.dry_run != true && needs.scan.result == 'success' && needs.check-alerts.result == 'success' + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + REPOSITORY: ${{ github.repository }} + DONE_STATE_ID: ${{ steps.match.outputs.done_state_id }} + run: | + set -uo pipefail + # Auto-close pipeline-created issues whose vulnerability is no longer reported by + # any scanner. Issues created by this workflow all carry a "[]" title + # prefix and embed their finding id in the title, so we fetch every open issue with + # that prefix and close any whose id is absent from the current findings set. This + # keeps resolved vulnerabilities (e.g. a CVE fixed by a merged upgrade) from + # lingering as open, SLA-breaching issues. + if [ -z "$DONE_STATE_ID" ]; then + echo "::warning::Could not resolve a completed (Done) workflow state. Skipping auto-close." + echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Skipped — no completed state found in the Linear team." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Current finding ids. An open issue is considered resolved when none of these + # ids appears in its title (mirrors how the match step locates issues by id). + jq -r '.cves[].cveId' findings.json > /tmp/current-ids.txt + CURRENT_COUNT=$(wc -l < /tmp/current-ids.txt | tr -d ' ') + echo "Reconciling against $CURRENT_COUNT current finding id(s)." + + PREFIX="[$REPOSITORY]" + 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 } } }' + + echo '[]' > /tmp/open-issues.json + AFTER="" + while true; do + if [ -z "$AFTER" ]; then + VARS=$(jq -n --arg prefix "$PREFIX" '{prefix: $prefix}') + else + VARS=$(jq -n --arg prefix "$PREFIX" --arg after "$AFTER" '{prefix: $prefix, after: $after}') + fi + PAYLOAD=$(jq -n --arg query "$SEARCH_QUERY" --argjson vars "$VARS" '{query: $query, variables: $vars}') + RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$PAYLOAD") + + if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then + echo "::warning::Failed to fetch open Linear issues: $(echo "$RESPONSE" | jq -c '.errors // .'). Skipping auto-close." + exit 0 + fi + + PAGE=$(echo "$RESPONSE" | jq '.data.issues.nodes') + jq -s '.[0] + .[1]' /tmp/open-issues.json <(echo "$PAGE") > /tmp/open-issues.tmp && mv /tmp/open-issues.tmp /tmp/open-issues.json + + HAS_NEXT=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.hasNextPage') + if [ "$HAS_NEXT" != "true" ]; then + break + fi + AFTER=$(echo "$RESPONSE" | jq -r '.data.issues.pageInfo.endCursor') + done + + OPEN_COUNT=$(jq 'length' /tmp/open-issues.json) + echo "Found $OPEN_COUNT open issue(s) with prefix '$PREFIX'." + + echo "## Auto-close Resolved Issues" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + CLOSE_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }' + + CLOSED_COUNT=0 + FAILED_COUNT=0 + + jq -c '.[]' /tmp/open-issues.json > /tmp/open-issues.jsonl + while IFS= read -r issue; do + ISSUE_ID=$(echo "$issue" | jq -r '.id') + ISSUE_IDENTIFIER=$(echo "$issue" | jq -r '.identifier') + ISSUE_URL=$(echo "$issue" | jq -r '.url') + ISSUE_TITLE=$(echo "$issue" | jq -r '.title') + + # Keep the issue open if any current finding id is present in its title. + STILL_PRESENT=false + while IFS= read -r id; do + [ -n "$id" ] || continue + case "$ISSUE_TITLE" in + *"$id"*) STILL_PRESENT=true; break ;; + esac + done < /tmp/current-ids.txt + + if [ "$STILL_PRESENT" = "true" ]; then + continue + fi + + echo "Closing $ISSUE_IDENTIFIER — vulnerability no longer reported ($ISSUE_URL)" + CLOSE_VARS=$(jq -n --arg issueId "$ISSUE_ID" --arg stateId "$DONE_STATE_ID" '{issueId: $issueId, stateId: $stateId}') + CLOSE_PAYLOAD=$(jq -n --arg query "$CLOSE_MUTATION" --argjson vars "$CLOSE_VARS" '{query: $query, variables: $vars}') + CLOSE_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$CLOSE_PAYLOAD") + + if [ "$(echo "$CLOSE_RESPONSE" | jq -r '.data.issueUpdate.success // false')" = "true" ]; then + echo "- Closed [$ISSUE_IDENTIFIER]($ISSUE_URL) — vulnerability no longer reported" >> "$GITHUB_STEP_SUMMARY" + CLOSED_COUNT=$((CLOSED_COUNT + 1)) + else + echo "::error::Failed to close $ISSUE_IDENTIFIER" + echo "$CLOSE_RESPONSE" | jq . + echo "- **FAILED** to close [$ISSUE_IDENTIFIER]($ISSUE_URL)" >> "$GITHUB_STEP_SUMMARY" + FAILED_COUNT=$((FAILED_COUNT + 1)) + fi + done < /tmp/open-issues.jsonl + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Summary:** Closed $CLOSED_COUNT resolved issue(s), failed $FAILED_COUNT." >> "$GITHUB_STEP_SUMMARY" + if [ "$CLOSED_COUNT" -eq 0 ] && [ "$FAILED_COUNT" -eq 0 ]; then + echo "No resolved issues to close." >> "$GITHUB_STEP_SUMMARY" + fi + + if [ "$FAILED_COUNT" -gt 0 ]; then + echo "::error::Failed to close $FAILED_COUNT Linear issue(s)" + exit 1 fi \ No newline at end of file From 116cbb0b35caf8a0d76fe1448c39afea97666e11 Mon Sep 17 00:00:00 2001 From: Brendan Kellam <10233483+brendan-kellam@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:13:55 +0000 Subject: [PATCH 2/2] docs: add CHANGELOG entry for auto-close triage change Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0402dab6..01ffe88d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Reduced Sentry span sampling to 10% outside development. [#1475](https://github.com/sourcebot-dev/sourcebot/pull/1475) +- Vulnerability triage pipeline now auto-closes resolved issues once their vulnerability is no longer reported. [#1501](https://github.com/sourcebot-dev/sourcebot/pull/1501) ## [5.1.3] - 2026-07-20