Skip to content

Vulnerability Scan & Triage #14

Vulnerability Scan & Triage

Vulnerability Scan & Triage #14

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: latest)'
required: false
default: 'latest'
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 || 'latest' }}"
format: "table"
output: "trivy-results.txt"
trivy-config: trivy.yaml
- name: Check for vulnerabilities
id: check
run: |
if [ -s trivy-results.txt ] && grep -qE "Total: [1-9]" trivy-results.txt; 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.txt
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 || 'latest' }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.check.outputs.has_vulnerabilities }}" = "true" ]; then
echo "Vulnerabilities detected. Results uploaded as artifact." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "<details><summary>Trivy output</summary>" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
cat trivy-results.txt >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "</details>" >> "$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: Ensure Trivy results file exists
run: |
if [ ! -f trivy-results.txt ]; then
echo "No Trivy vulnerabilities found." > trivy-results.txt
fi
- 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="[]"
PAGE=1
while true; do
RESPONSE=$(curl -s -w "\n%{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=100&page=$PAGE")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
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
COUNT=$(echo "$BODY" | jq 'length')
if [ "$COUNT" -eq 0 ]; then
break
fi
EXTRACTED=$(echo "$BODY" | jq '[.[] | {
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]')
if [ "$COUNT" -lt 100 ]; then
break
fi
PAGE=$((PAGE + 1))
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 '.[] | "| \(.cve_id // .ghsa_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 '[.[] | {
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 '.[] | "| \(.rule_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"}},"required":["cveId","severity","source","title","description","affectedPackage","linearIssueExists"]}}},"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:
1. **Trivy scan results** in `trivy-results.txt` — container image vulnerability scan
2. **Dependabot alerts** in `dependabot-alerts.json` — GitHub dependency vulnerability alerts
3. **CodeQL alerts** in `codeql-alerts.json` — GitHub code scanning findings
## Your Task
1. Read and analyze all three data sources. For **each unique finding**, produce a separate entry
in the `cves` array.
2. **Deduplication**: Trivy and Dependabot may report the same CVE. If a CVE ID appears in both
Trivy results and Dependabot alerts, merge them into a single entry with `source: "trivy+dependabot"`.
Combine information from both sources in the description. CodeQL alerts are always unique — they
use rule IDs, not CVE IDs.
3. For **Trivy and Dependabot findings**:
- Use the CVE ID (e.g., `CVE-2024-1234`) as `cveId`. If a Dependabot alert only has a GHSA ID
and no CVE ID, use the GHSA ID (e.g., `GHSA-xxxx-xxxx-xxxx`) 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**:
- Use `codeql:<rule_id>` as the `cveId` (e.g., `codeql:js/sql-injection`).
- Set `source` to `"codeql"`.
- Include the file location (path and line numbers) and rule description in the `description`.
- Include the alert URL for reference.
- Use `affectedPackage` to indicate the file path where the issue was found.
- Normalize `security_severity_level` to uppercase (CRITICAL/HIGH/MEDIUM/LOW).
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` (whether CVE ID, GHSA ID, or `codeql:<rule_id>`), run a GraphQL query
against the Linear API to search for issues whose title contains that ID.
- 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 } } }"}'
```
- Set `linearIssueExists` to `true` if any matching issue is found, `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 .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
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')
if [ "$LINEAR_EXISTS" = "true" ]; then
echo "Skipping $CVE_ID — Linear issue already exists."
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
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), 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