github-manage-security-alerts-skill 1.0.0 → 1.0.2

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