Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 136 additions & 2 deletions .github/workflows/vulnerability-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 \
Expand All @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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 "[<repository>]" 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading