github-manage-security-alerts-skill 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections import Counter
5
+ from types import SimpleNamespace
6
+ from typing import Any
7
+
8
+ from github_security_api import RepoContext, api_request, safe_api_request
9
+ from github_security_common import (
10
+ GitHubSecurityCliError,
11
+ expect_dict,
12
+ expect_list,
13
+ filter_non_null_values,
14
+ normalize_repeated_values,
15
+ parse_name_value_pairs,
16
+ )
17
+
18
+
19
+ def build_code_scanning_query(arguments: Any) -> dict[str, Any]:
20
+ """Build query parameters for code scanning list calls."""
21
+
22
+ return filter_non_null_values(
23
+ {
24
+ "tool_name": arguments.tool_name,
25
+ "tool_guid": arguments.tool_guid,
26
+ "state": arguments.state,
27
+ "severity": arguments.severity,
28
+ "assignees": arguments.assignees,
29
+ "ref": arguments.ref,
30
+ "pr": arguments.pr,
31
+ "sort": arguments.sort,
32
+ "direction": arguments.direction,
33
+ "page": arguments.page,
34
+ "per_page": arguments.per_page,
35
+ }
36
+ )
37
+
38
+
39
+ def build_dependabot_query(arguments: Any) -> dict[str, Any]:
40
+ """Build query parameters for Dependabot list calls."""
41
+
42
+ return filter_non_null_values(
43
+ {
44
+ "state": arguments.state,
45
+ "severity": arguments.severity,
46
+ "ecosystem": arguments.ecosystem,
47
+ "package": arguments.package,
48
+ "manifest": arguments.manifest,
49
+ "epss_percentage": arguments.epss_percentage,
50
+ "has": arguments.has_filter,
51
+ "assignee": arguments.assignee,
52
+ "scope": arguments.scope,
53
+ "sort": arguments.sort,
54
+ "direction": arguments.direction,
55
+ "before": arguments.before,
56
+ "after": arguments.after,
57
+ "per_page": arguments.per_page,
58
+ }
59
+ )
60
+
61
+
62
+ def build_secret_scanning_query(arguments: Any) -> dict[str, Any]:
63
+ """Build query parameters for secret scanning list calls."""
64
+
65
+ return filter_non_null_values(
66
+ {
67
+ "state": arguments.state,
68
+ "secret_type": arguments.secret_type,
69
+ "resolution": arguments.resolution,
70
+ "assignee": arguments.assignee,
71
+ "validity": arguments.validity,
72
+ "is_publicly_leaked": (
73
+ True if arguments.is_publicly_leaked else None
74
+ ),
75
+ "is_multi_repo": True if arguments.is_multi_repo else None,
76
+ "hide_secret": False if arguments.show_secret_values else True,
77
+ "sort": arguments.sort,
78
+ "direction": arguments.direction,
79
+ "page": arguments.page,
80
+ "per_page": arguments.per_page,
81
+ }
82
+ )
83
+
84
+
85
+ def fetch_code_scanning_alerts(
86
+ context: RepoContext, query: dict[str, Any]
87
+ ) -> list[dict[str, Any]]:
88
+ """List code scanning alerts."""
89
+
90
+ response = api_request(
91
+ context,
92
+ endpoint=f"/repos/{context.owner}/{context.repo}/code-scanning/alerts",
93
+ params=query,
94
+ )
95
+ return expect_list(response.data, "code scanning alerts")
96
+
97
+
98
+ def fetch_code_scanning_alert(
99
+ context: RepoContext, alert_number: int
100
+ ) -> dict[str, Any]:
101
+ """Fetch one code scanning alert."""
102
+
103
+ response = api_request(
104
+ context,
105
+ endpoint=(
106
+ f"/repos/{context.owner}/{context.repo}/code-scanning/alerts/{alert_number}"
107
+ ),
108
+ )
109
+ return expect_dict(response.data, "code scanning alert")
110
+
111
+
112
+ def fetch_code_scanning_instances(
113
+ context: RepoContext,
114
+ alert_number: int,
115
+ *,
116
+ page: int,
117
+ per_page: int,
118
+ ) -> list[dict[str, Any]]:
119
+ """Fetch alert instances for one code scanning alert."""
120
+
121
+ response = api_request(
122
+ context,
123
+ endpoint=(
124
+ f"/repos/{context.owner}/{context.repo}/code-scanning/alerts/{alert_number}/instances"
125
+ ),
126
+ params={"page": page, "per_page": per_page},
127
+ )
128
+ return expect_list(response.data, "code scanning instances")
129
+
130
+
131
+ def fetch_code_scanning_autofix(
132
+ context: RepoContext, alert_number: int
133
+ ) -> dict[str, Any] | None:
134
+ """Fetch code scanning autofix status when available."""
135
+
136
+ result = safe_api_request(
137
+ context,
138
+ endpoint=(
139
+ f"/repos/{context.owner}/{context.repo}/code-scanning/alerts/{alert_number}/autofix"
140
+ ),
141
+ )
142
+ if not result["ok"]:
143
+ return {"error": result["error"]}
144
+ return expect_dict(result["data"], "code scanning autofix status")
145
+
146
+
147
+ def build_code_scanning_update_payload(arguments: Any) -> dict[str, Any]:
148
+ """Build a code scanning alert update payload."""
149
+
150
+ payload: dict[str, Any] = {"state": arguments.state}
151
+ assignees = normalize_repeated_values(arguments.assignees)
152
+
153
+ if arguments.state == "dismissed":
154
+ if arguments.dismissed_reason is None:
155
+ raise GitHubSecurityCliError(
156
+ "--dismissed-reason is required when dismissing a code scanning alert."
157
+ )
158
+ payload["dismissed_reason"] = arguments.dismissed_reason
159
+ if arguments.comment is not None:
160
+ payload["dismissed_comment"] = arguments.comment
161
+ if arguments.create_request:
162
+ payload["create_request"] = True
163
+ else:
164
+ if arguments.dismissed_reason is not None:
165
+ raise GitHubSecurityCliError(
166
+ "--dismissed-reason can only be used when state is dismissed."
167
+ )
168
+ if arguments.comment is not None:
169
+ raise GitHubSecurityCliError(
170
+ "--comment can only be used when state is dismissed for code scanning alerts."
171
+ )
172
+
173
+ if arguments.clear_assignees:
174
+ payload["assignees"] = []
175
+ elif assignees:
176
+ payload["assignees"] = assignees
177
+
178
+ return payload
179
+
180
+
181
+ def update_code_scanning_alert(
182
+ context: RepoContext, arguments: Any
183
+ ) -> dict[str, Any]:
184
+ """Dismiss, reopen, or reassign a code scanning alert."""
185
+
186
+ payload = build_code_scanning_update_payload(arguments)
187
+ if arguments.dry_run:
188
+ return {
189
+ "dry_run": True,
190
+ "endpoint": f"/repos/{context.owner}/{context.repo}/code-scanning/alerts/{arguments.alert}",
191
+ "payload": payload,
192
+ }
193
+
194
+ response = api_request(
195
+ context,
196
+ endpoint=(
197
+ f"/repos/{context.owner}/{context.repo}/code-scanning/alerts/{arguments.alert}"
198
+ ),
199
+ method="PATCH",
200
+ body=payload,
201
+ )
202
+ return expect_dict(response.data, "updated code scanning alert")
203
+
204
+
205
+ def fetch_dependabot_alerts(
206
+ context: RepoContext, query: dict[str, Any]
207
+ ) -> list[dict[str, Any]]:
208
+ """List Dependabot alerts."""
209
+
210
+ response = api_request(
211
+ context,
212
+ endpoint=f"/repos/{context.owner}/{context.repo}/dependabot/alerts",
213
+ params=query,
214
+ )
215
+ return expect_list(response.data, "Dependabot alerts")
216
+
217
+
218
+ def fetch_dependabot_alert(
219
+ context: RepoContext, alert_number: int
220
+ ) -> dict[str, Any]:
221
+ """Fetch one Dependabot alert."""
222
+
223
+ response = api_request(
224
+ context,
225
+ endpoint=f"/repos/{context.owner}/{context.repo}/dependabot/alerts/{alert_number}",
226
+ )
227
+ return expect_dict(response.data, "Dependabot alert")
228
+
229
+
230
+ def build_dependabot_update_payload(arguments: Any) -> dict[str, Any]:
231
+ """Build a Dependabot alert update payload."""
232
+
233
+ payload: dict[str, Any] = {"state": arguments.state}
234
+ assignees = normalize_repeated_values(arguments.assignees)
235
+
236
+ if arguments.state == "dismissed":
237
+ if arguments.dismissed_reason is None:
238
+ raise GitHubSecurityCliError(
239
+ "--dismissed-reason is required when dismissing a Dependabot alert."
240
+ )
241
+ payload["dismissed_reason"] = arguments.dismissed_reason
242
+ if arguments.comment is not None:
243
+ payload["dismissed_comment"] = arguments.comment
244
+ else:
245
+ if arguments.dismissed_reason is not None:
246
+ raise GitHubSecurityCliError(
247
+ "--dismissed-reason can only be used when state is dismissed."
248
+ )
249
+ if arguments.comment is not None:
250
+ raise GitHubSecurityCliError(
251
+ "--comment can only be used when state is dismissed for Dependabot alerts."
252
+ )
253
+
254
+ if arguments.clear_assignees:
255
+ payload["assignees"] = []
256
+ elif assignees:
257
+ payload["assignees"] = assignees
258
+
259
+ return payload
260
+
261
+
262
+ def update_dependabot_alert(
263
+ context: RepoContext, arguments: Any
264
+ ) -> dict[str, Any]:
265
+ """Dismiss, reopen, or reassign a Dependabot alert."""
266
+
267
+ payload = build_dependabot_update_payload(arguments)
268
+ if arguments.dry_run:
269
+ return {
270
+ "dry_run": True,
271
+ "endpoint": f"/repos/{context.owner}/{context.repo}/dependabot/alerts/{arguments.alert}",
272
+ "payload": payload,
273
+ }
274
+
275
+ response = api_request(
276
+ context,
277
+ endpoint=f"/repos/{context.owner}/{context.repo}/dependabot/alerts/{arguments.alert}",
278
+ method="PATCH",
279
+ body=payload,
280
+ )
281
+ return expect_dict(response.data, "updated Dependabot alert")
282
+
283
+
284
+ def fetch_global_advisory_type(
285
+ context: RepoContext,
286
+ ghsa_id: str,
287
+ advisory_type_cache: dict[str, dict[str, Any] | None],
288
+ ) -> dict[str, Any] | None:
289
+ """Fetch one global advisory so malware alerts can be classified."""
290
+
291
+ cached_result = advisory_type_cache.get(ghsa_id)
292
+ if cached_result is not None or ghsa_id in advisory_type_cache:
293
+ return cached_result
294
+
295
+ result = safe_api_request(context, endpoint=f"/advisories/{ghsa_id}")
296
+ if not result["ok"]:
297
+ advisory_type_cache[ghsa_id] = {
298
+ "error": result["error"],
299
+ "ghsa_id": ghsa_id,
300
+ }
301
+ return advisory_type_cache[ghsa_id]
302
+
303
+ advisory = expect_dict(result["data"], f"advisory {ghsa_id}")
304
+ advisory_type_cache[ghsa_id] = advisory
305
+ return advisory
306
+
307
+
308
+ def get_alert_ghsa_id(alert: dict[str, Any]) -> str | None:
309
+ """Extract a GHSA identifier from a Dependabot alert payload."""
310
+
311
+ advisory = alert.get("security_advisory")
312
+ if isinstance(advisory, dict):
313
+ ghsa_id = advisory.get("ghsa_id")
314
+ if isinstance(ghsa_id, str) and ghsa_id.strip():
315
+ return ghsa_id.strip()
316
+
317
+ identifiers = advisory.get("identifiers")
318
+ if isinstance(identifiers, list):
319
+ for identifier in identifiers:
320
+ if not isinstance(identifier, dict):
321
+ continue
322
+ if identifier.get("type") != "GHSA":
323
+ continue
324
+ value = identifier.get("value")
325
+ if isinstance(value, str) and value.strip():
326
+ return value.strip()
327
+
328
+ return None
329
+
330
+
331
+ def classify_malware_alerts(
332
+ context: RepoContext,
333
+ alerts: list[dict[str, Any]],
334
+ advisory_cache: dict[str, dict[str, Any] | None] | None = None,
335
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
336
+ """Filter Dependabot alerts to those backed by malware advisories."""
337
+
338
+ malware_alerts: list[dict[str, Any]] = []
339
+ lookup_failures: list[dict[str, Any]] = []
340
+ cache = advisory_cache if advisory_cache is not None else {}
341
+
342
+ for alert in alerts:
343
+ ghsa_id = get_alert_ghsa_id(alert)
344
+ if ghsa_id is None:
345
+ continue
346
+
347
+ advisory = fetch_global_advisory_type(context, ghsa_id, cache)
348
+ if advisory is None:
349
+ continue
350
+ if "error" in advisory:
351
+ lookup_failures.append(
352
+ {
353
+ "alert_number": alert.get("number"),
354
+ "ghsa_id": ghsa_id,
355
+ "error": advisory["error"],
356
+ }
357
+ )
358
+ continue
359
+
360
+ if advisory.get("type") != "malware":
361
+ continue
362
+
363
+ malware_alert = dict(alert)
364
+ malware_alert["malware_advisory"] = advisory
365
+ malware_alerts.append(malware_alert)
366
+
367
+ return malware_alerts, lookup_failures
368
+
369
+
370
+ def maybe_raise_if_not_malware(
371
+ context: RepoContext,
372
+ alert_number: int,
373
+ advisory_cache: dict[str, dict[str, Any] | None],
374
+ ) -> dict[str, Any]:
375
+ """Ensure that one Dependabot alert is backed by a malware advisory."""
376
+
377
+ alert = fetch_dependabot_alert(context, alert_number)
378
+ ghsa_id = get_alert_ghsa_id(alert)
379
+ if ghsa_id is None:
380
+ raise GitHubSecurityCliError(
381
+ "Dependabot alert "
382
+ f"{alert_number} does not expose a GHSA identifier, so it "
383
+ "cannot be confirmed as a malware alert."
384
+ )
385
+
386
+ advisory = fetch_global_advisory_type(context, ghsa_id, advisory_cache)
387
+ if advisory is None or "error" in advisory:
388
+ raise GitHubSecurityCliError(
389
+ f"Could not verify malware advisory type for alert {alert_number} (GHSA {ghsa_id})."
390
+ )
391
+
392
+ if advisory.get("type") != "malware":
393
+ raise GitHubSecurityCliError(
394
+ "Dependabot alert "
395
+ f"{alert_number} is not backed by a malware advisory "
396
+ f"(type={advisory.get('type')})."
397
+ )
398
+
399
+ enriched_alert = dict(alert)
400
+ enriched_alert["malware_advisory"] = advisory
401
+ return enriched_alert
402
+
403
+
404
+ def fetch_secret_scanning_alerts(
405
+ context: RepoContext, query: dict[str, Any]
406
+ ) -> list[dict[str, Any]]:
407
+ """List secret scanning alerts."""
408
+
409
+ response = api_request(
410
+ context,
411
+ endpoint=f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts",
412
+ params=query,
413
+ )
414
+ return expect_list(response.data, "secret scanning alerts")
415
+
416
+
417
+ def fetch_secret_scanning_alert(
418
+ context: RepoContext,
419
+ *,
420
+ alert_number: int,
421
+ show_secret_values: bool,
422
+ ) -> dict[str, Any]:
423
+ """Fetch one secret scanning alert."""
424
+
425
+ response = api_request(
426
+ context,
427
+ endpoint=(
428
+ f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts/{alert_number}"
429
+ ),
430
+ params={"hide_secret": False if show_secret_values else True},
431
+ )
432
+ return expect_dict(response.data, "secret scanning alert")
433
+
434
+
435
+ def build_secret_scanning_update_payload(arguments: Any) -> dict[str, Any]:
436
+ """Build a secret scanning alert update payload."""
437
+
438
+ payload: dict[str, Any] = {"state": arguments.state}
439
+
440
+ if arguments.state == "resolved":
441
+ if arguments.resolution is None:
442
+ raise GitHubSecurityCliError(
443
+ "--resolution is required when resolving a secret scanning alert."
444
+ )
445
+ payload["resolution"] = arguments.resolution
446
+ if arguments.comment is not None:
447
+ payload["resolution_comment"] = arguments.comment
448
+ else:
449
+ if arguments.resolution is not None:
450
+ raise GitHubSecurityCliError(
451
+ "--resolution can only be used when state is resolved."
452
+ )
453
+ if arguments.comment is not None:
454
+ payload["resolution_comment"] = arguments.comment
455
+
456
+ if arguments.unassign:
457
+ if arguments.assignee is not None:
458
+ raise GitHubSecurityCliError(
459
+ "Use either --assignee or --unassign, but not both."
460
+ )
461
+ payload["assignee"] = None
462
+ elif arguments.assignee is not None:
463
+ payload["assignee"] = arguments.assignee
464
+
465
+ return payload
466
+
467
+
468
+ def update_secret_scanning_alert(
469
+ context: RepoContext, arguments: Any
470
+ ) -> dict[str, Any]:
471
+ """Resolve, reopen, or reassign a secret scanning alert."""
472
+
473
+ payload = build_secret_scanning_update_payload(arguments)
474
+ if arguments.dry_run:
475
+ return {
476
+ "dry_run": True,
477
+ "endpoint": f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts/{arguments.alert}",
478
+ "payload": payload,
479
+ }
480
+
481
+ response = api_request(
482
+ context,
483
+ endpoint=(
484
+ f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts/{arguments.alert}"
485
+ ),
486
+ method="PATCH",
487
+ body=payload,
488
+ )
489
+ return expect_dict(response.data, "updated secret scanning alert")
490
+
491
+
492
+ def fetch_secret_locations(
493
+ context: RepoContext,
494
+ *,
495
+ alert_number: int,
496
+ page: int,
497
+ per_page: int,
498
+ ) -> list[dict[str, Any]]:
499
+ """Fetch secret scanning locations for one alert."""
500
+
501
+ response = api_request(
502
+ context,
503
+ endpoint=(
504
+ f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts/{alert_number}/locations"
505
+ ),
506
+ params={"page": page, "per_page": per_page},
507
+ )
508
+ return expect_list(response.data, "secret scanning locations")
509
+
510
+
511
+ def fetch_secret_scan_history(context: RepoContext) -> dict[str, Any]:
512
+ """Fetch the secret scanning scan history for the repository."""
513
+
514
+ response = api_request(
515
+ context,
516
+ endpoint=f"/repos/{context.owner}/{context.repo}/secret-scanning/scan-history",
517
+ )
518
+ return expect_dict(response.data, "secret scanning scan history")
519
+
520
+
521
+ def fetch_repository_overview(context: RepoContext) -> dict[str, Any]:
522
+ """Fetch repository metadata and security_and_analysis settings."""
523
+
524
+ response = api_request(
525
+ context, endpoint=f"/repos/{context.owner}/{context.repo}"
526
+ )
527
+ repository = expect_dict(response.data, "repository overview")
528
+ return {
529
+ "api_base_url": context.api_base_url,
530
+ "full_name": context.full_name,
531
+ "html_url": repository.get("html_url"),
532
+ "private": repository.get("private"),
533
+ "security_and_analysis": repository.get("security_and_analysis"),
534
+ "visibility": repository.get("visibility"),
535
+ "web_base_url": context.web_base_url,
536
+ }
537
+
538
+
539
+ def summarize_alert_collection(
540
+ result: dict[str, Any], *, sample_size: int, summary_kind: str
541
+ ) -> dict[str, Any]:
542
+ """Convert a safe list-call result into a compact summary payload."""
543
+
544
+ if not result["ok"]:
545
+ return {"error": result["error"], "ok": False}
546
+
547
+ alerts = expect_list(result["data"], f"{summary_kind} alerts")
548
+ counts_by_state = Counter(
549
+ str(alert.get("state", "unknown")) for alert in alerts
550
+ )
551
+ return {
552
+ "counts_by_state": dict(counts_by_state),
553
+ "ok": True,
554
+ "sample_alerts": alerts[:sample_size],
555
+ "total": len(alerts),
556
+ }
557
+
558
+
559
+ def build_summary(context: RepoContext, arguments: Any) -> dict[str, Any]:
560
+ """Build a cross-surface security summary for the repository."""
561
+
562
+ code_scanning_result = safe_api_request(
563
+ context,
564
+ endpoint=f"/repos/{context.owner}/{context.repo}/code-scanning/alerts",
565
+ params={"page": 1, "per_page": arguments.per_page},
566
+ )
567
+ dependabot_result = safe_api_request(
568
+ context,
569
+ endpoint=f"/repos/{context.owner}/{context.repo}/dependabot/alerts",
570
+ params={"per_page": arguments.per_page},
571
+ )
572
+ secret_scanning_result = safe_api_request(
573
+ context,
574
+ endpoint=f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts",
575
+ params={
576
+ "page": 1,
577
+ "per_page": arguments.per_page,
578
+ "hide_secret": True,
579
+ },
580
+ )
581
+
582
+ advisory_cache: dict[str, dict[str, Any] | None] = {}
583
+
584
+ summary_sections: dict[str, Any] = {
585
+ "code_scanning": summarize_alert_collection(
586
+ code_scanning_result,
587
+ sample_size=arguments.sample_size,
588
+ summary_kind="code_scanning",
589
+ ),
590
+ "dependabot": summarize_alert_collection(
591
+ dependabot_result,
592
+ sample_size=arguments.sample_size,
593
+ summary_kind="dependabot",
594
+ ),
595
+ "secret_scanning": summarize_alert_collection(
596
+ secret_scanning_result,
597
+ sample_size=arguments.sample_size,
598
+ summary_kind="secret_scanning",
599
+ ),
600
+ }
601
+
602
+ summary: dict[str, Any] = {
603
+ "api_base_url": context.api_base_url,
604
+ "full_name": context.full_name,
605
+ "repository_html_url": f"{context.web_base_url}/{context.full_name}",
606
+ "token_env": context.token_env_name,
607
+ "sections": summary_sections,
608
+ }
609
+
610
+ if dependabot_result["ok"]:
611
+ dependabot_alerts = expect_list(
612
+ dependabot_result["data"], "Dependabot alerts"
613
+ )
614
+ malware_alerts, lookup_failures = classify_malware_alerts(
615
+ context,
616
+ dependabot_alerts,
617
+ advisory_cache,
618
+ )
619
+ summary_sections["malware"] = {
620
+ "counts_by_state": dict(
621
+ Counter(
622
+ str(alert.get("state", "unknown"))
623
+ for alert in malware_alerts
624
+ )
625
+ ),
626
+ "lookup_failures": lookup_failures,
627
+ "ok": True,
628
+ "sample_alerts": malware_alerts[: arguments.sample_size],
629
+ "total": len(malware_alerts),
630
+ }
631
+ else:
632
+ summary_sections["malware"] = {
633
+ "depends_on": "dependabot",
634
+ "error": dependabot_result["error"],
635
+ "ok": False,
636
+ }
637
+
638
+ return summary
639
+
640
+
641
+ def build_export_alerts(
642
+ context: RepoContext, arguments: Any
643
+ ) -> dict[str, Any]:
644
+ """Export full alert collections for bulk triage workflows."""
645
+
646
+ overview = fetch_repository_overview(context)
647
+ code_scanning_result = safe_api_request(
648
+ context,
649
+ endpoint=f"/repos/{context.owner}/{context.repo}/code-scanning/alerts",
650
+ params={
651
+ "page": 1,
652
+ "per_page": arguments.per_page,
653
+ "state": arguments.code_scanning_state,
654
+ },
655
+ )
656
+ dependabot_result = safe_api_request(
657
+ context,
658
+ endpoint=f"/repos/{context.owner}/{context.repo}/dependabot/alerts",
659
+ params={
660
+ "per_page": arguments.per_page,
661
+ "state": arguments.dependabot_state,
662
+ },
663
+ )
664
+ secret_scanning_result = safe_api_request(
665
+ context,
666
+ endpoint=f"/repos/{context.owner}/{context.repo}/secret-scanning/alerts",
667
+ params={
668
+ "page": 1,
669
+ "per_page": arguments.per_page,
670
+ "state": arguments.secret_scanning_state,
671
+ "hide_secret": False if arguments.show_secret_values else True,
672
+ },
673
+ )
674
+
675
+ export_sections: dict[str, Any] = {
676
+ "code_scanning": code_scanning_result,
677
+ "dependabot": dependabot_result,
678
+ "secret_scanning": secret_scanning_result,
679
+ }
680
+
681
+ export_bundle: dict[str, Any] = {
682
+ "api_base_url": context.api_base_url,
683
+ "full_name": context.full_name,
684
+ "repository": overview,
685
+ "sections": export_sections,
686
+ "token_env": context.token_env_name,
687
+ }
688
+
689
+ if dependabot_result["ok"]:
690
+ advisories_cache: dict[str, dict[str, Any] | None] = {}
691
+ dependabot_alerts = expect_list(
692
+ dependabot_result["data"], "Dependabot alerts"
693
+ )
694
+ malware_alerts, lookup_failures = classify_malware_alerts(
695
+ context,
696
+ dependabot_alerts,
697
+ advisories_cache,
698
+ )
699
+ export_sections["malware"] = {
700
+ "alerts": malware_alerts,
701
+ "lookup_failures": lookup_failures,
702
+ "ok": True,
703
+ "total": len(malware_alerts),
704
+ }
705
+ else:
706
+ export_sections["malware"] = {
707
+ "depends_on": "dependabot",
708
+ "error": dependabot_result["error"],
709
+ "ok": False,
710
+ }
711
+
712
+ return export_bundle
713
+
714
+
715
+ def build_bulk_code_scanning_query(arguments: Any) -> dict[str, Any]:
716
+ """Build selection query parameters for bulk code-scanning updates."""
717
+
718
+ return filter_non_null_values(
719
+ {
720
+ "tool_name": arguments.tool_name,
721
+ "tool_guid": arguments.tool_guid,
722
+ "state": arguments.select_state,
723
+ "severity": arguments.severity,
724
+ "assignees": arguments.assignee_filter,
725
+ "ref": arguments.ref,
726
+ "pr": arguments.pr,
727
+ "sort": arguments.sort,
728
+ "direction": arguments.direction,
729
+ "page": arguments.page,
730
+ "per_page": arguments.per_page,
731
+ }
732
+ )
733
+
734
+
735
+ def build_bulk_dependabot_query(arguments: Any) -> dict[str, Any]:
736
+ """Build selection query parameters for bulk Dependabot updates."""
737
+
738
+ return filter_non_null_values(
739
+ {
740
+ "state": arguments.select_state,
741
+ "severity": arguments.severity,
742
+ "ecosystem": arguments.ecosystem,
743
+ "package": arguments.package,
744
+ "manifest": arguments.manifest,
745
+ "epss_percentage": arguments.epss_percentage,
746
+ "has": arguments.has_filter,
747
+ "assignee": arguments.assignee_filter,
748
+ "scope": arguments.scope,
749
+ "sort": arguments.sort,
750
+ "direction": arguments.direction,
751
+ "before": arguments.before,
752
+ "after": arguments.after,
753
+ "per_page": arguments.per_page,
754
+ }
755
+ )
756
+
757
+
758
+ def build_bulk_secret_scanning_query(arguments: Any) -> dict[str, Any]:
759
+ """Build selection query parameters for bulk secret-scanning updates."""
760
+
761
+ return filter_non_null_values(
762
+ {
763
+ "state": arguments.select_state,
764
+ "secret_type": arguments.secret_type,
765
+ "resolution": arguments.resolution_filter,
766
+ "assignee": arguments.assignee_filter,
767
+ "validity": arguments.validity,
768
+ "is_publicly_leaked": (
769
+ True if arguments.is_publicly_leaked else None
770
+ ),
771
+ "is_multi_repo": True if arguments.is_multi_repo else None,
772
+ "hide_secret": False if arguments.show_secret_values else True,
773
+ "sort": arguments.sort,
774
+ "direction": arguments.direction,
775
+ "page": arguments.page,
776
+ "per_page": arguments.per_page,
777
+ }
778
+ )
779
+
780
+
781
+ def get_bulk_mutation_target_state(
782
+ *, arguments: Any, current_state: str, surface: str
783
+ ) -> str:
784
+ """Resolve the target state for one bulk alert mutation."""
785
+
786
+ target_state = arguments.target_state or current_state
787
+ allowed_states_by_surface = {
788
+ "code-scanning": {"open", "dismissed"},
789
+ "dependabot": {"open", "dismissed"},
790
+ "malware": {"open", "dismissed"},
791
+ "secret-scanning": {"open", "resolved"},
792
+ }
793
+
794
+ allowed_states = allowed_states_by_surface[surface]
795
+ if target_state not in allowed_states:
796
+ raise GitHubSecurityCliError(
797
+ f"Surface '{surface}' does not support target state '{target_state}'."
798
+ )
799
+
800
+ return target_state
801
+
802
+
803
+ def build_bulk_update_namespace(
804
+ *, alert: dict[str, Any], arguments: Any, surface: str
805
+ ) -> SimpleNamespace:
806
+ """Build an update namespace for one selected bulk alert mutation."""
807
+
808
+ current_state = str(alert.get("state", "unknown"))
809
+ target_state = get_bulk_mutation_target_state(
810
+ arguments=arguments,
811
+ current_state=current_state,
812
+ surface=surface,
813
+ )
814
+ alert_number = alert.get("number")
815
+ if not isinstance(alert_number, int):
816
+ raise GitHubSecurityCliError(
817
+ f"Selected alert is missing an integer alert number for surface '{surface}'."
818
+ )
819
+
820
+ if surface == "code-scanning":
821
+ return SimpleNamespace(
822
+ alert=alert_number,
823
+ assignees=arguments.assignees,
824
+ clear_assignees=arguments.clear_assignees,
825
+ comment=arguments.comment,
826
+ create_request=arguments.create_request,
827
+ dismissed_reason=arguments.dismissed_reason,
828
+ dry_run=arguments.dry_run,
829
+ state=target_state,
830
+ )
831
+
832
+ if surface in {"dependabot", "malware"}:
833
+ return SimpleNamespace(
834
+ alert=alert_number,
835
+ assignees=arguments.assignees,
836
+ clear_assignees=arguments.clear_assignees,
837
+ comment=arguments.comment,
838
+ dismissed_reason=arguments.dismissed_reason,
839
+ dry_run=arguments.dry_run,
840
+ state=target_state,
841
+ )
842
+
843
+ if arguments.assignees is not None and len(arguments.assignees) > 1:
844
+ raise GitHubSecurityCliError(
845
+ "Secret scanning bulk updates accept at most one --assignee value."
846
+ )
847
+
848
+ return SimpleNamespace(
849
+ alert=alert_number,
850
+ assignee=(arguments.assignees[0] if arguments.assignees else None),
851
+ comment=arguments.comment,
852
+ dry_run=arguments.dry_run,
853
+ resolution=arguments.resolution,
854
+ state=target_state,
855
+ unassign=arguments.clear_assignees,
856
+ )
857
+
858
+
859
+ def summarize_selected_alert(
860
+ alert: dict[str, Any], surface: str
861
+ ) -> dict[str, Any]:
862
+ """Create a compact selected-alert summary for bulk results."""
863
+
864
+ summary = {
865
+ "alert_number": alert.get("number"),
866
+ "html_url": alert.get("html_url"),
867
+ "state": alert.get("state"),
868
+ "surface": surface,
869
+ }
870
+
871
+ if surface == "code-scanning":
872
+ rule = alert.get("rule")
873
+ if isinstance(rule, dict):
874
+ summary["rule_id"] = rule.get("id") or rule.get("name")
875
+ summary["severity"] = rule.get(
876
+ "security_severity_level"
877
+ ) or rule.get("severity")
878
+ return summary
879
+
880
+ if surface in {"dependabot", "malware"}:
881
+ vulnerability = alert.get("security_vulnerability")
882
+ if isinstance(vulnerability, dict):
883
+ package = vulnerability.get("package")
884
+ if isinstance(package, dict):
885
+ summary["package"] = package.get("name")
886
+ summary["ecosystem"] = package.get("ecosystem")
887
+ summary["severity"] = vulnerability.get("severity")
888
+ dependency = alert.get("dependency")
889
+ if isinstance(dependency, dict):
890
+ summary["manifest_path"] = dependency.get("manifest_path")
891
+ return summary
892
+
893
+ summary["secret_type"] = alert.get("secret_type")
894
+ summary["resolution"] = alert.get("resolution")
895
+ return summary
896
+
897
+
898
+ def select_bulk_alerts(
899
+ context: RepoContext, arguments: Any
900
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
901
+ """Resolve the alerts targeted by a bulk-update command."""
902
+
903
+ surface = arguments.surface
904
+ advisory_cache: dict[str, dict[str, Any] | None] = {}
905
+ lookup_failures: list[dict[str, Any]] = []
906
+
907
+ if arguments.alerts:
908
+ selected_alerts: list[dict[str, Any]] = []
909
+ for alert_number in arguments.alerts:
910
+ if surface == "code-scanning":
911
+ selected_alerts.append(
912
+ fetch_code_scanning_alert(context, alert_number)
913
+ )
914
+ continue
915
+ if surface == "dependabot":
916
+ selected_alerts.append(
917
+ fetch_dependabot_alert(context, alert_number)
918
+ )
919
+ continue
920
+ if surface == "malware":
921
+ if arguments.skip_malware_check:
922
+ selected_alerts.append(
923
+ fetch_dependabot_alert(context, alert_number)
924
+ )
925
+ else:
926
+ selected_alerts.append(
927
+ maybe_raise_if_not_malware(
928
+ context, alert_number, advisory_cache
929
+ )
930
+ )
931
+ continue
932
+ selected_alerts.append(
933
+ fetch_secret_scanning_alert(
934
+ context,
935
+ alert_number=alert_number,
936
+ show_secret_values=arguments.show_secret_values,
937
+ )
938
+ )
939
+ return selected_alerts, lookup_failures
940
+
941
+ if surface == "code-scanning":
942
+ return (
943
+ fetch_code_scanning_alerts(
944
+ context, build_bulk_code_scanning_query(arguments)
945
+ ),
946
+ lookup_failures,
947
+ )
948
+
949
+ if surface == "dependabot":
950
+ return (
951
+ fetch_dependabot_alerts(
952
+ context, build_bulk_dependabot_query(arguments)
953
+ ),
954
+ lookup_failures,
955
+ )
956
+
957
+ if surface == "malware":
958
+ dependabot_alerts = fetch_dependabot_alerts(
959
+ context, build_bulk_dependabot_query(arguments)
960
+ )
961
+ malware_alerts, lookup_failures = classify_malware_alerts(
962
+ context,
963
+ dependabot_alerts,
964
+ advisory_cache,
965
+ )
966
+ return malware_alerts, lookup_failures
967
+
968
+ return (
969
+ fetch_secret_scanning_alerts(
970
+ context, build_bulk_secret_scanning_query(arguments)
971
+ ),
972
+ lookup_failures,
973
+ )
974
+
975
+
976
+ def bulk_update_alerts(context: RepoContext, arguments: Any) -> dict[str, Any]:
977
+ """Bulk update alerts across one selected GitHub security surface."""
978
+
979
+ selected_alerts, lookup_failures = select_bulk_alerts(context, arguments)
980
+ if arguments.limit is not None:
981
+ selected_alerts = selected_alerts[: arguments.limit]
982
+
983
+ selected_summaries = [
984
+ summarize_selected_alert(alert, arguments.surface)
985
+ for alert in selected_alerts
986
+ ]
987
+
988
+ if arguments.dry_run:
989
+ preview_updates = [
990
+ build_bulk_update_namespace(
991
+ alert=alert,
992
+ arguments=arguments,
993
+ surface=arguments.surface,
994
+ ).__dict__
995
+ for alert in selected_alerts
996
+ ]
997
+ return {
998
+ "dry_run": True,
999
+ "lookup_failures": lookup_failures,
1000
+ "selected_alerts": selected_summaries,
1001
+ "selected_count": len(selected_alerts),
1002
+ "surface": arguments.surface,
1003
+ "update_requests": preview_updates,
1004
+ }
1005
+
1006
+ successes: list[dict[str, Any]] = []
1007
+ failures: list[dict[str, Any]] = []
1008
+
1009
+ for alert in selected_alerts:
1010
+ try:
1011
+ update_args = build_bulk_update_namespace(
1012
+ alert=alert,
1013
+ arguments=arguments,
1014
+ surface=arguments.surface,
1015
+ )
1016
+ if arguments.surface == "code-scanning":
1017
+ result = update_code_scanning_alert(context, update_args)
1018
+ elif arguments.surface in {"dependabot", "malware"}:
1019
+ result = update_dependabot_alert(context, update_args)
1020
+ else:
1021
+ result = update_secret_scanning_alert(context, update_args)
1022
+ successes.append(result)
1023
+ except GitHubSecurityCliError as exc:
1024
+ failures.append(
1025
+ {
1026
+ "alert_number": alert.get("number"),
1027
+ "message": str(exc),
1028
+ "type": type(exc).__name__,
1029
+ }
1030
+ )
1031
+
1032
+ return {
1033
+ "failures": failures,
1034
+ "lookup_failures": lookup_failures,
1035
+ "selected_alerts": selected_summaries,
1036
+ "selected_count": len(selected_alerts),
1037
+ "success_count": len(successes),
1038
+ "surface": arguments.surface,
1039
+ "updated_alerts": successes,
1040
+ }
1041
+
1042
+
1043
+ def handle_command(context: RepoContext, arguments: Any) -> Any:
1044
+ """Dispatch the parsed command."""
1045
+
1046
+ command = arguments.command
1047
+
1048
+ if command == "summary":
1049
+ return build_summary(context, arguments)
1050
+
1051
+ if command == "repo-security-overview":
1052
+ return fetch_repository_overview(context)
1053
+
1054
+ if command == "export-alerts":
1055
+ return build_export_alerts(context, arguments)
1056
+
1057
+ if command == "bulk-update-alerts":
1058
+ return bulk_update_alerts(context, arguments)
1059
+
1060
+ if command == "list-code-scanning":
1061
+ return fetch_code_scanning_alerts(
1062
+ context, build_code_scanning_query(arguments)
1063
+ )
1064
+
1065
+ if command == "show-code-scanning":
1066
+ alert = fetch_code_scanning_alert(context, arguments.alert)
1067
+ if arguments.include_instances:
1068
+ alert["instances"] = fetch_code_scanning_instances(
1069
+ context,
1070
+ arguments.alert,
1071
+ page=1,
1072
+ per_page=arguments.instances_per_page,
1073
+ )
1074
+ if arguments.include_autofix:
1075
+ alert["autofix"] = fetch_code_scanning_autofix(
1076
+ context, arguments.alert
1077
+ )
1078
+ return alert
1079
+
1080
+ if command == "update-code-scanning":
1081
+ return update_code_scanning_alert(context, arguments)
1082
+
1083
+ if command == "list-dependabot":
1084
+ return fetch_dependabot_alerts(
1085
+ context, build_dependabot_query(arguments)
1086
+ )
1087
+
1088
+ if command == "show-dependabot":
1089
+ return fetch_dependabot_alert(context, arguments.alert)
1090
+
1091
+ if command == "update-dependabot":
1092
+ return update_dependabot_alert(context, arguments)
1093
+
1094
+ if command == "list-malware":
1095
+ advisories_cache: dict[str, dict[str, Any] | None] = {}
1096
+ alerts = fetch_dependabot_alerts(
1097
+ context, build_dependabot_query(arguments)
1098
+ )
1099
+ malware_alerts, lookup_failures = classify_malware_alerts(
1100
+ context,
1101
+ alerts,
1102
+ advisories_cache,
1103
+ )
1104
+ return {
1105
+ "alerts": malware_alerts,
1106
+ "lookup_failures": lookup_failures,
1107
+ "total": len(malware_alerts),
1108
+ }
1109
+
1110
+ if command == "show-malware":
1111
+ return maybe_raise_if_not_malware(context, arguments.alert, {})
1112
+
1113
+ if command == "update-malware":
1114
+ if not arguments.skip_malware_check:
1115
+ maybe_raise_if_not_malware(context, arguments.alert, {})
1116
+ return update_dependabot_alert(context, arguments)
1117
+
1118
+ if command == "list-secret-scanning":
1119
+ return fetch_secret_scanning_alerts(
1120
+ context, build_secret_scanning_query(arguments)
1121
+ )
1122
+
1123
+ if command == "show-secret-scanning":
1124
+ return fetch_secret_scanning_alert(
1125
+ context,
1126
+ alert_number=arguments.alert,
1127
+ show_secret_values=arguments.show_secret_values,
1128
+ )
1129
+
1130
+ if command == "update-secret-scanning":
1131
+ return update_secret_scanning_alert(context, arguments)
1132
+
1133
+ if command == "list-secret-locations":
1134
+ return fetch_secret_locations(
1135
+ context,
1136
+ alert_number=arguments.alert,
1137
+ page=arguments.page,
1138
+ per_page=arguments.per_page,
1139
+ )
1140
+
1141
+ if command == "secret-scan-history":
1142
+ return fetch_secret_scan_history(context)
1143
+
1144
+ if command == "api-call":
1145
+ response = api_request(
1146
+ context,
1147
+ endpoint=arguments.endpoint,
1148
+ method=arguments.method,
1149
+ params=parse_name_value_pairs(arguments.query_params),
1150
+ body=(
1151
+ None
1152
+ if arguments.body_json is None
1153
+ else json.loads(arguments.body_json)
1154
+ ),
1155
+ )
1156
+ return {
1157
+ "data": response.data,
1158
+ "status_code": response.status_code,
1159
+ "url": response.url,
1160
+ }
1161
+
1162
+ raise GitHubSecurityCliError(f"Unsupported command '{command}'.")