Skip to content

Commit a6e8914

Browse files
committed
Packaging ; improve terminal output
1 parent f4e6867 commit a6e8914

7 files changed

Lines changed: 163 additions & 47 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ build-backend = "setuptools.build_meta"
55
[project]
66
name = "security-overview"
77
version = "0.1.0"
8+
description = "Overview of GitHub security advisories across orgs"
9+
readme = "README.md"
810
requires-python = ">=3.10"
911
dependencies = ["httpx", "trio"]
1012

1113
[project.optional-dependencies]
1214
dev = ["pytest", "pytest-trio", "respx"]
1315

16+
[project.scripts]
17+
security-overview = "security_overview.cli:run"
18+
1419
[tool.setuptools.packages.find]
1520
where = ["."]
1621
include = ["security_overview*"]

security_overview/cli.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from . import render_md, render_terminal
77
from .constants import ALL_STATES
8-
from .fetch import check_token, fetch, fetch_first_pull
8+
from .fetch import check_token, fetch, fetch_pulls
99

1010
RENDERERS = {
1111
"terminal": render_terminal,
@@ -72,8 +72,15 @@ async def main():
7272
}
7373
async with trio.open_nursery() as nursery:
7474
for fork_url in fork_urls:
75-
nursery.start_soon(fetch_first_pull, client, fork_url, pull_results)
75+
nursery.start_soon(fetch_pulls, client, fork_url, pull_results)
7676

7777
for org in args.orgs:
78-
print(renderer.render_org(org, states, results, pull_results=pull_results, redact=args.redact))
78+
out = renderer.render_org(org, states, results, pull_results=pull_results, redact=args.redact)
79+
if out:
80+
print(out)
7981
print()
82+
83+
84+
def run():
85+
"""Synchronous console-script entry point."""
86+
trio.run(main)

security_overview/fetch.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,19 @@ def check_token():
1414
return token
1515

1616

17-
async def fetch_first_pull(client, fork_html_url, results):
17+
async def fetch_pulls(client, fork_html_url, results):
1818
path = fork_html_url.split("github.com/", 1)[-1].rstrip("/")
1919
url = f"{GITHUB_API}/repos/{path}/pulls"
20-
params = {"per_page": 1, "state": "all", "sort": "created", "direction": "asc"}
20+
params = {"per_page": 100, "state": "all", "sort": "created", "direction": "asc"}
2121
try:
2222
req = client.build_request("GET", url, params=params)
2323
resp = await client.send(req)
2424
if resp.status_code == 200:
25-
pulls = resp.json()
26-
results[fork_html_url] = pulls[0] if pulls else None
25+
results[fork_html_url] = resp.json()
2726
else:
28-
results[fork_html_url] = None
27+
results[fork_html_url] = []
2928
except httpx.HTTPError:
30-
results[fork_html_url] = None
29+
results[fork_html_url] = []
3130

3231

3332
async def fetch(client, org, state, results):

security_overview/render_md.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .render_common import days_ago, group_by_repo, pr_state
1+
from .render_common import days_ago, group_by_repo
22

33

44
def render_org(org, states, results_by_key, pull_results=None, redact=False):
@@ -9,14 +9,13 @@ def render_org(org, states, results_by_key, pull_results=None, redact=False):
99
lines = [f"\n## {'REDACTED ORG' if redact else org}"]
1010

1111
if not advisories:
12-
lines.append("_no advisories_")
13-
return "\n".join(lines)
12+
return ""
1413

1514
for repo, items in group_by_repo(advisories):
1615
items = sorted(items, key=lambda a: a.get("updated_at", ""), reverse=True)
1716
lines.append(f"\n### {'REDACTED REPO' if redact else repo}\n")
18-
lines.append("| Age | State | Advisory | Title | CVE | PR |")
19-
lines.append("|-----|-------|----------|-------|-----|----|")
17+
lines.append("| Age | State | Advisory | Title | CVE | PRs |")
18+
lines.append("|-----|-------|----------|-------|-----|-----|")
2019
for advisory in items:
2120
ghsa_id = advisory.get("ghsa_id", "") if not redact else "GHSA-xxxx-yyyy-zzzz"
2221
url = advisory.get("html_url", "")
@@ -26,11 +25,10 @@ def render_org(org, states, results_by_key, pull_results=None, redact=False):
2625
cve = advisory.get("cve_id") or ""
2726
fork = advisory.get("private_fork")
2827
fork_html_url = fork.get("html_url") if fork else None
29-
first_pull = (pull_results or {}).get(fork_html_url) if fork_html_url else None
30-
ps = pr_state(first_pull)
28+
pulls = (pull_results or {}).get(fork_html_url, []) if fork_html_url else []
3129
d = days_ago(date)
3230
advisory_cell = f"[{ghsa_id}]({url})" if (url and not redact) else ghsa_id
33-
pr_cell = f"[PR {ps}]({first_pull['html_url']})" if first_pull else ""
31+
pr_cell = "PRs: " + ", ".join(f"[#{p['number']}]({p['html_url']})" for p in pulls) if pulls else ""
3432
lines.append(f"| {d}d | {state_val} | {advisory_cell} | {title} | {cve} | {pr_cell} |")
3533

3634
return "\n".join(lines)
Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
import shutil
2+
13
from .constants import BOLD, GREY, RESET, PR_STATE_COLORS, STATE_COLORS
24
from .render_common import age_t, days_ago, group_by_repo, plasma_rgb, pr_state
35

6+
# Visible chars on the advisory line excluding title:
7+
# 4(connector) + 3(badge) + 2 + 9(state) + 2 = 20
8+
_FIXED_OVERHEAD = 20
9+
_NVD_URL = "https://nvd.nist.gov/vuln/detail/{}"
10+
411

512
def _link(text, target):
613
return f"\033[4m\033]8;;{target}\033\\{text}\033]8;;\033\\\033[24m"
@@ -10,6 +17,22 @@ def _color(text, c):
1017
return f"{c}{text}{RESET}"
1118

1219

20+
def _dim(text):
21+
return _color(text, GREY)
22+
23+
24+
def _fmt_age(days):
25+
if days < 7:
26+
s = f"{days}d"
27+
elif days < 30:
28+
s = f"{days // 7}w"
29+
elif days < 365:
30+
s = f"{days // 30}m"
31+
else:
32+
s = f"{days // 365}y"
33+
return s.rjust(3)
34+
35+
1336
def _age_badge(date_str, text):
1437
t = age_t(date_str) if date_str else 0.0
1538
r, g, b = plasma_rgb(t)
@@ -20,42 +43,64 @@ def _age_badge(date_str, text):
2043

2144

2245
def render_org(org, states, results_by_key, pull_results=None, redact=False):
46+
term_w = shutil.get_terminal_size(fallback=(120, 24)).columns
47+
title_w = max(10, term_w - _FIXED_OVERHEAD)
2348
advisories = []
2449
for state in states:
2550
advisories.extend(results_by_key.get((org, state), []))
2651

2752
lines = [f"\n{BOLD}=== {org if not redact else 'REDACTED ORG'} ==={RESET}"]
2853

2954
if not advisories:
30-
if not redact:
31-
lines.append(" (no advisories)")
32-
return "\n".join(lines)
55+
return ""
3356

57+
first_repo = True
3458
for repo, items in group_by_repo(advisories):
3559
items = sorted(items, key=lambda a: a.get("updated_at", ""), reverse=True)
60+
if not items:
61+
continue
62+
if not first_repo:
63+
lines.append("")
64+
first_repo = False
65+
3666
lines.append(f"{BOLD}{repo if not redact else 'REDACTED REPO'}{RESET}")
37-
for advisory in items:
38-
id_ = advisory.get("ghsa_id", "") if not redact else "GHSA-xxxx-yyyy-zzzz"
67+
for i, advisory in enumerate(items):
68+
is_last = i == len(items) - 1
69+
connector = _dim("└── " if is_last else "├── ")
70+
# prefix for sub-lines: continue the vertical bar only if more advisories follow
71+
sub_prefix = " " if is_last else "│ "
72+
3973
url = advisory.get("html_url", "")
4074
date = advisory.get("updated_at", "")
41-
title = (advisory.get("summary") or "")[:40].ljust(40)
4275
state_val = advisory.get("state") or "?"
4376
state_str = _color(state_val.ljust(9), STATE_COLORS.get(state_val, ""))
4477
cve = advisory.get("cve_id") or ""
45-
cve_str = _color(cve[:15].ljust(15), GREY)
4678
fork = advisory.get("private_fork")
4779
fork_html_url = fork.get("html_url") if fork else None
48-
first_pull = (pull_results or {}).get(fork_html_url) if fork_html_url else None
49-
ps = pr_state(first_pull)
50-
pull_str = (
51-
_color(_link("PR " + ps.ljust(6), first_pull["html_url"]), PR_STATE_COLORS.get(ps, GREY))
52-
if first_pull else " " * 9
53-
)
80+
pulls = (pull_results or {}).get(fork_html_url, []) if fork_html_url else []
81+
title = (advisory.get("summary") or "")[:title_w]
5482
d = days_ago(date)
55-
badge = _age_badge(date, (str(d) + "d").rjust(5))
83+
badge = _age_badge(date, _fmt_age(d))
84+
5685
if redact:
57-
lines.append(f" {badge}\t{id_} {state_str}")
86+
lines.append(f"{connector}{badge} {state_str}")
5887
else:
59-
lines.append(f" {badge}\t{state_str} {_color(_link(id_, url), GREY)} {title} {cve_str} {pull_str}")
88+
lines.append(f"{connector}{badge} {state_str} {_link(title, url)}")
89+
90+
sub_items = []
91+
if cve:
92+
sub_items.append(_link(cve, _NVD_URL.format(cve)))
93+
if pulls:
94+
pr_items = []
95+
for p in pulls:
96+
ps = pr_state(p)
97+
label = "opened" if ps == "open" else ps
98+
pr_items.append(_color(_link(f"#{p['number']} ({label})", p["html_url"]), PR_STATE_COLORS.get(ps, GREY)))
99+
sub_items.append("PRs: " + ", ".join(pr_items))
100+
101+
for j, sub_item in enumerate(sub_items):
102+
is_last_sub = j == len(sub_items) - 1
103+
sub_connector = _dim(sub_prefix + ("└── " if is_last_sub else "├── "))
104+
lines.append(f"{sub_connector}{sub_item}")
60105

61106
return "\n".join(lines)

tests/test_render_md.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def test_repo_heading():
2626

2727
def test_table_header():
2828
out = render_md.render_org(ORG, STATES, RESULTS)
29-
assert "| Age | State | Advisory | Title | CVE | PR |" in out
29+
assert "| Age | State | Advisory | Title | CVE | PRs |" in out
3030

3131

3232
def test_linked_ghsa():
@@ -41,7 +41,7 @@ def test_cve_in_row():
4141

4242
def test_no_advisories():
4343
out = render_md.render_org(ORG, STATES, {})
44-
assert "_no advisories_" in out
44+
assert out == ""
4545

4646

4747
def test_redact_hides_org():
@@ -64,11 +64,27 @@ def test_pr_link():
6464
}]
6565
}
6666
pull_results = {
67-
"https://github.com/acme/repo-x-private": {
68-
"html_url": "https://github.com/acme/repo-x-private/pull/1",
69-
"state": "open",
70-
"merged_at": None,
71-
}
67+
"https://github.com/acme/repo-x-private": [
68+
{
69+
"number": 1,
70+
"html_url": "https://github.com/acme/repo-x-private/pull/1",
71+
"state": "open",
72+
"merged_at": None,
73+
},
74+
{
75+
"number": 3,
76+
"html_url": "https://github.com/acme/repo-x-private/pull/3",
77+
"state": "open",
78+
"merged_at": None,
79+
},
80+
]
7281
}
7382
out = render_md.render_org(ORG, STATES, results, pull_results=pull_results)
74-
assert "[PR open](" in out
83+
assert "[#1](https://github.com/acme/repo-x-private/pull/1)" in out
84+
assert "[#3](https://github.com/acme/repo-x-private/pull/3)" in out
85+
assert "PRs:" in out
86+
87+
88+
def test_table_header_pr_column():
89+
out = render_md.render_org(ORG, STATES, RESULTS)
90+
assert "| PRs |" in out

tests/test_render_terminal.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,60 @@ def test_repo_name():
2424
assert "repo-x" in out
2525

2626

27-
def test_ghsa_id_present():
27+
def test_title_linked():
2828
out = render_terminal.render_org(ORG, STATES, RESULTS)
29-
assert "GHSA-1234-5678-abcd" in out
29+
assert "Remote code execution via evil input" in out
30+
assert ADVISORY["html_url"] in out
3031

3132

3233
def test_no_advisories():
3334
out = render_terminal.render_org(ORG, STATES, {})
34-
assert "(no advisories)" in out
35+
assert out == ""
36+
37+
38+
def test_tree_connector_single():
39+
out = render_terminal.render_org(ORG, STATES, RESULTS)
40+
assert "└── " in out
41+
42+
43+
def test_tree_connector_multiple():
44+
results = {
45+
(ORG, "draft"): [
46+
ADVISORY,
47+
{**ADVISORY, "ghsa_id": "GHSA-aaaa-bbbb-cccc", "updated_at": "2024-05-01T00:00:00Z"},
48+
]
49+
}
50+
out = render_terminal.render_org(ORG, STATES, results)
51+
assert "├── " in out
52+
assert "└── " in out
53+
54+
55+
def test_cve_on_sub_line():
56+
out = render_terminal.render_org(ORG, STATES, RESULTS)
57+
lines = out.splitlines()
58+
cve_line = next(l for l in lines if "CVE-2024-9999" in l)
59+
assert "└── " in cve_line
60+
assert "nvd.nist.gov" in cve_line
61+
62+
63+
def test_pr_on_sub_line():
64+
results = {
65+
(ORG, "draft"): [{
66+
**ADVISORY,
67+
"private_fork": {"html_url": "https://github.com/acme/repo-x-private"},
68+
}]
69+
}
70+
pull_results = {
71+
"https://github.com/acme/repo-x-private": [
72+
{"number": 7, "html_url": "https://github.com/acme/repo-x-private/pull/7",
73+
"state": "open", "merged_at": None},
74+
]
75+
}
76+
out = render_terminal.render_org(ORG, STATES, results, pull_results=pull_results)
77+
lines = out.splitlines()
78+
sub_line = next(l for l in lines if "#7" in l)
79+
assert "opened" in sub_line
80+
assert "└── " in sub_line
3581

3682

3783
def test_redact_hides_org():
@@ -45,7 +91,7 @@ def test_redact_hides_repo():
4591
assert "repo-x" not in out
4692

4793

48-
def test_redact_masks_ghsa():
94+
def test_redact_hides_title():
4995
out = render_terminal.render_org(ORG, STATES, RESULTS, redact=True)
50-
assert "GHSA-xxxx-yyyy-zzzz" in out
51-
assert "GHSA-1234-5678-abcd" not in out
96+
assert "Remote code execution" not in out
97+
assert ADVISORY["html_url"] not in out

0 commit comments

Comments
 (0)