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.
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghsa-cli
3
+ Version: 2026.4.2
4
+ Summary: CLI for efficiently interacting with GitHub Security Advisories (GHSA)
5
+ Requires-Python: >=3.14
6
+ License-Expression: MIT
@@ -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:]))