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 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