-
Notifications
You must be signed in to change notification settings - Fork 264
700 lines (620 loc) · 36.4 KB
/
vulnerability-triage.yml
File metadata and controls
700 lines (620 loc) · 36.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
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
workflow_call:
inputs:
image:
description: 'Full Docker image to scan with Trivy (e.g., ghcr.io/org/repo). Leave empty to skip Trivy scanning.'
required: false
type: string
default: ''
image_tag:
description: 'Image tag to scan'
required: false
type: string
default: 'main'
dry_run:
required: false
type: boolean
default: false
force_analysis:
required: false
type: boolean
default: false
secrets:
ANTHROPIC_API_KEY:
required: true
LINEAR_API_KEY:
required: true
LINEAR_TEAM_ID:
required: true
DEPENDABOT_PAT:
required: 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
if: github.repository == 'sourcebot-dev/sourcebot' || inputs.image != ''
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: "${{ inputs.image || 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:** \`${{ inputs.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" >> "$GITHUB_STEP_SUMMARY"
echo "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=100")
DEPENDABOT_COUNT=$(echo "$DEPENDABOT_RESPONSE" | jq 'if type == "array" then length else 0 end')
echo "### Dependabot — $DEPENDABOT_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY"
if [ "$DEPENDABOT_COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| CVE / GHSA | Severity | Package | Ecosystem | Patched Version | Link |" >> "$GITHUB_STEP_SUMMARY"
echo "|------------|----------|---------|-----------|-----------------|------|" >> "$GITHUB_STEP_SUMMARY"
echo "$DEPENDABOT_RESPONSE" | jq -r '.[] | "| \(.security_advisory.cve_id // .security_advisory.ghsa_id // "—") | \(.security_advisory.severity // "—") | \(.security_vulnerability.package.name // "—") | \(.security_vulnerability.package.ecosystem // "—") | \(.security_vulnerability.first_patched_version.identifier // "N/A") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY"
fi
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
# 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=100")
if [ "$CODEQL_STATUS" = "404" ]; then
echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY"
echo "Not enabled for this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "$CODEQL_STATUS" = "200" ]; then
CODEQL_RESPONSE=$(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=100")
CODEQL_COUNT=$(echo "$CODEQL_RESPONSE" | jq 'length')
echo "### CodeQL — $CODEQL_COUNT open alert(s)" >> "$GITHUB_STEP_SUMMARY"
if [ "$CODEQL_COUNT" -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Rule ID | Severity | Tool | File | Lines | Link |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|----------|------|------|-------|------|" >> "$GITHUB_STEP_SUMMARY"
echo "$CODEQL_RESPONSE" | jq -r '.[] | "| \(.rule.id // "—") | \(.rule.security_severity_level // "—") | \(.tool.name // "—") | \(.most_recent_instance.location.path // "—") | \(.most_recent_instance.location.start_line // "—")-\(.most_recent_instance.location.end_line // "—") | [View](\(.html_url)) |"' >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "### CodeQL" >> "$GITHUB_STEP_SUMMARY"
echo "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: >-
always() && !cancelled() && (
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'
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")
echo "Dependabot API response: HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ]; then
echo "::warning::Failed to fetch Dependabot alerts (HTTP $HTTP_CODE). Writing empty results."
echo "Response body: $(cat /tmp/dependabot-body.json | head -c 500)"
echo "[]" > dependabot-alerts.json
exit 0
fi
BODY=$(cat /tmp/dependabot-body.json)
COUNT=$(echo "$BODY" | jq 'length')
echo "Page returned $COUNT alert(s)"
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 // null),
ghsa_id: (.security_advisory.ghsa_id // null),
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 // "")
}]')
EXTRACTED_COUNT=$(echo "$EXTRACTED" | jq 'length')
echo "Extracted $EXTRACTED_COUNT alert(s) after parsing"
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) total"
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"},"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 <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 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 }}
run: |
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 "" >> "$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"
echo "| ID | Source | Severity | Package | Linear Status | Linear Issue |" >> "$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 (skip)" else "New (create)" end) | \(if .linearIssueUrl != "" then "[\(.linearIssueIdentifier)](\(.linearIssueUrl))" else "—" 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_IDENTIFIER=$(echo "$cve" | jq -r '.linearIssueIdentifier')
LINEAR_URL=$(echo "$cve" | jq -r '.linearIssueUrl')
LINEAR_CLOSED=$(echo "$cve" | jq -r '.linearIssueClosed')
if [ "$LINEAR_EXISTS" = "true" ] && [ "$LINEAR_CLOSED" = "false" ]; then
echo "Skipping $CVE_ID — Linear issue $LINEAR_IDENTIFIER already exists and is open ($LINEAR_URL)"
echo "- Skipped **$CVE_ID** — already tracked in [$LINEAR_IDENTIFIER]($LINEAR_URL) (open)" >> "$GITHUB_STEP_SUMMARY"
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
echo "Found closed Linear issue $LINEAR_IDENTIFIER for $CVE_ID ($LINEAR_URL) — will attempt to reopen"
if [ -z "$STATE_ID" ]; then
echo "::warning::Cannot reopen $CVE_ID ($LINEAR_IDENTIFIER) — no Triage state found. Skipping."
echo "- Skipped **$CVE_ID** — found closed issue [$LINEAR_IDENTIFIER]($LINEAR_URL) but no Triage state to reopen" >> "$GITHUB_STEP_SUMMARY"
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')
REOPEN_IDENTIFIER=$(echo "$REOPEN_RESPONSE" | jq -r '.data.issueUpdate.issue.identifier // empty')
if [ -n "$REOPEN_URL" ]; then
echo "Reopened Linear issue $REOPEN_IDENTIFIER for $CVE_ID: $REOPEN_URL"
echo "- Reopened [$REOPEN_IDENTIFIER]($REOPEN_URL) for **$CVE_ID** — $TITLE (moved back to Triage)" >> "$GITHUB_STEP_SUMMARY"
REOPENED_COUNT=$((REOPENED_COUNT + 1))
else
echo "::error::Failed to reopen Linear issue $LINEAR_IDENTIFIER for $CVE_ID"
echo "$REOPEN_RESPONSE" | jq .
echo "- **FAILED** to reopen [$LINEAR_IDENTIFIER]($LINEAR_URL) for **$CVE_ID**" >> "$GITHUB_STEP_SUMMARY"
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')
ISSUE_IDENTIFIER=$(echo "$RESPONSE" | jq -r '.data.issueCreate.issue.identifier // empty')
if [ -n "$ISSUE_URL" ]; then
echo "Created Linear issue $ISSUE_IDENTIFIER for $CVE_ID: $ISSUE_URL"
echo "- Created [$ISSUE_IDENTIFIER]($ISSUE_URL) for **$CVE_ID** — $TITLE (priority: $SEVERITY)" >> "$GITHUB_STEP_SUMMARY"
CREATED_COUNT=$((CREATED_COUNT + 1))
else
echo "::error::Failed to create Linear issue for $CVE_ID"
echo "$RESPONSE" | jq .
echo "- **FAILED** to create issue for **$CVE_ID** — $TITLE" >> "$GITHUB_STEP_SUMMARY"
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