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.
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +50 -0
- package/LICENSE +24 -0
- package/LICENSE.txt +24 -0
- package/README.md +209 -0
- package/SECURITY.md +41 -0
- package/SKILL.md +254 -0
- package/agents/openai.yaml +7 -0
- package/assets/github-manage-security-alerts-small.svg +10 -0
- package/assets/github-manage-security-alerts.png +0 -0
- package/package.json +51 -0
- package/scripts/github_security_api.py +358 -0
- package/scripts/github_security_cli.py +835 -0
- package/scripts/github_security_common.py +103 -0
- package/scripts/github_security_operations.py +1162 -0
- package/scripts/github_security_render.py +318 -0
- package/scripts/manage_github_security_alerts.py +58 -0
|
@@ -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}'.")
|