diff --git a/.github/workflows/vulnerability-triage.yml b/.github/workflows/vulnerability-triage.yml index 90b030b90..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 * * *' @@ -16,7 +19,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 +44,6 @@ on: type: boolean default: false secrets: - ANTHROPIC_API_KEY: - required: true LINEAR_API_KEY: required: true LINEAR_TEAM_ID: @@ -239,7 +240,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 +432,198 @@ 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 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 + + 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 (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 " + ($pkgs | join(", "))) end), + affectedPackage: ($pkgs | join(", ")), + description: ( + "**Source:** Trivy container scan" + (if $match then " + Dependabot" else "" 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) + + (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 (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 " + ($pkgs | join(", "))) end), + affectedPackage: ($pkgs | join(", ")), + description: ( + "**Source:** Dependabot\n\n" + + "**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" + + (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, 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 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 | + ($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 +641,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."