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,264 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Dependabot configuration posture and release/tag staleness.
|
|
4
|
+
|
|
5
|
+
These reporting categories sit outside the four-state per-signal model: they are
|
|
6
|
+
configuration-posture and freshness checks rendered as plain tables.
|
|
7
|
+
|
|
8
|
+
- **Dependabot** (beneath the open-alert table): three plain tables -- repos
|
|
9
|
+
with vulnerability **alerts** not enabled, repos with **security updates** not
|
|
10
|
+
enabled (two separate single-feature tables, not a combined matrix), and
|
|
11
|
+
configured ecosystems that set no update *cooldown* (a mandatory requirement
|
|
12
|
+
here -- any cooldown value passes). Only the two features GitHub exposes a
|
|
13
|
+
public per-repository API for are checked.
|
|
14
|
+
- **Releases / Tagging**: repositories that have gone too long without a release
|
|
15
|
+
or tag. Repositories younger than a configurable age are excluded (0 = none
|
|
16
|
+
excluded); specific repositories can also be excluded on demand. Releases and
|
|
17
|
+
tags are reported in separate columns; a hidden compound sort score (the sum
|
|
18
|
+
of the release-staleness and tag-staleness day counts, so a repo with neither
|
|
19
|
+
counts its age twice) ranks the worst offenders first but is never displayed.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import datetime as dt
|
|
25
|
+
import logging
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from github_security_report.models import Repo
|
|
31
|
+
from github_security_report.report import TableRow, TableSection
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RepoPosture:
|
|
38
|
+
"""Per-repository configuration/freshness facts for the extra sections."""
|
|
39
|
+
|
|
40
|
+
repo: Repo
|
|
41
|
+
# Dependabot repo-level feature flags (None = indeterminate).
|
|
42
|
+
dependabot_alerts: bool | None = None
|
|
43
|
+
security_updates: bool | None = None
|
|
44
|
+
# Ecosystems declared in .github/dependabot.yml that set no cooldown.
|
|
45
|
+
cooldown_missing: tuple[str, ...] = ()
|
|
46
|
+
# True when .github/dependabot.yml exists and declares version updates.
|
|
47
|
+
has_dependabot_config: bool = False
|
|
48
|
+
# Releases / tagging (UTC; None = none found).
|
|
49
|
+
latest_release_at: dt.datetime | None = None
|
|
50
|
+
latest_tag_at: dt.datetime | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cooldown_missing_ecosystems(dependabot_yaml: str) -> tuple[str, ...]:
|
|
54
|
+
"""Ecosystems in a ``dependabot.yml`` that declare no ``cooldown``.
|
|
55
|
+
|
|
56
|
+
Any ``cooldown`` value passes. Returns the ``package-ecosystem`` of each
|
|
57
|
+
``updates`` entry that omits a cooldown, de-duplicated and ordered. A
|
|
58
|
+
malformed document yields an empty tuple (treated as "nothing to flag").
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
data = yaml.safe_load(dependabot_yaml)
|
|
62
|
+
except yaml.YAMLError as exc: # malformed config; do not crash the run
|
|
63
|
+
log.warning("could not parse dependabot.yml: %s", exc)
|
|
64
|
+
return ()
|
|
65
|
+
if not isinstance(data, dict):
|
|
66
|
+
return ()
|
|
67
|
+
updates = data.get("updates")
|
|
68
|
+
if not isinstance(updates, list):
|
|
69
|
+
return ()
|
|
70
|
+
missing: list[str] = []
|
|
71
|
+
for entry in updates:
|
|
72
|
+
if not isinstance(entry, dict):
|
|
73
|
+
continue
|
|
74
|
+
ecosystem = entry.get("package-ecosystem")
|
|
75
|
+
if not isinstance(ecosystem, str) or not ecosystem:
|
|
76
|
+
continue
|
|
77
|
+
if "cooldown" not in entry and ecosystem not in missing:
|
|
78
|
+
missing.append(ecosystem)
|
|
79
|
+
return tuple(missing)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_release_excluded(
|
|
83
|
+
repo: Repo,
|
|
84
|
+
*,
|
|
85
|
+
generated_at: dt.datetime,
|
|
86
|
+
min_age_days: int,
|
|
87
|
+
exclude: frozenset[str] | set[str] | tuple[str, ...],
|
|
88
|
+
) -> bool:
|
|
89
|
+
"""Whether a repository is left out of the Releases / Tagging requirement.
|
|
90
|
+
|
|
91
|
+
A repository is excluded when its name is in ``exclude`` (never released /
|
|
92
|
+
not consumed externally) or when it was created within ``min_age_days``
|
|
93
|
+
(``0`` disables the age hold, so every repository is included). Used both to
|
|
94
|
+
skip the release/tag probes during collection and to filter the rendered
|
|
95
|
+
table, keeping the two decisions identical.
|
|
96
|
+
"""
|
|
97
|
+
if repo.name in exclude:
|
|
98
|
+
return True
|
|
99
|
+
repo_age = _age_days(repo.created_at, generated_at)
|
|
100
|
+
return min_age_days > 0 and repo_age is not None and repo_age < min_age_days
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _age_days(when: dt.datetime | None, now: dt.datetime) -> int | None:
|
|
104
|
+
"""Whole days between ``when`` and ``now`` (>= 0), or None when absent."""
|
|
105
|
+
if when is None:
|
|
106
|
+
return None
|
|
107
|
+
delta = (now - when).days
|
|
108
|
+
return max(delta, 0)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _age_cell(age: int | None) -> str:
|
|
112
|
+
if age is None:
|
|
113
|
+
return "never"
|
|
114
|
+
if age == 0:
|
|
115
|
+
return "today"
|
|
116
|
+
if age == 1:
|
|
117
|
+
return "1 day ago"
|
|
118
|
+
return f"{age} days ago"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_alerts_table(postures: list[RepoPosture]) -> TableSection:
|
|
122
|
+
"""Repositories where Dependabot vulnerability alerts are not enabled."""
|
|
123
|
+
rows = [
|
|
124
|
+
TableRow(repo=p.repo, cells=())
|
|
125
|
+
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
126
|
+
if p.dependabot_alerts is False
|
|
127
|
+
]
|
|
128
|
+
return TableSection(
|
|
129
|
+
title="Alerts Not Enabled",
|
|
130
|
+
columns=("Repository",),
|
|
131
|
+
rows=rows,
|
|
132
|
+
empty_note="No in-scope repository has Dependabot alerts confirmed disabled.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_security_updates_table(postures: list[RepoPosture]) -> TableSection:
|
|
137
|
+
"""Repositories where Dependabot security updates are not enabled."""
|
|
138
|
+
rows = [
|
|
139
|
+
TableRow(repo=p.repo, cells=())
|
|
140
|
+
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
141
|
+
if p.security_updates is False
|
|
142
|
+
]
|
|
143
|
+
return TableSection(
|
|
144
|
+
title="Dependabot: Security Updates",
|
|
145
|
+
columns=("Repositories NOT Enabled",),
|
|
146
|
+
rows=rows,
|
|
147
|
+
empty_note=(
|
|
148
|
+
"No in-scope repository has Dependabot security updates confirmed "
|
|
149
|
+
"disabled."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
|
|
155
|
+
"""A table of repositories/ecosystems that configure no update cooldown."""
|
|
156
|
+
rows = [
|
|
157
|
+
TableRow(repo=p.repo, cells=(", ".join(p.cooldown_missing),))
|
|
158
|
+
for p in sorted(postures, key=lambda p: p.repo.name)
|
|
159
|
+
if p.cooldown_missing
|
|
160
|
+
]
|
|
161
|
+
return TableSection(
|
|
162
|
+
title="Dependabot: Cooldown Settings",
|
|
163
|
+
columns=("Repository", "Ecosystems without cooldown"),
|
|
164
|
+
rows=rows,
|
|
165
|
+
empty_note=(
|
|
166
|
+
"Every configured Dependabot ecosystem sets an update cooldown."
|
|
167
|
+
),
|
|
168
|
+
note=(
|
|
169
|
+
"A cooldown is mandatory; any cooldown value passes. Repositories "
|
|
170
|
+
"with no Dependabot configuration are not listed here."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
|
|
176
|
+
"""All extra Dependabot posture tables, in render order.
|
|
177
|
+
|
|
178
|
+
The alerts and security-updates enablement checks are deliberately two
|
|
179
|
+
separate single-feature tables (rather than one multi-column matrix): with
|
|
180
|
+
only two public-API features the matrix read as contradictory.
|
|
181
|
+
"""
|
|
182
|
+
return [
|
|
183
|
+
build_alerts_table(postures),
|
|
184
|
+
build_security_updates_table(postures),
|
|
185
|
+
build_cooldown_table(postures),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_releases_table(
|
|
190
|
+
postures: list[RepoPosture],
|
|
191
|
+
*,
|
|
192
|
+
generated_at: dt.datetime,
|
|
193
|
+
min_age_days: int = 28,
|
|
194
|
+
exclude: tuple[str, ...] = (),
|
|
195
|
+
) -> TableSection:
|
|
196
|
+
"""The Releases / Tagging table, oldest-overall first.
|
|
197
|
+
|
|
198
|
+
Repositories created within ``min_age_days`` are excluded (0 = none
|
|
199
|
+
excluded), as are any whose name is in ``exclude``. Ranking uses a hidden
|
|
200
|
+
compound score = release-staleness-days + tag-staleness-days, where an
|
|
201
|
+
absent release or tag contributes the full repository age (so a repository
|
|
202
|
+
with neither effectively counts its age twice).
|
|
203
|
+
"""
|
|
204
|
+
excluded = frozenset(exclude)
|
|
205
|
+
ranked: list[tuple[int, RepoPosture, int | None, int | None]] = []
|
|
206
|
+
for posture in postures:
|
|
207
|
+
repo = posture.repo
|
|
208
|
+
if is_release_excluded(
|
|
209
|
+
repo,
|
|
210
|
+
generated_at=generated_at,
|
|
211
|
+
min_age_days=min_age_days,
|
|
212
|
+
exclude=excluded,
|
|
213
|
+
):
|
|
214
|
+
continue
|
|
215
|
+
repo_age = _age_days(repo.created_at, generated_at)
|
|
216
|
+
release_age = _age_days(posture.latest_release_at, generated_at)
|
|
217
|
+
tag_age = _age_days(posture.latest_tag_at, generated_at)
|
|
218
|
+
# Absent release/tag contributes the full repository age; an unknown
|
|
219
|
+
# creation date falls back to the staleness we do know (or zero).
|
|
220
|
+
fallback = repo_age if repo_age is not None else 0
|
|
221
|
+
compound = (release_age if release_age is not None else fallback) + (
|
|
222
|
+
tag_age if tag_age is not None else fallback
|
|
223
|
+
)
|
|
224
|
+
ranked.append((compound, posture, release_age, tag_age))
|
|
225
|
+
ranked.sort(key=lambda item: (-item[0], item[1].repo.name))
|
|
226
|
+
rows = [
|
|
227
|
+
TableRow(
|
|
228
|
+
repo=posture.repo,
|
|
229
|
+
cells=(_age_cell(release_age), _age_cell(tag_age)),
|
|
230
|
+
)
|
|
231
|
+
for _compound, posture, release_age, tag_age in ranked
|
|
232
|
+
]
|
|
233
|
+
if min_age_days > 0:
|
|
234
|
+
age_note = (
|
|
235
|
+
f"Repositories created within {min_age_days} day(s) are excluded. "
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
age_note = "All repositories are included (no minimum age). "
|
|
239
|
+
return TableSection(
|
|
240
|
+
title="Releases / Tagging",
|
|
241
|
+
columns=("Repository", "Last release", "Last tag"),
|
|
242
|
+
rows=rows,
|
|
243
|
+
empty_note=(
|
|
244
|
+
"No repositories to report (all were excluded by the minimum age "
|
|
245
|
+
"or the exclusion list)."
|
|
246
|
+
),
|
|
247
|
+
note=(
|
|
248
|
+
age_note
|
|
249
|
+
+ "Ranked by combined release and tag staleness (oldest first). "
|
|
250
|
+
"A repository with neither a release nor a tag ranks highest."
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
__all__ = [
|
|
256
|
+
"RepoPosture",
|
|
257
|
+
"is_release_excluded",
|
|
258
|
+
"cooldown_missing_ecosystems",
|
|
259
|
+
"build_dependabot_tables",
|
|
260
|
+
"build_releases_table",
|
|
261
|
+
"build_alerts_table",
|
|
262
|
+
"build_security_updates_table",
|
|
263
|
+
"build_cooldown_table",
|
|
264
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561. This package ships inline type information.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""HTML rendering (Jinja2 + Simple-DataTables).
|
|
4
|
+
|
|
5
|
+
Renders each organisation to a single scrollable page with sortable/searchable
|
|
6
|
+
tables (Simple-DataTables, version-pinned), and a card-grid index linking to
|
|
7
|
+
every org -- the GitHub Pages layout. See ``docs/BRIEF.md`` section 11.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
15
|
+
|
|
16
|
+
from github_security_report.models import RepoSignal, SignalType
|
|
17
|
+
from github_security_report.render import markdown
|
|
18
|
+
from github_security_report.report import (
|
|
19
|
+
OrgReport,
|
|
20
|
+
SignalSection,
|
|
21
|
+
TableSection,
|
|
22
|
+
truncate,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Pinned, not @latest (a security tool must not load a floating CDN asset).
|
|
26
|
+
DATATABLES_VERSION = "9.0.3"
|
|
27
|
+
# Subresource Integrity (sha384) for the exact pinned files: the browser
|
|
28
|
+
# verifies the fetched bytes against these, so a compromised/substituted CDN
|
|
29
|
+
# asset is rejected. Regenerate if DATATABLES_VERSION changes, e.g.:
|
|
30
|
+
# curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
|
31
|
+
DATATABLES_CSS_SRI = "sha384-xnK68E/OAsSGcbvbeWEOyhjix2K7rBxt8Eytj/Ow9zuPG7WwFGGqMPQ8SbexlsL0"
|
|
32
|
+
DATATABLES_JS_SRI = "sha384-JYQd44jQWQbU+FdjWIUlbjzENGRHPdOQcj7dAgjJEvSyt2js5lE85kaPOdC53JVu"
|
|
33
|
+
|
|
34
|
+
_env = Environment(
|
|
35
|
+
loader=PackageLoader("github_security_report", "templates"),
|
|
36
|
+
autoescape=select_autoescape(["html", "j2"]),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Anything outside this set is replaced; this also strips path separators and
|
|
41
|
+
# dots, so a hostile org name (e.g. "../etc") cannot escape the output dir.
|
|
42
|
+
_SLUG_UNSAFE = re.compile(r"[^a-z0-9_-]+")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def slugify(org: str) -> str:
|
|
46
|
+
"""Lowercase, filesystem- and URL-safe slug for an organisation name.
|
|
47
|
+
|
|
48
|
+
The result is used to build on-disk Pages paths (``output_dir / slug``) and
|
|
49
|
+
URLs, so it must never contain path separators or ``..``. Any character
|
|
50
|
+
outside ``[a-z0-9_-]`` (including ``/``, ``.`` and whitespace) collapses to
|
|
51
|
+
a single ``-``; a value that reduces to empty falls back to ``"org"``.
|
|
52
|
+
"""
|
|
53
|
+
slug = _SLUG_UNSAFE.sub("-", org.strip().lower()).strip("-")
|
|
54
|
+
return slug or "org"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _row_cells(sig: RepoSignal) -> list[str]:
|
|
58
|
+
# Reuse the Markdown row shape (public API), dropping the leading repo cell.
|
|
59
|
+
return markdown.row_cells(sig)[1:]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _table_context(section: TableSection, top_n: int | None = None) -> dict:
|
|
63
|
+
"""Context for a generic posture/freshness table (Dependabot, releases)."""
|
|
64
|
+
rows, hidden = truncate(section.rows, top_n)
|
|
65
|
+
return {
|
|
66
|
+
"title": section.title,
|
|
67
|
+
"columns": list(section.columns),
|
|
68
|
+
"rows": [
|
|
69
|
+
{"name": row.repo.name, "url": row.repo.html_url, "cells": list(row.cells)}
|
|
70
|
+
for row in rows
|
|
71
|
+
],
|
|
72
|
+
"hidden": hidden,
|
|
73
|
+
"empty_note": section.empty_note,
|
|
74
|
+
"note": section.note,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _section_context(section: SignalSection, top_n: int | None = None) -> dict:
|
|
79
|
+
offenders, hidden = truncate(section.offenders, top_n)
|
|
80
|
+
nag, nag_hidden = truncate(section.nag_repos, top_n)
|
|
81
|
+
return {
|
|
82
|
+
"title": section.signal.heading,
|
|
83
|
+
"columns": markdown.columns(section.signal),
|
|
84
|
+
"rows": [
|
|
85
|
+
{"name": s.repo.name, "url": s.repo.html_url, "cells": _row_cells(s)}
|
|
86
|
+
for s in offenders
|
|
87
|
+
],
|
|
88
|
+
"hidden": hidden,
|
|
89
|
+
"clean_count": section.clean_count,
|
|
90
|
+
"nag": [{"name": r.name, "url": r.html_url} for r in nag],
|
|
91
|
+
"nag_hidden": nag_hidden,
|
|
92
|
+
"unknown_count": section.unknown_count,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_org_html(org: OrgReport, *, top_n: int | None = None) -> str:
|
|
97
|
+
template = _env.get_template("report.html.j2")
|
|
98
|
+
sections: list[dict] = []
|
|
99
|
+
for section in org.sections:
|
|
100
|
+
ctx = _section_context(section, top_n)
|
|
101
|
+
# The Dependabot posture sub-tables render beneath the Dependabot
|
|
102
|
+
# Alerts section, inside the same card.
|
|
103
|
+
if section.signal is SignalType.DEPENDABOT:
|
|
104
|
+
ctx["extra_tables"] = [
|
|
105
|
+
_table_context(t, top_n) for t in org.dependabot_tables
|
|
106
|
+
]
|
|
107
|
+
sections.append(ctx)
|
|
108
|
+
excluded_shown, excluded_hidden = truncate(org.excluded_repos, top_n)
|
|
109
|
+
return str(
|
|
110
|
+
template.render(
|
|
111
|
+
org=org.org,
|
|
112
|
+
repo_count=org.repo_count,
|
|
113
|
+
generated_at=org.generated_at.strftime("%Y-%m-%d %H:%M UTC"),
|
|
114
|
+
partial=org.partial,
|
|
115
|
+
excluded=[
|
|
116
|
+
{"name": r.name, "url": r.html_url} for r in excluded_shown
|
|
117
|
+
],
|
|
118
|
+
excluded_total=len(org.excluded_repos),
|
|
119
|
+
excluded_hidden=excluded_hidden,
|
|
120
|
+
sections=sections,
|
|
121
|
+
releases=_table_context(org.releases, top_n) if org.releases else None,
|
|
122
|
+
datatables_version=DATATABLES_VERSION,
|
|
123
|
+
datatables_css_sri=DATATABLES_CSS_SRI,
|
|
124
|
+
datatables_js_sri=DATATABLES_JS_SRI,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render_index_html(orgs: list[OrgReport]) -> str:
|
|
130
|
+
template = _env.get_template("index.html.j2")
|
|
131
|
+
generated_at = (
|
|
132
|
+
max(o.generated_at for o in orgs).strftime("%Y-%m-%d %H:%M UTC") if orgs else ""
|
|
133
|
+
)
|
|
134
|
+
return str(
|
|
135
|
+
template.render(
|
|
136
|
+
orgs=[
|
|
137
|
+
{"name": o.org, "slug": slugify(o.org), "repo_count": o.repo_count}
|
|
138
|
+
for o in orgs
|
|
139
|
+
],
|
|
140
|
+
generated_at=generated_at,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
"""Canonical Markdown rendering.
|
|
4
|
+
|
|
5
|
+
One heading per signal, immediately followed by a table of offenders
|
|
6
|
+
(worst-first), then a clean count, a nag list, and an unknown-status footnote.
|
|
7
|
+
This is the canonical artifact; Slack and the job summary derive from the same
|
|
8
|
+
report model. See ``docs/BRIEF.md`` sections 4-6, 11.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from github_security_report.models import Repo, RepoSignal, SignalType
|
|
14
|
+
from github_security_report.report import (
|
|
15
|
+
OrgReport,
|
|
16
|
+
Report,
|
|
17
|
+
SignalSection,
|
|
18
|
+
TableSection,
|
|
19
|
+
truncate,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _link(repo: Repo) -> str:
|
|
24
|
+
return f"[{repo.name}]({repo.html_url})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _columns(signal: SignalType) -> list[str]:
|
|
28
|
+
if signal is SignalType.SECRET_SCANNING:
|
|
29
|
+
return ["Repository", "Open"]
|
|
30
|
+
if signal is SignalType.SCORECARD:
|
|
31
|
+
return ["Repository", "Score", "Critical", "High", "Medium", "Low"]
|
|
32
|
+
return ["Repository", "Critical", "High", "Medium", "Low", "Total"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _row(sig: RepoSignal) -> list[str]:
|
|
36
|
+
c = sig.counts
|
|
37
|
+
if sig.signal is SignalType.SECRET_SCANNING:
|
|
38
|
+
return [_link(sig.repo), str(c.total)]
|
|
39
|
+
if sig.signal is SignalType.SCORECARD:
|
|
40
|
+
score = f"{sig.score:.1f}" if sig.score is not None else "—"
|
|
41
|
+
return [_link(sig.repo), score, str(c.critical), str(c.high), str(c.medium), str(c.low)]
|
|
42
|
+
return [_link(sig.repo), str(c.critical), str(c.high), str(c.medium), str(c.low), str(c.total)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _table(section: SignalSection, top_n: int | None = None) -> list[str]:
|
|
46
|
+
cols = _columns(section.signal)
|
|
47
|
+
aligns = ["---"] + ["---:"] * (len(cols) - 1)
|
|
48
|
+
lines = ["| " + " | ".join(cols) + " |", "| " + " | ".join(aligns) + " |"]
|
|
49
|
+
offenders, hidden = truncate(section.offenders, top_n)
|
|
50
|
+
for sig in offenders:
|
|
51
|
+
lines.append("| " + " | ".join(_row(sig)) + " |")
|
|
52
|
+
if hidden:
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append(f"_… and {hidden} more_")
|
|
55
|
+
return lines
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Public, render-surface-agnostic accessors for the per-signal table shape, so
|
|
59
|
+
# other renderers (e.g. HTML) do not reach into this module's private helpers.
|
|
60
|
+
def columns(signal: SignalType) -> list[str]:
|
|
61
|
+
"""Column headings for a signal's offender table (repository first)."""
|
|
62
|
+
return _columns(signal)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def row_cells(sig: RepoSignal) -> list[str]:
|
|
66
|
+
"""Cells for one offender row (the repository link is the first cell)."""
|
|
67
|
+
return _row(sig)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render_section(section: SignalSection, *, top_n: int | None = None) -> str:
|
|
71
|
+
lines = [f"## {section.signal.heading}", ""]
|
|
72
|
+
if section.offenders:
|
|
73
|
+
lines.extend(_table(section, top_n))
|
|
74
|
+
lines.append("")
|
|
75
|
+
if section.clean_count:
|
|
76
|
+
lines.append(f"✅ {section.clean_count} repositories clean")
|
|
77
|
+
lines.append("")
|
|
78
|
+
if section.nag_repos:
|
|
79
|
+
nag, hidden = truncate(section.nag_repos, top_n)
|
|
80
|
+
lines.append("**Not enabled** — enable to appear in future reports:")
|
|
81
|
+
lines.append("")
|
|
82
|
+
lines.extend(f"- {_link(r)}" for r in nag)
|
|
83
|
+
if hidden:
|
|
84
|
+
lines.append(f"- _… and {hidden} more_")
|
|
85
|
+
lines.append("")
|
|
86
|
+
if section.unknown_count:
|
|
87
|
+
lines.append(
|
|
88
|
+
f"ℹ️ {section.unknown_count} repositories with unknown status "
|
|
89
|
+
"(insufficient permission or a transient read failure)"
|
|
90
|
+
)
|
|
91
|
+
lines.append("")
|
|
92
|
+
if not (
|
|
93
|
+
section.offenders
|
|
94
|
+
or section.clean_count
|
|
95
|
+
or section.nag_repos
|
|
96
|
+
or section.unknown_count
|
|
97
|
+
):
|
|
98
|
+
lines.append("_No data available._")
|
|
99
|
+
lines.append("")
|
|
100
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_table_section(
|
|
104
|
+
section: TableSection, *, level: int = 3, top_n: int | None = None
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Render a generic posture/freshness table at the given heading level."""
|
|
107
|
+
heading = "#" * level
|
|
108
|
+
lines = [f"{heading} {section.title}", ""]
|
|
109
|
+
rows, hidden = truncate(section.rows, top_n)
|
|
110
|
+
if rows:
|
|
111
|
+
aligns = ["---"] * len(section.columns)
|
|
112
|
+
lines.append("| " + " | ".join(section.columns) + " |")
|
|
113
|
+
lines.append("| " + " | ".join(aligns) + " |")
|
|
114
|
+
for row in rows:
|
|
115
|
+
cells = [_link(row.repo), *row.cells]
|
|
116
|
+
lines.append("| " + " | ".join(cells) + " |")
|
|
117
|
+
lines.append("")
|
|
118
|
+
if hidden:
|
|
119
|
+
lines.append(f"_… and {hidden} more_")
|
|
120
|
+
lines.append("")
|
|
121
|
+
if section.note:
|
|
122
|
+
# The note describes a populated table; omit it when empty.
|
|
123
|
+
lines.append(f"_{section.note}_")
|
|
124
|
+
lines.append("")
|
|
125
|
+
elif section.empty_note:
|
|
126
|
+
lines.append(f"✅ {section.empty_note}")
|
|
127
|
+
lines.append("")
|
|
128
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render_org(org: OrgReport, *, top_n: int | None = None) -> str:
|
|
132
|
+
when = org.generated_at.strftime("%Y-%m-%d %H:%M UTC")
|
|
133
|
+
parts = [
|
|
134
|
+
f"# Security report: {org.org}",
|
|
135
|
+
"",
|
|
136
|
+
f"_{org.repo_count} repositories analysed · generated {when}_",
|
|
137
|
+
"",
|
|
138
|
+
]
|
|
139
|
+
if org.partial:
|
|
140
|
+
parts.append(
|
|
141
|
+
"> ⚠️ **Incomplete:** the repository listing could not be fully "
|
|
142
|
+
"read, so some repositories may be missing from this report."
|
|
143
|
+
)
|
|
144
|
+
parts.append("")
|
|
145
|
+
if org.excluded_repos:
|
|
146
|
+
shown, hidden = truncate(org.excluded_repos, top_n)
|
|
147
|
+
names = ", ".join(f"`{r.name}`" for r in shown)
|
|
148
|
+
if hidden:
|
|
149
|
+
names += f" … (+{hidden} more)"
|
|
150
|
+
parts.append(
|
|
151
|
+
f"⏩ **Excluded from analysis ({len(org.excluded_repos)}):** {names}"
|
|
152
|
+
)
|
|
153
|
+
parts.append("")
|
|
154
|
+
for section in org.sections:
|
|
155
|
+
parts.append(render_section(section, top_n=top_n))
|
|
156
|
+
# The Dependabot configuration-posture sub-tables nest beneath the
|
|
157
|
+
# Dependabot signal heading.
|
|
158
|
+
if section.signal is SignalType.DEPENDABOT:
|
|
159
|
+
parts.extend(
|
|
160
|
+
render_table_section(table, level=3, top_n=top_n)
|
|
161
|
+
for table in org.dependabot_tables
|
|
162
|
+
)
|
|
163
|
+
if org.releases is not None:
|
|
164
|
+
parts.append(render_table_section(org.releases, level=2, top_n=top_n))
|
|
165
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_report(report: Report, *, top_n: int | None = None) -> str:
|
|
169
|
+
return (
|
|
170
|
+
"\n\n".join(render_org(org, top_n=top_n) for org in report.orgs).rstrip()
|
|
171
|
+
+ "\n"
|
|
172
|
+
)
|