github-security-report 0.1.0__py3-none-any.whl
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.
- github_security_report/__init__.py +13 -0
- github_security_report/_version.py +24 -0
- github_security_report/classify.py +208 -0
- github_security_report/cli.py +448 -0
- github_security_report/client.py +493 -0
- github_security_report/collect.py +376 -0
- github_security_report/config.py +343 -0
- github_security_report/gitctx.py +66 -0
- github_security_report/models.py +172 -0
- github_security_report/posture.py +264 -0
- github_security_report/py.typed +1 -0
- github_security_report/render/__init__.py +3 -0
- github_security_report/render/html.py +142 -0
- github_security_report/render/markdown.py +172 -0
- github_security_report/render/slack.py +215 -0
- github_security_report/render/terminal.py +163 -0
- github_security_report/report.py +173 -0
- github_security_report/rulesets.py +137 -0
- github_security_report/runner.py +147 -0
- github_security_report/scope.py +97 -0
- github_security_report/severity.py +83 -0
- github_security_report/templates/index.html.j2 +57 -0
- github_security_report/templates/report.html.j2 +162 -0
- github_security_report-0.1.0.dist-info/METADATA +318 -0
- github_security_report-0.1.0.dist-info/RECORD +28 -0
- github_security_report-0.1.0.dist-info/WHEEL +4 -0
- github_security_report-0.1.0.dist-info/entry_points.txt +2 -0
- github_security_report-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Slack rendering.
|
|
4
|
+
|
|
5
|
+
Slack mrkdwn cannot render Markdown tables, so the digest uses fixed-width
|
|
6
|
+
code-fenced blocks (the only way to align columns) showing the worst N
|
|
7
|
+
offenders per signal, plus a prominent link to the full GitHub Pages report.
|
|
8
|
+
Produces a ``chat.postMessage`` payload. See ``docs/BRIEF.md`` section 11.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from github_security_report.models import RepoSignal, SignalType
|
|
14
|
+
from github_security_report.report import (
|
|
15
|
+
OrgReport,
|
|
16
|
+
SignalSection,
|
|
17
|
+
TableSection,
|
|
18
|
+
truncate,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Slack rejects a chat.postMessage with more than 50 blocks, so a digest
|
|
22
|
+
# spanning many orgs must be capped or the whole message fails to deliver.
|
|
23
|
+
_SLACK_MAX_BLOCKS = 50
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _plain_columns(signal: SignalType) -> list[str]:
|
|
27
|
+
if signal is SignalType.SECRET_SCANNING:
|
|
28
|
+
return ["repo", "open"]
|
|
29
|
+
if signal is SignalType.SCORECARD:
|
|
30
|
+
return ["repo", "score", "C", "H", "M", "L"]
|
|
31
|
+
return ["repo", "C", "H", "M", "L"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _plain_row(sig: RepoSignal) -> list[str]:
|
|
35
|
+
c = sig.counts
|
|
36
|
+
if sig.signal is SignalType.SECRET_SCANNING:
|
|
37
|
+
return [sig.repo.name, str(c.total)]
|
|
38
|
+
if sig.signal is SignalType.SCORECARD:
|
|
39
|
+
score = f"{sig.score:.1f}" if sig.score is not None else "-"
|
|
40
|
+
return [sig.repo.name, score, str(c.critical), str(c.high), str(c.medium), str(c.low)]
|
|
41
|
+
return [sig.repo.name, str(c.critical), str(c.high), str(c.medium), str(c.low)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fixed_table(section: SignalSection, top_n: int) -> str:
|
|
45
|
+
cols = _plain_columns(section.signal)
|
|
46
|
+
shown, hidden = truncate(section.offenders, top_n)
|
|
47
|
+
rows = [_plain_row(s) for s in shown]
|
|
48
|
+
widths = [len(c) for c in cols]
|
|
49
|
+
for row in rows:
|
|
50
|
+
for i, cell in enumerate(row):
|
|
51
|
+
widths[i] = max(widths[i], len(cell))
|
|
52
|
+
# First column left-aligned (repo name), numeric columns right-aligned.
|
|
53
|
+
def fmt(row: list[str]) -> str:
|
|
54
|
+
cells = [row[0].ljust(widths[0])]
|
|
55
|
+
cells += [row[i].rjust(widths[i]) for i in range(1, len(row))]
|
|
56
|
+
return " ".join(cells)
|
|
57
|
+
|
|
58
|
+
lines = [fmt(cols)] + [fmt(row) for row in rows]
|
|
59
|
+
if hidden:
|
|
60
|
+
# Match the posture/release tables: surface the hidden count.
|
|
61
|
+
lines.append(f"… and {hidden} more")
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _summary(section: SignalSection) -> str:
|
|
66
|
+
bits = []
|
|
67
|
+
if section.offenders:
|
|
68
|
+
bits.append(f"{len(section.offenders)} with findings")
|
|
69
|
+
if section.clean_count:
|
|
70
|
+
bits.append(f"{section.clean_count} clean")
|
|
71
|
+
if section.nag_repos:
|
|
72
|
+
bits.append(f"{len(section.nag_repos)} not enabled")
|
|
73
|
+
if section.unknown_count:
|
|
74
|
+
bits.append(f"{section.unknown_count} unknown")
|
|
75
|
+
return ", ".join(bits) or "no data"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fixed_table_generic(columns: tuple[str, ...], rows: list[list[str]]) -> str:
|
|
79
|
+
"""Fixed-width text table for a generic posture/freshness table."""
|
|
80
|
+
widths = [len(c) for c in columns]
|
|
81
|
+
for row in rows:
|
|
82
|
+
for i, cell in enumerate(row):
|
|
83
|
+
widths[i] = max(widths[i], len(cell))
|
|
84
|
+
|
|
85
|
+
def fmt(row: list[str]) -> str:
|
|
86
|
+
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
|
|
87
|
+
|
|
88
|
+
lines = [fmt(list(columns))] + [fmt(row) for row in rows]
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _table_block(section: TableSection, top_n: int) -> dict | None:
|
|
93
|
+
"""A Slack section block for a posture/freshness table (None when empty).
|
|
94
|
+
|
|
95
|
+
Emoji cell glyphs are dropped from the fixed-width rendering so columns stay
|
|
96
|
+
aligned in Slack's monospace block; only the worst ``top_n`` rows are shown.
|
|
97
|
+
"""
|
|
98
|
+
if not section.rows:
|
|
99
|
+
return None
|
|
100
|
+
shown, hidden = truncate(section.rows, top_n)
|
|
101
|
+
rows = [
|
|
102
|
+
[row.repo.name, *(cell.replace("✅", "y").replace("❌", "n").replace("❓", "?") for cell in row.cells)]
|
|
103
|
+
for row in shown
|
|
104
|
+
]
|
|
105
|
+
table = _fixed_table_generic(section.columns, rows)
|
|
106
|
+
if hidden:
|
|
107
|
+
table += f"\n… and {hidden} more"
|
|
108
|
+
return {
|
|
109
|
+
"type": "section",
|
|
110
|
+
"text": {"type": "mrkdwn", "text": f"*{section.title}*\n```\n{table}\n```"},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_org_blocks(org: OrgReport, *, top_n: int, pages_url: str | None) -> list[dict]:
|
|
115
|
+
"""Slack blocks for one organisation."""
|
|
116
|
+
blocks: list[dict] = [
|
|
117
|
+
{
|
|
118
|
+
"type": "header",
|
|
119
|
+
"text": {"type": "plain_text", "text": f"🔐 Security report: {org.org}"},
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
if org.partial:
|
|
123
|
+
blocks.append(
|
|
124
|
+
{
|
|
125
|
+
"type": "context",
|
|
126
|
+
"elements": [
|
|
127
|
+
{
|
|
128
|
+
"type": "mrkdwn",
|
|
129
|
+
"text": "⚠️ Incomplete: the repository listing could not "
|
|
130
|
+
"be fully read; some repositories may be missing.",
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
if org.excluded_repos:
|
|
136
|
+
shown, hidden = truncate(org.excluded_repos, top_n)
|
|
137
|
+
names = ", ".join(r.name for r in shown)
|
|
138
|
+
if hidden:
|
|
139
|
+
names += f" … (+{hidden} more)"
|
|
140
|
+
blocks.append(
|
|
141
|
+
{
|
|
142
|
+
"type": "context",
|
|
143
|
+
"elements": [
|
|
144
|
+
{
|
|
145
|
+
"type": "mrkdwn",
|
|
146
|
+
"text": f"⏩ Excluded from analysis "
|
|
147
|
+
f"({len(org.excluded_repos)}): {names}",
|
|
148
|
+
}
|
|
149
|
+
],
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
for section in org.sections:
|
|
153
|
+
summary = _summary(section)
|
|
154
|
+
text = f"*{section.signal.heading}* — {summary}"
|
|
155
|
+
if section.offenders:
|
|
156
|
+
table = _fixed_table(section, top_n)
|
|
157
|
+
text += f"\n```\n{table}\n```"
|
|
158
|
+
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
|
|
159
|
+
# Dependabot posture sub-tables follow the Dependabot signal block.
|
|
160
|
+
if section.signal is SignalType.DEPENDABOT:
|
|
161
|
+
for table_section in org.dependabot_tables:
|
|
162
|
+
block = _table_block(table_section, top_n)
|
|
163
|
+
if block is not None:
|
|
164
|
+
blocks.append(block)
|
|
165
|
+
if org.releases is not None:
|
|
166
|
+
block = _table_block(org.releases, top_n)
|
|
167
|
+
if block is not None:
|
|
168
|
+
blocks.append(block)
|
|
169
|
+
if pages_url:
|
|
170
|
+
blocks.append(
|
|
171
|
+
{
|
|
172
|
+
"type": "context",
|
|
173
|
+
"elements": [
|
|
174
|
+
{"type": "mrkdwn", "text": f"<{pages_url}|View the full report>"}
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
return blocks
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _enforce_block_limit(blocks: list[dict], pages_url: str | None) -> list[dict]:
|
|
182
|
+
"""Cap blocks at Slack's per-message limit, noting any truncation.
|
|
183
|
+
|
|
184
|
+
A digest covering many orgs can exceed 50 blocks, which makes Slack reject
|
|
185
|
+
the entire message (no digest delivered). Keep the first blocks and replace
|
|
186
|
+
the overflow with a single note pointing at the full report.
|
|
187
|
+
"""
|
|
188
|
+
if len(blocks) <= _SLACK_MAX_BLOCKS:
|
|
189
|
+
return blocks
|
|
190
|
+
if pages_url:
|
|
191
|
+
note = (
|
|
192
|
+
f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit; "
|
|
193
|
+
f"<{pages_url}|view the full report>."
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
note = f"… digest truncated to Slack's {_SLACK_MAX_BLOCKS}-block limit."
|
|
197
|
+
kept = blocks[: _SLACK_MAX_BLOCKS - 1]
|
|
198
|
+
kept.append({"type": "context", "elements": [{"type": "mrkdwn", "text": note}]})
|
|
199
|
+
return kept
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render_payload(
|
|
203
|
+
orgs: list[OrgReport], *, channel: str, top_n: int = 10, pages_url: str | None = None
|
|
204
|
+
) -> dict:
|
|
205
|
+
"""Build a ``chat.postMessage`` payload across one or more organisations."""
|
|
206
|
+
blocks: list[dict] = []
|
|
207
|
+
for org in orgs:
|
|
208
|
+
blocks.extend(render_org_blocks(org, top_n=top_n, pages_url=pages_url))
|
|
209
|
+
blocks = _enforce_block_limit(blocks, pages_url)
|
|
210
|
+
names = ", ".join(o.org for o in orgs)
|
|
211
|
+
return {
|
|
212
|
+
"channel": channel,
|
|
213
|
+
"text": f"🔐 Security report: {names}",
|
|
214
|
+
"blocks": blocks,
|
|
215
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Rich terminal rendering.
|
|
4
|
+
|
|
5
|
+
The default presentation for local/TTY runs: one coloured table per signal,
|
|
6
|
+
worst-first, with clean/nag/unknown summaries beneath. The CLI falls back to a
|
|
7
|
+
plain console (no colour) in CI / non-TTY contexts. See ``docs/BRIEF.md``
|
|
8
|
+
sections 10-11.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from github_security_report.models import Repo, RepoSignal, SignalType
|
|
20
|
+
from github_security_report.report import (
|
|
21
|
+
OrgReport,
|
|
22
|
+
SignalSection,
|
|
23
|
+
TableSection,
|
|
24
|
+
truncate,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_SEVERITY_STYLE = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "dim"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _split_sentences(text: str) -> list[str]:
|
|
31
|
+
"""Split a footnote into one sentence per line for readable terminal output.
|
|
32
|
+
|
|
33
|
+
Splits on a sentence-ending period followed by whitespace, keeping the
|
|
34
|
+
period. A semicolon does not end a sentence, so a clause such as
|
|
35
|
+
"mandatory; any value passes." stays on one line. A single-sentence note is
|
|
36
|
+
returned unchanged as one line.
|
|
37
|
+
"""
|
|
38
|
+
parts = re.split(r"(?<=\.)\s+", text.strip())
|
|
39
|
+
return [part for part in parts if part]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _add_columns(table: Table, signal: SignalType) -> None:
|
|
43
|
+
table.add_column("Repository", overflow="fold")
|
|
44
|
+
if signal is SignalType.SECRET_SCANNING:
|
|
45
|
+
table.add_column("Open", justify="right")
|
|
46
|
+
return
|
|
47
|
+
if signal is SignalType.SCORECARD:
|
|
48
|
+
table.add_column("Score", justify="right")
|
|
49
|
+
for name, style in _SEVERITY_STYLE.items():
|
|
50
|
+
table.add_column(name.capitalize(), justify="right", style=style)
|
|
51
|
+
if signal is not SignalType.SCORECARD:
|
|
52
|
+
table.add_column("Total", justify="right")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _row(sig: RepoSignal) -> list[str]:
|
|
56
|
+
c = sig.counts
|
|
57
|
+
if sig.signal is SignalType.SECRET_SCANNING:
|
|
58
|
+
return [sig.repo.name, str(c.total)]
|
|
59
|
+
base = [str(c.critical), str(c.high), str(c.medium), str(c.low)]
|
|
60
|
+
if sig.signal is SignalType.SCORECARD:
|
|
61
|
+
score = f"{sig.score:.1f}" if sig.score is not None else "—"
|
|
62
|
+
return [sig.repo.name, score, *base]
|
|
63
|
+
return [sig.repo.name, *base, str(c.total)]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _names(repos: Sequence[Repo], top_n: int | None) -> str:
|
|
67
|
+
"""Comma-joined repo names limited to ``top_n`` with a '(+N more)' tail."""
|
|
68
|
+
shown, hidden = truncate(repos, top_n)
|
|
69
|
+
text = ", ".join(r.name for r in shown)
|
|
70
|
+
if hidden:
|
|
71
|
+
text += f" … (+{hidden} more)"
|
|
72
|
+
return text
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_section(
|
|
76
|
+
section: SignalSection,
|
|
77
|
+
console: Console,
|
|
78
|
+
*,
|
|
79
|
+
excluded: Sequence[Repo] = (),
|
|
80
|
+
top_n: int | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
offenders, hidden_offenders = truncate(section.offenders, top_n)
|
|
83
|
+
if offenders:
|
|
84
|
+
table = Table(title=section.signal.heading, title_justify="left", title_style="bold")
|
|
85
|
+
_add_columns(table, section.signal)
|
|
86
|
+
for sig in offenders:
|
|
87
|
+
table.add_row(*_row(sig))
|
|
88
|
+
console.print(table)
|
|
89
|
+
if hidden_offenders:
|
|
90
|
+
console.print(f" [dim]… and {hidden_offenders} more[/dim]")
|
|
91
|
+
else:
|
|
92
|
+
console.print(f"[bold]{section.signal.heading}[/bold]")
|
|
93
|
+
# Numerical totals first (each on its own line, always the true total), then
|
|
94
|
+
# the repository-name breakdowns -- numbers and names are never mixed on one
|
|
95
|
+
# line, and the name lists honour the same offender limit as the tables.
|
|
96
|
+
totals: list[str] = []
|
|
97
|
+
if section.clean_count:
|
|
98
|
+
totals.append(f"[green]✅ {section.clean_count} Clean[/green]")
|
|
99
|
+
if section.nag_repos:
|
|
100
|
+
totals.append(f"[yellow]❌ {len(section.nag_repos)} Disabled[/yellow]")
|
|
101
|
+
if excluded:
|
|
102
|
+
totals.append(f"[blue]⏩ {len(excluded)} Excluded[/blue]")
|
|
103
|
+
if section.unknown_count:
|
|
104
|
+
totals.append(f"[dim]❓ {section.unknown_count} Unknown[/dim]")
|
|
105
|
+
if not (offenders or totals):
|
|
106
|
+
totals.append("[dim]No data[/dim]")
|
|
107
|
+
for line in totals:
|
|
108
|
+
console.print(" " + line)
|
|
109
|
+
if section.nag_repos:
|
|
110
|
+
console.print(f" [yellow]Disabled:[/yellow] {_names(section.nag_repos, top_n)}")
|
|
111
|
+
if excluded:
|
|
112
|
+
console.print(f" [blue]Excluded:[/blue] {_names(excluded, top_n)}")
|
|
113
|
+
console.print()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def render_table_section(
|
|
117
|
+
section: TableSection, console: Console, *, top_n: int | None = None
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Render a generic posture/freshness table to the terminal."""
|
|
120
|
+
rows, hidden = truncate(section.rows, top_n)
|
|
121
|
+
if rows:
|
|
122
|
+
table = Table(title=section.title, title_justify="left", title_style="bold")
|
|
123
|
+
for i, col in enumerate(section.columns):
|
|
124
|
+
table.add_column(col, overflow="fold", justify="left" if i == 0 else "right")
|
|
125
|
+
for row in rows:
|
|
126
|
+
table.add_row(row.repo.name, *row.cells)
|
|
127
|
+
console.print(table)
|
|
128
|
+
if hidden:
|
|
129
|
+
console.print(f" [dim]… and {hidden} more[/dim]")
|
|
130
|
+
if section.note:
|
|
131
|
+
# A long footnote reads better split one sentence per line. It only
|
|
132
|
+
# describes a populated table, so it is omitted when empty.
|
|
133
|
+
for sentence in _split_sentences(section.note):
|
|
134
|
+
console.print(f" [dim]{sentence}[/dim]")
|
|
135
|
+
else:
|
|
136
|
+
console.print(f"[bold]{section.title}[/bold]")
|
|
137
|
+
if section.empty_note:
|
|
138
|
+
console.print(f" [green]✅ {section.empty_note}[/green]")
|
|
139
|
+
console.print()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def render_org(org: OrgReport, console: Console, *, top_n: int | None = None) -> None:
|
|
143
|
+
console.rule(f"[bold]Security report: {org.org}[/bold]")
|
|
144
|
+
console.print(f"[dim]{org.repo_count} repositories analysed[/dim]\n")
|
|
145
|
+
if org.partial:
|
|
146
|
+
console.print(
|
|
147
|
+
"[yellow]⚠ Incomplete: the repository listing could not be fully "
|
|
148
|
+
"read; some repositories may be missing.[/yellow]\n"
|
|
149
|
+
)
|
|
150
|
+
for section in org.sections:
|
|
151
|
+
render_section(section, console, excluded=org.excluded_repos, top_n=top_n)
|
|
152
|
+
if section.signal is SignalType.DEPENDABOT:
|
|
153
|
+
for table in org.dependabot_tables:
|
|
154
|
+
render_table_section(table, console, top_n=top_n)
|
|
155
|
+
if org.releases is not None:
|
|
156
|
+
render_table_section(org.releases, console, top_n=top_n)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def render_orgs(
|
|
160
|
+
orgs: list[OrgReport], console: Console, *, top_n: int | None = None
|
|
161
|
+
) -> None:
|
|
162
|
+
for org in orgs:
|
|
163
|
+
render_org(org, console, top_n=top_n)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Report aggregation.
|
|
4
|
+
|
|
5
|
+
Groups classified :class:`RepoSignal` results into the renderable report
|
|
6
|
+
structure: one section per signal, each with ranked offenders (full list -- the
|
|
7
|
+
top-N limit applies only to Slack), a clean count, a nag list (archived/test
|
|
8
|
+
repos excluded), and an unknown count. See ``docs/BRIEF.md`` sections 4-6, 11.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as dt
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
from github_security_report import scope
|
|
19
|
+
from github_security_report.models import (
|
|
20
|
+
Repo,
|
|
21
|
+
RepoSignal,
|
|
22
|
+
RepoState,
|
|
23
|
+
SignalType,
|
|
24
|
+
rank_offenders,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Render order for the five sections.
|
|
28
|
+
SIGNAL_ORDER: tuple[SignalType, ...] = (
|
|
29
|
+
SignalType.CODEQL,
|
|
30
|
+
SignalType.SCORECARD,
|
|
31
|
+
SignalType.ZIZMOR,
|
|
32
|
+
SignalType.DEPENDABOT,
|
|
33
|
+
SignalType.SECRET_SCANNING,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SignalSection:
|
|
39
|
+
"""One signal's results for one organisation."""
|
|
40
|
+
|
|
41
|
+
signal: SignalType
|
|
42
|
+
offenders: list[RepoSignal] = field(default_factory=list) # ranked worst-first
|
|
43
|
+
clean_count: int = 0
|
|
44
|
+
nag_repos: list[Repo] = field(default_factory=list)
|
|
45
|
+
unknown_count: int = 0
|
|
46
|
+
|
|
47
|
+
def top(self, n: int) -> list[RepoSignal]:
|
|
48
|
+
"""The worst N offenders (used for the Slack digest only)."""
|
|
49
|
+
return self.offenders[:n]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TableRow:
|
|
54
|
+
"""A generic, repository-keyed table row with pre-formatted cells.
|
|
55
|
+
|
|
56
|
+
Used by the Dependabot posture and Releases/Tagging tables, which do not fit
|
|
57
|
+
the four-state :class:`SignalSection` model. ``cells`` excludes the leading
|
|
58
|
+
repository link cell (each renderer supplies that from ``repo``).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
repo: Repo
|
|
62
|
+
cells: tuple[str, ...]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class TableSection:
|
|
67
|
+
"""A generic titled table rendered as a sub-section under a heading.
|
|
68
|
+
|
|
69
|
+
The **first** column is always the repository column -- every renderer puts
|
|
70
|
+
the repository link/name there (from each :class:`TableRow`'s ``repo``).
|
|
71
|
+
Its header *label* is free-form (usually ``"Repository"``, but a single-list
|
|
72
|
+
table may describe its contents instead, e.g. ``"Repositories NOT
|
|
73
|
+
Enabled"``); downstream consumers should treat column 0 as the repository
|
|
74
|
+
regardless of the label.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
title: str
|
|
78
|
+
columns: tuple[str, ...] # column 0 is the repository column (label varies)
|
|
79
|
+
rows: list[TableRow] = field(default_factory=list)
|
|
80
|
+
# Shown in place of the table when there are no rows (a clean state).
|
|
81
|
+
empty_note: str = ""
|
|
82
|
+
# Optional explanatory footnote rendered beneath the table.
|
|
83
|
+
note: str = ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class OrgReport:
|
|
88
|
+
org: str
|
|
89
|
+
sections: list[SignalSection]
|
|
90
|
+
repo_count: int
|
|
91
|
+
generated_at: dt.datetime
|
|
92
|
+
# True when the repository listing was incomplete (e.g. a truncated or
|
|
93
|
+
# forbidden org repos read), so the report may omit repositories.
|
|
94
|
+
partial: bool = False
|
|
95
|
+
# Repositories removed from analysis by the per-org ``exclude`` list. These
|
|
96
|
+
# are reported as "excluded" (counted, never analysed) so an explicit
|
|
97
|
+
# exclusion is visible and distinct from a "not enabled" nag.
|
|
98
|
+
excluded_repos: list[Repo] = field(default_factory=list)
|
|
99
|
+
# Extra Dependabot posture tables rendered as sub-sections beneath the
|
|
100
|
+
# Dependabot signal heading (alerts not enabled, security updates not
|
|
101
|
+
# enabled, cooldown settings). Empty in repo mode / when not collected.
|
|
102
|
+
dependabot_tables: list[TableSection] = field(default_factory=list)
|
|
103
|
+
# The Releases / Tagging table (release and tag staleness). None only when
|
|
104
|
+
# not collected (repo mode); org mode always assigns a section, which may
|
|
105
|
+
# have zero rows and render its empty_note instead.
|
|
106
|
+
releases: TableSection | None = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Report:
|
|
111
|
+
orgs: list[OrgReport]
|
|
112
|
+
generated_at: dt.datetime
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_T = TypeVar("_T")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def truncate(items: Sequence[_T], top_n: int | None) -> tuple[list[_T], int]:
|
|
119
|
+
"""Limit a sequence for display, returning ``(shown, hidden_count)``.
|
|
120
|
+
|
|
121
|
+
The single place every render surface applies an offender limit, so the
|
|
122
|
+
GitHub Pages, terminal and Slack outputs truncate tables and name lists
|
|
123
|
+
identically. ``top_n`` of ``None`` or a negative value (or one at least the
|
|
124
|
+
sequence length) shows everything and reports ``0`` hidden -- the negative
|
|
125
|
+
case is a defensive no-op, since negative slicing would otherwise drop
|
|
126
|
+
items from the end.
|
|
127
|
+
"""
|
|
128
|
+
seq = list(items)
|
|
129
|
+
if top_n is None or top_n < 0 or len(seq) <= top_n:
|
|
130
|
+
return seq, 0
|
|
131
|
+
return seq[:top_n], len(seq) - top_n
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def build_org_report(
|
|
135
|
+
org: str,
|
|
136
|
+
repo_signals: list[RepoSignal],
|
|
137
|
+
*,
|
|
138
|
+
repo_count: int,
|
|
139
|
+
generated_at: dt.datetime | None = None,
|
|
140
|
+
partial: bool = False,
|
|
141
|
+
excluded_repos: list[Repo] | None = None,
|
|
142
|
+
) -> OrgReport:
|
|
143
|
+
"""Assemble an :class:`OrgReport` from a flat list of classified signals."""
|
|
144
|
+
when = generated_at or dt.datetime.now(dt.timezone.utc)
|
|
145
|
+
by_signal: dict[SignalType, list[RepoSignal]] = {s: [] for s in SIGNAL_ORDER}
|
|
146
|
+
for sig in repo_signals:
|
|
147
|
+
by_signal.setdefault(sig.signal, []).append(sig)
|
|
148
|
+
|
|
149
|
+
sections: list[SignalSection] = []
|
|
150
|
+
for signal in SIGNAL_ORDER:
|
|
151
|
+
results = by_signal.get(signal, [])
|
|
152
|
+
nag = [
|
|
153
|
+
s.repo
|
|
154
|
+
for s in results
|
|
155
|
+
if s.state is RepoState.NAG and scope.in_nag_scope(s.repo)
|
|
156
|
+
]
|
|
157
|
+
sections.append(
|
|
158
|
+
SignalSection(
|
|
159
|
+
signal=signal,
|
|
160
|
+
offenders=rank_offenders(results),
|
|
161
|
+
clean_count=sum(1 for s in results if s.state is RepoState.CLEAN),
|
|
162
|
+
nag_repos=sorted(nag, key=lambda r: r.name),
|
|
163
|
+
unknown_count=sum(1 for s in results if s.state is RepoState.UNKNOWN),
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return OrgReport(
|
|
167
|
+
org=org,
|
|
168
|
+
sections=sections,
|
|
169
|
+
repo_count=repo_count,
|
|
170
|
+
generated_at=when,
|
|
171
|
+
partial=partial,
|
|
172
|
+
excluded_repos=sorted(excluded_repos or [], key=lambda r: r.name),
|
|
173
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Org repository-ruleset coverage for workflow-driven tools.
|
|
4
|
+
|
|
5
|
+
Some tools (e.g. zizmor) are not enabled per-repository; they are enforced
|
|
6
|
+
across the estate by an **organisation repository ruleset** containing a
|
|
7
|
+
``workflows`` rule that requires a central workflow on every pull request. Such
|
|
8
|
+
a repo runs the tool even though it has no matching ``.github/workflows`` file
|
|
9
|
+
of its own, so the per-repo enabled-probe would wrongly nag it.
|
|
10
|
+
|
|
11
|
+
This module reads the authoritative ruleset definitions and computes, for a
|
|
12
|
+
given repository, which signals are covered. A signal is mapped to a ruleset by
|
|
13
|
+
a case-insensitive keyword match against the required-workflow path (e.g. the
|
|
14
|
+
``zizmor`` signal matches ``.github/workflows/zizmor.yaml``). See the
|
|
15
|
+
``GET /orgs/{org}/rulesets`` and ``GET /repos/{o}/{r}/rules/branches/{branch}``
|
|
16
|
+
GitHub APIs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import fnmatch
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
# Patterns GitHub uses to mean "every repository".
|
|
26
|
+
_MATCH_ALL = {"*", "~all"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class WorkflowRuleset:
|
|
31
|
+
"""An active org ruleset that requires one or more workflows."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
workflow_paths: tuple[str, ...]
|
|
35
|
+
include: tuple[str, ...] # repository_name include patterns
|
|
36
|
+
exclude: tuple[str, ...] # repository_name exclude patterns
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_workflow_rulesets(details: list[dict]) -> list[WorkflowRuleset]:
|
|
40
|
+
"""Extract active, branch-targeted rulesets that require workflows."""
|
|
41
|
+
out: list[WorkflowRuleset] = []
|
|
42
|
+
for ruleset in details:
|
|
43
|
+
if ruleset.get("enforcement") != "active":
|
|
44
|
+
continue
|
|
45
|
+
if ruleset.get("target") not in (None, "branch"):
|
|
46
|
+
continue
|
|
47
|
+
paths: list[str] = []
|
|
48
|
+
for rule in ruleset.get("rules") or []:
|
|
49
|
+
if rule.get("type") != "workflows":
|
|
50
|
+
continue
|
|
51
|
+
for wf in (rule.get("parameters") or {}).get("workflows") or []:
|
|
52
|
+
path = wf.get("path")
|
|
53
|
+
if path:
|
|
54
|
+
paths.append(path)
|
|
55
|
+
if not paths:
|
|
56
|
+
continue
|
|
57
|
+
cond = (ruleset.get("conditions") or {}).get("repository_name") or {}
|
|
58
|
+
out.append(
|
|
59
|
+
WorkflowRuleset(
|
|
60
|
+
name=ruleset.get("name", ""),
|
|
61
|
+
workflow_paths=tuple(paths),
|
|
62
|
+
include=tuple(cond.get("include", [])),
|
|
63
|
+
exclude=tuple(cond.get("exclude", [])),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _name_matches(name: str, patterns: tuple[str, ...]) -> bool:
|
|
70
|
+
candidate = name.lower()
|
|
71
|
+
for pattern in patterns:
|
|
72
|
+
lowered = pattern.lower()
|
|
73
|
+
if lowered in _MATCH_ALL or fnmatch.fnmatch(candidate, lowered):
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def repo_covered(name: str, ruleset: WorkflowRuleset) -> bool:
|
|
79
|
+
"""Whether a repository name is targeted by the ruleset's conditions."""
|
|
80
|
+
return _name_matches(name, ruleset.include) and not _name_matches(
|
|
81
|
+
name, ruleset.exclude
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _paths_match_keyword(paths: tuple[str, ...] | list[str], keyword: str) -> bool:
|
|
86
|
+
kw = keyword.lower()
|
|
87
|
+
# An empty keyword would substring-match every path; treat it as no match
|
|
88
|
+
# so a misconfigured mapping cannot mark every repo as covered.
|
|
89
|
+
if not kw:
|
|
90
|
+
return False
|
|
91
|
+
return any(kw in path.lower() for path in paths)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def signals_covered(
|
|
95
|
+
name: str,
|
|
96
|
+
rulesets: list[WorkflowRuleset],
|
|
97
|
+
signal_keywords: Mapping[str, str],
|
|
98
|
+
) -> set[str]:
|
|
99
|
+
"""Signals (by value) that an org ruleset enforces for this repository.
|
|
100
|
+
|
|
101
|
+
``signal_keywords`` maps a signal value (e.g. ``"zizmor"``) to a keyword
|
|
102
|
+
that must appear in a required-workflow path.
|
|
103
|
+
"""
|
|
104
|
+
covered: set[str] = set()
|
|
105
|
+
for signal, keyword in signal_keywords.items():
|
|
106
|
+
for ruleset in rulesets:
|
|
107
|
+
if _paths_match_keyword(ruleset.workflow_paths, keyword) and repo_covered(
|
|
108
|
+
name, ruleset
|
|
109
|
+
):
|
|
110
|
+
covered.add(signal)
|
|
111
|
+
break
|
|
112
|
+
return covered
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def signals_from_branch_rules(
|
|
116
|
+
rules: list[dict],
|
|
117
|
+
signal_keywords: Mapping[str, str],
|
|
118
|
+
) -> set[str]:
|
|
119
|
+
"""Signals covered for a single repo, from its effective branch rules.
|
|
120
|
+
|
|
121
|
+
Used in repo mode: ``GET /repos/{o}/{r}/rules/branches/{branch}`` already
|
|
122
|
+
returns the rules in effect for this repository (including inherited org
|
|
123
|
+
rulesets), so no name matching is needed.
|
|
124
|
+
"""
|
|
125
|
+
paths: list[str] = []
|
|
126
|
+
for rule in rules:
|
|
127
|
+
if rule.get("type") != "workflows":
|
|
128
|
+
continue
|
|
129
|
+
for wf in (rule.get("parameters") or {}).get("workflows") or []:
|
|
130
|
+
path = wf.get("path")
|
|
131
|
+
if path:
|
|
132
|
+
paths.append(path)
|
|
133
|
+
return {
|
|
134
|
+
signal
|
|
135
|
+
for signal, keyword in signal_keywords.items()
|
|
136
|
+
if _paths_match_keyword(paths, keyword)
|
|
137
|
+
}
|