github-security-report 0.4.1__tar.gz → 0.5.0__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 (31) hide show
  1. {github_security_report-0.4.1 → github_security_report-0.5.0}/PKG-INFO +18 -9
  2. {github_security_report-0.4.1 → github_security_report-0.5.0}/README.md +16 -7
  3. {github_security_report-0.4.1 → github_security_report-0.5.0}/pyproject.toml +2 -2
  4. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/categories.py +17 -0
  6. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/cli.py +9 -1
  7. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/client.py +26 -0
  8. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/collect.py +21 -5
  9. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/posture.py +22 -0
  10. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/render/html.py +10 -0
  11. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/render/markdown.py +11 -0
  12. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/render/slack.py +8 -0
  13. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/render/terminal.py +50 -11
  14. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/report.py +3 -0
  15. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/templates/report.html.j2 +7 -0
  16. {github_security_report-0.4.1 → github_security_report-0.5.0}/.gitignore +0 -0
  17. {github_security_report-0.4.1 → github_security_report-0.5.0}/LICENSE +0 -0
  18. {github_security_report-0.4.1 → github_security_report-0.5.0}/LICENSES/Apache-2.0.txt +0 -0
  19. {github_security_report-0.4.1 → github_security_report-0.5.0}/scripts/README.md +0 -0
  20. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/__init__.py +0 -0
  21. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/classify.py +0 -0
  22. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/config.py +0 -0
  23. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/gitctx.py +0 -0
  24. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/models.py +0 -0
  25. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/py.typed +0 -0
  26. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/render/__init__.py +0 -0
  27. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/rulesets.py +0 -0
  28. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/runner.py +0 -0
  29. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/scope.py +0 -0
  30. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/severity.py +0 -0
  31. {github_security_report-0.4.1 → github_security_report-0.5.0}/src/github_security_report/templates/index.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-security-report
3
- Version: 0.4.1
3
+ Version: 0.5.0
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
@@ -36,7 +36,7 @@ Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
36
36
  Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
37
  Requires-Dist: pytest==9.1.1; extra == 'dev'
38
38
  Requires-Dist: respx==0.23.1; extra == 'dev'
39
- Requires-Dist: ruff==0.15.19; extra == 'dev'
39
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
40
40
  Requires-Dist: syrupy==5.3.3; extra == 'dev'
41
41
  Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
42
42
  Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
@@ -87,7 +87,7 @@ back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md) and
87
87
  [`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and the
88
88
  API research it is built on.
89
89
 
90
- Two further sections report **configuration posture** and **freshness** as plain
90
+ Further sections report **configuration posture** and **freshness** as plain
91
91
  tables (org mode):
92
92
 
93
93
  - **Dependabot** — three tables: repositories with vulnerability **alerts not
@@ -101,6 +101,12 @@ tables (org mode):
101
101
  release or tag is older than `release_max_age_days` (default 60; `0` flags
102
102
  every eligible repository), so a repository released or tagged within that
103
103
  window counts as recently maintained and drops out of the table.
104
+ - **Private Vulnerability Reporting** — repositories where GitHub's private
105
+ vulnerability reporting feature is **not enabled**, so security researchers
106
+ cannot privately disclose vulnerabilities. Probed per repository (GitHub
107
+ exposes no org-wide or GraphQL equivalent) and, like every other category,
108
+ always collected; hide it with the `private_vulnerability_reporting` render
109
+ toggle.
104
110
 
105
111
  ## Operating modes
106
112
 
@@ -176,7 +182,7 @@ organisations. Grant these scopes:
176
182
 
177
183
  > For organisations that enforce SSO, the PAT must be **SSO-authorised** for
178
184
  > each target organisation, or the org-level endpoints return `403` (reported as
179
- > unknown). Store the token as a secret (e.g. `SECURITY_REPORT_PAT`) and
185
+ > unknown). Store the token as a secret (e.g. `LFRELENG_ACTIONS_REPORT_PAT`) and
180
186
  > reference it by env-var name via `token_env`; never embed it in the config.
181
187
 
182
188
  ## Usage
@@ -190,7 +196,9 @@ organisations. Grant these scopes:
190
196
  with:
191
197
  scope: "org"
192
198
  config: "${{ secrets.GSR_CONFIG || vars.GSR_CONFIG }}"
193
- token: "${{ secrets.SECURITY_REPORT_PAT }}"
199
+ token: "${{ secrets.LFRELENG_ACTIONS_REPORT_PAT }}"
200
+ # Must match the per-org "token_env" in your config (below).
201
+ token_env: "LFRELENG_ACTIONS_REPORT_PAT"
194
202
  output_dir: "site"
195
203
  pages_url: "https://lfreleng-actions.github.io/github-security-report-action/"
196
204
  ```
@@ -235,7 +243,7 @@ environment-variable name, never embedded.
235
243
  "organizations": [
236
244
  {
237
245
  "name": "lfreleng-actions",
238
- "token_env": "GITHUB_TOKEN",
246
+ "token_env": "LFRELENG_ACTIONS_REPORT_PAT",
239
247
  "exclude": ["actions-template"],
240
248
  "releases_exclude": ["internal-only-repo"]
241
249
  }
@@ -305,7 +313,8 @@ out of the terminal and Slack while still publishing it to the Markdown and HTML
305
313
  Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
306
314
  `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
307
315
  `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
308
- `mutable_releases`. Like the other `report` settings, `categories` can be set
316
+ `mutable_releases`, `private_vulnerability_reporting`. Like the other `report`
317
+ settings, `categories` can be set
309
318
  globally and overridden per organisation (overrides merge key-by-key, so
310
319
  flipping one output leaves the rest untouched). The machine-readable
311
320
  `report.json` artifact always contains the complete dataset, regardless of these
@@ -365,7 +374,7 @@ $XDG_CONFIG_HOME/github-security-report/config.json
365
374
  An explicit `--config`, `--config-data`, or `--org` always takes precedence, and
366
375
  the action itself never reads this path (it is supplied configuration directly).
367
376
  Secrets stay out of the file: reference the token by environment-variable name
368
- via `token_env` (e.g. `SECURITY_REPORT_PAT`, exported in your shell or sourced
377
+ via `token_env` (e.g. `LFRELENG_ACTIONS_REPORT_PAT`, exported in your shell or sourced
369
378
  from a secrets file) — the channel ID is the only Slack value the file holds,
370
379
  and the Slack **bot token** is consumed by the workflow, not the CLI.
371
380
 
@@ -380,7 +389,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
380
389
  | `org` | No | — | Single organisation (shorthand for org mode) |
381
390
  | `repo` | No | detected | `owner/name` for repo mode |
382
391
  | `token` | No | `${{ github.token }}` | PAT (org mode) or `GITHUB_TOKEN` (repo mode) |
383
- | `token_env` | No | `GITHUB_TOKEN` | Env var name the tool reads the token from |
392
+ | `token_env` | No | `GITHUB_TOKEN` | Env var name the token is exported under. In org mode it **must match** the per-org `token_env` in your config (e.g. `LFRELENG_ACTIONS_REPORT_PAT`), otherwise the tool looks up an unset variable and reports no token. |
384
393
  | `output_dir` | No | — | Directory for Pages output (org mode) |
385
394
  | `pages_url` | No | — | Published Pages URL (used in the Slack link) |
386
395
  | `slack_channel` | No | — | Slack channel ID; overrides the config `slack.channel` (e.g. the `SLACK_CHANNEL_ID` variable) |
@@ -43,7 +43,7 @@ back to code-scanning findings. See [`docs/BRIEF.md`](docs/BRIEF.md) and
43
43
  [`docs/phase0-findings.md`](docs/phase0-findings.md) for the full design and the
44
44
  API research it is built on.
45
45
 
46
- Two further sections report **configuration posture** and **freshness** as plain
46
+ Further sections report **configuration posture** and **freshness** as plain
47
47
  tables (org mode):
48
48
 
49
49
  - **Dependabot** — three tables: repositories with vulnerability **alerts not
@@ -57,6 +57,12 @@ tables (org mode):
57
57
  release or tag is older than `release_max_age_days` (default 60; `0` flags
58
58
  every eligible repository), so a repository released or tagged within that
59
59
  window counts as recently maintained and drops out of the table.
60
+ - **Private Vulnerability Reporting** — repositories where GitHub's private
61
+ vulnerability reporting feature is **not enabled**, so security researchers
62
+ cannot privately disclose vulnerabilities. Probed per repository (GitHub
63
+ exposes no org-wide or GraphQL equivalent) and, like every other category,
64
+ always collected; hide it with the `private_vulnerability_reporting` render
65
+ toggle.
60
66
 
61
67
  ## Operating modes
62
68
 
@@ -132,7 +138,7 @@ organisations. Grant these scopes:
132
138
 
133
139
  > For organisations that enforce SSO, the PAT must be **SSO-authorised** for
134
140
  > each target organisation, or the org-level endpoints return `403` (reported as
135
- > unknown). Store the token as a secret (e.g. `SECURITY_REPORT_PAT`) and
141
+ > unknown). Store the token as a secret (e.g. `LFRELENG_ACTIONS_REPORT_PAT`) and
136
142
  > reference it by env-var name via `token_env`; never embed it in the config.
137
143
 
138
144
  ## Usage
@@ -146,7 +152,9 @@ organisations. Grant these scopes:
146
152
  with:
147
153
  scope: "org"
148
154
  config: "${{ secrets.GSR_CONFIG || vars.GSR_CONFIG }}"
149
- token: "${{ secrets.SECURITY_REPORT_PAT }}"
155
+ token: "${{ secrets.LFRELENG_ACTIONS_REPORT_PAT }}"
156
+ # Must match the per-org "token_env" in your config (below).
157
+ token_env: "LFRELENG_ACTIONS_REPORT_PAT"
150
158
  output_dir: "site"
151
159
  pages_url: "https://lfreleng-actions.github.io/github-security-report-action/"
152
160
  ```
@@ -191,7 +199,7 @@ environment-variable name, never embedded.
191
199
  "organizations": [
192
200
  {
193
201
  "name": "lfreleng-actions",
194
- "token_env": "GITHUB_TOKEN",
202
+ "token_env": "LFRELENG_ACTIONS_REPORT_PAT",
195
203
  "exclude": ["actions-template"],
196
204
  "releases_exclude": ["internal-only-repo"]
197
205
  }
@@ -261,7 +269,8 @@ out of the terminal and Slack while still publishing it to the Markdown and HTML
261
269
  Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
262
270
  `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
263
271
  `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
264
- `mutable_releases`. Like the other `report` settings, `categories` can be set
272
+ `mutable_releases`, `private_vulnerability_reporting`. Like the other `report`
273
+ settings, `categories` can be set
265
274
  globally and overridden per organisation (overrides merge key-by-key, so
266
275
  flipping one output leaves the rest untouched). The machine-readable
267
276
  `report.json` artifact always contains the complete dataset, regardless of these
@@ -321,7 +330,7 @@ $XDG_CONFIG_HOME/github-security-report/config.json
321
330
  An explicit `--config`, `--config-data`, or `--org` always takes precedence, and
322
331
  the action itself never reads this path (it is supplied configuration directly).
323
332
  Secrets stay out of the file: reference the token by environment-variable name
324
- via `token_env` (e.g. `SECURITY_REPORT_PAT`, exported in your shell or sourced
333
+ via `token_env` (e.g. `LFRELENG_ACTIONS_REPORT_PAT`, exported in your shell or sourced
325
334
  from a secrets file) — the channel ID is the only Slack value the file holds,
326
335
  and the Slack **bot token** is consumed by the workflow, not the CLI.
327
336
 
@@ -336,7 +345,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
336
345
  | `org` | No | — | Single organisation (shorthand for org mode) |
337
346
  | `repo` | No | detected | `owner/name` for repo mode |
338
347
  | `token` | No | `${{ github.token }}` | PAT (org mode) or `GITHUB_TOKEN` (repo mode) |
339
- | `token_env` | No | `GITHUB_TOKEN` | Env var name the tool reads the token from |
348
+ | `token_env` | No | `GITHUB_TOKEN` | Env var name the token is exported under. In org mode it **must match** the per-org `token_env` in your config (e.g. `LFRELENG_ACTIONS_REPORT_PAT`), otherwise the tool looks up an unset variable and reports no token. |
340
349
  | `output_dir` | No | — | Directory for Pages output (org mode) |
341
350
  | `pages_url` | No | — | Published Pages URL (used in the Slack link) |
342
351
  | `slack_channel` | No | — | Slack channel ID; overrides the config `slack.channel` (e.g. the `SLACK_CHANNEL_ID` variable) |
@@ -65,7 +65,7 @@ dev = [
65
65
  "respx==0.23.1",
66
66
  "syrupy==5.3.3",
67
67
  "mypy==2.1.0",
68
- "ruff==0.15.19",
68
+ "ruff==0.15.20",
69
69
  "types-jsonschema==4.26.0.20260518",
70
70
  "types-PyYAML==6.0.12.20250915",
71
71
  ]
@@ -97,7 +97,7 @@ dev = [
97
97
  "respx==0.23.1",
98
98
  "syrupy==5.3.3",
99
99
  "mypy==2.1.0",
100
- "ruff==0.15.19",
100
+ "ruff==0.15.20",
101
101
  "types-jsonschema==4.26.0.20260518",
102
102
  "types-PyYAML==6.0.12.20250915",
103
103
  ]
@@ -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.4.1'
22
- __version_tuple__ = version_tuple = (0, 4, 1)
21
+ __version__ = version = '0.5.0'
22
+ __version_tuple__ = version_tuple = (0, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -38,6 +38,7 @@ class CategoryKey(str, Enum):
38
38
  DEPENDABOT_COOLDOWN = "dependabot_cooldown"
39
39
  RELEASES = "releases"
40
40
  MUTABLE_RELEASES = "mutable_releases"
41
+ PRIVATE_VULNERABILITY_REPORTING = "private_vulnerability_reporting"
41
42
 
42
43
 
43
44
  @dataclass(frozen=True)
@@ -210,6 +211,22 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
210
211
  "cannot change after the fact."
211
212
  ),
212
213
  ),
214
+ CategoryKey.PRIVATE_VULNERABILITY_REPORTING: CategoryMeta(
215
+ key=CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
216
+ title="Private Vulnerability Reporting",
217
+ pass_label="Enabled",
218
+ fail_label="Not enabled",
219
+ url=(
220
+ "https://docs.github.com/en/code-security/security-advisories/"
221
+ "working-with-repository-security-advisories/"
222
+ "configuring-private-vulnerability-reporting-for-a-repository"
223
+ ),
224
+ description=(
225
+ "Repositories with private vulnerability reporting disabled. Enable "
226
+ "it so security researchers can privately report vulnerabilities "
227
+ "instead of disclosing them publicly."
228
+ ),
229
+ ),
213
230
  }
214
231
 
215
232
 
@@ -48,7 +48,10 @@ def _version_callback(value: bool) -> None:
48
48
  if value:
49
49
  # Match the dependamerge style: a label emoji plus a Rich-highlighted
50
50
  # version number (Rich colourises the numeric version automatically).
51
- Console().print(f"🏷️ github-security-report version {__version__}")
51
+ # A single space follows the emoji: terminals that honour the VS16
52
+ # emoji-presentation width (e.g. Ghostty) render it two cells wide, so
53
+ # the extra pad the old double space added now reads as a gap.
54
+ Console().print(f"🏷️ github-security-report version {__version__}")
52
55
  raise typer.Exit()
53
56
 
54
57
 
@@ -133,6 +136,11 @@ def _org_to_dict(org: OrgReport) -> dict:
133
136
  "mutable_releases": (
134
137
  _table_to_dict(org.mutable_releases) if org.mutable_releases else None
135
138
  ),
139
+ "private_vulnerability_reporting": (
140
+ _table_to_dict(org.private_vulnerability_reporting)
141
+ if org.private_vulnerability_reporting
142
+ else None
143
+ ),
136
144
  }
137
145
 
138
146
 
@@ -756,6 +756,32 @@ class GitHubClient:
756
756
  await resp.aclose() # release the connection once the body is read
757
757
  return bool(data.get("enabled"))
758
758
 
759
+ async def private_vulnerability_reporting(
760
+ self, org: str, repo: str
761
+ ) -> bool | None:
762
+ """Whether private vulnerability reporting is enabled (None = unknown).
763
+
764
+ ``GET .../private-vulnerability-reporting`` returns ``{enabled}`` (200);
765
+ any other status (e.g. 422) is treated as indeterminate. A classic PAT
766
+ needs the ``repo`` scope (``public_repo`` suffices for public repos);
767
+ fine-grained tokens and Apps need only ``Metadata: read``. Org mode
768
+ already grants ``repo``, so it works with the same token used for the
769
+ other read probes -- but GitHub exposes no org-wide or GraphQL
770
+ equivalent, so it is one REST call per repository, gathered alongside
771
+ the automated-security-fixes probe.
772
+ """
773
+ resp = await self._request(
774
+ "GET",
775
+ f"{self._api_url}/repos/{org}/{repo}/private-vulnerability-reporting",
776
+ )
777
+ status = resp.status_code
778
+ if status != 200:
779
+ await resp.aclose() # unread body would leak a pooled connection
780
+ return None
781
+ data = resp.json()
782
+ await resp.aclose() # release the connection once the body is read
783
+ return bool(data.get("enabled"))
784
+
759
785
  async def repo_graph_batch(
760
786
  self, org: str, names: list[str]
761
787
  ) -> dict[str, RepoGraphData]:
@@ -72,6 +72,12 @@ class ClientProtocol(Protocol):
72
72
  """Whether Dependabot automated security fixes are enabled."""
73
73
  raise NotImplementedError
74
74
 
75
+ async def private_vulnerability_reporting(
76
+ self, org: str, repo: str
77
+ ) -> bool | None:
78
+ """Whether private vulnerability reporting is enabled for a repository."""
79
+ raise NotImplementedError
80
+
75
81
  async def repo_graph_batch(
76
82
  self, org: str, names: list[str]
77
83
  ) -> dict[str, RepoGraphData]:
@@ -199,11 +205,18 @@ async def _posture_for_repo(
199
205
  """Build one repo's Dependabot posture and release/tag freshness.
200
206
 
201
207
  ``dependabot_alerts`` and the release/tag/``dependabot.yml`` data come from
202
- the batched GraphQL prefetch (:func:`_collect_graph`); only the
203
- security-updates flag remains a per-repo REST call, since GitHub exposes no
204
- GraphQL equivalent.
208
+ the batched GraphQL prefetch (:func:`_collect_graph`); the security-updates
209
+ and private-vulnerability-reporting flags remain per-repo REST calls, since
210
+ GitHub exposes no GraphQL equivalent. They are independent, so they are
211
+ gathered together and the two reads overlap (bounded by the client
212
+ semaphore); private vulnerability reporting is always probed, like every
213
+ other signal, with the per-category toggle governing only whether the
214
+ resulting table renders.
205
215
  """
206
- security_updates = await client.automated_security_fixes(org, repo.name)
216
+ security_updates, pvr = await asyncio.gather(
217
+ client.automated_security_fixes(org, repo.name),
218
+ client.private_vulnerability_reporting(org, repo.name),
219
+ )
207
220
  config_text = graph.dependabot_config
208
221
  has_config = config_text is not None
209
222
  cooldown_missing = (
@@ -215,6 +228,7 @@ async def _posture_for_repo(
215
228
  repo=repo,
216
229
  dependabot_alerts=dependabot_alerts,
217
230
  security_updates=security_updates,
231
+ private_vulnerability_reporting=pvr,
218
232
  cooldown_missing=cooldown_missing,
219
233
  has_dependabot_config=has_config,
220
234
  latest_release_at=graph.latest_release_at,
@@ -356,7 +370,8 @@ async def collect_org(
356
370
  # Extra reporting categories (outside the four-state model): Dependabot
357
371
  # configuration posture and release/tag freshness. The Dependabot alerts
358
372
  # enablement flag and the release/tag data are reused from the batched
359
- # GraphQL prefetch; only the security-updates flag is still a per-repo call.
373
+ # GraphQL prefetch; the security-updates and private-vulnerability-reporting
374
+ # flags are still per-repo calls.
360
375
  when = report.generated_at
361
376
  dependabot_on = {f.repo.name: f.dependabot_enabled for f in facts}
362
377
  postures: list[RepoPosture] = []
@@ -384,6 +399,7 @@ async def collect_org(
384
399
  exclude=org_cfg.releases_exclude,
385
400
  )
386
401
  report.mutable_releases = posture.build_mutable_releases_table(postures)
402
+ report.private_vulnerability_reporting = posture.build_pvr_table(postures)
387
403
  # The Dependabot alerts enablement sub-table carries the repositories with
388
404
  # Dependabot alerts disabled, so drop them from the Dependabot signal
389
405
  # section's nag list to avoid listing the same repositories twice under the
@@ -48,6 +48,8 @@ class RepoPosture:
48
48
  # Dependabot repo-level feature flags (None = indeterminate).
49
49
  dependabot_alerts: bool | None = None
50
50
  security_updates: bool | None = None
51
+ # GitHub "private vulnerability reporting" enablement (None = indeterminate).
52
+ private_vulnerability_reporting: bool | None = None
51
53
  # Ecosystems declared in .github/dependabot.yml that set no cooldown.
52
54
  cooldown_missing: tuple[str, ...] = ()
53
55
  # True when .github/dependabot.yml exists and declares version updates.
@@ -240,6 +242,25 @@ def build_dependabot_tables(postures: list[RepoPosture]) -> list[TableSection]:
240
242
  ]
241
243
 
242
244
 
245
+ def build_pvr_table(postures: list[RepoPosture]) -> TableSection:
246
+ """Repositories where private vulnerability reporting is not enabled.
247
+
248
+ Like the Dependabot enablement tables this is a single-boolean feature
249
+ check, so it reuses :func:`_build_feature_table`: offenders are repositories
250
+ where the feature is confirmed off; an indeterminate (``None``) reading
251
+ counts towards neither side of the standardised summary footer. The table is
252
+ always built (the flag is probed for every repository); the per-category
253
+ render toggle governs whether it is shown.
254
+ """
255
+ return _build_feature_table(
256
+ postures,
257
+ category_key=CategoryKey.PRIVATE_VULNERABILITY_REPORTING,
258
+ columns=("Repository",),
259
+ enabled_of=lambda p: p.private_vulnerability_reporting,
260
+ )
261
+
262
+
263
+
243
264
  def build_releases_table(
244
265
  postures: list[RepoPosture],
245
266
  *,
@@ -390,4 +411,5 @@ __all__ = [
390
411
  "build_alerts_table",
391
412
  "build_security_updates_table",
392
413
  "build_cooldown_table",
414
+ "build_pvr_table",
393
415
  ]
@@ -239,6 +239,16 @@ def render_org_html(
239
239
  and visible(org.mutable_releases.category.key)
240
240
  else None
241
241
  ),
242
+ private_vulnerability_reporting=(
243
+ _table_context(
244
+ org.private_vulnerability_reporting,
245
+ excluded=excluded,
246
+ top_n=top_n,
247
+ )
248
+ if org.private_vulnerability_reporting
249
+ and visible(org.private_vulnerability_reporting.category.key)
250
+ else None
251
+ ),
242
252
  datatables_version=DATATABLES_VERSION,
243
253
  datatables_css_sri=DATATABLES_CSS_SRI,
244
254
  datatables_js_sri=DATATABLES_JS_SRI,
@@ -281,6 +281,17 @@ def render_org(
281
281
  org.mutable_releases, level=2, excluded=excluded, top_n=top_n
282
282
  )
283
283
  )
284
+ if org.private_vulnerability_reporting is not None and visible(
285
+ org.private_vulnerability_reporting.category.key
286
+ ):
287
+ parts.append(
288
+ render_table_section(
289
+ org.private_vulnerability_reporting,
290
+ level=2,
291
+ excluded=excluded,
292
+ top_n=top_n,
293
+ )
294
+ )
284
295
  return "\n".join(parts).rstrip() + "\n"
285
296
 
286
297
 
@@ -239,6 +239,14 @@ def render_org_blocks(
239
239
  block = _table_block(org.mutable_releases, top_n, excluded=excluded)
240
240
  if block is not None:
241
241
  blocks.append(block)
242
+ if org.private_vulnerability_reporting is not None and visible(
243
+ org.private_vulnerability_reporting.category.key
244
+ ):
245
+ block = _table_block(
246
+ org.private_vulnerability_reporting, top_n, excluded=excluded
247
+ )
248
+ if block is not None:
249
+ blocks.append(block)
242
250
  if pages_url:
243
251
  blocks.append(
244
252
  {
@@ -11,6 +11,7 @@ sections 10-11.
11
11
  from __future__ import annotations
12
12
 
13
13
  from collections.abc import Callable, Sequence
14
+ from dataclasses import replace
14
15
 
15
16
  from rich.console import Console
16
17
  from rich.table import Table
@@ -87,20 +88,28 @@ def _truncated_names(names: Sequence[str], top_n: int | None) -> str:
87
88
 
88
89
 
89
90
  def _render_summary(
90
- console: Console, lines: Sequence[SummaryLine], *, top_n: int | None
91
+ console: Console,
92
+ lines: Sequence[SummaryLine],
93
+ *,
94
+ top_n: int | None,
95
+ name_labels: dict[str, str] | None = None,
91
96
  ) -> None:
92
97
  """Print the standardised footer: count lines, then any name lists.
93
98
 
94
99
  Counts come first (failures and not-enabled at the top, the healthy pass
95
- line lower down), then the repository-name breakdowns for the disabled and
96
- excluded kinds -- numbers and names are never mixed on one line, and the
97
- name lists honour the same offender limit as the tables.
100
+ line lower down), then the repository-name breakdowns for the kinds in
101
+ ``name_labels`` -- numbers and names are never mixed on one line, and the
102
+ name lists honour the same offender limit as the tables. ``name_labels``
103
+ defaults to the disabled/excluded kinds; a boolean feature table passes an
104
+ extended map so its offenders list inline under the fail line too.
98
105
  """
106
+ if name_labels is None:
107
+ name_labels = _NAME_LIST_LABEL
99
108
  for line in lines:
100
109
  style = _SUMMARY_STYLE[line.kind]
101
110
  console.print(f" [{style}]{SUMMARY_EMOJI[line.kind]} {line.text}[/{style}]")
102
111
  for line in lines:
103
- label = _NAME_LIST_LABEL.get(line.kind)
112
+ label = name_labels.get(line.kind)
104
113
  if label and line.names:
105
114
  style = _SUMMARY_STYLE[line.kind]
106
115
  console.print(
@@ -155,13 +164,20 @@ def render_table_section(
155
164
  ) -> None:
156
165
  """Render a generic posture/freshness table to the terminal.
157
166
 
158
- The explanatory description is deliberately omitted here: the terminal is a
159
- brevity-first surface, so the guidance text is reserved for the Markdown and
160
- HTML (GitHub Pages) outputs.
167
+ A section with a single column carries only repository names -- a boolean
168
+ feature check (enabled/not enabled) with no qualitative data -- so it is
169
+ rendered like a signal section: no table, just the standardised footer with
170
+ the offenders listed inline under the fail line (e.g. ``Not enabled:``).
171
+ Tables are reserved for sections whose extra columns carry qualitative data
172
+ that cannot be expressed as a count (release/tag ages, ecosystems, release
173
+ tags). The explanatory description is deliberately omitted either way: the
174
+ terminal is a brevity-first surface, so the guidance text is reserved for
175
+ the Markdown and HTML (GitHub Pages) outputs.
161
176
  """
177
+ inline = len(section.columns) == 1
162
178
  rows, hidden = truncate(section.rows, top_n)
163
179
  console.print(f"[bold]{section.title}[/bold]")
164
- if rows:
180
+ if not inline and rows:
165
181
  table = Table(title_justify="left", title_style="bold")
166
182
  for i, col in enumerate(section.columns):
167
183
  table.add_column(col, overflow="fold", justify="left" if i == 0 else "right")
@@ -170,9 +186,23 @@ def render_table_section(
170
186
  console.print(table)
171
187
  if hidden:
172
188
  console.print(f" [dim]\u2026 and {hidden} more[/dim]")
173
- lines = build_summary(section.summary_counts(excluded))
189
+ counts = section.summary_counts(excluded)
190
+ name_labels = _NAME_LIST_LABEL
191
+ if inline:
192
+ # Surface the offenders inline under the fail line, labelled with the
193
+ # category's fail wording (e.g. "Not enabled"), instead of a one-column
194
+ # table. The name list honours top_n like every other breakdown.
195
+ fail_label = section.category.fail_label or "Failing"
196
+ counts = [
197
+ replace(c, names=tuple(r.repo.name for r in section.rows))
198
+ if c.kind == "fail"
199
+ else c
200
+ for c in counts
201
+ ]
202
+ name_labels = {**_NAME_LIST_LABEL, "fail": fail_label}
203
+ lines = build_summary(counts)
174
204
  if lines:
175
- _render_summary(console, lines, top_n=top_n)
205
+ _render_summary(console, lines, top_n=top_n, name_labels=name_labels)
176
206
  elif not rows:
177
207
  console.print(" [dim]No data[/dim]")
178
208
  console.print()
@@ -214,6 +244,15 @@ def render_org(
214
244
  render_table_section(
215
245
  org.mutable_releases, console, excluded=org.excluded_repos, top_n=top_n
216
246
  )
247
+ if org.private_vulnerability_reporting is not None and visible(
248
+ org.private_vulnerability_reporting.category.key
249
+ ):
250
+ render_table_section(
251
+ org.private_vulnerability_reporting,
252
+ console,
253
+ excluded=org.excluded_repos,
254
+ top_n=top_n,
255
+ )
217
256
 
218
257
 
219
258
  def render_orgs(
@@ -252,6 +252,9 @@ class OrgReport:
252
252
  # The Mutable Releases table: repositories whose "Latest" or last-published
253
253
  # release is not immutable. None in repo mode / when not collected.
254
254
  mutable_releases: TableSection | None = None
255
+ # The Private Vulnerability Reporting table: repositories where the feature
256
+ # is not enabled. None in repo mode / when not collected.
257
+ private_vulnerability_reporting: TableSection | None = None
255
258
 
256
259
 
257
260
  @dataclass
@@ -125,6 +125,13 @@
125
125
  </section>
126
126
  {% endif %}
127
127
 
128
+ {% if private_vulnerability_reporting %}
129
+ <section>
130
+ <h2>{{ private_vulnerability_reporting.title }}</h2>
131
+ {{ render_table(private_vulnerability_reporting) }}
132
+ </section>
133
+ {% endif %}
134
+
128
135
  <footer>
129
136
  Generated by
130
137
  <a href="https://github.com/lfreleng-actions/github-security-report-action">github-security-report</a>