Skip to content

Vulnerability Scan & Triage #17

Vulnerability Scan & Triage

Vulnerability Scan & Triage #17

name: Vulnerability Scan & Triage
on:
schedule:
# Run daily at 6am Pacific (13:00 UTC during PDT)
- cron: '0 13 * * *'
workflow_dispatch:
inputs:
image_tag:
description: 'Image tag to scan (default: main)'
required: false
default: 'main'
dry_run:
description: 'Dry run (analyze but do not create Linear issues)'
required: false
type: boolean
default: false
force_analysis:
description: 'Force Claude analysis even if no vulnerabilities are found'
required: false
type: boolean
default: false
env:
IMAGE: ghcr.io/sourcebot-dev/sourcebot
permissions:
contents: read
packages: read
security-events: read # Required for CodeQL alerts API
id-token: write # Required for OIDC authentication
jobs:
scan:
name: Trivy Scan
runs-on: ubuntu-latest
outputs:
has_vulnerabilities: ${{ steps.check.outputs.has_vulnerabilities }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scan
uses: aquasecurity/trivy-action@master
with:
image-ref: "${{ env.IMAGE }}:${{ inputs.image_tag || 'main' }}"
format: "json"
output: "trivy-results.json"
trivy-config: trivy.yaml
- name: Check for vulnerabilities
id: check
run: |
VULN_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]?] | length' trivy-results.json)
if [ "$VULN_COUNT" -gt 0 ]; then
echo "has_vulnerabilities=true" >> "$GITHUB_OUTPUT"
else
echo "has_vulnerabilities=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload scan results
if: steps.check.outputs.has_vulnerabilities == 'true' || inputs.force_analysis == true
uses: actions/upload-artifact@v4
with:
name: trivy-results
path: trivy-results.json
retention-days: 30
- name: Write Trivy summary
run: |
echo "## Trivy Scan" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Image:** \`${{ env.IMAGE }}:${{ inputs.image_tag || 'main' }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.check.outputs.has_vulnerabilities }}" = "true" ]; then
VULN_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]?] | length' trivy-results.json)
CRIT_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-results.json)
HIGH_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-results.json)
MED_COUNT=$(jq '[.Results[]? | .Vulnerabilities[]? | select(.Severity == "MEDIUM")] | length' trivy-results.json)
echo "**$VULN_COUNT** vulnerabilities found: **$CRIT_COUNT** critical, **$HIGH_COUNT** high, **$MED_COUNT** medium." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE ID | Severity | Package | Installed | Fixed |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|----------|---------|-----------|-------|" >> "$GITHUB_STEP_SUMMARY"
jq -r '[.Results[]? | .Vulnerabilities[]?] | sort_by(.Severity) | .[] | "| \(.VulnerabilityID) | \(.Severity) | \(.PkgName) | \(.InstalledVersion) | \(.FixedVersion // "N/A") |"' trivy-results.json >> "$GITHUB_STEP_SUMMARY"
else
echo "No vulnerabilities found." >> "$GITHUB_STEP_SUMMARY"
fi
check-alerts:
name: Check Dependabot & CodeQL Alerts
runs-on: ubuntu-latest
outputs:
has_alerts: ${{ steps.check.outputs.has_alerts }}
steps:
- name: Check for open alerts
id: check
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }}
run: |
HAS_ALERTS=false
# Check Dependabot alerts (requires DEPENDABOT_PAT)
if [ -n "$DEPENDABOT_PAT" ]; then
DEPENDABOT_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1")
if [ "$DEPENDABOT_STATUS" = "200" ]; then
DEPENDABOT_COUNT=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1" | jq 'length')
if [ "$DEPENDABOT_COUNT" -gt 0 ]; then
echo "Found open Dependabot alerts"
HAS_ALERTS=true
fi
else
echo "::warning::Could not fetch Dependabot alerts (HTTP $DEPENDABOT_STATUS). Is DEPENDABOT_PAT configured?"
fi
else
echo "::warning::DEPENDABOT_PAT not configured. Skipping Dependabot alert check."
fi
# Check CodeQL alerts (uses GITHUB_TOKEN with security-events: read)
CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1")
if [ "$CODEQL_STATUS" = "200" ]; then
CODEQL_COUNT=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1" | jq 'length')
if [ "$CODEQL_COUNT" -gt 0 ]; then
echo "Found open CodeQL alerts"
HAS_ALERTS=true
fi
elif [ "$CODEQL_STATUS" = "404" ]; then
echo "CodeQL is not enabled for this repository. Skipping."
else
echo "::warning::Could not fetch CodeQL alerts (HTTP $CODEQL_STATUS)"
fi
echo "has_alerts=$HAS_ALERTS" >> "$GITHUB_OUTPUT"
- name: Write alerts summary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }}
run: |
echo "## Dependabot & CodeQL Alert Check" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Dependabot status
if [ -z "$DEPENDABOT_PAT" ]; then
echo "- **Dependabot:** Skipped (DEPENDABOT_PAT not configured)" >> "$GITHUB_STEP_SUMMARY"
else
DEPENDABOT_RESPONSE=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=1")
DEPENDABOT_COUNT=$(echo "$DEPENDABOT_RESPONSE" | jq 'if type == "array" then length else 0 end')
if [ "$DEPENDABOT_COUNT" -gt 0 ]; then
echo "- **Dependabot:** Open alerts found" >> "$GITHUB_STEP_SUMMARY"
else
echo "- **Dependabot:** No open alerts" >> "$GITHUB_STEP_SUMMARY"
fi
fi
# CodeQL status
CODEQL_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1")
if [ "$CODEQL_STATUS" = "404" ]; then
echo "- **CodeQL:** Not enabled for this repository" >> "$GITHUB_STEP_SUMMARY"
elif [ "$CODEQL_STATUS" = "200" ]; then
CODEQL_COUNT=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=1" | jq 'length')
if [ "$CODEQL_COUNT" -gt 0 ]; then
echo "- **CodeQL:** Open alerts found" >> "$GITHUB_STEP_SUMMARY"
else
echo "- **CodeQL:** No open alerts" >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "- **CodeQL:** Failed to check (HTTP $CODEQL_STATUS)" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Result:** has_alerts=${{ steps.check.outputs.has_alerts }}" >> "$GITHUB_STEP_SUMMARY"
triage:
name: Claude Analysis & Linear Triage
needs: [scan, check-alerts]
if: >-
needs.scan.outputs.has_vulnerabilities == 'true' ||
needs.check-alerts.outputs.has_alerts == 'true' ||
inputs.force_analysis == true
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Download scan results
if: needs.scan.outputs.has_vulnerabilities == 'true' || inputs.force_analysis == true
uses: actions/download-artifact@v4
with:
name: trivy-results
- name: Normalize Trivy results
run: |
if [ ! -f trivy-results.json ]; then
echo '{"Results":[]}' > trivy-results.json
fi
jq '[.Results[]? | .Vulnerabilities[]? | {
id: .VulnerabilityID,
severity: .Severity,
pkg_name: .PkgName,
installed_version: .InstalledVersion,
fixed_version: (.FixedVersion // ""),
title: (.Title // ""),
description: (.Description // ""),
references: ([.References[]?] // [])
}]' trivy-results.json > trivy-alerts.json
- name: Fetch Dependabot alerts
env:
DEPENDABOT_PAT: ${{ secrets.DEPENDABOT_PAT }}
run: |
if [ -z "$DEPENDABOT_PAT" ]; then
echo "::warning::DEPENDABOT_PAT not configured. Writing empty Dependabot alerts."
echo "[]" > dependabot-alerts.json
exit 0
fi
ALL_ALERTS="[]"
URL="https://api.github.com/repos/${{ github.repository }}/dependabot/alerts?state=open&per_page=100"
while [ -n "$URL" ]; do
# Fetch with headers saved to parse Link for cursor pagination
HTTP_CODE=$(curl -s -o /tmp/dependabot-body.json -w "%{http_code}" -D /tmp/dependabot-headers.txt \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $DEPENDABOT_PAT" \
"$URL")
if [ "$HTTP_CODE" != "200" ]; then
echo "::warning::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Writing empty results."
echo "[]" > dependabot-alerts.json
exit 0
fi
BODY=$(cat /tmp/dependabot-body.json)
COUNT=$(echo "$BODY" | jq 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
EXTRACTED=$(echo "$BODY" | jq '[.[] | {
id: (.security_advisory.cve_id // .security_advisory.ghsa_id // ""),
cve_id: (.security_advisory.cve_id // empty),
ghsa_id: (.security_advisory.ghsa_id // empty),
severity: (.security_advisory.severity // "medium"),
summary: (.security_advisory.summary // ""),
description: (.security_advisory.description // ""),
package_name: (.security_vulnerability.package.name // ""),
package_ecosystem: (.security_vulnerability.package.ecosystem // ""),
manifest_path: (.dependency.manifest_path // ""),
html_url: (.html_url // ""),
first_patched_version: (.security_vulnerability.first_patched_version.identifier // "")
}]')
ALL_ALERTS=$(echo "$ALL_ALERTS" "$EXTRACTED" | jq -s '.[0] + .[1]')
# Parse Link header for next page URL (cursor-based pagination)
URL=$(sed -n 's/.*<\([^>]*\)>; *rel="next".*/\1/p' /tmp/dependabot-headers.txt || true)
done
ALERT_COUNT=$(echo "$ALL_ALERTS" | jq 'length')
echo "Fetched $ALERT_COUNT Dependabot alert(s)"
echo "$ALL_ALERTS" > dependabot-alerts.json
- name: Write Dependabot fetch summary
run: |
echo "## Dependabot Alerts Fetched" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ ! -f dependabot-alerts.json ]; then
echo "No Dependabot alerts file found." >> "$GITHUB_STEP_SUMMARY"
else
COUNT=$(jq 'length' dependabot-alerts.json)
echo "**$COUNT** open Dependabot alert(s) fetched." >> "$GITHUB_STEP_SUMMARY"
if [ "$COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version |" >> "$GITHUB_STEP_SUMMARY"
echo "|------------|----------|---------|-----------|-----------------|" >> "$GITHUB_STEP_SUMMARY"
jq -r '.[] | "| \(.id) | \(.severity) | \(.package_name) | \(.package_ecosystem) | \(.first_patched_version // "N/A") |"' dependabot-alerts.json >> "$GITHUB_STEP_SUMMARY"
fi
fi
- name: Fetch CodeQL alerts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
ALL_ALERTS="[]"
PAGE=1
while true; do
RESPONSE=$(curl -s -w "\n%{http_code}" \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/code-scanning/alerts?state=open&per_page=100&page=$PAGE")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP_CODE" = "404" ]; then
echo "CodeQL is not enabled for this repository. Writing empty results."
echo "[]" > codeql-alerts.json
exit 0
fi
if [ "$HTTP_CODE" != "200" ]; then
echo "::warning::Failed to fetch CodeQL alerts (HTTP $HTTP_CODE). Writing empty results."
echo "[]" > codeql-alerts.json
exit 0
fi
COUNT=$(echo "$BODY" | jq 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
EXTRACTED=$(echo "$BODY" | jq '[.[] | {
id: ("codeql:" + (.rule.id // "")),
number: .number,
rule_id: (.rule.id // ""),
rule_description: (.rule.description // ""),
security_severity_level: (.rule.security_severity_level // "medium"),
tool_name: (.tool.name // ""),
location_path: (.most_recent_instance.location.path // ""),
location_start_line: (.most_recent_instance.location.start_line // 0),
location_end_line: (.most_recent_instance.location.end_line // 0),
html_url: (.html_url // ""),
state: (.state // "")
}]')
ALL_ALERTS=$(echo "$ALL_ALERTS" "$EXTRACTED" | jq -s '.[0] + .[1]')
if [ "$COUNT" -lt 100 ]; then
break
fi
PAGE=$((PAGE + 1))
done
ALERT_COUNT=$(echo "$ALL_ALERTS" | jq 'length')
echo "Fetched $ALERT_COUNT CodeQL alert(s)"
echo "$ALL_ALERTS" > codeql-alerts.json
- name: Write CodeQL fetch summary
run: |
echo "## CodeQL Alerts Fetched" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ ! -f codeql-alerts.json ]; then
echo "No CodeQL alerts file found." >> "$GITHUB_STEP_SUMMARY"
else
COUNT=$(jq 'length' codeql-alerts.json)
echo "**$COUNT** open CodeQL alert(s) fetched." >> "$GITHUB_STEP_SUMMARY"
if [ "$COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Rule ID | Severity | Tool | File | Lines |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|----------|------|------|-------|" >> "$GITHUB_STEP_SUMMARY"
jq -r '.[] | "| \(.id) | \(.security_severity_level) | \(.tool_name) | \(.location_path) | \(.location_start_line)-\(.location_end_line) |"' codeql-alerts.json >> "$GITHUB_STEP_SUMMARY"
fi
fi
- name: Analyze vulnerabilities with Claude
id: claude
uses: anthropics/claude-code-action@v1
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:<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"]}'
prompt: |
You are a security engineer triaging vulnerabilities and security findings for the Sourcebot Docker image.
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 <package> --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: \"<ID>\" } }) { nodes { id title state { type } } } }"}'
```
- Set `linearIssueExists` to `true` if any matching issue 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 `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 }}
run: |
CVE_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '.cves | length')
NEW_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == false)] | length')
EXISTING_COUNT=$(echo "$STRUCTURED_OUTPUT" | jq '[.cves[] | select(.linearIssueExists == true)] | length')
echo "## Claude Analysis" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**$CVE_COUNT** finding(s): **$NEW_COUNT** new, **$EXISTING_COUNT** already tracked in Linear." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| ID | Source | Severity | Package | Linear Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|----|--------|----------|---------|---------------|" >> "$GITHUB_STEP_SUMMARY"
echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "| \(.cveId) | \(.source) | \(.severity) | \(.affectedPackage) | \(if .linearIssueClosed then "Reopen" elif .linearIssueExists then "Existing" else "New" end) |"' >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Details" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "$STRUCTURED_OUTPUT" | jq -r '.cves[] | "#### \(.cveId): \(.title)\n\n\(.description)\n"' >> "$GITHUB_STEP_SUMMARY"
- name: Create Linear issues
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 }}
run: |
# Look up the "CVE" label ID and "Triage" state ID for the team
METADATA_QUERY='query($teamId: String!) { team(id: $teamId) { id labels(filter: { name: { eq: "CVE" } }) { nodes { id } } states(filter: { name: { eq: "Triage" } }) { nodes { 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')
if [ -z "$TEAM_UUID" ]; then
echo "::error::Could not resolve team UUID from LINEAR_TEAM_ID. Check the secret value."
exit 1
fi
if [ -z "$LABEL_ID" ]; then
echo "::warning::Could not find 'CVE' label in Linear team. Creating issues without label."
fi
if [ -z "$STATE_ID" ]; then
echo "::warning::Could not find 'Triage' state in Linear team. Using default state."
fi
# Map severity to Linear priority
severity_to_priority() {
case "$1" in
CRITICAL) echo 1 ;;
HIGH) echo 2 ;;
MEDIUM) echo 3 ;;
LOW) echo 4 ;;
*) echo 3 ;;
esac
}
CREATED_COUNT=0
SKIPPED_COUNT=0
REOPENED_COUNT=0
FAILED_COUNT=0
echo "## Linear Issue Creation" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Write CVEs to temp file so the while loop doesn't run in a pipe subshell
echo "$STRUCTURED_OUTPUT" | jq -c '.cves[]' > /tmp/cves.jsonl
MUTATION='mutation CreateIssue($teamId: String!, $title: String!, $description: String, $priority: Int, $labelIds: [String!], $stateId: String) { issueCreate(input: { teamId: $teamId, title: $title, description: $description, priority: $priority, labelIds: $labelIds, stateId: $stateId }) { success issue { id identifier url } } }'
while IFS= read -r cve; do
CVE_ID=$(echo "$cve" | jq -r '.cveId')
SEVERITY=$(echo "$cve" | jq -r '.severity')
TITLE=$(echo "$cve" | jq -r '.title')
DESCRIPTION=$(echo "$cve" | jq -r '.description')
LINEAR_EXISTS=$(echo "$cve" | jq -r '.linearIssueExists')
LINEAR_ISSUE_ID=$(echo "$cve" | jq -r '.linearIssueId')
LINEAR_CLOSED=$(echo "$cve" | jq -r '.linearIssueClosed')
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "false" ]; then
echo "Skipping $CVE_ID — Linear issue already exists and is open."
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "true" ]; then
# Reopen the closed issue by setting its state back to Triage
if [ -z "$STATE_ID" ]; then
echo "::warning::Cannot reopen $CVE_ID — no Triage state found. Skipping."
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
REOPEN_MUTATION='mutation($issueId: String!, $stateId: String!) { issueUpdate(id: $issueId, input: { stateId: $stateId }) { success issue { id identifier url } } }'
REOPEN_VARIABLES=$(jq -n \
--arg issueId "$LINEAR_ISSUE_ID" \
--arg stateId "$STATE_ID" \
'{issueId: $issueId, stateId: $stateId}')
REOPEN_PAYLOAD=$(jq -n --arg query "$REOPEN_MUTATION" --argjson vars "$REOPEN_VARIABLES" '{query: $query, variables: $vars}')
REOPEN_RESPONSE=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$REOPEN_PAYLOAD")
REOPEN_URL=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.url // empty')
if [ -n "$REOPEN_URL" ]; then
echo "Reopened Linear issue for $CVE_ID: $REOPEN_URL"
echo "- Reopened [$CVE_ID]($REOPEN_URL) (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY"
REOPENED_COUNT=$((REOPENED_COUNT + 1))
else
echo "::error::Failed to reopen Linear issue for $CVE_ID"
echo "$REOPEN_RESPONSE" | jq .
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
continue
fi
# Create new issue
PRIORITY=$(severity_to_priority "$SEVERITY")
ISSUE_TITLE="[$REPOSITORY] $CVE_ID: $TITLE"
# Build variables JSON with jq to handle all escaping properly
VARIABLES=$(jq -n \
--arg teamId "$TEAM_UUID" \
--arg title "$ISSUE_TITLE" \
--arg desc "$DESCRIPTION" \
--argjson priority "$PRIORITY" \
'{teamId: $teamId, title: $title, description: $desc, priority: $priority}')
if [ -n "$LABEL_ID" ]; then
VARIABLES=$(echo "$VARIABLES" | jq --arg lid "$LABEL_ID" '. + {labelIds: [$lid]}')
fi
if [ -n "$STATE_ID" ]; then
VARIABLES=$(echo "$VARIABLES" | jq --arg sid "$STATE_ID" '. + {stateId: $sid}')
fi
PAYLOAD=$(jq -n --arg query "$MUTATION" --argjson vars "$VARIABLES" '{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")
ISSUE_URL=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.url // empty')
if [ -n "$ISSUE_URL" ]; then
echo "Created Linear issue for $CVE_ID: $ISSUE_URL"
echo "- Created [$CVE_ID]($ISSUE_URL) (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY"
CREATED_COUNT=$((CREATED_COUNT + 1))
else
echo "::error::Failed to create Linear issue for $CVE_ID"
echo "$RESPONSE" | jq .
FAILED_COUNT=$((FAILED_COUNT + 1))
fi
done < /tmp/cves.jsonl
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Summary:** Created $CREATED_COUNT issue(s), reopened $REOPENED_COUNT issue(s), skipped $SKIPPED_COUNT existing issue(s), failed $FAILED_COUNT issue(s)." >> "$GITHUB_STEP_SUMMARY"
if [ "$FAILED_COUNT" -gt 0 ]; then
echo "::error::Failed to create $FAILED_COUNT Linear issue(s)"
exit 1
fi