ghsa-cli 2026.4.2__tar.gz
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.
- ghsa_cli-2026.4.2/PKG-INFO +6 -0
- ghsa_cli-2026.4.2/pyproject.toml +16 -0
- ghsa_cli-2026.4.2/src/ghsa_cli.py +234 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
dynamic = ['version']
|
|
3
|
+
name = 'ghsa-cli'
|
|
4
|
+
description = 'CLI for efficiently interacting with GitHub Security Advisories (GHSA)'
|
|
5
|
+
license = 'MIT'
|
|
6
|
+
requires-python = '>=3.14'
|
|
7
|
+
requires = [
|
|
8
|
+
'urllib3>=2',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
ghsa-cli = "ghsa_cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ['flit-core>=3.11,<4']
|
|
16
|
+
build-backend = 'flit_core.buildapi'
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import datetime
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import typing
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
import urllib3
|
|
11
|
+
|
|
12
|
+
__version__ = "2026.04.02"
|
|
13
|
+
|
|
14
|
+
http = urllib3.PoolManager()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def command_credit(args: argparse.Namespace) -> int:
|
|
18
|
+
gh_token = args.gh_token
|
|
19
|
+
url = f"https://api.github.com/repos/{args.repo_owner}/{args.repo_name}/security-advisories/{args.ghsa_id}"
|
|
20
|
+
|
|
21
|
+
resp = gh_request("GET", url, gh_token=gh_token)
|
|
22
|
+
if resp.status >= 300:
|
|
23
|
+
print("Could not fetch GHSA", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
credits = resp.json()["credits"][:]
|
|
26
|
+
|
|
27
|
+
add_credits = []
|
|
28
|
+
if args.reporter is not None:
|
|
29
|
+
add_credits.append({"type": "reporter", "login": args.reporter})
|
|
30
|
+
if args.coordinator is not None:
|
|
31
|
+
add_credits.append({"type": "coordinator", "login": args.coordinator})
|
|
32
|
+
if args.remediation_developer is not None:
|
|
33
|
+
add_credits.append(
|
|
34
|
+
{"type": "remediation_developer", "login": args.remediation_developer}
|
|
35
|
+
)
|
|
36
|
+
if args.remediation_reviewer is not None:
|
|
37
|
+
add_credits.append(
|
|
38
|
+
{"type": "remediation_reviewer", "login": args.remediation_reviewer}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
for add_credit in add_credits:
|
|
42
|
+
if add_credit not in credits:
|
|
43
|
+
credits.append(add_credit)
|
|
44
|
+
|
|
45
|
+
resp = gh_request("PATCH", url, gh_token=gh_token, body={"credits": credits})
|
|
46
|
+
if resp.status >= 300:
|
|
47
|
+
print("Could not update credits for GHSA", file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _command_set_state(
|
|
53
|
+
args: argparse.Namespace, state: str, from_states: list[str]
|
|
54
|
+
) -> int:
|
|
55
|
+
gh_token = args.gh_token
|
|
56
|
+
url = f"https://api.github.com/repos/{args.repo_owner}/{args.repo_name}/security-advisories/{args.ghsa_id}"
|
|
57
|
+
|
|
58
|
+
resp = gh_request("GET", url, gh_token=gh_token)
|
|
59
|
+
if resp.status >= 300:
|
|
60
|
+
print("Could not fetch GHSA", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
from_state = resp.json()["state"]
|
|
64
|
+
if from_state not in from_states:
|
|
65
|
+
print(
|
|
66
|
+
f"Could not move GHSA to state '{state}' from state '{from_state}'",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
return 1
|
|
70
|
+
|
|
71
|
+
resp = gh_request("PATCH", url, gh_token=gh_token, body={"state": state})
|
|
72
|
+
if resp.status >= 300:
|
|
73
|
+
print("Could not update state for GHSA", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def command_accept(args: argparse.Namespace) -> int:
|
|
79
|
+
return _command_set_state(args, state="draft", from_states=["triage"])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def command_close(args: argparse.Namespace) -> int:
|
|
83
|
+
return _command_set_state(args, state="close", from_states=["triage", "draft"])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command_move_to_issue(args: argparse.Namespace) -> int:
|
|
87
|
+
return 0 # TODO
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
if argv is None:
|
|
92
|
+
argv = sys.argv[1:]
|
|
93
|
+
try:
|
|
94
|
+
gh_token = os.environ["GH_TOKEN"]
|
|
95
|
+
except KeyError:
|
|
96
|
+
print("Requires 'GH_TOKEN' environment variable to be set", file=sys.stderr)
|
|
97
|
+
return 1
|
|
98
|
+
try:
|
|
99
|
+
cve_token = os.environ["CVE_TOKEN"] or None
|
|
100
|
+
except KeyError:
|
|
101
|
+
cve_token = None
|
|
102
|
+
|
|
103
|
+
parser = argparse.ArgumentParser()
|
|
104
|
+
parser.add_argument("--repo", help="GitHub repository owner and name", default=None)
|
|
105
|
+
subparsers = parser.add_subparsers(required=True, dest="command")
|
|
106
|
+
|
|
107
|
+
# 'credit'
|
|
108
|
+
parser_credit = subparsers.add_parser(
|
|
109
|
+
"credit",
|
|
110
|
+
description="Assign credit to a GitHub account. Use 'me' as an alias to assign your own account.",
|
|
111
|
+
)
|
|
112
|
+
parser_credit.add_argument("ghsa_id", help="GitHub Security Advisory ID")
|
|
113
|
+
parser_credit.add_argument(
|
|
114
|
+
"--reporter", help="GitHub login to assign to the 'Reporter' role"
|
|
115
|
+
)
|
|
116
|
+
parser_credit.add_argument(
|
|
117
|
+
"--coordinator", help="GitHub login to assign the 'Coordinator' role"
|
|
118
|
+
)
|
|
119
|
+
parser_credit.add_argument(
|
|
120
|
+
"--remediation-developer",
|
|
121
|
+
help="GitHub login to assign the 'Remediation Developer' role",
|
|
122
|
+
)
|
|
123
|
+
parser_credit.add_argument(
|
|
124
|
+
"--remediation-reviewer",
|
|
125
|
+
help="GitHub login to assign the 'Remediation Reviewer' role",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# 'accept'
|
|
129
|
+
parser_accept = subparsers.add_parser(
|
|
130
|
+
"accept", description="Accept a report that is in 'Triage'"
|
|
131
|
+
)
|
|
132
|
+
parser_accept.add_argument("ghsa_id", help="GitHub Security Advisory ID")
|
|
133
|
+
|
|
134
|
+
# 'close'
|
|
135
|
+
parser_close = subparsers.add_parser(
|
|
136
|
+
"close", description="Close a report from any state"
|
|
137
|
+
)
|
|
138
|
+
parser_close.add_argument("ghsa_id", help="GitHub Security Advisory ID")
|
|
139
|
+
|
|
140
|
+
# 'move-to-issue'
|
|
141
|
+
parser_move_to_issue = subparsers.add_parser(
|
|
142
|
+
"move-to-issue",
|
|
143
|
+
description="Open a new GitHub issue from a report and close the report",
|
|
144
|
+
)
|
|
145
|
+
parser_move_to_issue.add_argument("ghsa_id", help="GitHub Security Advisory ID")
|
|
146
|
+
|
|
147
|
+
args = parser.parse_args(argv)
|
|
148
|
+
args.gh_token = gh_token
|
|
149
|
+
args.cve_token = cve_token
|
|
150
|
+
if args.repo is None:
|
|
151
|
+
args.repo = default_repo()
|
|
152
|
+
if args.repo is None:
|
|
153
|
+
print(
|
|
154
|
+
"No GitHub repository defined, use 'GH_REPO' environment variable, "
|
|
155
|
+
"'--repo' parameter, or set a git remote named 'upstream' or 'origin'",
|
|
156
|
+
file=sys.stderr,
|
|
157
|
+
)
|
|
158
|
+
return 1
|
|
159
|
+
elif not re.search(r"\A[^/]+/[^/]+\z", args.repo):
|
|
160
|
+
print("GitHub repository must be in the form 'owner/repo'", file=sys.stderr)
|
|
161
|
+
return 1
|
|
162
|
+
args.repo_owner, args.repo_name = args.repo.split("/", 1)
|
|
163
|
+
|
|
164
|
+
if args.ghsa_id is not None and not re.search(
|
|
165
|
+
r"\AGHSA(?:-[a-z0-9]{4}){3}\z", args.ghsa_id
|
|
166
|
+
):
|
|
167
|
+
print(
|
|
168
|
+
"GitHub Security Advisory ID must be in the form 'GHSA-xxxx-xxxx-xxxx'",
|
|
169
|
+
file=sys.stderr,
|
|
170
|
+
)
|
|
171
|
+
return 1
|
|
172
|
+
|
|
173
|
+
command_funcs: dict[str, typing.Callable[[argparse.Namespace], int]] = {
|
|
174
|
+
"credit": command_credit,
|
|
175
|
+
"accept": command_accept,
|
|
176
|
+
"close": command_close,
|
|
177
|
+
"move-to-issue": command_move_to_issue,
|
|
178
|
+
}
|
|
179
|
+
command_func = command_funcs[args.command]
|
|
180
|
+
return command_func(args)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_rfc3339(value: str | None) -> datetime.datetime | None:
|
|
184
|
+
"""Parse a GitHub date according to RFC 3339"""
|
|
185
|
+
if not isinstance(value, str):
|
|
186
|
+
return value
|
|
187
|
+
return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def default_repo() -> str | None:
|
|
191
|
+
"""Resolve a default for the '--repo' argument using current working directory"""
|
|
192
|
+
if "GH_REPO" in os.environ:
|
|
193
|
+
return os.environ["GH_REPO"]
|
|
194
|
+
proc = subprocess.run(
|
|
195
|
+
["git", "remote", "-v"], cwd=os.getcwd(), stdout=subprocess.PIPE
|
|
196
|
+
)
|
|
197
|
+
if proc.returncode != 0:
|
|
198
|
+
return None
|
|
199
|
+
remotes = dict(
|
|
200
|
+
re.findall(r"^([\w+])\s+((?:https?|ssh)://.+)$", proc.stdout.decode("utf-8"))
|
|
201
|
+
)
|
|
202
|
+
for remote in ("upstream", "origin"):
|
|
203
|
+
if remote not in remotes:
|
|
204
|
+
continue
|
|
205
|
+
remote_url = remotes[remote]
|
|
206
|
+
mat = re.search(r"/github\.com/([^/]+)/([^/\s]+)", remote_url)
|
|
207
|
+
if mat:
|
|
208
|
+
return f"{mat.group(1)}/{mat.group(2)}"
|
|
209
|
+
else:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def gh_request(
|
|
214
|
+
method, url, *, gh_token, fields=None, body=None
|
|
215
|
+
) -> urllib3.BaseHTTPResponse:
|
|
216
|
+
"""Make a GitHub API request with logging"""
|
|
217
|
+
try:
|
|
218
|
+
headers = {
|
|
219
|
+
"Accept": "application/vnd.github.v3+json",
|
|
220
|
+
"Authorization": f"Bearer {gh_token}",
|
|
221
|
+
"X-GitHub-Api-Version": "2026-03-10",
|
|
222
|
+
}
|
|
223
|
+
if isinstance(body, dict):
|
|
224
|
+
body = json.dumps(body).encode("utf-8")
|
|
225
|
+
resp = http.request(method, url, fields=fields, body=body, headers=headers)
|
|
226
|
+
print(f"[{resp.status}] {method} {url}", file=sys.stderr)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
print(f"[---] {method} {url}", file=sys.stderr)
|
|
229
|
+
raise e
|
|
230
|
+
return resp
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
sys.exit(main(sys.argv[1:]))
|