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