github-security-report 0.1.0__tar.gz → 0.3.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 (37) hide show
  1. {github_security_report-0.1.0 → github_security_report-0.3.0}/PKG-INFO +177 -29
  2. {github_security_report-0.1.0 → github_security_report-0.3.0}/README.md +174 -26
  3. {github_security_report-0.1.0 → github_security_report-0.3.0}/pyproject.toml +4 -4
  4. {github_security_report-0.1.0 → github_security_report-0.3.0}/scripts/README.md +52 -0
  5. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/_version.py +2 -2
  6. github_security_report-0.3.0/src/github_security_report/categories.py +223 -0
  7. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/classify.py +70 -16
  8. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/cli.py +133 -46
  9. github_security_report-0.3.0/src/github_security_report/client.py +798 -0
  10. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/collect.py +145 -61
  11. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/config.py +224 -8
  12. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/models.py +103 -15
  13. github_security_report-0.3.0/src/github_security_report/posture.py +393 -0
  14. github_security_report-0.3.0/src/github_security_report/render/html.py +254 -0
  15. github_security_report-0.3.0/src/github_security_report/render/markdown.py +278 -0
  16. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/render/slack.py +132 -57
  17. github_security_report-0.3.0/src/github_security_report/render/terminal.py +210 -0
  18. github_security_report-0.3.0/src/github_security_report/report.py +343 -0
  19. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/severity.py +25 -13
  20. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/templates/report.html.j2 +57 -71
  21. github_security_report-0.1.0/src/github_security_report/client.py +0 -493
  22. github_security_report-0.1.0/src/github_security_report/posture.py +0 -264
  23. github_security_report-0.1.0/src/github_security_report/render/html.py +0 -142
  24. github_security_report-0.1.0/src/github_security_report/render/markdown.py +0 -172
  25. github_security_report-0.1.0/src/github_security_report/render/terminal.py +0 -163
  26. github_security_report-0.1.0/src/github_security_report/report.py +0 -173
  27. {github_security_report-0.1.0 → github_security_report-0.3.0}/.gitignore +0 -0
  28. {github_security_report-0.1.0 → github_security_report-0.3.0}/LICENSE +0 -0
  29. {github_security_report-0.1.0 → github_security_report-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
  30. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/__init__.py +0 -0
  31. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/gitctx.py +0 -0
  32. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/py.typed +0 -0
  33. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/render/__init__.py +0 -0
  34. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/rulesets.py +0 -0
  35. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/runner.py +0 -0
  36. {github_security_report-0.1.0 → github_security_report-0.3.0}/src/github_security_report/scope.py +0 -0
  37. {github_security_report-0.1.0 → github_security_report-0.3.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.1.0
3
+ Version: 0.3.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
@@ -34,9 +34,9 @@ Provides-Extra: dev
34
34
  Requires-Dist: mypy==2.1.0; extra == 'dev'
35
35
  Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
36
36
  Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
- Requires-Dist: pytest==9.0.3; extra == 'dev'
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.16; extra == 'dev'
39
+ Requires-Dist: ruff==0.15.18; extra == 'dev'
40
40
  Requires-Dist: syrupy==5.3.2; 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'
@@ -70,9 +70,16 @@ states and rendered worst-first:
70
70
 
71
71
  - **Offenders** — enabled with open findings (a ranked table row).
72
72
  - **Clean** — enabled with zero findings (a count beneath the table).
73
- - **Not enabled** — supported but switched off (a nag list prompting you to
74
- enable it).
75
- - **Unknown** — indeterminate (insufficient permission), footnoted separately.
73
+ - **Not enabled** — supported but switched off (a counted "disabled" footer
74
+ line, with the affected repositories named).
75
+ - **Unknown** — indeterminate (insufficient permission), counted separately.
76
+
77
+ Every category renders the same **standardised summary footer** beneath its
78
+ table: remediation-first count lines (failures, disabled, unknown, then the
79
+ healthy pass line, then excluded). The pass line reads **"All <state>"** when
80
+ nothing needs attention, or **"N <state>"** otherwise. The terminal and Slack
81
+ stay brevity-first; the explanatory per-category description and documentation
82
+ link are shown only on the richer Markdown and HTML (GitHub Pages) outputs.
76
83
 
77
84
  The single GitHub code-scanning feed is partitioned by `tool.name` into CodeQL,
78
85
  Scorecard, and zizmor; Scorecard prefers the external aggregate score and falls
@@ -86,10 +93,14 @@ tables (org mode):
86
93
  - **Dependabot** — three tables: repositories with vulnerability **alerts not
87
94
  enabled**, repositories with **security updates not enabled**, and ecosystems
88
95
  with no update `cooldown` configured (mandatory; any value passes).
89
- - **Releases / Tagging** — repositories overdue a release or tag, ranked by a
90
- hidden compound staleness score. Repositories younger than
91
- `release_min_age_days` (default 28; `0` includes all) and those in
92
- `releases_exclude` are omitted.
96
+ - **Releases / Tagging** — repositories overdue a release or tag, ranked by
97
+ release/tag staleness (repository age never affects ordering; a repository
98
+ with no release or tag ranks highest). Repositories younger than
99
+ `repo_min_age_days` (default 28; `0` includes all) and those in
100
+ `releases_exclude` are omitted. A repository is flagged only when its newest
101
+ release or tag is older than `release_max_age_days` (default 60; `0` flags
102
+ every eligible repository), so a repository released or tagged within that
103
+ window counts as recently maintained and drops out of the table.
93
104
 
94
105
  ## Operating modes
95
106
 
@@ -109,11 +120,20 @@ Repo mode needs nothing beyond the workflow's ephemeral `GITHUB_TOKEN`. Org mode
109
120
  needs a Personal Access Token; choose **one** of the two options below depending
110
121
  on how many organisations the report covers.
111
122
 
112
- All required access is **read-only**. The tool degrades any read it is not
113
- permitted to make to an "unknown" status rather than reporting a repository as
114
- clean, so an under-scoped token surfaces as unknowns in the report instead of
123
+ Almost all required access is **read-only**. The tool degrades any read it is
124
+ not permitted to make to an "unknown" status rather than reporting a repository
125
+ as clean, so an under-scoped token surfaces as unknowns in the report instead of
115
126
  silently wrong results — start minimal and widen if you see unknowns.
116
127
 
128
+ The **one** exception is organisation-ruleset coverage. GitHub gates the
129
+ org-rulesets endpoint behind an org-admin permission (classic `admin:org` scope,
130
+ or fine-grained Administration **write**), even though the tool only reads it.
131
+ That coverage is **optional**: it detects tools enforced through an org ruleset
132
+ (for example a required-workflow or code-scanning ruleset). Without it that one
133
+ signal is skipped and every other part of the report is unaffected, so the
134
+ minimal tokens below omit it. Grant the org-admin permission only if you want
135
+ ruleset-based tool coverage.
136
+
117
137
  ### Single organisation — fine-grained PAT
118
138
 
119
139
  A fine-grained PAT is bound to one resource owner, so it works for a report
@@ -131,14 +151,15 @@ organisation and **Repository access** set to *All repositories*, then grant:
131
151
  | Secret scanning alerts | Open secret-scanning alerts |
132
152
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
133
153
 
134
- **Organization permissions** (Read-only):
154
+ **Organization permissions:**
135
155
 
136
- | Permission | Used for |
137
- | ---------- | -------- |
138
- | Administration | Organisation rulesets (detect tools enabled via a required workflow) |
156
+ | Permission | Access | Used for |
157
+ | ---------- | ------ | -------- |
158
+ | Administration | Read and write | *Optional* — organisation rulesets (detect tools enforced through an org ruleset). GitHub gates this endpoint behind Administration **write**; omit it to keep the token read-only and skip ruleset-based tool coverage. |
139
159
 
140
- > A fine-grained token cannot span organisations. For a report covering more
141
- > than one org, use a classic PAT (below).
160
+ > Read-only is enough for everything except the optional ruleset coverage
161
+ > above. A fine-grained token cannot span organisations. For a report covering
162
+ > more than one org, use a classic PAT (below).
142
163
 
143
164
  ### Multiple organisations — classic PAT
144
165
 
@@ -150,7 +171,8 @@ organisations. Grant these scopes:
150
171
  | ----- | -------- |
151
172
  | `repo` | Repository data, including private repositories |
152
173
  | `security_events` | Code scanning, secret scanning, and Dependabot alerts (org-bulk and per-repo) |
153
- | `read:org` | Listing organisation repositories and reading organisation rulesets |
174
+ | `read:org` | Listing organisation repositories |
175
+ | `admin:org` | *Optional* — reading organisation rulesets for ruleset-based tool coverage. GitHub gates `GET /orgs/{org}/rulesets` behind the full `admin:org` scope; `read:org` and `write:org` return 404. Omit it to skip that one signal; everything else is unaffected. |
154
176
 
155
177
  > For organisations that enforce SSO, the PAT must be **SSO-authorised** for
156
178
  > each target organisation, or the org-level endpoints return `403` (reported as
@@ -207,7 +229,8 @@ environment-variable name, never embedded.
207
229
  "top_n_slack": 10,
208
230
  "include_archived": false,
209
231
  "include_test": false,
210
- "release_min_age_days": 28
232
+ "repo_min_age_days": 28,
233
+ "release_max_age_days": 60
211
234
  },
212
235
  "organizations": [
213
236
  {
@@ -226,17 +249,102 @@ environment-variable name, never embedded.
226
249
  `top_n` controls how many offenders are shown per signal. It is the shared
227
250
  default for all three outputs; set any of `top_n_report` (GitHub Pages),
228
251
  `top_n_cli` (terminal), or `top_n_slack` (Slack digest) to override an
229
- individual output. Each can also be set at the CLI with `--top-n`,
252
+ individual output. Set a value to `0` to remove the limit entirely and show
253
+ every offender. Each can also be set at the CLI with `--top-n`,
230
254
  `--top-n-report`, `--top-n-cli`, and `--top-n-slack`.
231
255
 
232
- `report.release_min_age_days` (default `28`, `0` = include all) and the per-org
233
- `releases_exclude` tune the Releases / Tagging section; they can be overridden
234
- locally with `--release-min-age-days` and the repeatable `--releases-exclude`.
256
+ The Releases / Tagging section has two independent freshness levers:
257
+
258
+ - `report.repo_min_age_days` (default `28`, `0` = include all) is a grace
259
+ period that omits **brand-new repositories** — those *created* within that
260
+ many days — before a release or tag is expected of them. CLI:
261
+ `--repo-min-age-days`.
262
+ - `report.release_max_age_days` (default `60`; `0` = flag everything) is the
263
+ release-staleness threshold: a repository is only flagged when its newest
264
+ release **or** tag is older than that many days (a repository with neither is
265
+ always flagged). Tune it to match your release cadence so actively released
266
+ repositories drop out of the table. CLI: `--release-max-age-days`.
267
+
268
+ The per-org `releases_exclude` (CLI `--releases-exclude`, repeatable) drops
269
+ named repositories from the section entirely.
270
+
271
+ > The former `release_min_age_days` key was a misleading name for
272
+ > `repo_min_age_days` (it gates *repository* age, not *release* age). It is
273
+ > still accepted as a deprecated alias and emits a warning; prefer
274
+ > `repo_min_age_days`.
235
275
 
236
276
  The per-org `exclude` list removes repositories from analysis entirely; they are
237
277
  reported as **excluded** (distinct from "not enabled"), so an intentional
238
278
  exclusion is visible rather than silently dropped.
239
279
 
280
+ ### Per-category render toggles
281
+
282
+ Every reporting category can be switched on or off, globally and per output
283
+ surface, under `report.categories`. Data is **always** collected; these toggles
284
+ govern presentation only. Each category key takes an `enabled` switch (highest
285
+ precedence — `false` hides it everywhere) and a lower-precedence `outputs` map
286
+ for the four surfaces (`cli`, `slack`, `markdown`, `html`). Everything defaults
287
+ to `true`, so an omitted category or key stays fully enabled. A category is
288
+ rendered on a surface only when `enabled` **and** that surface's toggle are
289
+ both true.
290
+
291
+ ```json
292
+ {
293
+ "report": {
294
+ "categories": {
295
+ "zizmor": { "enabled": false },
296
+ "releases": { "outputs": { "cli": false, "slack": false } }
297
+ }
298
+ },
299
+ "organizations": [{ "name": "lfreleng-actions" }]
300
+ }
301
+ ```
302
+
303
+ The example above hides Zizmor on every surface, and keeps Releases / Tagging
304
+ out of the terminal and Slack while still publishing it to the Markdown and HTML
305
+ Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
306
+ `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
307
+ `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
308
+ `mutable_releases`. Like the other `report` settings, `categories` can be set
309
+ globally and overridden per organisation (overrides merge key-by-key, so
310
+ flipping one output leaves the rest untouched). The machine-readable
311
+ `report.json` artifact always contains the complete dataset, regardless of these
312
+ toggles.
313
+
314
+ When several organisations share one Slack channel they render into a single
315
+ combined digest, so the per-org Slack toggles are unioned for that channel: a
316
+ category appears if **any** contributing org would show it on Slack. An org-level
317
+ Slack disable therefore does not suppress a category in a shared-channel digest
318
+ unless every org sharing that channel also disables it (this mirrors the
319
+ most-generous `top_n` rule applied to the same grouping). The terminal, Markdown
320
+ and HTML surfaces are per-org and are not affected by this union.
321
+
322
+ ### Pass/fail severity cutoff
323
+
324
+ The severity-ranked signals (CodeQL, Scorecard, Zizmor, Dependabot alerts) use a
325
+ `fail_severity` cutoff to decide when a repository counts as a failure. A
326
+ repository is flagged as an offender only when it carries a finding **at or
327
+ above** the cutoff; findings below it fold into the clean count. Severities run
328
+ (lowest to highest) `informational`, `low`, `medium`, `high`, `critical` —
329
+ `informational` being the new sub-low rung that SARIF `note`/`none` findings
330
+ (the bulk of a tool like Zizmor) normalise to.
331
+
332
+ The global default cutoff is `medium`, so `low` and `informational` findings
333
+ pass. Zizmor defaults to `low` (only `informational` passes). Override the
334
+ cutoff per category under `report.categories.<key>.fail_severity`:
335
+
336
+ ```json
337
+ {
338
+ "report": {
339
+ "categories": {
340
+ "codeql": { "fail_severity": "low" },
341
+ "zizmor": { "fail_severity": "informational" }
342
+ }
343
+ },
344
+ "organizations": [{ "name": "lfreleng-actions" }]
345
+ }
346
+ ```
347
+
240
348
  `slack.channel` is optional. The action's `slack_channel` input (wired to the
241
349
  `SLACK_CHANNEL_ID` variable in `reporting.yaml`) overrides it, so the channel
242
350
  can live as an org/repo variable rather than in the config JSON. It must be the
@@ -276,10 +384,10 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
276
384
  | `output_dir` | No | — | Directory for Pages output (org mode) |
277
385
  | `pages_url` | No | — | Published Pages URL (used in the Slack link) |
278
386
  | `slack_channel` | No | — | Slack channel ID; overrides the config `slack.channel` (e.g. the `SLACK_CHANNEL_ID` variable) |
279
- | `top_n` | No | `10` | Offenders per signal across all outputs (shared default) |
280
- | `top_n_report` | No | — | Offenders per signal in the GitHub Pages output (overrides `top_n`) |
281
- | `top_n_cli` | No | — | Offenders per signal in the terminal output (overrides `top_n`) |
282
- | `top_n_slack` | No | — | Offenders per signal in the Slack digest (overrides `top_n`) |
387
+ | `top_n` | No | `10` | Offenders per signal across all outputs (shared default; `0` = no limit) |
388
+ | `top_n_report` | No | — | Offenders per signal in the GitHub Pages output (`0` = no limit; overrides `top_n`) |
389
+ | `top_n_cli` | No | — | Offenders per signal in the terminal output (`0` = no limit; overrides `top_n`) |
390
+ | `top_n_slack` | No | — | Offenders per signal in the Slack digest (`0` = no limit; overrides `top_n`) |
283
391
  | `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
284
392
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
285
393
  | `tool_version` | No | `0.1.0` | Published PyPI version (ignored on pull requests) |
@@ -309,6 +417,46 @@ uvx github-security-report report
309
417
  uvx github-security-report report --org lfreleng-actions
310
418
  ```
311
419
 
420
+ ## Bulk Remediation Scripts
421
+
422
+ The report ends with **nag lists** — repositories where a supported feature is
423
+ switched off. Where GitHub exposes the relevant toggle through its REST API,
424
+ the [`scripts/`](scripts/) directory ships standalone helpers that clear a whole
425
+ nag list in one pass instead of clicking through each repository's settings.
426
+ They reuse the tool's own scoping rules
427
+ ([`src/github_security_report/scope.py`](src/github_security_report/scope.py)),
428
+ so they act on exactly the repositories the report does. See
429
+ [`scripts/README.md`](scripts/README.md) for full details.
430
+
431
+ Each script is a self-contained [PEP 723](https://peps.python.org/pep-0723/)
432
+ program: `uv run` resolves its inline dependencies on the fly — no project
433
+ install required.
434
+
435
+ ### `enable_dependabot_security_updates.py`
436
+
437
+ Enables **Dependabot security updates** (and the prerequisite alerts) across an
438
+ organisation, clearing the "Dependabot: Security Updates" nag list. It reads
439
+ the current state of each repository, enables the feature where it is off, and
440
+ verifies the result.
441
+
442
+ ```bash
443
+ # An org-admin token is required (classic PAT with repo admin / admin:org).
444
+ source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
445
+
446
+ # Dry run (default): preview every change, touch nothing.
447
+ uv run scripts/enable_dependabot_security_updates.py \
448
+ --config ~/.config/github-security-report/config.json
449
+
450
+ # Apply: switch the feature on for every in-scope repository.
451
+ uv run scripts/enable_dependabot_security_updates.py \
452
+ --config ~/.config/github-security-report/config.json --apply
453
+ ```
454
+
455
+ `--config` reads the organisation name and exclusions straight from the
456
+ reporting tool's JSON config, so the script and the report never drift. The
457
+ operation is **dry-run by default** (these are privileged writes) and reversible
458
+ via `DELETE /repos/{owner}/{repo}/automated-security-fixes`.
459
+
312
460
  ## Development
313
461
 
314
462
  ```bash
@@ -26,9 +26,16 @@ states and rendered worst-first:
26
26
 
27
27
  - **Offenders** — enabled with open findings (a ranked table row).
28
28
  - **Clean** — enabled with zero findings (a count beneath the table).
29
- - **Not enabled** — supported but switched off (a nag list prompting you to
30
- enable it).
31
- - **Unknown** — indeterminate (insufficient permission), footnoted separately.
29
+ - **Not enabled** — supported but switched off (a counted "disabled" footer
30
+ line, with the affected repositories named).
31
+ - **Unknown** — indeterminate (insufficient permission), counted separately.
32
+
33
+ Every category renders the same **standardised summary footer** beneath its
34
+ table: remediation-first count lines (failures, disabled, unknown, then the
35
+ healthy pass line, then excluded). The pass line reads **"All <state>"** when
36
+ nothing needs attention, or **"N <state>"** otherwise. The terminal and Slack
37
+ stay brevity-first; the explanatory per-category description and documentation
38
+ link are shown only on the richer Markdown and HTML (GitHub Pages) outputs.
32
39
 
33
40
  The single GitHub code-scanning feed is partitioned by `tool.name` into CodeQL,
34
41
  Scorecard, and zizmor; Scorecard prefers the external aggregate score and falls
@@ -42,10 +49,14 @@ tables (org mode):
42
49
  - **Dependabot** — three tables: repositories with vulnerability **alerts not
43
50
  enabled**, repositories with **security updates not enabled**, and ecosystems
44
51
  with no update `cooldown` configured (mandatory; any value passes).
45
- - **Releases / Tagging** — repositories overdue a release or tag, ranked by a
46
- hidden compound staleness score. Repositories younger than
47
- `release_min_age_days` (default 28; `0` includes all) and those in
48
- `releases_exclude` are omitted.
52
+ - **Releases / Tagging** — repositories overdue a release or tag, ranked by
53
+ release/tag staleness (repository age never affects ordering; a repository
54
+ with no release or tag ranks highest). Repositories younger than
55
+ `repo_min_age_days` (default 28; `0` includes all) and those in
56
+ `releases_exclude` are omitted. A repository is flagged only when its newest
57
+ release or tag is older than `release_max_age_days` (default 60; `0` flags
58
+ every eligible repository), so a repository released or tagged within that
59
+ window counts as recently maintained and drops out of the table.
49
60
 
50
61
  ## Operating modes
51
62
 
@@ -65,11 +76,20 @@ Repo mode needs nothing beyond the workflow's ephemeral `GITHUB_TOKEN`. Org mode
65
76
  needs a Personal Access Token; choose **one** of the two options below depending
66
77
  on how many organisations the report covers.
67
78
 
68
- All required access is **read-only**. The tool degrades any read it is not
69
- permitted to make to an "unknown" status rather than reporting a repository as
70
- clean, so an under-scoped token surfaces as unknowns in the report instead of
79
+ Almost all required access is **read-only**. The tool degrades any read it is
80
+ not permitted to make to an "unknown" status rather than reporting a repository
81
+ as clean, so an under-scoped token surfaces as unknowns in the report instead of
71
82
  silently wrong results — start minimal and widen if you see unknowns.
72
83
 
84
+ The **one** exception is organisation-ruleset coverage. GitHub gates the
85
+ org-rulesets endpoint behind an org-admin permission (classic `admin:org` scope,
86
+ or fine-grained Administration **write**), even though the tool only reads it.
87
+ That coverage is **optional**: it detects tools enforced through an org ruleset
88
+ (for example a required-workflow or code-scanning ruleset). Without it that one
89
+ signal is skipped and every other part of the report is unaffected, so the
90
+ minimal tokens below omit it. Grant the org-admin permission only if you want
91
+ ruleset-based tool coverage.
92
+
73
93
  ### Single organisation — fine-grained PAT
74
94
 
75
95
  A fine-grained PAT is bound to one resource owner, so it works for a report
@@ -87,14 +107,15 @@ organisation and **Repository access** set to *All repositories*, then grant:
87
107
  | Secret scanning alerts | Open secret-scanning alerts |
88
108
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
89
109
 
90
- **Organization permissions** (Read-only):
110
+ **Organization permissions:**
91
111
 
92
- | Permission | Used for |
93
- | ---------- | -------- |
94
- | Administration | Organisation rulesets (detect tools enabled via a required workflow) |
112
+ | Permission | Access | Used for |
113
+ | ---------- | ------ | -------- |
114
+ | Administration | Read and write | *Optional* — organisation rulesets (detect tools enforced through an org ruleset). GitHub gates this endpoint behind Administration **write**; omit it to keep the token read-only and skip ruleset-based tool coverage. |
95
115
 
96
- > A fine-grained token cannot span organisations. For a report covering more
97
- > than one org, use a classic PAT (below).
116
+ > Read-only is enough for everything except the optional ruleset coverage
117
+ > above. A fine-grained token cannot span organisations. For a report covering
118
+ > more than one org, use a classic PAT (below).
98
119
 
99
120
  ### Multiple organisations — classic PAT
100
121
 
@@ -106,7 +127,8 @@ organisations. Grant these scopes:
106
127
  | ----- | -------- |
107
128
  | `repo` | Repository data, including private repositories |
108
129
  | `security_events` | Code scanning, secret scanning, and Dependabot alerts (org-bulk and per-repo) |
109
- | `read:org` | Listing organisation repositories and reading organisation rulesets |
130
+ | `read:org` | Listing organisation repositories |
131
+ | `admin:org` | *Optional* — reading organisation rulesets for ruleset-based tool coverage. GitHub gates `GET /orgs/{org}/rulesets` behind the full `admin:org` scope; `read:org` and `write:org` return 404. Omit it to skip that one signal; everything else is unaffected. |
110
132
 
111
133
  > For organisations that enforce SSO, the PAT must be **SSO-authorised** for
112
134
  > each target organisation, or the org-level endpoints return `403` (reported as
@@ -163,7 +185,8 @@ environment-variable name, never embedded.
163
185
  "top_n_slack": 10,
164
186
  "include_archived": false,
165
187
  "include_test": false,
166
- "release_min_age_days": 28
188
+ "repo_min_age_days": 28,
189
+ "release_max_age_days": 60
167
190
  },
168
191
  "organizations": [
169
192
  {
@@ -182,17 +205,102 @@ environment-variable name, never embedded.
182
205
  `top_n` controls how many offenders are shown per signal. It is the shared
183
206
  default for all three outputs; set any of `top_n_report` (GitHub Pages),
184
207
  `top_n_cli` (terminal), or `top_n_slack` (Slack digest) to override an
185
- individual output. Each can also be set at the CLI with `--top-n`,
208
+ individual output. Set a value to `0` to remove the limit entirely and show
209
+ every offender. Each can also be set at the CLI with `--top-n`,
186
210
  `--top-n-report`, `--top-n-cli`, and `--top-n-slack`.
187
211
 
188
- `report.release_min_age_days` (default `28`, `0` = include all) and the per-org
189
- `releases_exclude` tune the Releases / Tagging section; they can be overridden
190
- locally with `--release-min-age-days` and the repeatable `--releases-exclude`.
212
+ The Releases / Tagging section has two independent freshness levers:
213
+
214
+ - `report.repo_min_age_days` (default `28`, `0` = include all) is a grace
215
+ period that omits **brand-new repositories** — those *created* within that
216
+ many days — before a release or tag is expected of them. CLI:
217
+ `--repo-min-age-days`.
218
+ - `report.release_max_age_days` (default `60`; `0` = flag everything) is the
219
+ release-staleness threshold: a repository is only flagged when its newest
220
+ release **or** tag is older than that many days (a repository with neither is
221
+ always flagged). Tune it to match your release cadence so actively released
222
+ repositories drop out of the table. CLI: `--release-max-age-days`.
223
+
224
+ The per-org `releases_exclude` (CLI `--releases-exclude`, repeatable) drops
225
+ named repositories from the section entirely.
226
+
227
+ > The former `release_min_age_days` key was a misleading name for
228
+ > `repo_min_age_days` (it gates *repository* age, not *release* age). It is
229
+ > still accepted as a deprecated alias and emits a warning; prefer
230
+ > `repo_min_age_days`.
191
231
 
192
232
  The per-org `exclude` list removes repositories from analysis entirely; they are
193
233
  reported as **excluded** (distinct from "not enabled"), so an intentional
194
234
  exclusion is visible rather than silently dropped.
195
235
 
236
+ ### Per-category render toggles
237
+
238
+ Every reporting category can be switched on or off, globally and per output
239
+ surface, under `report.categories`. Data is **always** collected; these toggles
240
+ govern presentation only. Each category key takes an `enabled` switch (highest
241
+ precedence — `false` hides it everywhere) and a lower-precedence `outputs` map
242
+ for the four surfaces (`cli`, `slack`, `markdown`, `html`). Everything defaults
243
+ to `true`, so an omitted category or key stays fully enabled. A category is
244
+ rendered on a surface only when `enabled` **and** that surface's toggle are
245
+ both true.
246
+
247
+ ```json
248
+ {
249
+ "report": {
250
+ "categories": {
251
+ "zizmor": { "enabled": false },
252
+ "releases": { "outputs": { "cli": false, "slack": false } }
253
+ }
254
+ },
255
+ "organizations": [{ "name": "lfreleng-actions" }]
256
+ }
257
+ ```
258
+
259
+ The example above hides Zizmor on every surface, and keeps Releases / Tagging
260
+ out of the terminal and Slack while still publishing it to the Markdown and HTML
261
+ Pages output. The valid category keys are: `codeql`, `scorecard`, `zizmor`,
262
+ `dependabot_alerts`, `secret_scanning`, `dependabot_alerts_enabled`,
263
+ `dependabot_updates_enabled`, `dependabot_cooldown`, `releases`,
264
+ `mutable_releases`. Like the other `report` settings, `categories` can be set
265
+ globally and overridden per organisation (overrides merge key-by-key, so
266
+ flipping one output leaves the rest untouched). The machine-readable
267
+ `report.json` artifact always contains the complete dataset, regardless of these
268
+ toggles.
269
+
270
+ When several organisations share one Slack channel they render into a single
271
+ combined digest, so the per-org Slack toggles are unioned for that channel: a
272
+ category appears if **any** contributing org would show it on Slack. An org-level
273
+ Slack disable therefore does not suppress a category in a shared-channel digest
274
+ unless every org sharing that channel also disables it (this mirrors the
275
+ most-generous `top_n` rule applied to the same grouping). The terminal, Markdown
276
+ and HTML surfaces are per-org and are not affected by this union.
277
+
278
+ ### Pass/fail severity cutoff
279
+
280
+ The severity-ranked signals (CodeQL, Scorecard, Zizmor, Dependabot alerts) use a
281
+ `fail_severity` cutoff to decide when a repository counts as a failure. A
282
+ repository is flagged as an offender only when it carries a finding **at or
283
+ above** the cutoff; findings below it fold into the clean count. Severities run
284
+ (lowest to highest) `informational`, `low`, `medium`, `high`, `critical` —
285
+ `informational` being the new sub-low rung that SARIF `note`/`none` findings
286
+ (the bulk of a tool like Zizmor) normalise to.
287
+
288
+ The global default cutoff is `medium`, so `low` and `informational` findings
289
+ pass. Zizmor defaults to `low` (only `informational` passes). Override the
290
+ cutoff per category under `report.categories.<key>.fail_severity`:
291
+
292
+ ```json
293
+ {
294
+ "report": {
295
+ "categories": {
296
+ "codeql": { "fail_severity": "low" },
297
+ "zizmor": { "fail_severity": "informational" }
298
+ }
299
+ },
300
+ "organizations": [{ "name": "lfreleng-actions" }]
301
+ }
302
+ ```
303
+
196
304
  `slack.channel` is optional. The action's `slack_channel` input (wired to the
197
305
  `SLACK_CHANNEL_ID` variable in `reporting.yaml`) overrides it, so the channel
198
306
  can live as an org/repo variable rather than in the config JSON. It must be the
@@ -232,10 +340,10 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
232
340
  | `output_dir` | No | — | Directory for Pages output (org mode) |
233
341
  | `pages_url` | No | — | Published Pages URL (used in the Slack link) |
234
342
  | `slack_channel` | No | — | Slack channel ID; overrides the config `slack.channel` (e.g. the `SLACK_CHANNEL_ID` variable) |
235
- | `top_n` | No | `10` | Offenders per signal across all outputs (shared default) |
236
- | `top_n_report` | No | — | Offenders per signal in the GitHub Pages output (overrides `top_n`) |
237
- | `top_n_cli` | No | — | Offenders per signal in the terminal output (overrides `top_n`) |
238
- | `top_n_slack` | No | — | Offenders per signal in the Slack digest (overrides `top_n`) |
343
+ | `top_n` | No | `10` | Offenders per signal across all outputs (shared default; `0` = no limit) |
344
+ | `top_n_report` | No | — | Offenders per signal in the GitHub Pages output (`0` = no limit; overrides `top_n`) |
345
+ | `top_n_cli` | No | — | Offenders per signal in the terminal output (`0` = no limit; overrides `top_n`) |
346
+ | `top_n_slack` | No | — | Offenders per signal in the Slack digest (`0` = no limit; overrides `top_n`) |
239
347
  | `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
240
348
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
241
349
  | `tool_version` | No | `0.1.0` | Published PyPI version (ignored on pull requests) |
@@ -265,6 +373,46 @@ uvx github-security-report report
265
373
  uvx github-security-report report --org lfreleng-actions
266
374
  ```
267
375
 
376
+ ## Bulk Remediation Scripts
377
+
378
+ The report ends with **nag lists** — repositories where a supported feature is
379
+ switched off. Where GitHub exposes the relevant toggle through its REST API,
380
+ the [`scripts/`](scripts/) directory ships standalone helpers that clear a whole
381
+ nag list in one pass instead of clicking through each repository's settings.
382
+ They reuse the tool's own scoping rules
383
+ ([`src/github_security_report/scope.py`](src/github_security_report/scope.py)),
384
+ so they act on exactly the repositories the report does. See
385
+ [`scripts/README.md`](scripts/README.md) for full details.
386
+
387
+ Each script is a self-contained [PEP 723](https://peps.python.org/pep-0723/)
388
+ program: `uv run` resolves its inline dependencies on the fly — no project
389
+ install required.
390
+
391
+ ### `enable_dependabot_security_updates.py`
392
+
393
+ Enables **Dependabot security updates** (and the prerequisite alerts) across an
394
+ organisation, clearing the "Dependabot: Security Updates" nag list. It reads
395
+ the current state of each repository, enables the feature where it is off, and
396
+ verifies the result.
397
+
398
+ ```bash
399
+ # An org-admin token is required (classic PAT with repo admin / admin:org).
400
+ source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
401
+
402
+ # Dry run (default): preview every change, touch nothing.
403
+ uv run scripts/enable_dependabot_security_updates.py \
404
+ --config ~/.config/github-security-report/config.json
405
+
406
+ # Apply: switch the feature on for every in-scope repository.
407
+ uv run scripts/enable_dependabot_security_updates.py \
408
+ --config ~/.config/github-security-report/config.json --apply
409
+ ```
410
+
411
+ `--config` reads the organisation name and exclusions straight from the
412
+ reporting tool's JSON config, so the script and the report never drift. The
413
+ operation is **dry-run by default** (these are privileged writes) and reversible
414
+ via `DELETE /repos/{owner}/{repo}/automated-security-fixes`.
415
+
268
416
  ## Development
269
417
 
270
418
  ```bash
@@ -59,13 +59,13 @@ Issues = "https://github.com/lfreleng-actions/github-security-report-action/issu
59
59
 
60
60
  [project.optional-dependencies]
61
61
  dev = [
62
- "pytest==9.0.3",
62
+ "pytest==9.1.1",
63
63
  "pytest-asyncio==1.4.0",
64
64
  "pytest-cov==7.1.0",
65
65
  "respx==0.23.1",
66
66
  "syrupy==5.3.2",
67
67
  "mypy==2.1.0",
68
- "ruff==0.15.16",
68
+ "ruff==0.15.18",
69
69
  "types-jsonschema==4.26.0.20260518",
70
70
  "types-PyYAML==6.0.12.20250915",
71
71
  ]
@@ -91,13 +91,13 @@ include = ["src", "README.md", "LICENSE*", "pyproject.toml"]
91
91
 
92
92
  [dependency-groups]
93
93
  dev = [
94
- "pytest==9.0.3",
94
+ "pytest==9.1.1",
95
95
  "pytest-asyncio==1.4.0",
96
96
  "pytest-cov==7.1.0",
97
97
  "respx==0.23.1",
98
98
  "syrupy==5.3.2",
99
99
  "mypy==2.1.0",
100
- "ruff==0.15.16",
100
+ "ruff==0.15.18",
101
101
  "types-jsonschema==4.26.0.20260518",
102
102
  "types-PyYAML==6.0.12.20250915",
103
103
  ]
@@ -5,6 +5,58 @@
5
5
 
6
6
  # Scripts
7
7
 
8
+ ## `enable_dependabot_security_updates.py`
9
+
10
+ Bulk-enables **Dependabot security updates** across an organisation, clearing
11
+ the "Dependabot: Security Updates" nag list the reporting tool produces. It
12
+ drives three REST endpoints per repository:
13
+
14
+ - `PUT /repos/{owner}/{repo}/vulnerability-alerts` — Dependabot *alerts* (the
15
+ prerequisite; idempotent).
16
+ - `PUT /repos/{owner}/{repo}/automated-security-fixes` — Dependabot *security
17
+ updates*.
18
+ - `GET /repos/{owner}/{repo}/automated-security-fixes` — current state, read
19
+ before and after each change.
20
+
21
+ By default the scope matches the reporting tool (it reuses the same fork /
22
+ template / archived / test-name / explicit-exclude rules from
23
+ [`../src/github_security_report/scope.py`](../src/github_security_report/scope.py)),
24
+ and likewise skips empty repositories, which the reporting tool already drops
25
+ at the listing stage. The `--include-empty` flag deliberately widens that scope
26
+ to act on empty repositories the report never includes. Pass `--config` to read
27
+ the org name and exclusions straight from the tool's JSON config so the two
28
+ never drift.
29
+
30
+ It is a self-contained [PEP 723](https://peps.python.org/pep-0723/) script; `uv`
31
+ resolves its inline dependencies (`httpx`, `rich`) on the fly.
32
+
33
+ ### Run
34
+
35
+ ```bash
36
+ # An org-admin token is required (e.g. a classic PAT with repo admin /
37
+ # admin:org). The god token export publishes it as $GITHUB_TOKEN:
38
+ source ~/.secrets.github.classic.god
39
+
40
+ # Dry run (default): previews exactly what would change, touches nothing.
41
+ uv run scripts/enable_dependabot_security_updates.py \
42
+ --config ~/.config/github-security-report/config.json
43
+
44
+ # Apply: switch the feature on for every in-scope repository.
45
+ uv run scripts/enable_dependabot_security_updates.py \
46
+ --config ~/.config/github-security-report/config.json --apply
47
+
48
+ # Or drive it from flags, with a different token variable:
49
+ uv run scripts/enable_dependabot_security_updates.py \
50
+ --org lfreleng-actions --token-env SECURITY_REPORT_PAT \
51
+ --exclude project-reporting-artifacts --apply
52
+ ```
53
+
54
+ **Dry-run by default.** These are privileged writes, so the script previews
55
+ unless you pass `--apply`. Useful extra flags: `--repo` (operate on named repos
56
+ only, skipping scope), `--limit N`, and `--include-archived` /
57
+ `--include-test` / `--include-empty`. The operation is reversible via
58
+ `DELETE /repos/{owner}/{repo}/automated-security-fixes`.
59
+
8
60
  ## `phase0_capability_spike.py` (throwaway)
9
61
 
10
62
  A self-contained [PEP 723](https://peps.python.org/pep-0723/) spike that