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
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "github-manage-security-alerts-skill",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Codex skill for inspecting and triaging GitHub security alerts.",
|
|
6
|
+
"license": "Unlicense",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Nick2bad4u/Github-Security-CodeScanning-Alerts-Skill.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Nick2bad4u/Github-Security-CodeScanning-Alerts-Skill/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Nick2bad4u/Github-Security-CodeScanning-Alerts-Skill#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"agent-skill",
|
|
18
|
+
"code-scanning",
|
|
19
|
+
"codex",
|
|
20
|
+
"dependabot",
|
|
21
|
+
"github-security",
|
|
22
|
+
"openai",
|
|
23
|
+
"secret-scanning"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"SKILL.md",
|
|
27
|
+
"LICENSE.txt",
|
|
28
|
+
"agents/",
|
|
29
|
+
"assets/",
|
|
30
|
+
"scripts/",
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"CONTRIBUTING.md",
|
|
33
|
+
"README.md",
|
|
34
|
+
"SECURITY.md"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.14"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"validate": "node tools/validate-skill-package.mjs",
|
|
41
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
42
|
+
"publish:local": "npm run release:verify && npm publish",
|
|
43
|
+
"release:verify": "npm run validate && npm run pack:dry-run",
|
|
44
|
+
"stage:local": "npm run release:verify && npm stage publish"
|
|
45
|
+
},
|
|
46
|
+
"codexSkill": {
|
|
47
|
+
"name": "github-manage-security-alerts",
|
|
48
|
+
"path": ".",
|
|
49
|
+
"githubReleaseAssetPrefix": "github-security-codescanning-alerts-skill"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib import error, parse, request
|
|
11
|
+
|
|
12
|
+
from github_security_common import GitHubSecurityCliError
|
|
13
|
+
|
|
14
|
+
DEFAULT_ACCEPT = "application/vnd.github+json"
|
|
15
|
+
DEFAULT_API_VERSION = "2026-03-10"
|
|
16
|
+
DEFAULT_TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN")
|
|
17
|
+
GITHUB_DOT_COM_HOST = "github.com"
|
|
18
|
+
GITHUB_DOT_COM_API_BASE = "https://api.github.com"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class RepoContext:
|
|
23
|
+
"""Resolved repository and authentication context."""
|
|
24
|
+
|
|
25
|
+
api_base_url: str
|
|
26
|
+
owner: str
|
|
27
|
+
repo: str
|
|
28
|
+
repo_path: Path
|
|
29
|
+
token: str
|
|
30
|
+
token_env_name: str
|
|
31
|
+
web_base_url: str
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def full_name(self) -> str:
|
|
35
|
+
return f"{self.owner}/{self.repo}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class GitHubApiResponse:
|
|
40
|
+
"""HTTP response wrapper used by the helper."""
|
|
41
|
+
|
|
42
|
+
data: Any
|
|
43
|
+
headers: dict[str, str]
|
|
44
|
+
status_code: int
|
|
45
|
+
url: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GitHubApiError(GitHubSecurityCliError):
|
|
49
|
+
"""Raised when the GitHub API returns a non-success response."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
endpoint: str,
|
|
55
|
+
message: str,
|
|
56
|
+
response_data: Any,
|
|
57
|
+
status_code: int,
|
|
58
|
+
url: str,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.endpoint = endpoint
|
|
62
|
+
self.response_data = response_data
|
|
63
|
+
self.status_code = status_code
|
|
64
|
+
self.url = url
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def run_git(repo_path: Path, *arguments: str) -> str:
|
|
68
|
+
"""Run a git command inside the target repository."""
|
|
69
|
+
|
|
70
|
+
completed = subprocess.run(
|
|
71
|
+
["git", "-C", str(repo_path), *arguments],
|
|
72
|
+
check=False,
|
|
73
|
+
capture_output=True,
|
|
74
|
+
text=True,
|
|
75
|
+
)
|
|
76
|
+
if completed.returncode != 0:
|
|
77
|
+
stderr = completed.stderr.strip()
|
|
78
|
+
raise GitHubSecurityCliError(
|
|
79
|
+
f"Git command failed in '{repo_path}': git {' '.join(arguments)}"
|
|
80
|
+
+ (f"\n{stderr}" if stderr else "")
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return completed.stdout.strip()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_repository_input(repository: str) -> tuple[str, str, str, str]:
|
|
87
|
+
"""Parse owner/repo or a repository URL into host/owner/repo/base URL pieces."""
|
|
88
|
+
|
|
89
|
+
repository = repository.strip()
|
|
90
|
+
if not repository:
|
|
91
|
+
raise GitHubSecurityCliError("Repository input cannot be empty.")
|
|
92
|
+
|
|
93
|
+
owner_repo_match = re.fullmatch(
|
|
94
|
+
r"(?P<owner>[^/]+)/(?P<repo>[^/]+)", repository
|
|
95
|
+
)
|
|
96
|
+
if owner_repo_match:
|
|
97
|
+
return (
|
|
98
|
+
GITHUB_DOT_COM_HOST,
|
|
99
|
+
owner_repo_match.group("owner"),
|
|
100
|
+
owner_repo_match.group("repo"),
|
|
101
|
+
"https",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
remote_match = re.fullmatch(
|
|
105
|
+
r"git@(?P<host>[^:]+):(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?",
|
|
106
|
+
repository,
|
|
107
|
+
)
|
|
108
|
+
if remote_match:
|
|
109
|
+
return (
|
|
110
|
+
remote_match.group("host"),
|
|
111
|
+
remote_match.group("owner"),
|
|
112
|
+
remote_match.group("repo"),
|
|
113
|
+
"https",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
parsed = parse.urlparse(repository)
|
|
117
|
+
if parsed.scheme and parsed.netloc:
|
|
118
|
+
path_segments = [
|
|
119
|
+
segment for segment in parsed.path.split("/") if segment
|
|
120
|
+
]
|
|
121
|
+
if len(path_segments) < 2:
|
|
122
|
+
raise GitHubSecurityCliError(
|
|
123
|
+
f"Could not parse owner/repo from repository URL '{repository}'."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
repo_name = path_segments[-1]
|
|
127
|
+
if repo_name.endswith(".git"):
|
|
128
|
+
repo_name = repo_name[:-4]
|
|
129
|
+
|
|
130
|
+
return parsed.netloc, path_segments[-2], repo_name, parsed.scheme
|
|
131
|
+
|
|
132
|
+
raise GitHubSecurityCliError(
|
|
133
|
+
f"Unsupported repository input '{repository}'. Use owner/repo or a GitHub URL."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_remote_url(remote_url: str) -> tuple[str, str, str, str]:
|
|
138
|
+
"""Parse a git remote URL into host/owner/repo/scheme."""
|
|
139
|
+
|
|
140
|
+
return parse_repository_input(remote_url)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def resolve_token(token_envs: list[str] | None) -> tuple[str, str]:
|
|
144
|
+
"""Resolve the first non-empty token from the candidate environment variables."""
|
|
145
|
+
|
|
146
|
+
candidates = token_envs or list(DEFAULT_TOKEN_ENVS)
|
|
147
|
+
|
|
148
|
+
for candidate in candidates:
|
|
149
|
+
token = os.environ.get(candidate)
|
|
150
|
+
if token and token.strip():
|
|
151
|
+
return candidate, token.strip()
|
|
152
|
+
|
|
153
|
+
candidate_text = ", ".join(candidates)
|
|
154
|
+
raise GitHubSecurityCliError(
|
|
155
|
+
"Could not find a GitHub token in any configured environment variable "
|
|
156
|
+
f"({candidate_text}).\n"
|
|
157
|
+
"Populate one first, for example in PowerShell:\n"
|
|
158
|
+
"$env:GITHUB_TOKEN = Get-Secret GITHUB_TOKEN -AsPlainText"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolve_context(arguments: Any) -> RepoContext:
|
|
163
|
+
"""Resolve repository ownership, host, and token context."""
|
|
164
|
+
|
|
165
|
+
repo_path = Path(arguments.repo).expanduser().resolve()
|
|
166
|
+
token_env_name, token = resolve_token(arguments.token_envs)
|
|
167
|
+
|
|
168
|
+
if arguments.repository is not None:
|
|
169
|
+
host, owner, repo_name, scheme = parse_repository_input(
|
|
170
|
+
arguments.repository
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
remote_url = run_git(repo_path, "config", "--get", "remote.origin.url")
|
|
174
|
+
host, owner, repo_name, scheme = parse_remote_url(remote_url)
|
|
175
|
+
|
|
176
|
+
web_base_url = (arguments.web_base_url or f"{scheme}://{host}").rstrip("/")
|
|
177
|
+
if arguments.api_base_url is not None:
|
|
178
|
+
api_base_url = arguments.api_base_url.rstrip("/")
|
|
179
|
+
elif host.lower() == GITHUB_DOT_COM_HOST:
|
|
180
|
+
api_base_url = GITHUB_DOT_COM_API_BASE
|
|
181
|
+
else:
|
|
182
|
+
api_base_url = f"{scheme}://{host.rstrip('/')}/api/v3"
|
|
183
|
+
|
|
184
|
+
return RepoContext(
|
|
185
|
+
api_base_url=api_base_url,
|
|
186
|
+
owner=owner,
|
|
187
|
+
repo=repo_name,
|
|
188
|
+
repo_path=repo_path,
|
|
189
|
+
token=token,
|
|
190
|
+
token_env_name=token_env_name,
|
|
191
|
+
web_base_url=web_base_url,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def normalize_query_value(value: Any) -> str:
|
|
196
|
+
"""Normalize query-parameter values to GitHub-friendly strings."""
|
|
197
|
+
|
|
198
|
+
if isinstance(value, bool):
|
|
199
|
+
return "true" if value else "false"
|
|
200
|
+
|
|
201
|
+
return str(value)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_query_string(params: dict[str, Any] | None) -> str:
|
|
205
|
+
"""Serialize query parameters, omitting null values."""
|
|
206
|
+
|
|
207
|
+
if not params:
|
|
208
|
+
return ""
|
|
209
|
+
|
|
210
|
+
normalized_params: list[tuple[str, str]] = []
|
|
211
|
+
|
|
212
|
+
for key, value in params.items():
|
|
213
|
+
if value is None:
|
|
214
|
+
continue
|
|
215
|
+
if isinstance(value, list):
|
|
216
|
+
for item in value:
|
|
217
|
+
normalized_params.append((key, normalize_query_value(item)))
|
|
218
|
+
continue
|
|
219
|
+
normalized_params.append((key, normalize_query_value(value)))
|
|
220
|
+
|
|
221
|
+
if not normalized_params:
|
|
222
|
+
return ""
|
|
223
|
+
|
|
224
|
+
return "?" + parse.urlencode(normalized_params, doseq=True)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def extract_api_error_message(payload: Any) -> str:
|
|
228
|
+
"""Extract a readable message from a GitHub API error payload."""
|
|
229
|
+
|
|
230
|
+
if isinstance(payload, dict):
|
|
231
|
+
message = payload.get("message")
|
|
232
|
+
if isinstance(message, str) and message.strip():
|
|
233
|
+
return message.strip()
|
|
234
|
+
|
|
235
|
+
if isinstance(payload, str) and payload.strip():
|
|
236
|
+
return payload.strip()
|
|
237
|
+
|
|
238
|
+
return "GitHub API request failed."
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def api_request(
|
|
242
|
+
context: RepoContext,
|
|
243
|
+
*,
|
|
244
|
+
endpoint: str,
|
|
245
|
+
method: str = "GET",
|
|
246
|
+
params: dict[str, Any] | None = None,
|
|
247
|
+
body: dict[str, Any] | list[Any] | None = None,
|
|
248
|
+
accept: str = DEFAULT_ACCEPT,
|
|
249
|
+
) -> GitHubApiResponse:
|
|
250
|
+
"""Send a GitHub REST API request."""
|
|
251
|
+
|
|
252
|
+
query_string = build_query_string(params)
|
|
253
|
+
if endpoint.startswith(("http://", "https://")):
|
|
254
|
+
url = endpoint
|
|
255
|
+
else:
|
|
256
|
+
url = f"{context.api_base_url}{endpoint}"
|
|
257
|
+
url = f"{url}{query_string}"
|
|
258
|
+
|
|
259
|
+
request_body: bytes | None = None
|
|
260
|
+
headers = {
|
|
261
|
+
"Accept": accept,
|
|
262
|
+
"Authorization": f"Bearer {context.token}",
|
|
263
|
+
"User-Agent": "github-manage-security-alerts-skill",
|
|
264
|
+
"X-GitHub-Api-Version": DEFAULT_API_VERSION,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if body is not None:
|
|
268
|
+
request_body = json.dumps(body).encode("utf-8")
|
|
269
|
+
headers["Content-Type"] = "application/json"
|
|
270
|
+
|
|
271
|
+
http_request = request.Request(
|
|
272
|
+
url,
|
|
273
|
+
data=request_body,
|
|
274
|
+
headers=headers,
|
|
275
|
+
method=method.upper(),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
with request.urlopen(http_request) as response:
|
|
280
|
+
response_headers = {
|
|
281
|
+
key.lower(): value for key, value in response.headers.items()
|
|
282
|
+
}
|
|
283
|
+
raw_body = response.read()
|
|
284
|
+
content_type = response_headers.get("content-type", "")
|
|
285
|
+
if not raw_body:
|
|
286
|
+
parsed_body: Any = None
|
|
287
|
+
elif (
|
|
288
|
+
"application/json" in content_type or accept == DEFAULT_ACCEPT
|
|
289
|
+
):
|
|
290
|
+
parsed_body = json.loads(raw_body.decode("utf-8"))
|
|
291
|
+
else:
|
|
292
|
+
parsed_body = raw_body.decode("utf-8")
|
|
293
|
+
|
|
294
|
+
return GitHubApiResponse(
|
|
295
|
+
data=parsed_body,
|
|
296
|
+
headers=response_headers,
|
|
297
|
+
status_code=response.status,
|
|
298
|
+
url=url,
|
|
299
|
+
)
|
|
300
|
+
except error.HTTPError as exc:
|
|
301
|
+
raw_error_body = exc.read()
|
|
302
|
+
content_type = exc.headers.get("Content-Type", "")
|
|
303
|
+
if raw_error_body:
|
|
304
|
+
if "application/json" in content_type:
|
|
305
|
+
response_data = json.loads(raw_error_body.decode("utf-8"))
|
|
306
|
+
else:
|
|
307
|
+
response_data = raw_error_body.decode("utf-8")
|
|
308
|
+
else:
|
|
309
|
+
response_data = None
|
|
310
|
+
|
|
311
|
+
raise GitHubApiError(
|
|
312
|
+
endpoint=endpoint,
|
|
313
|
+
message=extract_api_error_message(response_data),
|
|
314
|
+
response_data=response_data,
|
|
315
|
+
status_code=exc.code,
|
|
316
|
+
url=url,
|
|
317
|
+
) from exc
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def safe_api_request(
|
|
321
|
+
context: RepoContext,
|
|
322
|
+
*,
|
|
323
|
+
endpoint: str,
|
|
324
|
+
method: str = "GET",
|
|
325
|
+
params: dict[str, Any] | None = None,
|
|
326
|
+
body: dict[str, Any] | list[Any] | None = None,
|
|
327
|
+
accept: str = DEFAULT_ACCEPT,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
"""Execute an API request and return a structured success/error result."""
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
response = api_request(
|
|
333
|
+
context,
|
|
334
|
+
endpoint=endpoint,
|
|
335
|
+
method=method,
|
|
336
|
+
params=params,
|
|
337
|
+
body=body,
|
|
338
|
+
accept=accept,
|
|
339
|
+
)
|
|
340
|
+
except GitHubApiError as exc:
|
|
341
|
+
return {
|
|
342
|
+
"error": {
|
|
343
|
+
"endpoint": exc.endpoint,
|
|
344
|
+
"message": str(exc),
|
|
345
|
+
"response": exc.response_data,
|
|
346
|
+
"status_code": exc.status_code,
|
|
347
|
+
"url": exc.url,
|
|
348
|
+
},
|
|
349
|
+
"ok": False,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
"data": response.data,
|
|
354
|
+
"headers": response.headers,
|
|
355
|
+
"ok": True,
|
|
356
|
+
"status_code": response.status_code,
|
|
357
|
+
"url": response.url,
|
|
358
|
+
}
|