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,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from github_security_common import expect_dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_text(command: str, payload: Any) -> str:
|
|
10
|
+
"""Render command output as human-readable text."""
|
|
11
|
+
|
|
12
|
+
if command == "summary":
|
|
13
|
+
return render_summary(payload)
|
|
14
|
+
if command == "repo-security-overview":
|
|
15
|
+
return render_repo_security_overview(payload)
|
|
16
|
+
if command == "bulk-update-alerts":
|
|
17
|
+
return render_json_like(payload)
|
|
18
|
+
if command == "list-code-scanning":
|
|
19
|
+
return render_code_scanning_list(payload)
|
|
20
|
+
if command == "show-code-scanning":
|
|
21
|
+
return render_json_like(payload)
|
|
22
|
+
if command == "update-code-scanning":
|
|
23
|
+
return render_update_result(payload)
|
|
24
|
+
if command == "list-dependabot":
|
|
25
|
+
return render_dependabot_list(payload)
|
|
26
|
+
if command == "show-dependabot":
|
|
27
|
+
return render_json_like(payload)
|
|
28
|
+
if command == "update-dependabot":
|
|
29
|
+
return render_update_result(payload)
|
|
30
|
+
if command == "list-malware":
|
|
31
|
+
return render_dependabot_list(
|
|
32
|
+
payload, heading="Dependabot malware alerts"
|
|
33
|
+
)
|
|
34
|
+
if command == "show-malware":
|
|
35
|
+
return render_json_like(payload)
|
|
36
|
+
if command == "update-malware":
|
|
37
|
+
return render_update_result(payload)
|
|
38
|
+
if command == "list-secret-scanning":
|
|
39
|
+
return render_secret_scanning_list(payload)
|
|
40
|
+
if command == "show-secret-scanning":
|
|
41
|
+
return render_json_like(payload)
|
|
42
|
+
if command == "update-secret-scanning":
|
|
43
|
+
return render_update_result(payload)
|
|
44
|
+
if command == "list-secret-locations":
|
|
45
|
+
return render_secret_locations(payload)
|
|
46
|
+
if command == "secret-scan-history":
|
|
47
|
+
return render_json_like(payload)
|
|
48
|
+
if command == "export-alerts":
|
|
49
|
+
return render_json_like(payload)
|
|
50
|
+
if command == "api-call":
|
|
51
|
+
return render_json_like(payload)
|
|
52
|
+
|
|
53
|
+
return render_json_like(payload)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def emit_output(payload: Any, *, as_json: bool, command: str) -> None:
|
|
57
|
+
"""Print output in JSON or text form."""
|
|
58
|
+
|
|
59
|
+
if as_json:
|
|
60
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
text_payload = payload
|
|
64
|
+
if command == "list-malware" and isinstance(payload, dict):
|
|
65
|
+
text_payload = payload.get("alerts", [])
|
|
66
|
+
print(render_text(command, text_payload))
|
|
67
|
+
if (
|
|
68
|
+
command == "list-malware"
|
|
69
|
+
and isinstance(payload, dict)
|
|
70
|
+
and payload.get("lookup_failures")
|
|
71
|
+
):
|
|
72
|
+
print("\nMalware advisory lookup failures:")
|
|
73
|
+
print(json.dumps(payload["lookup_failures"], indent=2, sort_keys=True))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_summary(payload: dict[str, Any]) -> str:
|
|
77
|
+
"""Render a repository security summary."""
|
|
78
|
+
|
|
79
|
+
lines = [
|
|
80
|
+
f"Repository: {payload['full_name']}",
|
|
81
|
+
f"Repository URL: {payload['repository_html_url']}",
|
|
82
|
+
f"API base URL: {payload['api_base_url']}",
|
|
83
|
+
f"Token environment variable: {payload['token_env']}",
|
|
84
|
+
"",
|
|
85
|
+
"Sections:",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
for section_name in (
|
|
89
|
+
"code_scanning",
|
|
90
|
+
"dependabot",
|
|
91
|
+
"malware",
|
|
92
|
+
"secret_scanning",
|
|
93
|
+
):
|
|
94
|
+
section = payload["sections"][section_name]
|
|
95
|
+
pretty_name = section_name.replace("_", " ")
|
|
96
|
+
lines.append(f"- {pretty_name}:")
|
|
97
|
+
if not section.get("ok"):
|
|
98
|
+
error_payload = section.get("error", {})
|
|
99
|
+
lines.append(
|
|
100
|
+
" status: unavailable "
|
|
101
|
+
f"({error_payload.get('status_code', 'n/a')}) "
|
|
102
|
+
f"{error_payload.get('message', 'unknown error')}"
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
counts = section.get("counts_by_state", {})
|
|
107
|
+
lines.append(f" total: {section.get('total', 0)}")
|
|
108
|
+
if counts:
|
|
109
|
+
lines.append(
|
|
110
|
+
" counts: "
|
|
111
|
+
+ ", ".join(
|
|
112
|
+
f"{state}={count}"
|
|
113
|
+
for state, count in sorted(counts.items())
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
samples = section.get("sample_alerts", [])
|
|
117
|
+
if samples:
|
|
118
|
+
lines.append(" samples:")
|
|
119
|
+
for alert in samples:
|
|
120
|
+
lines.append(
|
|
121
|
+
f" - {render_alert_brief(alert, section_name)}"
|
|
122
|
+
)
|
|
123
|
+
lookup_failures = section.get("lookup_failures")
|
|
124
|
+
if lookup_failures:
|
|
125
|
+
lines.append(
|
|
126
|
+
f" malware-type lookup failures: {len(lookup_failures)}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def render_repo_security_overview(payload: dict[str, Any]) -> str:
|
|
133
|
+
"""Render repository settings overview."""
|
|
134
|
+
|
|
135
|
+
lines = [
|
|
136
|
+
f"Repository: {payload['full_name']}",
|
|
137
|
+
f"Repository URL: {payload.get('html_url')}",
|
|
138
|
+
f"Visibility: {payload.get('visibility')}",
|
|
139
|
+
f"Private: {payload.get('private')}",
|
|
140
|
+
f"API base URL: {payload['api_base_url']}",
|
|
141
|
+
"",
|
|
142
|
+
"security_and_analysis:",
|
|
143
|
+
json.dumps(
|
|
144
|
+
payload.get("security_and_analysis"), indent=2, sort_keys=True
|
|
145
|
+
),
|
|
146
|
+
]
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def render_code_scanning_list(alerts: list[dict[str, Any]]) -> str:
|
|
151
|
+
"""Render code scanning alerts as lines of text."""
|
|
152
|
+
|
|
153
|
+
return render_alert_list(
|
|
154
|
+
alerts, heading="Code scanning alerts", kind="code_scanning"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def render_dependabot_list(
|
|
159
|
+
alerts: list[dict[str, Any]],
|
|
160
|
+
*,
|
|
161
|
+
heading: str = "Dependabot alerts",
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Render Dependabot alerts as lines of text."""
|
|
164
|
+
|
|
165
|
+
return render_alert_list(alerts, heading=heading, kind="dependabot")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_secret_scanning_list(alerts: list[dict[str, Any]]) -> str:
|
|
169
|
+
"""Render secret scanning alerts as lines of text."""
|
|
170
|
+
|
|
171
|
+
return render_alert_list(
|
|
172
|
+
alerts, heading="Secret scanning alerts", kind="secret_scanning"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_secret_locations(locations: list[dict[str, Any]]) -> str:
|
|
177
|
+
"""Render secret scanning locations."""
|
|
178
|
+
|
|
179
|
+
if not locations:
|
|
180
|
+
return "No secret locations found."
|
|
181
|
+
|
|
182
|
+
lines = ["Secret scanning alert locations:"]
|
|
183
|
+
for location in locations:
|
|
184
|
+
location_type = location.get("type", "unknown")
|
|
185
|
+
details = location.get("details")
|
|
186
|
+
if isinstance(details, dict):
|
|
187
|
+
lines.append(
|
|
188
|
+
f"- {location_type}: {json.dumps(details, sort_keys=True)}"
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
lines.append(f"- {location_type}: {details}")
|
|
192
|
+
|
|
193
|
+
return "\n".join(lines)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render_alert_list(
|
|
197
|
+
alerts: list[dict[str, Any]], *, heading: str, kind: str
|
|
198
|
+
) -> str:
|
|
199
|
+
"""Render a generic alert list with one alert per line."""
|
|
200
|
+
|
|
201
|
+
if not alerts:
|
|
202
|
+
return f"{heading}: none"
|
|
203
|
+
|
|
204
|
+
lines = [f"{heading} ({len(alerts)}):"]
|
|
205
|
+
for alert in alerts:
|
|
206
|
+
lines.append(f"- {render_alert_brief(alert, kind)}")
|
|
207
|
+
return "\n".join(lines)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def render_alert_brief(alert: dict[str, Any], kind: str) -> str:
|
|
211
|
+
"""Create a one-line summary for one alert."""
|
|
212
|
+
|
|
213
|
+
number = alert.get("number", "?")
|
|
214
|
+
state = alert.get("state", "unknown")
|
|
215
|
+
html_url = alert.get("html_url")
|
|
216
|
+
|
|
217
|
+
if kind == "code_scanning":
|
|
218
|
+
rule = (
|
|
219
|
+
expect_dict(alert.get("rule") or {}, "code scanning rule")
|
|
220
|
+
if alert.get("rule") is not None
|
|
221
|
+
else {}
|
|
222
|
+
)
|
|
223
|
+
instance = (
|
|
224
|
+
expect_dict(
|
|
225
|
+
alert.get("most_recent_instance") or {},
|
|
226
|
+
"code scanning instance",
|
|
227
|
+
)
|
|
228
|
+
if alert.get("most_recent_instance") is not None
|
|
229
|
+
else {}
|
|
230
|
+
)
|
|
231
|
+
location = (
|
|
232
|
+
expect_dict(
|
|
233
|
+
instance.get("location") or {}, "code scanning location"
|
|
234
|
+
)
|
|
235
|
+
if instance.get("location") is not None
|
|
236
|
+
else {}
|
|
237
|
+
)
|
|
238
|
+
severity = (
|
|
239
|
+
rule.get("security_severity_level")
|
|
240
|
+
or rule.get("severity")
|
|
241
|
+
or "unknown"
|
|
242
|
+
)
|
|
243
|
+
rule_name = rule.get("id") or rule.get("name") or "unknown-rule"
|
|
244
|
+
path = location.get("path") or "<unknown-path>"
|
|
245
|
+
return (
|
|
246
|
+
f"#{number} [{state}] severity={severity} rule={rule_name} "
|
|
247
|
+
f"path={path} url={html_url}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if kind in {"dependabot", "malware"}:
|
|
251
|
+
vulnerability = (
|
|
252
|
+
expect_dict(
|
|
253
|
+
alert.get("security_vulnerability") or {},
|
|
254
|
+
"Dependabot vulnerability",
|
|
255
|
+
)
|
|
256
|
+
if alert.get("security_vulnerability") is not None
|
|
257
|
+
else {}
|
|
258
|
+
)
|
|
259
|
+
package = (
|
|
260
|
+
expect_dict(
|
|
261
|
+
vulnerability.get("package") or {}, "Dependabot package"
|
|
262
|
+
)
|
|
263
|
+
if vulnerability.get("package") is not None
|
|
264
|
+
else {}
|
|
265
|
+
)
|
|
266
|
+
dependency = (
|
|
267
|
+
expect_dict(alert.get("dependency") or {}, "Dependabot dependency")
|
|
268
|
+
if alert.get("dependency") is not None
|
|
269
|
+
else {}
|
|
270
|
+
)
|
|
271
|
+
severity = (
|
|
272
|
+
vulnerability.get("severity") or alert.get("state") or "unknown"
|
|
273
|
+
)
|
|
274
|
+
if package.get("name") is not None:
|
|
275
|
+
package_name = package.get("name")
|
|
276
|
+
elif isinstance(dependency.get("package"), dict):
|
|
277
|
+
package_name = dependency["package"].get("name")
|
|
278
|
+
else:
|
|
279
|
+
package_name = None
|
|
280
|
+
manifest_path = dependency.get("manifest_path") or "<unknown-manifest>"
|
|
281
|
+
ghsa_id = (
|
|
282
|
+
alert.get("security_advisory", {}).get("ghsa_id")
|
|
283
|
+
if isinstance(alert.get("security_advisory"), dict)
|
|
284
|
+
else None
|
|
285
|
+
)
|
|
286
|
+
ghsa_id = ghsa_id or "unknown-ghsa"
|
|
287
|
+
malware_suffix = " malware" if kind == "malware" else ""
|
|
288
|
+
return (
|
|
289
|
+
f"#{number} [{state}] severity={severity} package={package_name} "
|
|
290
|
+
f"manifest={manifest_path} ghsa={ghsa_id}{malware_suffix} url={html_url}"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if kind == "secret_scanning":
|
|
294
|
+
secret_type = alert.get("secret_type") or "unknown-secret-type"
|
|
295
|
+
resolution = alert.get("resolution") or "unresolved"
|
|
296
|
+
validity = alert.get("validity") or "unknown"
|
|
297
|
+
leaked = alert.get("publicly_leaked")
|
|
298
|
+
return (
|
|
299
|
+
f"#{number} [{state}] secret_type={secret_type} resolution={resolution} "
|
|
300
|
+
f"validity={validity} publicly_leaked={leaked} url={html_url}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
return f"#{number} [{state}] url={html_url}"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def render_update_result(payload: dict[str, Any]) -> str:
|
|
307
|
+
"""Render dry-run or mutation results."""
|
|
308
|
+
|
|
309
|
+
if payload.get("dry_run"):
|
|
310
|
+
return "Dry run:\n" + json.dumps(payload, indent=2, sort_keys=True)
|
|
311
|
+
|
|
312
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def render_json_like(payload: Any) -> str:
|
|
316
|
+
"""Render arbitrary payloads as pretty JSON."""
|
|
317
|
+
|
|
318
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Inspect and manage GitHub repository security alerts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
11
|
+
if str(SCRIPT_DIR) not in sys.path:
|
|
12
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
"""CLI entry point."""
|
|
17
|
+
|
|
18
|
+
from github_security_api import GitHubApiError, resolve_context
|
|
19
|
+
from github_security_cli import parse_args
|
|
20
|
+
from github_security_common import GitHubSecurityCliError
|
|
21
|
+
from github_security_operations import handle_command
|
|
22
|
+
from github_security_render import emit_output
|
|
23
|
+
|
|
24
|
+
arguments = parse_args()
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
context = resolve_context(arguments)
|
|
28
|
+
payload = handle_command(context, arguments)
|
|
29
|
+
emit_output(payload, as_json=arguments.json, command=arguments.command)
|
|
30
|
+
except (
|
|
31
|
+
GitHubApiError,
|
|
32
|
+
GitHubSecurityCliError,
|
|
33
|
+
json.JSONDecodeError,
|
|
34
|
+
) as exc:
|
|
35
|
+
if arguments.json:
|
|
36
|
+
print(
|
|
37
|
+
json.dumps(
|
|
38
|
+
{
|
|
39
|
+
"error": {
|
|
40
|
+
"command": arguments.command,
|
|
41
|
+
"message": str(exc),
|
|
42
|
+
"type": type(exc).__name__,
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
indent=2,
|
|
46
|
+
sort_keys=True,
|
|
47
|
+
),
|
|
48
|
+
file=sys.stderr,
|
|
49
|
+
)
|
|
50
|
+
else:
|
|
51
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
52
|
+
return 1
|
|
53
|
+
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
raise SystemExit(main())
|