Skip to content

Commit f4e6867

Browse files
committed
Add markdown output and tests
1 parent 9f90cfb commit f4e6867

16 files changed

Lines changed: 558 additions & 254 deletions

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- uses: astral-sh/setup-uv@v5
14+
15+
- name: Install dependencies
16+
run: uv sync --extra dev
17+
18+
- name: Run tests
19+
run: uv run pytest tests/

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
.claude
2+
.vscode
3+
.venv
4+
.pytest_cache
5+
.ruff_cache
6+
__pycache__
7+
*.egg-info
8+
task*.md

pyproject.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[build-system]
2+
requires = ["setuptools"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "security-overview"
7+
version = "0.1.0"
8+
requires-python = ">=3.10"
9+
dependencies = ["httpx", "trio"]
10+
11+
[project.optional-dependencies]
12+
dev = ["pytest", "pytest-trio", "respx"]
13+
14+
[tool.setuptools.packages.find]
15+
where = ["."]
16+
include = ["security_overview*"]

security-overview

Lines changed: 2 additions & 254 deletions
Original file line numberDiff line numberDiff line change
@@ -1,257 +1,5 @@
11
#!/usr/bin/env python3
2-
import argparse
3-
import os
4-
import sys
5-
from datetime import datetime, timezone
6-
7-
import httpx
82
import trio
3+
from security_overview.cli import main
94

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)

security_overview/__init__.py

Whitespace-only changes.

security_overview/cli.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import argparse
2+
3+
import httpx
4+
import trio
5+
6+
from . import render_md, render_terminal
7+
from .constants import ALL_STATES
8+
from .fetch import check_token, fetch, fetch_first_pull
9+
10+
RENDERERS = {
11+
"terminal": render_terminal,
12+
"md": render_md,
13+
}
14+
15+
16+
def parse_states(value):
17+
states = [s.strip() for s in value.split(",") if s.strip()]
18+
for s in states:
19+
if s not in ALL_STATES:
20+
raise argparse.ArgumentTypeError(
21+
f"invalid state {s!r} (choose from {', '.join(ALL_STATES)})"
22+
)
23+
return states
24+
25+
26+
async def main():
27+
token = check_token()
28+
parser = argparse.ArgumentParser(prog="security-overview")
29+
parser.add_argument("orgs", nargs="+", metavar="org")
30+
parser.add_argument(
31+
"--state",
32+
type=parse_states,
33+
action="append",
34+
metavar="STATE[,STATE...]",
35+
help="filter by state; repeatable and/or comma-separated (default: all states)",
36+
)
37+
parser.add_argument(
38+
"--redact",
39+
action="store_true",
40+
help="hide org/repo names, GHSA random chars, and drop title + headers",
41+
)
42+
parser.add_argument(
43+
"--format",
44+
choices=["terminal", "md"],
45+
default="terminal",
46+
help="output format (default: terminal)",
47+
)
48+
args = parser.parse_args()
49+
states = [s for group in (args.state or []) for s in group] or ALL_STATES
50+
renderer = RENDERERS[args.format]
51+
52+
headers = {
53+
"Authorization": f"Bearer {token}",
54+
"Accept": "application/vnd.github+json",
55+
"X-GitHub-Api-Version": "2026-03-10",
56+
"User-Agent": "security-overview",
57+
}
58+
59+
results = {}
60+
pull_results = {}
61+
async with httpx.AsyncClient(headers=headers, timeout=30.0) as client:
62+
async with trio.open_nursery() as nursery:
63+
for org in args.orgs:
64+
for state in states:
65+
nursery.start_soon(fetch, client, org, state, results)
66+
67+
fork_urls = {
68+
advisory["private_fork"]["html_url"]
69+
for advisories in results.values()
70+
for advisory in advisories
71+
if advisory.get("private_fork") and advisory["private_fork"].get("html_url")
72+
}
73+
async with trio.open_nursery() as nursery:
74+
for fork_url in fork_urls:
75+
nursery.start_soon(fetch_first_pull, client, fork_url, pull_results)
76+
77+
for org in args.orgs:
78+
print(renderer.render_org(org, states, results, pull_results=pull_results, redact=args.redact))
79+
print()

0 commit comments

Comments
 (0)