|
1 | 1 | #!/usr/bin/env python3 |
2 | | -import argparse |
3 | | -import os |
4 | | -import sys |
5 | | -from datetime import datetime, timezone |
6 | | - |
7 | | -import httpx |
8 | 2 | import trio |
| 3 | +from security_overview.cli import main |
9 | 4 |
|
10 | | -ALL_STATES = ["published", "draft", "triage", "closed"] |
11 | | - |
12 | | -RESET = "\033[0m" |
13 | | -BOLD = "\033[1m" |
14 | | -GREY = "\033[2m" |
15 | | - |
16 | | -STATE_COLORS = { |
17 | | - "draft": "\033[36m", # cyan |
18 | | - "triage": "\033[33m", # yellow |
19 | | - "published": "\033[32m", # green |
20 | | - "closed": GREY, |
21 | | -} |
22 | | - |
23 | | -PR_STATE_COLORS = { |
24 | | - "open": "\033[32m", # green |
25 | | - "merged": "\033[35m", # purple |
26 | | - "closed": "\033[31m", # red |
27 | | -} |
28 | | - |
29 | | - |
30 | | -def pr_state(pull): |
31 | | - if pull is None: |
32 | | - return None |
33 | | - if pull.get("merged_at"): |
34 | | - return "merged" |
35 | | - return pull.get("state", "?") # "open" or "closed" |
36 | | - |
37 | | - |
38 | | -def plasma_rgb(t): |
39 | | - """Plasma color: t=0 → purple (old), t=1 → yellow (recent).""" |
40 | | - r = int(13 + (253 - 13) * t) |
41 | | - g = int(8 + (231 - 8) * t) |
42 | | - b = int(135 + (37 - 135) * t) |
43 | | - return r, g, b |
44 | | - |
45 | | - |
46 | | -def age_t(date_str): |
47 | | - dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) |
48 | | - age = (datetime.now(timezone.utc) - dt).days |
49 | | - return max(0.0, 1.0 - age / 365) |
50 | | - |
51 | | - |
52 | | -def age_badge(date_str, text): |
53 | | - """Render text on a plasma background; foreground chosen for legibility.""" |
54 | | - t = age_t(date_str) if date_str else 0.0 |
55 | | - r, g, b = plasma_rgb(t) |
56 | | - luminance = 0.299 * r + 0.587 * g + 0.114 * b |
57 | | - fg = "\033[30m" if luminance > 128 else "\033[97m" |
58 | | - bg = f"\033[48;2;{r};{g};{b}m" |
59 | | - return f"{bg}{fg}{text}{RESET}" |
60 | | - |
61 | | - |
62 | | -def redact_str(s): |
63 | | - """Replace alphanumeric characters with '•', preserving spaces and punctuation.""" |
64 | | - return "".join("•" if c.isalnum() else c for c in s) |
65 | | - |
66 | | - |
67 | | -GITHUB_API = "https://api.github.com" |
68 | | - |
69 | | - |
70 | | -def check_token(): |
71 | | - token = os.environ.get("GITHUB_TOKEN") |
72 | | - if not token: |
73 | | - print("error: GITHUB_TOKEN environment variable not set", file=sys.stderr) |
74 | | - sys.exit(1) |
75 | | - return token |
76 | | - |
77 | | - |
78 | | -async def fetch_first_pull(client, fork_html_url, results): |
79 | | - """Fetch the first pull request (by creation date) for a private fork repo.""" |
80 | | - path = fork_html_url.split("github.com/", 1)[-1].rstrip("/") |
81 | | - url = f"{GITHUB_API}/repos/{path}/pulls" |
82 | | - params = {"per_page": 1, "state": "all", "sort": "created", "direction": "asc"} |
83 | | - try: |
84 | | - req = client.build_request("GET", url, params=params) |
85 | | - resp = await client.send(req) |
86 | | - if resp.status_code == 200: |
87 | | - pulls = resp.json() |
88 | | - results[fork_html_url] = pulls[0] if pulls else None |
89 | | - else: |
90 | | - results[fork_html_url] = None |
91 | | - except httpx.HTTPError: |
92 | | - results[fork_html_url] = None |
93 | | - |
94 | | - |
95 | | -async def fetch(client, org, state, results): |
96 | | - """Fetch all advisories for (org, state), following pagination, storing in results[(org, state)].""" |
97 | | - url = f"{GITHUB_API}/orgs/{org}/security-advisories" |
98 | | - params = {"state": state, "per_page": 100} |
99 | | - advisories = [] |
100 | | - while url is not None: |
101 | | - try: |
102 | | - req = client.build_request("GET", url, params=params) |
103 | | - resp = await client.send(req) |
104 | | - except httpx.HTTPError as e: |
105 | | - print(f"[error] {org} ({state}): {e}", file=sys.stderr) |
106 | | - break |
107 | | - if resp.status_code != 200: |
108 | | - print( |
109 | | - f"[error] {org} ({state}): HTTP {resp.status_code} {resp.text.strip()}", |
110 | | - file=sys.stderr, |
111 | | - ) |
112 | | - break |
113 | | - try: |
114 | | - page = resp.json() |
115 | | - except ValueError: |
116 | | - break |
117 | | - if isinstance(page, list): |
118 | | - advisories.extend(page) |
119 | | - next_link = resp.links.get("next") |
120 | | - url = next_link["url"] if next_link else None |
121 | | - params = None # next URL already has the pagination cursor |
122 | | - results[(org, state)] = advisories |
123 | | - |
124 | | - |
125 | | -def link(text, target): |
126 | | - return f"\033[4m\033]8;;{target}\033\\{text}\033]8;;\033\\\033[24m" |
127 | | - |
128 | | - |
129 | | -def color(text, color): |
130 | | - return f"{color}{text}{RESET}" |
131 | | - |
132 | | - |
133 | | -def print_org(org, states, results_by_key, pull_results=None, redact=False): |
134 | | - advisories = [] |
135 | | - for state in states: |
136 | | - advisories.extend(results_by_key.get((org, state), [])) |
137 | | - print(f"\n{BOLD}=== {org if not redact else 'REDACTED ORG'} ==={RESET}") |
138 | | - if not advisories: |
139 | | - if not redact: |
140 | | - print(" (no advisories)") |
141 | | - return |
142 | | - |
143 | | - by_repo = {} |
144 | | - for advisory in advisories: |
145 | | - repo = (advisory.get("html_url") or "").split("/")[4] or "unknown" |
146 | | - by_repo.setdefault(repo, []).append(advisory) |
147 | | - |
148 | | - advisories_by_repo = sorted( |
149 | | - by_repo.items(), |
150 | | - key=lambda x: max(a.get("updated_at", "") for a in x[1]), |
151 | | - reverse=True, |
152 | | - ) |
153 | | - |
154 | | - for repo, items in advisories_by_repo: |
155 | | - items = sorted(items, key=lambda a: a.get("updated_at", ""), reverse=True) |
156 | | - print(f"{BOLD}{repo if not redact else 'REDACTED REPO'}{RESET}") |
157 | | - for advisory in items: |
158 | | - id = advisory.get("ghsa_id", "") if not redact else "GHSA-xxxx-yyyy-zzzz" |
159 | | - url = advisory.get("html_url", "") |
160 | | - date = advisory.get("updated_at", "") |
161 | | - title = (advisory.get("summary") or "")[:40].ljust(40) |
162 | | - state_val = advisory.get("state") or "?" |
163 | | - state_str = color(state_val.ljust(9), STATE_COLORS.get(state_val, "")) |
164 | | - cve = advisory.get("cve_id") or "" |
165 | | - fork = advisory.get("private_fork") |
166 | | - fork_html_url = fork.get("html_url") if fork else None |
167 | | - cve_str = cve[:15].ljust(15) |
168 | | - cve_str = color(cve_str, GREY) |
169 | | - first_pull = ( |
170 | | - (pull_results or {}).get(fork_html_url) if fork_html_url else None |
171 | | - ) |
172 | | - ps = pr_state(first_pull) |
173 | | - pull_str = ( |
174 | | - color(link("PR " + ps.ljust(6), first_pull["html_url"]), PR_STATE_COLORS.get(ps, GREY)) |
175 | | - if first_pull else " " * 9 |
176 | | - ) |
177 | | - days_ago = ( |
178 | | - ( |
179 | | - datetime.now(timezone.utc) |
180 | | - - datetime.fromisoformat(date.replace("Z", "+00:00")) |
181 | | - ).days |
182 | | - if date |
183 | | - else "?" |
184 | | - ) |
185 | | - badge = age_badge(date, (str(days_ago) + 'd').rjust(5)) |
186 | | - if redact: |
187 | | - print( |
188 | | - f" {badge}\t{link(id, url)} {state_str}" |
189 | | - ) |
190 | | - else: |
191 | | - print( |
192 | | - f" {badge}\t{state_str} {color(link(id, url), GREY)} {title} {cve_str} {pull_str}" |
193 | | - ) |
194 | | - |
195 | | - |
196 | | -def parse_states(value): |
197 | | - """Accept a comma-separated list of states; validate each against ALL_STATES.""" |
198 | | - states = [s.strip() for s in value.split(",") if s.strip()] |
199 | | - for s in states: |
200 | | - if s not in ALL_STATES: |
201 | | - raise argparse.ArgumentTypeError( |
202 | | - f"invalid state {s!r} (choose from {', '.join(ALL_STATES)})" |
203 | | - ) |
204 | | - return states |
205 | | - |
206 | | - |
207 | | -async def main(): |
208 | | - token = check_token() |
209 | | - parser = argparse.ArgumentParser(prog="security-overview") |
210 | | - parser.add_argument("orgs", nargs="+", metavar="org") |
211 | | - parser.add_argument( |
212 | | - "--state", |
213 | | - type=parse_states, |
214 | | - action="append", |
215 | | - metavar="STATE[,STATE...]", |
216 | | - help="filter by state; repeatable and/or comma-separated (default: all states)", |
217 | | - ) |
218 | | - parser.add_argument( |
219 | | - "--redact", |
220 | | - action="store_true", |
221 | | - help="hide org/repo names (as ORG/REPO), GHSA random chars, and drop title + headers", |
222 | | - ) |
223 | | - args = parser.parse_args() |
224 | | - states = [s for group in (args.state or []) for s in group] or ALL_STATES |
225 | | - |
226 | | - headers = { |
227 | | - "Authorization": f"Bearer {token}", |
228 | | - "Accept": "application/vnd.github+json", |
229 | | - "X-GitHub-Api-Version": "2026-03-10", |
230 | | - "User-Agent": "security-overview", |
231 | | - } |
232 | | - |
233 | | - results = {} |
234 | | - pull_results = {} |
235 | | - async with httpx.AsyncClient(headers=headers, timeout=30.0) as client: |
236 | | - async with trio.open_nursery() as nursery: |
237 | | - for org in args.orgs: |
238 | | - for state in states: |
239 | | - nursery.start_soon(fetch, client, org, state, results) |
240 | | - |
241 | | - fork_urls = { |
242 | | - advisory["private_fork"]["html_url"] |
243 | | - for advisories in results.values() |
244 | | - for advisory in advisories |
245 | | - if advisory.get("private_fork") and advisory["private_fork"].get("html_url") |
246 | | - } |
247 | | - async with trio.open_nursery() as nursery: |
248 | | - for fork_url in fork_urls: |
249 | | - nursery.start_soon(fetch_first_pull, client, fork_url, pull_results) |
250 | | - |
251 | | - for org in args.orgs: |
252 | | - print_org(org, states, results, pull_results=pull_results, redact=args.redact) |
253 | | - print() |
254 | | - |
255 | | - |
256 | | -if __name__ == "__main__": |
257 | | - trio.run(main) |
| 5 | +trio.run(main) |
0 commit comments