Skip to content

Commit b8145a6

Browse files
msukkariclaude
andcommitted
fix: scope vulnerability triage Linear dedup by repository
The Linear search was matching issues from other repos (e.g., a sourcebot-helm-chart finding would match an existing sourcebot issue with the same CVE ID). Now Claude verifies the [$REPOSITORY] prefix in issue titles before considering it a match. Also improves step summary visibility: - Check-alerts summary now shows individual alert details (CVE, severity, package, link) instead of just "Open alerts found" - Claude analysis summary includes Linear issue links and distinguishes open/closed/new statuses - Linear issue creation step logs issue identifiers and URLs for every action (skip, reopen, create, fail) - Adds linearIssueIdentifier and linearIssueUrl to structured output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent da4cc71 commit b8145a6

1 file changed

Lines changed: 56 additions & 29 deletions

File tree

.github/workflows/vulnerability-triage.yml

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -190,39 +190,49 @@ jobs:
190190
191191
# Dependabot status
192192
if [ -z "$DEPENDABOT_PAT" ]; then
193-
echo "- **Dependabot:** Skipped (DEPENDABOT_PAT not configured)" >> "$GITHUB_STEP_SUMMARY"
193+
echo "### Dependabot" >> "$GITHUB_STEP_SUMMARY"
194+
echo "Skipped (DEPENDABOT_PAT not configured)" >> "$GITHUB_STEP_SUMMARY"
194195
else
195196
DEPENDABOT_RESPONSE=$(curl -s \
196197
-H "Accept: application/vnd.github+json" \
197198
-H "Authorization: Bearer $DEPENDABOT_PAT" \
198-
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1")
199+
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=100")
199200
DEPENDABOT_COUNT=$(echo "$DEPENDABOT_RESPONSE" | jq 'if type == "array" then length else 0 end')
201+
echo "### Dependabot — $DEPENDABOT_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY"
200202
if [ "$DEPENDABOT_COUNT" -gt 0 ]; then
201-
echo "- **Dependabot:** Open alerts found" >> "$GITHUB_STEP_SUMMARY"
202-
else
203-
echo "- **Dependabot:** No open alerts" >> "$GITHUB_STEP_SUMMARY"
203+
echo "" >> "$GITHUB_STEP_SUMMARY"
204+
echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version | Link |" >> "$GITHUB_STEP_SUMMARY"
205+
echo "|------------|----------|---------|-----------|-----------------|------|" >> "$GITHUB_STEP_SUMMARY"
206+
echo "$DEPENDABOT_RESPONSE" | jq -r '.[] | "| \(.security_advisory.cve_id // .security_advisory.ghsa_id // "—") | \(.security_advisory.severity // "—") | \(.security_vulnerability.package.name // "—") | \(.security_vulnerability.package.ecosystem // "—") | \(.security_vulnerability.first_patched_version.identifier // "N/A") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY"
204207
fi
205208
fi
206209
210+
echo "" >> "$GITHUB_STEP_SUMMARY"
211+
207212
# CodeQL status
208213
CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
209214
-H "Accept: application/vnd.github+json" \
210215
-H "Authorization: Bearer $GH_TOKEN" \
211-
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1")
216+
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100")
212217
if [ "$CODEQL_STATUS" = "404" ]; then
213-
echo "- **CodeQL:** Not enabled for this repository" >> "$GITHUB_STEP_SUMMARY"
218+
echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY"
219+
echo "Not enabled for this repository." >> "$GITHUB_STEP_SUMMARY"
214220
elif [ "$CODEQL_STATUS" = "200" ]; then
215-
CODEQL_COUNT=$(curl -s \
221+
CODEQL_RESPONSE=$(curl -s \
216222
-H "Accept: application/vnd.github+json" \
217223
-H "Authorization: Bearer $GH_TOKEN" \
218-
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1" | jq 'length')
224+
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100")
225+
CODEQL_COUNT=$(echo "$CODEQL_RESPONSE" | jq 'length')
226+
echo "### CodeQL — $CODEQL_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY"
219227
if [ "$CODEQL_COUNT" -gt 0 ]; then
220-
echo "- **CodeQL:** Open alerts found" >> "$GITHUB_STEP_SUMMARY"
221-
else
222-
echo "- **CodeQL:** No open alerts" >> "$GITHUB_STEP_SUMMARY"
228+
echo "" >> "$GITHUB_STEP_SUMMARY"
229+
echo "| Rule ID | Severity | Tool | File | Lines | Link |" >> "$GITHUB_STEP_SUMMARY"
230+
echo "|---------|----------|------|------|-------|------|" >> "$GITHUB_STEP_SUMMARY"
231+
echo "$CODEQL_RESPONSE" | jq -r '.[] | "| \(.rule.id // "—") | \(.rule.security_severity_level // "—") | \(.tool.name // "—") | \(.most_recent_instance.location.path // "—") | \(.most_recent_instance.location.start_line // "—")-\(.most_recent_instance.location.end_line // "—") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY"
223232
fi
224233
else
225-
echo "- **CodeQL:** Failed to check (HTTP $CODEQL_STATUS)" >> "$GITHUB_STEP_SUMMARY"
234+
echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY"
235+
echo "Failed to check (HTTP $CODEQL_STATUS)" >> "$GITHUB_STEP_SUMMARY"
226236
fi
227237
228238
echo "" >> "$GITHUB_STEP_SUMMARY"
@@ -432,9 +442,9 @@ jobs:
432442
claude_args: |
433443
--allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch
434444
--model claude-sonnet-4-6
435-
--json-schema '{"type":"object","properties":{"cves":{"type":"array","items":{"type":"object","properties":{"cveId":{"type":"string","description":"CVE ID, GHSA ID, or codeql:<rule-id>"},"severity":{"type":"string","enum":["CRITICAL","HIGH","MEDIUM","LOW"]},"source":{"type":"string","enum":["trivy","dependabot","codeql","trivy+dependabot"],"description":"Which scanner(s) reported this finding"},"title":{"type":"string","description":"Short summary for the Linear issue title"},"description":{"type":"string","description":"Markdown analysis: affected packages, direct vs transitive, remediation steps, and references"},"affectedPackage":{"type":"string"},"linearIssueExists":{"type":"boolean"},"linearIssueId":{"type":"string","description":"The Linear issue UUID if a matching issue was found, empty string otherwise"},"linearIssueClosed":{"type":"boolean","description":"True if the matching Linear issue is in a completed or canceled state"}},"required":["cveId","severity","source","title","description","affectedPackage","linearIssueExists","linearIssueId","linearIssueClosed"]}}},"required":["cves"]}'
445+
--json-schema '{"type":"object","properties":{"cves":{"type":"array","items":{"type":"object","properties":{"cveId":{"type":"string","description":"CVE ID, GHSA ID, or codeql:<rule-id>"},"severity":{"type":"string","enum":["CRITICAL","HIGH","MEDIUM","LOW"]},"source":{"type":"string","enum":["trivy","dependabot","codeql","trivy+dependabot"],"description":"Which scanner(s) reported this finding"},"title":{"type":"string","description":"Short summary for the Linear issue title"},"description":{"type":"string","description":"Markdown analysis: affected packages, direct vs transitive, remediation steps, and references"},"affectedPackage":{"type":"string"},"linearIssueExists":{"type":"boolean"},"linearIssueId":{"type":"string","description":"The Linear issue UUID if a matching issue was found, empty string otherwise"},"linearIssueIdentifier":{"type":"string","description":"The Linear issue identifier (e.g. SOU-926) if found, empty string otherwise"},"linearIssueUrl":{"type":"string","description":"The Linear issue URL if found, empty string otherwise"},"linearIssueClosed":{"type":"boolean","description":"True if the matching Linear issue is in a completed or canceled state"}},"required":["cveId","severity","source","title","description","affectedPackage","linearIssueExists","linearIssueId","linearIssueIdentifier","linearIssueUrl","linearIssueClosed"]}}},"required":["cves"]}'
436446
prompt: |
437-
You are a security engineer triaging vulnerabilities and security findings for the Sourcebot Docker image.
447+
You are a security engineer triaging vulnerabilities and security findings for the repository **${{ github.repository }}**.
438448
You have three data sources to analyze. Each is a JSON array where every entry has a pre-computed
439449
`id` field for deterministic deduplication:
440450
@@ -488,12 +498,19 @@ jobs:
488498
curl -s -X POST https://api.linear.app/graphql \
489499
-H "Content-Type: application/json" \
490500
-H "Authorization: $LINEAR_API_KEY" \
491-
-d '{"query": "query { issues(filter: { team: { id: { eq: \"'$LINEAR_TEAM_ID'\" } }, title: { contains: \"<ID>\" } }) { nodes { id title state { type } } } }"}'
501+
-d '{"query": "query { issues(filter: { team: { id: { eq: \"'$LINEAR_TEAM_ID'\" } }, title: { contains: \"<ID>\" } }) { nodes { id identifier url title state { type } } } }"}'
492502
```
493-
- Set `linearIssueExists` to `true` if any matching issue is found, `false` otherwise.
503+
- **IMPORTANT: Repository scoping.** Linear issues are titled with a `[$REPOSITORY]` prefix
504+
(e.g., `[sourcebot-dev/sourcebot] CVE-2024-1234: ...`). When checking for existing issues,
505+
you MUST verify that the matched issue's title starts with `[${{ github.repository }}]`.
506+
An issue for `[sourcebot-dev/sourcebot]` is NOT the same as one for `[sourcebot-dev/sourcebot-helm-chart]`.
507+
Ignore issues whose title prefix does not match the current repository `${{ github.repository }}`.
508+
- Set `linearIssueExists` to `true` if a matching issue scoped to this repo is found, `false` otherwise.
494509
- If multiple issues match, prefer the one with an open state (i.e., state type is NOT `"completed"` or `"canceled"`).
495510
Only use a closed issue if no open issue exists for that finding.
496511
- Set `linearIssueId` to the `id` (UUID) of the selected matching issue, or `""` if none found.
512+
- Set `linearIssueIdentifier` to the issue identifier (e.g., `SOU-926`) of the selected matching issue, or `""` if none found.
513+
- Set `linearIssueUrl` to the `url` of the selected matching issue, or `""` if none found.
497514
- Set `linearIssueClosed` to `true` if the selected issue's `state.type` is `"completed"` or `"canceled"`, `false` otherwise.
498515
499516
8. Return the structured JSON with all findings in the `cves` array.
@@ -504,16 +521,17 @@ jobs:
504521
run: |
505522
CVE_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '.cves | length')
506523
NEW_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == false)] | length')
507-
EXISTING_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true)] | length')
524+
EXISTING_OPEN_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true and .linearIssueClosed == false)] | length')
525+
EXISTING_CLOSED_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true and .linearIssueClosed == true)] | length')
508526
509527
echo "## Claude Analysis" >> "$GITHUB_STEP_SUMMARY"
510528
echo "" >> "$GITHUB_STEP_SUMMARY"
511-
echo "**$CVE_COUNT** finding(s): **$NEW_COUNT** new, **$EXISTING_COUNT** already tracked in Linear." >> "$GITHUB_STEP_SUMMARY"
529+
echo "**$CVE_COUNT** finding(s): **$NEW_COUNT** new, **$EXISTING_OPEN_COUNT** already tracked (open), **$EXISTING_CLOSED_COUNT** previously closed (will reopen)." >> "$GITHUB_STEP_SUMMARY"
512530
echo "" >> "$GITHUB_STEP_SUMMARY"
513-
echo "| ID | Source | Severity | Package | Linear Status |" >> "$GITHUB_STEP_SUMMARY"
514-
echo "|----|--------|----------|---------|---------------|" >> "$GITHUB_STEP_SUMMARY"
531+
echo "| ID | Source | Severity | Package | Linear Status | Linear Issue |" >> "$GITHUB_STEP_SUMMARY"
532+
echo "|----|--------|----------|---------|---------------|--------------|" >> "$GITHUB_STEP_SUMMARY"
515533
516-
echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "| \(.cveId) | \(.source) | \(.severity) | \(.affectedPackage) | \(if .linearIssueClosed then "Reopen" elif .linearIssueExists then "Existing" else "New" end) |"' >> "$GITHUB_STEP_SUMMARY"
534+
echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "| \(.cveId) | \(.source) | \(.severity) | \(.affectedPackage) | \(if .linearIssueClosed then "Reopen" elif .linearIssueExists then "Existing (skip)" else "New (create)" end) | \(if .linearIssueUrl != "" then "[\(.linearIssueIdentifier)](\(.linearIssueUrl))" else "—" end) |"' >> "$GITHUB_STEP_SUMMARY"
517535
518536
echo "" >> "$GITHUB_STEP_SUMMARY"
519537
echo "### Details" >> "$GITHUB_STEP_SUMMARY"
@@ -585,18 +603,23 @@ jobs:
585603
DESCRIPTION=$(echo "$cve" | jq -r '.description')
586604
LINEAR_EXISTS=$(echo "$cve" | jq -r '.linearIssueExists')
587605
LINEAR_ISSUE_ID=$(echo "$cve" | jq -r '.linearIssueId')
606+
LINEAR_IDENTIFIER=$(echo "$cve" | jq -r '.linearIssueIdentifier')
607+
LINEAR_URL=$(echo "$cve" | jq -r '.linearIssueUrl')
588608
LINEAR_CLOSED=$(echo "$cve" | jq -r '.linearIssueClosed')
589609
590610
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "false" ]; then
591-
echo "Skipping $CVE_ID — Linear issue already exists and is open."
611+
echo "Skipping $CVE_ID — Linear issue $LINEAR_IDENTIFIER already exists and is open ($LINEAR_URL)"
612+
echo "- Skipped **$CVE_ID** — already tracked in [$LINEAR_IDENTIFIER]($LINEAR_URL) (open)" >> "$GITHUB_STEP_SUMMARY"
592613
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
593614
continue
594615
fi
595616
596617
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "true" ]; then
597618
# Reopen the closed issue by setting its state back to Triage
619+
echo "Found closed Linear issue $LINEAR_IDENTIFIER for $CVE_ID ($LINEAR_URL) — will attempt to reopen"
598620
if [ -z "$STATE_ID" ]; then
599-
echo "::warning::Cannot reopen $CVE_ID — no Triage state found. Skipping."
621+
echo "::warning::Cannot reopen $CVE_ID ($LINEAR_IDENTIFIER) — no Triage state found. Skipping."
622+
echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no Triage state to reopen" >> "$GITHUB_STEP_SUMMARY"
600623
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
601624
continue
602625
fi
@@ -614,13 +637,15 @@ jobs:
614637
-d "$REOPEN_PAYLOAD")
615638
616639
REOPEN_URL=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.url // empty')
640+
REOPEN_IDENTIFIER=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.identifier // empty')
617641
if [ -n "$REOPEN_URL" ]; then
618-
echo "Reopened Linear issue for $CVE_ID: $REOPEN_URL"
619-
echo "- Reopened [$CVE_ID]($REOPEN_URL) (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY"
642+
echo "Reopened Linear issue $REOPEN_IDENTIFIER for $CVE_ID: $REOPEN_URL"
643+
echo "- Reopened [$REOPEN_IDENTIFIER]($REOPEN_URL) for **$CVE_ID** — $TITLE (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY"
620644
REOPENED_COUNT=$((REOPENED_COUNT + 1))
621645
else
622-
echo "::error::Failed to reopen Linear issue for $CVE_ID"
646+
echo "::error::Failed to reopen Linear issue $LINEAR_IDENTIFIER for $CVE_ID"
623647
echo "$REOPEN_RESPONSE" | jq .
648+
echo "- **FAILED** to reopen [$LINEAR_IDENTIFIER]($LINEAR_URL) for **$CVE_ID**" >> "$GITHUB_STEP_SUMMARY"
624649
FAILED_COUNT=$((FAILED_COUNT + 1))
625650
fi
626651
continue
@@ -653,13 +678,15 @@ jobs:
653678
-d "$PAYLOAD")
654679
655680
ISSUE_URL=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.url // empty')
681+
ISSUE_IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.identifier // empty')
656682
if [ -n "$ISSUE_URL" ]; then
657-
echo "Created Linear issue for $CVE_ID: $ISSUE_URL"
658-
echo "- Created [$CVE_ID]($ISSUE_URL) (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY"
683+
echo "Created Linear issue $ISSUE_IDENTIFIER for $CVE_ID: $ISSUE_URL"
684+
echo "- Created [$ISSUE_IDENTIFIER]($ISSUE_URL) for **$CVE_ID** — $TITLE (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY"
659685
CREATED_COUNT=$((CREATED_COUNT + 1))
660686
else
661687
echo "::error::Failed to create Linear issue for $CVE_ID"
662688
echo "$RESPONSE" | jq .
689+
echo "- **FAILED** to create issue for **$CVE_ID** — $TITLE" >> "$GITHUB_STEP_SUMMARY"
663690
FAILED_COUNT=$((FAILED_COUNT + 1))
664691
fi
665692
done < /tmp/cves.jsonl

0 commit comments

Comments
 (0)