github-security-report 0.11.2__tar.gz → 0.11.3__tar.gz

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.
Files changed (60) hide show
  1. {github_security_report-0.11.2 → github_security_report-0.11.3}/PKG-INFO +1 -1
  2. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/_version.py +2 -2
  3. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/__init__.py +5 -2
  4. github_security_report-0.11.3/src/github_security_report/client/org_reads.py +239 -0
  5. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/reads.py +11 -220
  6. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/writes.py +6 -5
  7. github_security_report-0.11.3/src/github_security_report/posture/__init__.py +57 -0
  8. github_security_report-0.11.3/src/github_security_report/posture/enablement.py +161 -0
  9. github_security_report-0.11.3/src/github_security_report/posture/facts.py +37 -0
  10. github_security_report-0.11.2/src/github_security_report/posture.py → github_security_report-0.11.3/src/github_security_report/posture/releases.py +11 -210
  11. {github_security_report-0.11.2 → github_security_report-0.11.3}/.gitignore +0 -0
  12. {github_security_report-0.11.2 → github_security_report-0.11.3}/LICENSE +0 -0
  13. {github_security_report-0.11.2 → github_security_report-0.11.3}/LICENSES/Apache-2.0.txt +0 -0
  14. {github_security_report-0.11.2 → github_security_report-0.11.3}/README.md +0 -0
  15. {github_security_report-0.11.2 → github_security_report-0.11.3}/pyproject.toml +0 -0
  16. {github_security_report-0.11.2 → github_security_report-0.11.3}/scripts/README.md +0 -0
  17. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/__init__.py +0 -0
  18. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/categories.py +0 -0
  19. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/classify.py +0 -0
  20. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/__init__.py +0 -0
  21. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/__main__.py +0 -0
  22. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/app.py +0 -0
  23. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/modes.py +0 -0
  24. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/outputs.py +0 -0
  25. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/cli/serialise.py +0 -0
  26. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/endpoints.py +0 -0
  27. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/parsers.py +0 -0
  28. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/queries.py +0 -0
  29. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/client/transport.py +0 -0
  30. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/__init__.py +0 -0
  31. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/context.py +0 -0
  32. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/extras.py +0 -0
  33. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/org.py +0 -0
  34. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/protocols.py +0 -0
  35. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/collect/repo.py +0 -0
  36. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/__init__.py +0 -0
  37. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/loader.py +0 -0
  38. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/models.py +0 -0
  39. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/config/schema.py +0 -0
  40. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/gating.py +0 -0
  41. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/gitctx.py +0 -0
  42. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/issues.py +0 -0
  43. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/models.py +0 -0
  44. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/ordering.py +0 -0
  45. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/py.typed +0 -0
  46. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/remediate.py +0 -0
  47. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/__init__.py +0 -0
  48. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/html.py +0 -0
  49. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/markdown.py +0 -0
  50. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/slack.py +0 -0
  51. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/slack_limits.py +0 -0
  52. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/render/terminal.py +0 -0
  53. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/report.py +0 -0
  54. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/rulesets.py +0 -0
  55. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/runner.py +0 -0
  56. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/scope.py +0 -0
  57. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/severity.py +0 -0
  58. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/summary.py +0 -0
  59. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/templates/index.html.j2 +0 -0
  60. {github_security_report-0.11.2 → github_security_report-0.11.3}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-security-report
3
- Version: 0.11.2
3
+ Version: 0.11.3
4
4
  Summary: Security and quality reporting across GitHub organisations
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.11.2'
22
- __version_tuple__ = version_tuple = (0, 11, 2)
21
+ __version__ = version = '0.11.3'
22
+ __version_tuple__ = version_tuple = (0, 11, 3)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -11,8 +11,9 @@ See ``docs/BRIEF.md`` sections 9, 13 and ``docs/phase0-findings.md``.
11
11
  The implementation is split across ``endpoints`` (environment-resolved API URLs
12
12
  and the shared retry/backoff policy), ``queries`` (GraphQL documents),
13
13
  ``parsers`` (pure header/node parsing), ``transport`` (connection lifecycle and
14
- the retrying request primitives), ``reads`` (the reporting reads) and ``writes``
15
- (the remediation writes plus the public :class:`GitHubClient`). This module
14
+ the retrying request primitives), ``org_reads`` (the reads issued once per
15
+ organisation), ``reads`` (the per-repository reads) and ``writes`` (the
16
+ remediation writes plus the public :class:`GitHubClient`). This module
16
17
  re-exports the public surface, so importing from
17
18
  ``github_security_report.client`` is unchanged.
18
19
  """
@@ -32,6 +33,7 @@ from github_security_report.client.endpoints import (
32
33
  SCORECARD_API,
33
34
  _https_endpoint,
34
35
  )
36
+ from github_security_report.client.org_reads import OrgReadClient
35
37
  from github_security_report.client.parsers import (
36
38
  _last_published,
37
39
  _next_page_url,
@@ -66,6 +68,7 @@ __all__ = [
66
68
  "SCORECARD_API",
67
69
  "GitHubClient",
68
70
  "NetworkError",
71
+ "OrgReadClient",
69
72
  "ReadClient",
70
73
  "Transport",
71
74
  "_CODE_SCANNING_SIGNAL_TOOLS",
@@ -0,0 +1,239 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Org-scope GitHub reads: repository listing, bulk sweeps and prefetch.
4
+
5
+ :class:`OrgReadClient` holds the reads issued once per organisation -- the
6
+ repository listing, the org-bulk alert sweeps, the org ruleset fetch and the
7
+ batched GraphQL prefetch -- leaving the per-repository probes to
8
+ :class:`~github_security_report.client.reads.ReadClient`, which extends it.
9
+ Methods return raw parsed JSON (and HTTP status where the status itself is the
10
+ signal, e.g. 404 = feature disabled).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+
17
+ from github_security_report.client.endpoints import BULK_KINDS
18
+ from github_security_report.client.parsers import _parse_iso, _parse_repo_node
19
+ from github_security_report.client.queries import _REPO_GRAPH_FRAGMENT
20
+ from github_security_report.client.transport import NetworkError, Transport
21
+ from github_security_report.models import Repo, RepoGraphData
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ def _aliases_with_errors(errors: object, alias_count: int) -> set[str]:
27
+ """Alias keys implicated by a batched query's ``errors`` array.
28
+
29
+ GitHub reports a *field-level* failure with HTTP 200: the alias is still a
30
+ populated dictionary, the field that failed is null, and an ``errors``
31
+ entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
32
+ node would convert a read failure into a confident negative -- a nulled
33
+ ``latestRelease`` is indistinguishable from "never released" -- so the
34
+ whole alias is treated as unreadable rather than partially trusted.
35
+
36
+ The alias is failed wholesale rather than per field: a finer-grained flag
37
+ per field would have to be threaded through every table to be honest
38
+ about which half of a row is trustworthy, whereas one unknown repository
39
+ is already a state every table renders correctly.
40
+
41
+ An error whose path names no alias cannot be attributed, so it implicates
42
+ every alias in the batch: with no way to tell which repositories it
43
+ touched, treating any of them as successfully read would be a guess.
44
+ """
45
+ all_aliases = {f"r{i}" for i in range(alias_count)}
46
+ if not isinstance(errors, list):
47
+ return set()
48
+ affected: set[str] = set()
49
+ for entry in errors:
50
+ path = entry.get("path") if isinstance(entry, dict) else None
51
+ head = path[0] if isinstance(path, list) and path else None
52
+ if isinstance(head, str) and head in all_aliases:
53
+ affected.add(head)
54
+ else:
55
+ return all_aliases
56
+ return affected
57
+
58
+
59
+ class OrgReadClient(Transport):
60
+ """The org-scope reads: listing, bulk sweeps, rulesets and prefetch."""
61
+
62
+ # ------------------------------------------------------------------ #
63
+ # Repositories
64
+ # ------------------------------------------------------------------ #
65
+ async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]:
66
+ """List an organisation's repositories, skipping disabled/empty ones.
67
+
68
+ Returns the listing status alongside the repos: a non-200 (a failed or
69
+ mid-pagination-truncated listing) means the set is incomplete, so the
70
+ caller can flag a partial report rather than silently omitting repos
71
+ (and their offenders).
72
+ """
73
+ status, raws = await self._get_list(
74
+ f"{self._api_url}/orgs/{org}/repos", type="all"
75
+ )
76
+ repos: list[Repo] = []
77
+ for raw in raws:
78
+ if raw.get("disabled") or raw.get("size", 0) == 0:
79
+ log.info("skipping %s: disabled or empty", raw.get("full_name"))
80
+ continue
81
+ repos.append(
82
+ Repo(
83
+ name=raw["name"],
84
+ full_name=raw["full_name"],
85
+ html_url=raw["html_url"],
86
+ archived=raw.get("archived", False),
87
+ fork=raw.get("fork", False),
88
+ is_template=raw.get("is_template", False),
89
+ private=raw.get("private", False),
90
+ created_at=_parse_iso(raw.get("created_at")),
91
+ )
92
+ )
93
+ return status, repos
94
+
95
+ # ------------------------------------------------------------------ #
96
+ # Org-bulk alert sweeps
97
+ # ------------------------------------------------------------------ #
98
+ async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]:
99
+ """Sweep all open alerts of one kind across the org.
100
+
101
+ Returns the first-page HTTP status alongside the alerts so callers can
102
+ tell an authoritative empty result (200 ``[]``) apart from an unreadable
103
+ sweep (403/404/5xx), which must never be reported as "clean".
104
+ """
105
+ path = BULK_KINDS[kind]
106
+ return await self._get_list(f"{self._api_url}/orgs/{org}/{path}", state="open")
107
+
108
+ # ------------------------------------------------------------------ #
109
+ # Organisation rulesets (workflow-driven tool enablement)
110
+ # ------------------------------------------------------------------ #
111
+ async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]:
112
+ """Active, branch-targeted org rulesets, each with full rule details.
113
+
114
+ Returns ``(status, details)``; status is the org-rulesets list status
115
+ (e.g. 403 when the token lacks org access) so coverage can degrade
116
+ gracefully. The list endpoint returns summaries, so each active branch
117
+ ruleset is fetched in detail to expose its rules and conditions.
118
+ """
119
+ status, summaries = await self._get_list(f"{self._api_url}/orgs/{org}/rulesets")
120
+ if status != 200:
121
+ return status, []
122
+ details: list[dict] = []
123
+ for summary in summaries:
124
+ if summary.get("enforcement") != "active":
125
+ continue
126
+ if summary.get("target") not in (None, "branch"):
127
+ continue
128
+ resp = await self._request(
129
+ "GET", f"{self._api_url}/orgs/{org}/rulesets/{summary['id']}"
130
+ )
131
+ if resp.status_code == 200:
132
+ details.append(resp.json())
133
+ await resp.aclose() # release the connection once the body is read
134
+ return 200, details
135
+
136
+ # ------------------------------------------------------------------ #
137
+ # Batched per-repo prefetch (one query for many repositories)
138
+ # ------------------------------------------------------------------ #
139
+ async def repo_graph_batch(
140
+ self, org: str, names: list[str]
141
+ ) -> dict[str, RepoGraphData]:
142
+ """Prefetch per-repo data for many repositories in one GraphQL query.
143
+
144
+ Returns a ``RepoGraphData`` per requested name. This data is
145
+ load-bearing for whole report sections (releases/tags, Dependabot
146
+ enablement, open issues), and its defaults are indistinguishable from
147
+ confident negatives ("never released"), so a wholly failed query --
148
+ a non-200 response that survived the shared retry/backoff policy, or
149
+ a 200 carrying no ``data`` object -- raises :class:`NetworkError` to
150
+ abort the run rather than fabricating results. A repository that
151
+ cannot be fully read -- a ``null`` alias, or a populated alias whose
152
+ ``errors`` entry shows a field failed to resolve -- degrades to
153
+ ``RepoGraphData(unreadable=True)`` so downstream tables report it as
154
+ unknown. An empty ``names`` issues no request.
155
+ """
156
+ # Seed every requested name as unreadable; only a successfully parsed
157
+ # alias replaces its entry, so nothing failed can masquerade as read.
158
+ out = {name: RepoGraphData(unreadable=True) for name in names}
159
+ if not names:
160
+ return out
161
+ aliases = "\n".join(
162
+ f" r{i}: repository(owner: $owner, name: $n{i}) {{ ...RepoData }}"
163
+ for i in range(len(names))
164
+ )
165
+ var_decls = "".join(f", $n{i}: String!" for i in range(len(names)))
166
+ query = (
167
+ f"query($owner: String!{var_decls}) {{\n{aliases}\n}}\n"
168
+ f"{_REPO_GRAPH_FRAGMENT}"
169
+ )
170
+ variables: dict[str, str] = {"owner": org}
171
+ for i, name in enumerate(names):
172
+ variables[f"n{i}"] = name
173
+ resp = await self._request(
174
+ "POST",
175
+ self._graphql_url,
176
+ json={"query": query, "variables": variables},
177
+ )
178
+ if resp.status_code != 200:
179
+ status = resp.status_code
180
+ await resp.aclose() # unread body would leak a pooled connection
181
+ raise NetworkError(
182
+ f"GraphQL prefetch for {org} failed with HTTP {status} after "
183
+ "exhausting retries; aborting because the release/tag, "
184
+ "Dependabot-enablement and open-issues data for "
185
+ f"{len(names)} repositories would otherwise be fabricated "
186
+ "from defaults (e.g. reported as never released)."
187
+ )
188
+ body = resp.json()
189
+ data = body.get("data")
190
+ await resp.aclose() # release the connection once the body is read
191
+ # GitHub answers a partially-refused query with HTTP 200: the readable
192
+ # aliases populated, the rest null or missing individual fields, and an
193
+ # ``errors`` array explaining why. The paths are both logged for
194
+ # diagnosis and used to fail the affected aliases, since a field nulled
195
+ # by a failed read is indistinguishable from a genuine absence.
196
+ errors = body.get("errors")
197
+ errored_aliases = _aliases_with_errors(errors, len(names))
198
+ if errors:
199
+ log.warning(
200
+ "GraphQL prefetch for %s returned %d error(s); affected data is "
201
+ "reported as unknown: %s",
202
+ org,
203
+ len(errors),
204
+ "; ".join(
205
+ f"{'.'.join(str(p) for p in (e.get('path') or []))}: "
206
+ f"{e.get('message', '')}"
207
+ for e in errors[:5]
208
+ if isinstance(e, dict)
209
+ ),
210
+ )
211
+ if not isinstance(data, dict):
212
+ # HTTP 200 with no data object at all: the whole batch failed
213
+ # (e.g. a timed-out or refused query). Same stakes as a non-200.
214
+ raise NetworkError(
215
+ f"GraphQL prefetch for {org} returned no data for any of "
216
+ f"{len(names)} repositories; aborting rather than reporting "
217
+ "fabricated defaults. "
218
+ f"errors={errors!r}"
219
+ )
220
+ for i, name in enumerate(names):
221
+ alias = f"r{i}"
222
+ if alias in errored_aliases:
223
+ # A field of this alias failed to resolve, so its null fields
224
+ # cannot be told apart from genuine absences. Leave the
225
+ # pre-seeded unreadable default in place.
226
+ continue
227
+ node = data.get(alias)
228
+ if isinstance(node, dict):
229
+ out[name] = _parse_repo_node(node)
230
+ unreadable = sorted(name for name, d in out.items() if d.unreadable)
231
+ if unreadable:
232
+ log.warning(
233
+ "GraphQL prefetch for %s could not read %d repositories "
234
+ "(reported as unknown): %s",
235
+ org,
236
+ len(unreadable),
237
+ ", ".join(unreadable),
238
+ )
239
+ return out
@@ -1,10 +1,12 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
- """Read-only GitHub API surface: repo listing, sweeps, probes and prefetch.
3
+ """Per-repository GitHub reads: enabled-probes, alerts and posture flags.
4
4
 
5
- :class:`ReadClient` adds every reporting read to :class:`Transport`. Methods
6
- return raw parsed JSON (and HTTP status where the status itself is the signal,
7
- e.g. 404 = feature disabled).
5
+ :class:`ReadClient` completes the reporting read surface by adding the reads
6
+ issued once per repository to the org-scope reads of
7
+ :class:`~github_security_report.client.org_reads.OrgReadClient`. Methods return
8
+ raw parsed JSON (and HTTP status where the status itself is the signal, e.g.
9
+ 404 = feature disabled).
8
10
  """
9
11
 
10
12
  from __future__ import annotations
@@ -12,100 +14,19 @@ from __future__ import annotations
12
14
  import asyncio
13
15
  import logging
14
16
 
15
- from github_security_report.client.endpoints import BULK_KINDS
16
- from github_security_report.client.parsers import _parse_iso, _parse_repo_node
17
+ from github_security_report.client.org_reads import OrgReadClient
18
+ from github_security_report.client.parsers import _parse_iso
17
19
  from github_security_report.client.queries import (
18
20
  _CODE_SCANNING_SIGNAL_TOOLS,
19
21
  _DEPENDABOT_ENABLED_QUERY,
20
- _REPO_GRAPH_FRAGMENT,
21
22
  )
22
- from github_security_report.client.transport import NetworkError, Transport
23
- from github_security_report.models import Repo, RepoGraphData
23
+ from github_security_report.models import Repo
24
24
 
25
25
  log = logging.getLogger(__name__)
26
26
 
27
27
 
28
- def _aliases_with_errors(errors: object, alias_count: int) -> set[str]:
29
- """Alias keys implicated by a batched query's ``errors`` array.
30
-
31
- GitHub reports a *field-level* failure with HTTP 200: the alias is still a
32
- populated dictionary, the field that failed is null, and an ``errors``
33
- entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
34
- node would convert a read failure into a confident negative -- a nulled
35
- ``latestRelease`` is indistinguishable from "never released" -- so the
36
- whole alias is treated as unreadable rather than partially trusted.
37
-
38
- The alias is failed wholesale rather than per field: a finer-grained flag
39
- per field would have to be threaded through every table to be honest
40
- about which half of a row is trustworthy, whereas one unknown repository
41
- is already a state every table renders correctly.
42
-
43
- An error whose path names no alias cannot be attributed, so it implicates
44
- every alias in the batch: with no way to tell which repositories it
45
- touched, treating any of them as successfully read would be a guess.
46
- """
47
- all_aliases = {f"r{i}" for i in range(alias_count)}
48
- if not isinstance(errors, list):
49
- return set()
50
- affected: set[str] = set()
51
- for entry in errors:
52
- path = entry.get("path") if isinstance(entry, dict) else None
53
- head = path[0] if isinstance(path, list) and path else None
54
- if isinstance(head, str) and head in all_aliases:
55
- affected.add(head)
56
- else:
57
- return all_aliases
58
- return affected
59
-
60
-
61
- class ReadClient(Transport):
62
- """The reporting reads: sweeps, per-repo probes and batched prefetch."""
63
-
64
- # ------------------------------------------------------------------ #
65
- # Repositories
66
- # ------------------------------------------------------------------ #
67
- async def list_org_repos(self, org: str) -> tuple[int, list[Repo]]:
68
- """List an organisation's repositories, skipping disabled/empty ones.
69
-
70
- Returns the listing status alongside the repos: a non-200 (a failed or
71
- mid-pagination-truncated listing) means the set is incomplete, so the
72
- caller can flag a partial report rather than silently omitting repos
73
- (and their offenders).
74
- """
75
- status, raws = await self._get_list(
76
- f"{self._api_url}/orgs/{org}/repos", type="all"
77
- )
78
- repos: list[Repo] = []
79
- for raw in raws:
80
- if raw.get("disabled") or raw.get("size", 0) == 0:
81
- log.info("skipping %s: disabled or empty", raw.get("full_name"))
82
- continue
83
- repos.append(
84
- Repo(
85
- name=raw["name"],
86
- full_name=raw["full_name"],
87
- html_url=raw["html_url"],
88
- archived=raw.get("archived", False),
89
- fork=raw.get("fork", False),
90
- is_template=raw.get("is_template", False),
91
- private=raw.get("private", False),
92
- created_at=_parse_iso(raw.get("created_at")),
93
- )
94
- )
95
- return status, repos
96
-
97
- # ------------------------------------------------------------------ #
98
- # Org-bulk alert sweeps
99
- # ------------------------------------------------------------------ #
100
- async def org_bulk_alerts(self, org: str, kind: str) -> tuple[int, list[dict]]:
101
- """Sweep all open alerts of one kind across the org.
102
-
103
- Returns the first-page HTTP status alongside the alerts so callers can
104
- tell an authoritative empty result (200 ``[]``) apart from an unreadable
105
- sweep (403/404/5xx), which must never be reported as "clean".
106
- """
107
- path = BULK_KINDS[kind]
108
- return await self._get_list(f"{self._api_url}/orgs/{org}/{path}", state="open")
28
+ class ReadClient(OrgReadClient):
29
+ """The reporting reads: org sweeps plus the per-repository probes."""
109
30
 
110
31
  # ------------------------------------------------------------------ #
111
32
  # Per-repo enabled-probes
@@ -226,34 +147,6 @@ class ReadClient(Transport):
226
147
  await resp.aclose() # release the connection once the body is read
227
148
  return 200, score
228
149
 
229
- # ------------------------------------------------------------------ #
230
- # Repository rulesets (workflow-driven tool enablement)
231
- # ------------------------------------------------------------------ #
232
- async def org_workflow_rulesets(self, org: str) -> tuple[int, list[dict]]:
233
- """Active, branch-targeted org rulesets, each with full rule details.
234
-
235
- Returns ``(status, details)``; status is the org-rulesets list status
236
- (e.g. 403 when the token lacks org access) so coverage can degrade
237
- gracefully. The list endpoint returns summaries, so each active branch
238
- ruleset is fetched in detail to expose its rules and conditions.
239
- """
240
- status, summaries = await self._get_list(f"{self._api_url}/orgs/{org}/rulesets")
241
- if status != 200:
242
- return status, []
243
- details: list[dict] = []
244
- for summary in summaries:
245
- if summary.get("enforcement") != "active":
246
- continue
247
- if summary.get("target") not in (None, "branch"):
248
- continue
249
- resp = await self._request(
250
- "GET", f"{self._api_url}/orgs/{org}/rulesets/{summary['id']}"
251
- )
252
- if resp.status_code == 200:
253
- details.append(resp.json())
254
- await resp.aclose() # release the connection once the body is read
255
- return 200, details
256
-
257
150
  async def repo_branch_rules(
258
151
  self, org: str, repo: str, branch: str
259
152
  ) -> tuple[int, list[dict]]:
@@ -361,105 +254,3 @@ class ReadClient(Transport):
361
254
  data = resp.json()
362
255
  await resp.aclose() # release the connection once the body is read
363
256
  return bool(data.get("enabled"))
364
-
365
- async def repo_graph_batch(
366
- self, org: str, names: list[str]
367
- ) -> dict[str, RepoGraphData]:
368
- """Prefetch per-repo data for many repositories in one GraphQL query.
369
-
370
- Returns a ``RepoGraphData`` per requested name. This data is
371
- load-bearing for whole report sections (releases/tags, Dependabot
372
- enablement, open issues), and its defaults are indistinguishable from
373
- confident negatives ("never released"), so a wholly failed query --
374
- a non-200 response that survived the shared retry/backoff policy, or
375
- a 200 carrying no ``data`` object -- raises :class:`NetworkError` to
376
- abort the run rather than fabricating results. A repository that
377
- cannot be fully read -- a ``null`` alias, or a populated alias whose
378
- ``errors`` entry shows a field failed to resolve -- degrades to
379
- ``RepoGraphData(unreadable=True)`` so downstream tables report it as
380
- unknown. An empty ``names`` issues no request.
381
- """
382
- # Seed every requested name as unreadable; only a successfully parsed
383
- # alias replaces its entry, so nothing failed can masquerade as read.
384
- out = {name: RepoGraphData(unreadable=True) for name in names}
385
- if not names:
386
- return out
387
- aliases = "\n".join(
388
- f" r{i}: repository(owner: $owner, name: $n{i}) {{ ...RepoData }}"
389
- for i in range(len(names))
390
- )
391
- var_decls = "".join(f", $n{i}: String!" for i in range(len(names)))
392
- query = (
393
- f"query($owner: String!{var_decls}) {{\n{aliases}\n}}\n"
394
- f"{_REPO_GRAPH_FRAGMENT}"
395
- )
396
- variables: dict[str, str] = {"owner": org}
397
- for i, name in enumerate(names):
398
- variables[f"n{i}"] = name
399
- resp = await self._request(
400
- "POST",
401
- self._graphql_url,
402
- json={"query": query, "variables": variables},
403
- )
404
- if resp.status_code != 200:
405
- status = resp.status_code
406
- await resp.aclose() # unread body would leak a pooled connection
407
- raise NetworkError(
408
- f"GraphQL prefetch for {org} failed with HTTP {status} after "
409
- "exhausting retries; aborting because the release/tag, "
410
- "Dependabot-enablement and open-issues data for "
411
- f"{len(names)} repositories would otherwise be fabricated "
412
- "from defaults (e.g. reported as never released)."
413
- )
414
- body = resp.json()
415
- data = body.get("data")
416
- await resp.aclose() # release the connection once the body is read
417
- # GitHub answers a partially-refused query with HTTP 200: the readable
418
- # aliases populated, the rest null or missing individual fields, and an
419
- # ``errors`` array explaining why. The paths are both logged for
420
- # diagnosis and used to fail the affected aliases, since a field nulled
421
- # by a failed read is indistinguishable from a genuine absence.
422
- errors = body.get("errors")
423
- errored_aliases = _aliases_with_errors(errors, len(names))
424
- if errors:
425
- log.warning(
426
- "GraphQL prefetch for %s returned %d error(s); affected data is "
427
- "reported as unknown: %s",
428
- org,
429
- len(errors),
430
- "; ".join(
431
- f"{'.'.join(str(p) for p in (e.get('path') or []))}: "
432
- f"{e.get('message', '')}"
433
- for e in errors[:5]
434
- if isinstance(e, dict)
435
- ),
436
- )
437
- if not isinstance(data, dict):
438
- # HTTP 200 with no data object at all: the whole batch failed
439
- # (e.g. a timed-out or refused query). Same stakes as a non-200.
440
- raise NetworkError(
441
- f"GraphQL prefetch for {org} returned no data for any of "
442
- f"{len(names)} repositories; aborting rather than reporting "
443
- "fabricated defaults. "
444
- f"errors={errors!r}"
445
- )
446
- for i, name in enumerate(names):
447
- alias = f"r{i}"
448
- if alias in errored_aliases:
449
- # A field of this alias failed to resolve, so its null fields
450
- # cannot be told apart from genuine absences. Leave the
451
- # pre-seeded unreadable default in place.
452
- continue
453
- node = data.get(alias)
454
- if isinstance(node, dict):
455
- out[name] = _parse_repo_node(node)
456
- unreadable = sorted(name for name, d in out.items() if d.unreadable)
457
- if unreadable:
458
- log.warning(
459
- "GraphQL prefetch for %s could not read %d repositories "
460
- "(reported as unknown): %s",
461
- org,
462
- len(unreadable),
463
- ", ".join(unreadable),
464
- )
465
- return out
@@ -2,11 +2,12 @@
2
2
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
3
  """Remediation writes, and the public :class:`GitHubClient` façade.
4
4
 
5
- ``GitHubClient`` completes the linear ``Transport -> ReadClient ->
6
- GitHubClient`` chain. The chain exists so each layer stays a readable size --
7
- connection/retry mechanics, reporting reads, and remediation writes are
8
- separable concerns -- while callers still get a single object, because one
9
- client instance serves both the reporting reads and the remediation writes.
5
+ ``GitHubClient`` completes the linear ``Transport -> OrgReadClient ->
6
+ ReadClient -> GitHubClient`` chain. The chain exists so each layer stays a
7
+ readable size -- connection/retry mechanics, org-scope reads, per-repository
8
+ reads, and remediation writes are separable concerns -- while callers still get
9
+ a single object, because one client instance serves both the reporting reads
10
+ and the remediation writes.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -0,0 +1,57 @@
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 and the rows are ranked by release/tag
18
+ staleness alone (repository age only gates scope): a missing release or tag
19
+ counts as the worst possible signal, so a repo with neither ranks first. The
20
+ ranking key itself is never displayed.
21
+
22
+ The implementation is split across ``facts`` (the :class:`RepoPosture` record
23
+ every table reads), ``enablement`` (the boolean feature checks and the
24
+ Dependabot cooldown check) and ``releases`` (the release/tag freshness and
25
+ release-immutability tables). This module re-exports the public surface, so
26
+ importing from ``github_security_report.posture`` is unchanged.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from github_security_report.posture.enablement import (
32
+ build_alerts_table,
33
+ build_cooldown_table,
34
+ build_dependabot_tables,
35
+ build_pvr_table,
36
+ build_security_updates_table,
37
+ cooldown_missing_ecosystems,
38
+ )
39
+ from github_security_report.posture.facts import RepoPosture
40
+ from github_security_report.posture.releases import (
41
+ build_mutable_releases_table,
42
+ build_releases_table,
43
+ is_release_excluded,
44
+ )
45
+
46
+ __all__ = [
47
+ "RepoPosture",
48
+ "is_release_excluded",
49
+ "cooldown_missing_ecosystems",
50
+ "build_dependabot_tables",
51
+ "build_releases_table",
52
+ "build_mutable_releases_table",
53
+ "build_alerts_table",
54
+ "build_security_updates_table",
55
+ "build_cooldown_table",
56
+ "build_pvr_table",
57
+ ]
@@ -0,0 +1,161 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Configuration-posture tables: feature enablement and update cooldowns.
4
+
5
+ Every table here is built from the boolean (or indeterminate) configuration
6
+ facts on :class:`RepoPosture`: the two Dependabot features GitHub exposes a
7
+ public per-repository API for, the private-vulnerability-reporting flag, and
8
+ the per-ecosystem cooldown declared in a repository's ``dependabot.yml``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections.abc import Callable
15
+
16
+ import yaml
17
+
18
+ from github_security_report.categories import CategoryKey, category_meta
19
+ from github_security_report.posture.facts import RepoPosture
20
+ from github_security_report.report import TableRow, TableSection
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ def cooldown_missing_ecosystems(dependabot_yaml: str) -> tuple[str, ...]:
26
+ """Ecosystems in a ``dependabot.yml`` that declare no ``cooldown``.
27
+
28
+ Any ``cooldown`` value passes. Returns the ``package-ecosystem`` of each
29
+ ``updates`` entry that omits a cooldown, de-duplicated and ordered. A
30
+ malformed document yields an empty tuple (treated as "nothing to flag").
31
+ """
32
+ try:
33
+ data = yaml.safe_load(dependabot_yaml)
34
+ except yaml.YAMLError as exc: # malformed config; do not crash the run
35
+ log.warning("could not parse dependabot.yml: %s", exc)
36
+ return ()
37
+ if not isinstance(data, dict):
38
+ return ()
39
+ updates = data.get("updates")
40
+ if not isinstance(updates, list):
41
+ return ()
42
+ missing: list[str] = []
43
+ for entry in updates:
44
+ if not isinstance(entry, dict):
45
+ continue
46
+ ecosystem = entry.get("package-ecosystem")
47
+ if not isinstance(ecosystem, str) or not ecosystem:
48
+ continue
49
+ if "cooldown" not in entry and ecosystem not in missing:
50
+ missing.append(ecosystem)
51
+ return tuple(missing)
52
+
53
+
54
+ def _build_feature_table(
55
+ postures: list[RepoPosture],
56
+ *,
57
+ category_key: CategoryKey,
58
+ columns: tuple[str, ...],
59
+ enabled_of: Callable[[RepoPosture], bool | None],
60
+ ) -> TableSection:
61
+ """A single-feature enablement table (offenders = feature confirmed off).
62
+
63
+ Shared by the Dependabot alerts and security-updates checks: both list the
64
+ repositories where one boolean feature is explicitly disabled and report the
65
+ enabled/not-enabled/indeterminate split as the standardised footer counts.
66
+ An indeterminate (``None``) reading counts towards neither pass nor fail; it
67
+ becomes the unknown count, so an empty table never over-claims that every
68
+ repository is enabled.
69
+ """
70
+ rows = [
71
+ TableRow(repo=p.repo, cells=())
72
+ for p in sorted(postures, key=lambda p: p.repo.name)
73
+ if enabled_of(p) is False
74
+ ]
75
+ not_enabled = sum(1 for p in postures if enabled_of(p) is False)
76
+ enabled = sum(1 for p in postures if enabled_of(p) is True)
77
+ indeterminate = sum(1 for p in postures if enabled_of(p) is None)
78
+ return TableSection(
79
+ category=category_meta(category_key),
80
+ columns=columns,
81
+ rows=rows,
82
+ pass_count=enabled,
83
+ fail_count=not_enabled,
84
+ unknown_count=indeterminate,
85
+ )
86
+
87
+
88
+ def build_alerts_table(postures: list[RepoPosture]) -> TableSection:
89
+ """Repositories where Dependabot vulnerability alerts are not enabled."""
90
+ return _build_feature_table(
91
+ postures,
92
+ category_key=CategoryKey.DEPENDABOT_ALERTS_ENABLED,
93
+ columns=("Repository",),
94
+ enabled_of=lambda p: p.dependabot_alerts,
95
+ )
96
+
97
+
98
+ def build_security_updates_table(postures: list[RepoPosture]) -> TableSection:
99
+ """Repositories where Dependabot security updates are not enabled."""
100
+ return _build_feature_table(
101
+ postures,
102
+ category_key=CategoryKey.DEPENDABOT_UPDATES_ENABLED,
103
+ columns=("Repository",),
104
+ enabled_of=lambda p: p.security_updates,
105
+ )
106
+
107
+
108
+ def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
109
+ """A table of repositories/ecosystems that configure no update cooldown."""
110
+ rows = [
111
+ TableRow(repo=p.repo, cells=(joined,), sort_values=(joined,))
112
+ for p in sorted(postures, key=lambda p: p.repo.name)
113
+ if p.cooldown_missing and (joined := ", ".join(p.cooldown_missing))
114
+ ]
115
+ missing = sum(1 for p in postures if p.cooldown_missing)
116
+ with_cooldown = sum(
117
+ 1 for p in postures if p.has_dependabot_config and not p.cooldown_missing
118
+ )
119
+ # An unreadable prefetch means the dependabot.yml itself is unknown, not
120
+ # absent -- count it as unknown rather than silently dropping the repo.
121
+ indeterminate = sum(1 for p in postures if p.graph_unreadable)
122
+ return TableSection(
123
+ category=category_meta(CategoryKey.DEPENDABOT_COOLDOWN),
124
+ columns=("Repository", "Ecosystems without cooldown"),
125
+ rows=rows,
126
+ pass_count=with_cooldown,
127
+ fail_count=missing,
128
+ unknown_count=indeterminate,
129
+ )
130
+
131
+
132
+ def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
133
+ """All extra Dependabot posture tables, in render order.
134
+
135
+ The alerts and security-updates enablement checks are deliberately two
136
+ separate single-feature tables (rather than one multi-column matrix): with
137
+ only two public-API features the matrix read as contradictory.
138
+ """
139
+ return [
140
+ build_alerts_table(postures),
141
+ build_security_updates_table(postures),
142
+ build_cooldown_table(postures),
143
+ ]
144
+
145
+
146
+ def build_pvr_table(postures: list[RepoPosture]) -> TableSection:
147
+ """Repositories where private vulnerability reporting is not enabled.
148
+
149
+ Like the Dependabot enablement tables this is a single-boolean feature
150
+ check, so it reuses :func:`_build_feature_table`: offenders are repositories
151
+ where the feature is confirmed off; an indeterminate (``None``) reading
152
+ counts towards neither side of the standardised summary footer. The table is
153
+ always built (the flag is probed for every repository); the per-category
154
+ render toggle governs whether it is shown.
155
+ """
156
+ return _build_feature_table(
157
+ postures,
158
+ category_key=CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
159
+ columns=("Repository",),
160
+ enabled_of=lambda p: p.private_vulnerability_reporting,
161
+ )
@@ -0,0 +1,37 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """The per-repository facts the posture and freshness tables are built from."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import datetime as dt
8
+ from dataclasses import dataclass
9
+
10
+ from github_security_report.models import ReleaseRef, Repo
11
+
12
+
13
+ @dataclass
14
+ class RepoPosture:
15
+ """Per-repository configuration/freshness facts for the extra sections."""
16
+
17
+ repo: Repo
18
+ # True when the batched GraphQL prefetch could not read this repository at
19
+ # all: the release/tag and dependabot.yml facts below are then unknown, not
20
+ # absent, and the tables must count the repository as unknown rather than
21
+ # render confident negatives such as "never released".
22
+ graph_unreadable: bool = False
23
+ # Dependabot repo-level feature flags (None = indeterminate).
24
+ dependabot_alerts: bool | None = None
25
+ security_updates: bool | None = None
26
+ # GitHub "private vulnerability reporting" enablement (None = indeterminate).
27
+ private_vulnerability_reporting: bool | None = None
28
+ # Ecosystems declared in .github/dependabot.yml that set no cooldown.
29
+ cooldown_missing: tuple[str, ...] = ()
30
+ # True when .github/dependabot.yml exists and declares version updates.
31
+ has_dependabot_config: bool = False
32
+ # Releases / tagging (UTC; None = none found).
33
+ latest_release_at: dt.datetime | None = None
34
+ latest_tag_at: dt.datetime | None = None
35
+ # Release identities for the immutability check (None = absent).
36
+ latest_release: ReleaseRef | None = None
37
+ last_published_release: ReleaseRef | None = None
@@ -1,99 +1,32 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
- """Dependabot configuration posture and release/tag staleness.
3
+ """Release/tag freshness and release-immutability tables.
4
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 and the rows are ranked by release/tag
18
- staleness alone (repository age only gates scope): a missing release or tag
19
- counts as the worst possible signal, so a repo with neither ranks first. The
20
- ranking key itself is never displayed.
5
+ Repositories that have gone too long without a release or tag, and releases
6
+ that remain mutable. Repositories younger than a configurable age are excluded
7
+ (0 = none excluded); specific repositories can also be excluded on demand.
21
8
  """
22
9
 
23
10
  from __future__ import annotations
24
11
 
25
12
  import datetime as dt
26
- import logging
27
- from collections.abc import Callable
28
- from dataclasses import dataclass
29
-
30
- import yaml
31
13
 
32
14
  from github_security_report.categories import CategoryKey, category_meta
33
15
  from github_security_report.models import ReleaseRef, Repo
16
+ from github_security_report.posture.facts import RepoPosture
34
17
  from github_security_report.report import TableRow, TableSection
35
18
 
36
- log = logging.getLogger(__name__)
37
-
38
19
  # Aware sentinel so releases lacking a publish timestamp sort oldest (last) when
39
20
  # ordering most-recent-first, without ever comparing a naive and aware value.
40
21
  _MIN_AWARE = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
41
22
 
42
23
 
43
- @dataclass
44
- class RepoPosture:
45
- """Per-repository configuration/freshness facts for the extra sections."""
46
-
47
- repo: Repo
48
- # True when the batched GraphQL prefetch could not read this repository at
49
- # all: the release/tag and dependabot.yml facts below are then unknown, not
50
- # absent, and the tables must count the repository as unknown rather than
51
- # render confident negatives such as "never released".
52
- graph_unreadable: bool = False
53
- # Dependabot repo-level feature flags (None = indeterminate).
54
- dependabot_alerts: bool | None = None
55
- security_updates: bool | None = None
56
- # GitHub "private vulnerability reporting" enablement (None = indeterminate).
57
- private_vulnerability_reporting: bool | None = None
58
- # Ecosystems declared in .github/dependabot.yml that set no cooldown.
59
- cooldown_missing: tuple[str, ...] = ()
60
- # True when .github/dependabot.yml exists and declares version updates.
61
- has_dependabot_config: bool = False
62
- # Releases / tagging (UTC; None = none found).
63
- latest_release_at: dt.datetime | None = None
64
- latest_tag_at: dt.datetime | None = None
65
- # Release identities for the immutability check (None = absent).
66
- latest_release: ReleaseRef | None = None
67
- last_published_release: ReleaseRef | None = None
68
-
69
-
70
- def cooldown_missing_ecosystems(dependabot_yaml: str) -> tuple[str, ...]:
71
- """Ecosystems in a ``dependabot.yml`` that declare no ``cooldown``.
72
-
73
- Any ``cooldown`` value passes. Returns the ``package-ecosystem`` of each
74
- ``updates`` entry that omits a cooldown, de-duplicated and ordered. A
75
- malformed document yields an empty tuple (treated as "nothing to flag").
76
- """
77
- try:
78
- data = yaml.safe_load(dependabot_yaml)
79
- except yaml.YAMLError as exc: # malformed config; do not crash the run
80
- log.warning("could not parse dependabot.yml: %s", exc)
81
- return ()
82
- if not isinstance(data, dict):
83
- return ()
84
- updates = data.get("updates")
85
- if not isinstance(updates, list):
86
- return ()
87
- missing: list[str] = []
88
- for entry in updates:
89
- if not isinstance(entry, dict):
90
- continue
91
- ecosystem = entry.get("package-ecosystem")
92
- if not isinstance(ecosystem, str) or not ecosystem:
93
- continue
94
- if "cooldown" not in entry and ecosystem not in missing:
95
- missing.append(ecosystem)
96
- return tuple(missing)
24
+ def _age_days(when: dt.datetime | None, now: dt.datetime) -> int | None:
25
+ """Whole days between ``when`` and ``now`` (>= 0), or None when absent."""
26
+ if when is None:
27
+ return None
28
+ delta = (now - when).days
29
+ return max(delta, 0)
97
30
 
98
31
 
99
32
  def is_release_excluded(
@@ -119,14 +52,6 @@ def is_release_excluded(
119
52
  )
120
53
 
121
54
 
122
- def _age_days(when: dt.datetime | None, now: dt.datetime) -> int | None:
123
- """Whole days between ``when`` and ``now`` (>= 0), or None when absent."""
124
- if when is None:
125
- return None
126
- delta = (now - when).days
127
- return max(delta, 0)
128
-
129
-
130
55
  def _release_is_current(
131
56
  release_age: int | None, tag_age: int | None, release_max_age_days: int
132
57
  ) -> bool:
@@ -157,116 +82,6 @@ def _age_cell(age: int | None) -> str:
157
82
  return f"{age} days ago"
158
83
 
159
84
 
160
- def _build_feature_table(
161
- postures: list[RepoPosture],
162
- *,
163
- category_key: CategoryKey,
164
- columns: tuple[str, ...],
165
- enabled_of: Callable[[RepoPosture], bool | None],
166
- ) -> TableSection:
167
- """A single-feature enablement table (offenders = feature confirmed off).
168
-
169
- Shared by the Dependabot alerts and security-updates checks: both list the
170
- repositories where one boolean feature is explicitly disabled and report the
171
- enabled/not-enabled/indeterminate split as the standardised footer counts.
172
- An indeterminate (``None``) reading counts towards neither pass nor fail; it
173
- becomes the unknown count, so an empty table never over-claims that every
174
- repository is enabled.
175
- """
176
- rows = [
177
- TableRow(repo=p.repo, cells=())
178
- for p in sorted(postures, key=lambda p: p.repo.name)
179
- if enabled_of(p) is False
180
- ]
181
- not_enabled = sum(1 for p in postures if enabled_of(p) is False)
182
- enabled = sum(1 for p in postures if enabled_of(p) is True)
183
- indeterminate = sum(1 for p in postures if enabled_of(p) is None)
184
- return TableSection(
185
- category=category_meta(category_key),
186
- columns=columns,
187
- rows=rows,
188
- pass_count=enabled,
189
- fail_count=not_enabled,
190
- unknown_count=indeterminate,
191
- )
192
-
193
-
194
- def build_alerts_table(postures: list[RepoPosture]) -> TableSection:
195
- """Repositories where Dependabot vulnerability alerts are not enabled."""
196
- return _build_feature_table(
197
- postures,
198
- category_key=CategoryKey.DEPENDABOT_ALERTS_ENABLED,
199
- columns=("Repository",),
200
- enabled_of=lambda p: p.dependabot_alerts,
201
- )
202
-
203
-
204
- def build_security_updates_table(postures: list[RepoPosture]) -> TableSection:
205
- """Repositories where Dependabot security updates are not enabled."""
206
- return _build_feature_table(
207
- postures,
208
- category_key=CategoryKey.DEPENDABOT_UPDATES_ENABLED,
209
- columns=("Repository",),
210
- enabled_of=lambda p: p.security_updates,
211
- )
212
-
213
-
214
- def build_cooldown_table(postures: list[RepoPosture]) -> TableSection:
215
- """A table of repositories/ecosystems that configure no update cooldown."""
216
- rows = [
217
- TableRow(repo=p.repo, cells=(joined,), sort_values=(joined,))
218
- for p in sorted(postures, key=lambda p: p.repo.name)
219
- if p.cooldown_missing and (joined := ", ".join(p.cooldown_missing))
220
- ]
221
- missing = sum(1 for p in postures if p.cooldown_missing)
222
- with_cooldown = sum(
223
- 1 for p in postures if p.has_dependabot_config and not p.cooldown_missing
224
- )
225
- # An unreadable prefetch means the dependabot.yml itself is unknown, not
226
- # absent -- count it as unknown rather than silently dropping the repo.
227
- indeterminate = sum(1 for p in postures if p.graph_unreadable)
228
- return TableSection(
229
- category=category_meta(CategoryKey.DEPENDABOT_COOLDOWN),
230
- columns=("Repository", "Ecosystems without cooldown"),
231
- rows=rows,
232
- pass_count=with_cooldown,
233
- fail_count=missing,
234
- unknown_count=indeterminate,
235
- )
236
-
237
-
238
- def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
239
- """All extra Dependabot posture tables, in render order.
240
-
241
- The alerts and security-updates enablement checks are deliberately two
242
- separate single-feature tables (rather than one multi-column matrix): with
243
- only two public-API features the matrix read as contradictory.
244
- """
245
- return [
246
- build_alerts_table(postures),
247
- build_security_updates_table(postures),
248
- build_cooldown_table(postures),
249
- ]
250
-
251
-
252
- def build_pvr_table(postures: list[RepoPosture]) -> TableSection:
253
- """Repositories where private vulnerability reporting is not enabled.
254
-
255
- Like the Dependabot enablement tables this is a single-boolean feature
256
- check, so it reuses :func:`_build_feature_table`: offenders are repositories
257
- where the feature is confirmed off; an indeterminate (``None``) reading
258
- counts towards neither side of the standardised summary footer. The table is
259
- always built (the flag is probed for every repository); the per-category
260
- render toggle governs whether it is shown.
261
- """
262
- return _build_feature_table(
263
- postures,
264
- category_key=CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
265
- columns=("Repository",),
266
- enabled_of=lambda p: p.private_vulnerability_reporting,
267
- )
268
-
269
-
270
85
  def build_releases_table(
271
86
  postures: list[RepoPosture],
272
87
  *,
@@ -425,17 +240,3 @@ def build_mutable_releases_table(postures: list[RepoPosture]) -> TableSection:
425
240
  fail_count=finding_count,
426
241
  unknown_count=indeterminate_count,
427
242
  )
428
-
429
-
430
- __all__ = [
431
- "RepoPosture",
432
- "is_release_excluded",
433
- "cooldown_missing_ecosystems",
434
- "build_dependabot_tables",
435
- "build_releases_table",
436
- "build_mutable_releases_table",
437
- "build_alerts_table",
438
- "build_security_updates_table",
439
- "build_cooldown_table",
440
- "build_pvr_table",
441
- ]