From 70c3952946a8f1555f413d98d8028cd9e74a42c1 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 17 Jun 2026 16:03:51 -0700 Subject: [PATCH 1/4] chore(ci): remove LLM summary generation from vulnerability triage Replace the Claude analysis step in the vulnerability-triage workflow with deterministic jq/curl scripting. Findings are now built directly from the normalized Trivy, Dependabot, and CodeQL scan files (dedup by pre-computed id, CodeQL grouped by rule, templated titles/descriptions), and existing Linear issues are matched via GraphQL. No LLM is used anywhere. Drops the ANTHROPIC_API_KEY secret from the reusable workflow (no callers pass it). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/vulnerability-triage.yml | 286 +++++++++++++-------- 1 file changed, 176 insertions(+), 110 deletions(-) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 90b030b90..93b58d344 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -16,7 +16,7 @@ on: type: boolean default: false force_analysis: - description: 'Force Claude analysis even if no vulnerabilities are found' + description: 'Force triage even if no vulnerabilities are found' required: false type: boolean default: false @@ -41,8 +41,6 @@ on: type: boolean default: false secrets: - ANTHROPIC_API_KEY: - required: true LINEAR_API_KEY: required: true LINEAR_TEAM_ID: @@ -239,7 +237,7 @@ jobs: echo "**Result:** has_alerts=${{ steps.check.outputs.has_alerts }}" >> "$GITHUB_STEP_SUMMARY" triage: - name: Claude Analysis & Linear Triage + name: Linear Triage needs: [scan, check-alerts] if: >- always() && !cancelled() && ( @@ -431,100 +429,181 @@ jobs: fi fi - - name: Analyze vulnerabilities with Claude - id: claude - uses: anthropics/claude-code-action@v1 + - name: Build findings + run: | + set -euo pipefail + # Deterministically build the findings list from the three normalized scan + # files. Dedup by the pre-computed `id` field: a CVE/GHSA id present in both + # Trivy and Dependabot is merged into one entry; CodeQL alerts are grouped by + # rule id. Titles and descriptions are templated from the scan fields — no LLM. + for f in trivy-alerts.json dependabot-alerts.json codeql-alerts.json; do + [ -f "$f" ] || echo "[]" > "$f" + done + + jq -n \ + --slurpfile trivy trivy-alerts.json \ + --slurpfile dependabot dependabot-alerts.json \ + --slurpfile codeql codeql-alerts.json ' + def sev: (. // "medium") | ascii_upcase; + + ($trivy[0] // []) as $trivy_raw | + ($dependabot[0] // []) as $dep_raw | + ($codeql[0] // []) as $codeql_raw | + ($trivy_raw | map(.id)) as $trivy_ids | + + # --- Trivy findings (merged with Dependabot when ids collide) --- + ($trivy_raw | map( + . as $t | + ($dep_raw | map(select(.id == $t.id)) | .[0]) as $match | + { + cveId: $t.id, + severity: ($t.severity | sev), + source: (if $match then "trivy+dependabot" else "trivy" end), + title: (if (($t.title // "") != "") then $t.title else (($t.severity | sev) + " vulnerability in " + $t.pkg_name) end), + affectedPackage: $t.pkg_name, + description: ( + "**Source:** Trivy container scan" + (if $match then " + Dependabot" else "" end) + "\n\n" + + "**Package:** `" + $t.pkg_name + "` (installed `" + $t.installed_version + "`" + + (if (($t.fixed_version // "") != "") then ", fixed in `" + $t.fixed_version + "`" else ", no fix available" end) + ")\n\n" + + "**Severity:** " + ($t.severity | sev) + "\n\n" + + (if (($t.title // "") != "") then "**" + $t.title + "**\n\n" else "" end) + + (if (($t.description // "") != "") then $t.description + "\n\n" else "" end) + + (if (($match.html_url // "") != "") then "**Dependabot alert:** " + $match.html_url + "\n\n" else "" end) + + (if (($match.first_patched_version // "") != "") then "**Dependabot patched version:** `" + $match.first_patched_version + "`\n\n" else "" end) + + (if ((($t.references // []) | length) > 0) then "**References:**\n" + ($t.references | map("- " + .) | join("\n")) + "\n" else "" end) + ) + } + )) as $trivy_findings | + + # --- Dependabot-only findings (ids not already covered by Trivy) --- + ($dep_raw | map(select(.id as $id | ($trivy_ids | index($id)) | not)) | map( + . as $d | + { + cveId: $d.id, + severity: ($d.severity | sev), + source: "dependabot", + title: (if (($d.summary // "") != "") then $d.summary else (($d.severity | sev) + " vulnerability in " + $d.package_name) end), + affectedPackage: $d.package_name, + description: ( + "**Source:** Dependabot\n\n" + + "**Package:** `" + $d.package_name + "` (" + $d.package_ecosystem + ")" + + (if (($d.first_patched_version // "") != "") then " — patched in `" + $d.first_patched_version + "`" else " — no patched version available" end) + "\n\n" + + (if (($d.manifest_path // "") != "") then "**Manifest:** `" + $d.manifest_path + "`\n\n" else "" end) + + "**Severity:** " + ($d.severity | sev) + "\n\n" + + (if (($d.summary // "") != "") then "**" + $d.summary + "**\n\n" else "" end) + + (if (($d.description // "") != "") then $d.description + "\n\n" else "" end) + + (if (($d.html_url // "") != "") then "**Alert:** " + $d.html_url + "\n" else "" end) + ) + } + )) as $dep_findings | + + # --- CodeQL findings (grouped by rule id) --- + ($codeql_raw | group_by(.id) | map( + . as $group | + ($group[0]) as $first | + ($group | map(.location_path) | unique) as $paths | + { + cveId: $first.id, + severity: ($first.security_severity_level | sev), + source: "codeql", + title: (($first.security_severity_level | sev) + " " + $first.rule_id + " (" + (($group | length) | tostring) + " location(s))"), + affectedPackage: ($paths | join(", ")), + description: ( + "**Source:** CodeQL static analysis\n\n" + + "**Rule:** `" + $first.rule_id + "`" + + (if (($first.rule_description // "") != "") then " — " + $first.rule_description else "" end) + "\n\n" + + "**Severity:** " + ($first.security_severity_level | sev) + "\n\n" + + "This rule was triggered in " + (($group | length) | tostring) + " location(s):\n" + + ($group | map("- `" + .location_path + ":" + (.location_start_line | tostring) + "-" + (.location_end_line | tostring) + "` ([view](" + .html_url + "))") | join("\n")) + "\n" + ) + } + )) as $codeql_findings | + + {cves: ($trivy_findings + $dep_findings + $codeql_findings)} + ' > findings-base.json + + echo "Built $(jq '.cves | length' findings-base.json) finding(s)." + + - name: Match existing Linear issues + id: match env: LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - claude_args: | - --allowedTools Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch - --model claude-sonnet-4-6 - --json-schema '{"type":"object","properties":{"cves":{"type":"array","items":{"type":"object","properties":{"cveId":{"type":"string","description":"CVE ID, GHSA ID, or codeql:"},"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"]}' - prompt: | - You are a security engineer triaging vulnerabilities and security findings for the repository **${{ github.repository }}**. - You have three data sources to analyze. Each is a JSON array where every entry has a pre-computed - `id` field for deterministic deduplication: - - 1. **Trivy scan results** in `trivy-alerts.json` — each entry has `id` (CVE ID, e.g., `CVE-2024-1234`) - 2. **Dependabot alerts** in `dependabot-alerts.json` — each entry has `id` (CVE ID or GHSA ID) - 3. **CodeQL alerts** in `codeql-alerts.json` — each entry has `id` (prefixed, e.g., `codeql:js/sql-injection`). Multiple entries may share the same `id` (same rule, different locations). - - ## Your Task - - 1. Read and analyze all three data sources. For **each unique `id`**, produce a separate entry - in the `cves` array. Use the `id` field as the `cveId`. - - 2. **Deduplication**: If the same `id` appears in both `trivy-alerts.json` and `dependabot-alerts.json`, - merge them into a single entry with `source: "trivy+dependabot"`. Combine information from both - sources in the description. CodeQL `id` values are prefixed with `codeql:` so they never collide. - - 3. For **Trivy and Dependabot findings**: - - Use the `id` field as `cveId`. - - Set `source` to `"trivy"`, `"dependabot"`, or `"trivy+dependabot"` as appropriate. - - Include the affected package, severity, remediation steps, and whether it is direct or transitive. - - 4. For **CodeQL findings**: - - **Group all alerts with the same `id` (rule ID) into a single entry.** Multiple alerts for - the same rule in different files/locations should produce ONE finding, not separate ones. - - Use the `id` field as `cveId` (e.g., `codeql:js/path-injection`). - - Set `source` to `"codeql"`. - - Set `affectedPackage` to a comma-separated list of affected file paths, or the primary one - if there are many. - - Normalize `security_severity_level` to uppercase (CRITICAL/HIGH/MEDIUM/LOW). - - The `description` MUST include details for **every alert instance** in the group: - - The rule ID and what it detects - - For **each** alert: the exact file path, line number(s), and a link to its alert URL (`html_url`) - - For **each** alert: an explanation of the specific code at that location and why it's flagged - - Concrete remediation steps with code examples where possible - - A link to the CodeQL rule documentation - - A summary count (e.g., "This rule was triggered in 3 locations:") - - 5. For each finding, determine: - - A short `title` suitable for a Linear issue title. - - A `description` in markdown with your full analysis, references, and remediation guidance. - - The `severity` (CRITICAL, HIGH, MEDIUM, or LOW) — normalize to uppercase from all sources. - - 6. Read files such as `Dockerfile`, `package.json`, and `go.mod` to gather context about - dependencies. Use `yarn why --recursive` to determine why an npm package is included. - - 7. **Check Linear for existing issues** for each finding: - - For each `cveId`, run a GraphQL query against the Linear API to search for issues - whose title contains that ID. Search ALL issues regardless of state (open, completed, cancelled). - - Use the following curl command pattern: - ``` - curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d '{"query": "query { issues(filter: { team: { id: { eq: \"'$LINEAR_TEAM_ID'\" } }, title: { contains: \"\" } }) { nodes { id identifier url title state { type } } } }"}' - ``` - - **IMPORTANT: Repository scoping.** Linear issues are titled with a `[$REPOSITORY]` prefix - (e.g., `[sourcebot-dev/sourcebot] CVE-2024-1234: ...`). When checking for existing issues, - you MUST verify that the matched issue's title starts with `[${{ github.repository }}]`. - An issue for `[sourcebot-dev/sourcebot]` is NOT the same as one for `[sourcebot-dev/sourcebot-helm-chart]`. - Ignore issues whose title prefix does not match the current repository `${{ github.repository }}`. - - Set `linearIssueExists` to `true` if a matching issue scoped to this repo is found, `false` otherwise. - - If multiple issues match, prefer the one with an open state (i.e., state type is NOT `"completed"` or `"canceled"`). - Only use a closed issue if no open issue exists for that finding. - - Set `linearIssueId` to the `id` (UUID) of the selected matching issue, or `""` if none found. - - Set `linearIssueIdentifier` to the issue identifier (e.g., `SOU-926`) of the selected matching issue, or `""` if none found. - - Set `linearIssueUrl` to the `url` of the selected matching issue, or `""` if none found. - - Set `linearIssueClosed` to `true` if the selected issue's `state.type` is `"completed"` or `"canceled"`, `false` otherwise. - - 8. Return the structured JSON with all findings in the `cves` array. - - - name: Write Claude analysis summary - env: - STRUCTURED_OUTPUT: ${{ steps.claude.outputs.structured_output }} + REPOSITORY: ${{ github.repository }} run: | + 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_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 \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$METADATA_PAYLOAD") + + 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') + VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty') + + if [ -z "$TEAM_UUID" ]; then + echo "::error::Could not resolve team UUID from LINEAR_TEAM_ID. Check the secret value." + exit 1 + fi + + { + echo "team_uuid=$TEAM_UUID" + echo "label_id=$LABEL_ID" + echo "state_id=$STATE_ID" + echo "viewer_id=$VIEWER_ID" + } >> "$GITHUB_OUTPUT" + + # For each finding, search Linear for an existing issue whose title contains the + # finding id AND is scoped to this repo (title starts with "[]"). + # Prefer an open issue over a closed one. + SEARCH_QUERY='query($teamId: String!, $text: String!) { issues(filter: { team: { id: { eq: $teamId } }, title: { contains: $text } }) { nodes { id identifier url title state { type } } } }' + + echo '[]' > /tmp/matched.json + jq -c '.cves[]' findings-base.json > /tmp/findings.jsonl + + while IFS= read -r finding; do + CVE_ID=$(echo "$finding" | jq -r '.cveId') + VARS=$(jq -n --arg teamId "$TEAM_UUID" --arg text "$CVE_ID" '{teamId: $teamId, text: $text}') + 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") + + SELECTED=$(echo "$RESPONSE" | jq --arg prefix "[$REPOSITORY]" ' + [.data.issues.nodes[]? | select(.title | startswith($prefix))] as $matches | + ($matches | map(select(.state.type != "completed" and .state.type != "canceled")) | .[0]) as $open | + ($open // $matches[0]) as $chosen | + if $chosen == null then + {linearIssueExists: false, linearIssueId: "", linearIssueIdentifier: "", linearIssueUrl: "", linearIssueClosed: false} + else + {linearIssueExists: true, linearIssueId: $chosen.id, linearIssueIdentifier: $chosen.identifier, linearIssueUrl: $chosen.url, linearIssueClosed: (($chosen.state.type == "completed") or ($chosen.state.type == "canceled"))} + end + ') + + MERGED=$(echo "$finding" "$SELECTED" | jq -s '.[0] + .[1]') + jq --argjson item "$MERGED" '. + [$item]' /tmp/matched.json > /tmp/matched.tmp && mv /tmp/matched.tmp /tmp/matched.json + done < /tmp/findings.jsonl + + jq -n --slurpfile cves /tmp/matched.json '{cves: $cves[0]}' > findings.json + echo "Matched $(jq '.cves | length' findings.json) finding(s) against Linear." + + - name: Write findings summary + run: | + set -euo pipefail + STRUCTURED_OUTPUT=$(cat findings.json) CVE_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '.cves | length') NEW_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == false)] | length') EXISTING_OPEN_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true and .linearIssueClosed == false)] | length') EXISTING_CLOSED_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true and .linearIssueClosed == true)] | length') - echo "## Claude Analysis" >> "$GITHUB_STEP_SUMMARY" + echo "## Findings" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "**$CVE_COUNT** finding(s): **$NEW_COUNT** new, **$EXISTING_OPEN_COUNT** already tracked (open), **$EXISTING_CLOSED_COUNT** previously closed (will reopen)." >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" @@ -542,29 +621,16 @@ jobs: if: inputs.dry_run != true env: LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} - LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }} - STRUCTURED_OUTPUT: ${{ steps.claude.outputs.structured_output }} REPOSITORY: ${{ github.repository }} + TEAM_UUID: ${{ steps.match.outputs.team_uuid }} + LABEL_ID: ${{ steps.match.outputs.label_id }} + STATE_ID: ${{ steps.match.outputs.state_id }} + VIEWER_ID: ${{ steps.match.outputs.viewer_id }} run: | - # Look up the "CVE" label ID, "Triage" state ID, and the API key owner's user ID - 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_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 \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$METADATA_PAYLOAD") - - # Resolve the actual team UUID (LINEAR_TEAM_ID may be a slug/key, but issueCreate requires a UUID) - 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') - VIEWER_ID=$(echo "$METADATA_RESPONSE" | jq -r '.data.viewer.id // empty') - - if [ -z "$TEAM_UUID" ]; then - echo "::error::Could not resolve team UUID from LINEAR_TEAM_ID. Check the secret value." - exit 1 - fi + set -uo pipefail + # Team UUID, "CVE" label, "Triage" state, and the API key owner's user ID were + # already resolved by the "Match existing Linear issues" step and passed in as env. + STRUCTURED_OUTPUT=$(cat findings.json) if [ -z "$LABEL_ID" ]; then echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label." From a685d01227d7917fbbdf52b5dc34e08a9ebd34f2 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 17 Jun 2026 16:16:36 -0700 Subject: [PATCH 2/4] fix(ci): dedupe vulnerability findings by id within each scanner Trivy can report the same CVE multiple times (across lockfiles/targets or when a CVE affects several packages). The previous build mapped over every Trivy entry, so duplicate ids produced duplicate findings and therefore duplicate Linear issues. Group Trivy and Dependabot by id so each unique id yields exactly one finding, collecting all affected packages into it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/vulnerability-triage.yml | 40 +++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 93b58d344..2274d0c61 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -433,9 +433,11 @@ jobs: run: | set -euo pipefail # Deterministically build the findings list from the three normalized scan - # files. Dedup by the pre-computed `id` field: a CVE/GHSA id present in both - # Trivy and Dependabot is merged into one entry; CodeQL alerts are grouped by - # rule id. Titles and descriptions are templated from the scan fields — no LLM. + # files. Dedup by the pre-computed `id` field so each unique id yields exactly + # one finding: duplicate ids within a single scanner are collapsed, a CVE/GHSA + # id present in both Trivy and Dependabot is merged into one entry, and CodeQL + # alerts are grouped by rule id. Titles and descriptions are templated from the + # scan fields — no LLM. for f in trivy-alerts.json dependabot-alerts.json codeql-alerts.json; do [ -f "$f" ] || echo "[]" > "$f" done @@ -451,20 +453,24 @@ jobs: ($codeql[0] // []) as $codeql_raw | ($trivy_raw | map(.id)) as $trivy_ids | - # --- Trivy findings (merged with Dependabot when ids collide) --- - ($trivy_raw | map( - . as $t | + # --- Trivy findings (deduped by id, merged with Dependabot when ids collide) --- + # Trivy can report the same CVE multiple times (across lockfiles/targets or + # affecting several packages), so group by id and collect every package. + ($trivy_raw | group_by(.id) | map( + . as $group | + ($group[0]) as $t | + ($group | map(.pkg_name) | unique) as $pkgs | ($dep_raw | map(select(.id == $t.id)) | .[0]) as $match | { cveId: $t.id, severity: ($t.severity | sev), source: (if $match then "trivy+dependabot" else "trivy" end), - title: (if (($t.title // "") != "") then $t.title else (($t.severity | sev) + " vulnerability in " + $t.pkg_name) end), - affectedPackage: $t.pkg_name, + title: (if (($t.title // "") != "") then $t.title else (($t.severity | sev) + " vulnerability in " + ($pkgs | join(", "))) end), + affectedPackage: ($pkgs | join(", ")), description: ( "**Source:** Trivy container scan" + (if $match then " + Dependabot" else "" end) + "\n\n" - + "**Package:** `" + $t.pkg_name + "` (installed `" + $t.installed_version + "`" - + (if (($t.fixed_version // "") != "") then ", fixed in `" + $t.fixed_version + "`" else ", no fix available" end) + ")\n\n" + + "**Affected package(s):**\n" + + ($group | map("- `" + .pkg_name + "` (installed `" + .installed_version + "`" + (if ((.fixed_version // "") != "") then ", fixed in `" + .fixed_version + "`" else ", no fix available" end) + ")") | unique | join("\n")) + "\n\n" + "**Severity:** " + ($t.severity | sev) + "\n\n" + (if (($t.title // "") != "") then "**" + $t.title + "**\n\n" else "" end) + (if (($t.description // "") != "") then $t.description + "\n\n" else "" end) @@ -475,18 +481,20 @@ jobs: } )) as $trivy_findings | - # --- Dependabot-only findings (ids not already covered by Trivy) --- - ($dep_raw | map(select(.id as $id | ($trivy_ids | index($id)) | not)) | map( - . as $d | + # --- Dependabot-only findings (deduped by id, ids not already covered by Trivy) --- + ($dep_raw | group_by(.id) | map(select((.[0].id) as $id | ($trivy_ids | index($id)) | not)) | map( + . as $group | + ($group[0]) as $d | + ($group | map(.package_name) | unique) as $pkgs | { cveId: $d.id, severity: ($d.severity | sev), source: "dependabot", - title: (if (($d.summary // "") != "") then $d.summary else (($d.severity | sev) + " vulnerability in " + $d.package_name) end), - affectedPackage: $d.package_name, + title: (if (($d.summary // "") != "") then $d.summary else (($d.severity | sev) + " vulnerability in " + ($pkgs | join(", "))) end), + affectedPackage: ($pkgs | join(", ")), description: ( "**Source:** Dependabot\n\n" - + "**Package:** `" + $d.package_name + "` (" + $d.package_ecosystem + ")" + + "**Package:** `" + ($pkgs | join("`, `")) + "` (" + $d.package_ecosystem + ")" + (if (($d.first_patched_version // "") != "") then " — patched in `" + $d.first_patched_version + "`" else " — no patched version available" end) + "\n\n" + (if (($d.manifest_path // "") != "") then "**Manifest:** `" + $d.manifest_path + "`\n\n" else "" end) + "**Severity:** " + ($d.severity | sev) + "\n\n" From 03f9893a62e9aa93365af5d71bda1279e99f7f24 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 17 Jun 2026 16:35:23 -0700 Subject: [PATCH 3/4] fix(ci): match existing Linear issues so triage stops creating duplicates The Linear search bound the team UUID as a `String!` GraphQL variable into `team.id.eq`, which expects `ID`. That variable-type mismatch made every search return null data, so no finding ever matched an existing issue and the job re-filed every CVE on each run. Drop the team filter (repo-prefix title scoping is the authoritative filter anyway) and warn when a search errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/vulnerability-triage.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 2274d0c61..9cba00884 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -568,22 +568,31 @@ jobs: } >> "$GITHUB_OUTPUT" # For each finding, search Linear for an existing issue whose title contains the - # finding id AND is scoped to this repo (title starts with "[]"). - # Prefer an open issue over a closed one. - SEARCH_QUERY='query($teamId: String!, $text: String!) { issues(filter: { team: { id: { eq: $teamId } }, title: { contains: $text } }) { nodes { id identifier url title state { type } } } }' + # finding id, then scope to this repo via the "[]" title prefix and + # prefer an open issue over a closed one. The team is intentionally NOT part of + # the GraphQL filter: its `id.eq` expects an `ID`, and binding our `String` + # team-UUID variable there is a type error that makes every search return null. + # Repo-prefix scoping below is the authoritative filter anyway. + SEARCH_QUERY='query($text: String!) { issues(filter: { title: { contains: $text } }) { nodes { id identifier url title state { type } } } }' echo '[]' > /tmp/matched.json jq -c '.cves[]' findings-base.json > /tmp/findings.jsonl while IFS= read -r finding; do CVE_ID=$(echo "$finding" | jq -r '.cveId') - VARS=$(jq -n --arg teamId "$TEAM_UUID" --arg text "$CVE_ID" '{teamId: $teamId, text: $text}') + VARS=$(jq -n --arg text "$CVE_ID" '{text: $text}') 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") + # Surface query failures instead of silently treating them as "no match" + # (which would create a duplicate issue). + if [ "$(echo "$RESPONSE" | jq 'has("errors") or (.data.issues == null)')" = "true" ]; then + echo "::warning::Linear search for $CVE_ID failed: $(echo "$RESPONSE" | jq -c '.errors // .')" + fi + SELECTED=$(echo "$RESPONSE" | jq --arg prefix "[$REPOSITORY]" ' [.data.issues.nodes[]? | select(.title | startswith($prefix))] as $matches | ($matches | map(select(.state.type != "completed" and .state.type != "canceled")) | .[0]) as $open | From 6e221caadb6cb6920b694d39e094ff30ba4f5f4b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 17 Jun 2026 17:05:52 -0700 Subject: [PATCH 4/4] ci: run vulnerability triage on every push to main Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/vulnerability-triage.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 9cba00884..2c4ccccd0 100644 --- a/.github/workflows/vulnerability-triage.yml +++ b/.github/workflows/vulnerability-triage.yml @@ -1,6 +1,9 @@ name: Vulnerability Scan & Triage on: + push: + branches: + - main schedule: # Run daily at 6am Pacific (13:00 UTC during PDT) - cron: '0 13 * * *'