github-security-report 0.1.0__tar.gz → 0.2.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.1.0 → github_security_report-0.2.0}/PKG-INFO +98 -26
  2. {github_security_report-0.1.0 → github_security_report-0.2.0}/README.md +95 -23
  3. {github_security_report-0.1.0 → github_security_report-0.2.0}/pyproject.toml +4 -4
  4. {github_security_report-0.1.0 → github_security_report-0.2.0}/scripts/README.md +52 -0
  5. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/_version.py +2 -2
  6. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/cli.py +38 -19
  7. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/client.py +189 -68
  8. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/collect.py +132 -60
  9. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/config.py +46 -8
  10. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/models.py +41 -0
  11. github_security_report-0.2.0/src/github_security_report/posture.py +446 -0
  12. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/render/html.py +16 -0
  13. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/render/markdown.py +37 -0
  14. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/render/slack.py +49 -5
  15. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/render/terminal.py +22 -19
  16. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/report.py +46 -5
  17. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/templates/report.html.j2 +35 -2
  18. github_security_report-0.1.0/src/github_security_report/posture.py +0 -264
  19. {github_security_report-0.1.0 → github_security_report-0.2.0}/.gitignore +0 -0
  20. {github_security_report-0.1.0 → github_security_report-0.2.0}/LICENSE +0 -0
  21. {github_security_report-0.1.0 → github_security_report-0.2.0}/LICENSES/Apache-2.0.txt +0 -0
  22. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/__init__.py +0 -0
  23. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/classify.py +0 -0
  24. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/gitctx.py +0 -0
  25. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/py.typed +0 -0
  26. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/render/__init__.py +0 -0
  27. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/rulesets.py +0 -0
  28. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/runner.py +0 -0
  29. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/scope.py +0 -0
  30. {github_security_report-0.1.0 → github_security_report-0.2.0}/src/github_security_report/severity.py +0 -0
  31. {github_security_report-0.1.0 → github_security_report-0.2.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.2.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.0; 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.17; 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'
@@ -86,10 +86,13 @@ tables (org mode):
86
86
  - **Dependabot** — three tables: repositories with vulnerability **alerts not
87
87
  enabled**, repositories with **security updates not enabled**, and ecosystems
88
88
  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.
89
+ - **Releases / Tagging** — repositories overdue a release or tag, ranked by
90
+ release/tag staleness (repository age never affects ordering; a repository
91
+ with no release or tag ranks highest). Repositories younger than
92
+ `repo_min_age_days` (default 28; `0` includes all) and those in
93
+ `releases_exclude` are omitted. Set `release_max_age_days` to only flag
94
+ repositories whose newest release or tag is older than that many days
95
+ (default `0` = flag every eligible repository).
93
96
 
94
97
  ## Operating modes
95
98
 
@@ -109,11 +112,20 @@ Repo mode needs nothing beyond the workflow's ephemeral `GITHUB_TOKEN`. Org mode
109
112
  needs a Personal Access Token; choose **one** of the two options below depending
110
113
  on how many organisations the report covers.
111
114
 
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
115
+ Almost all required access is **read-only**. The tool degrades any read it is
116
+ not permitted to make to an "unknown" status rather than reporting a repository
117
+ as clean, so an under-scoped token surfaces as unknowns in the report instead of
115
118
  silently wrong results — start minimal and widen if you see unknowns.
116
119
 
120
+ The **one** exception is organisation-ruleset coverage. GitHub gates the
121
+ org-rulesets endpoint behind an org-admin permission (classic `admin:org` scope,
122
+ or fine-grained Administration **write**), even though the tool only reads it.
123
+ That coverage is **optional**: it detects tools enforced through an org ruleset
124
+ (for example a required-workflow or code-scanning ruleset). Without it that one
125
+ signal is skipped and every other part of the report is unaffected, so the
126
+ minimal tokens below omit it. Grant the org-admin permission only if you want
127
+ ruleset-based tool coverage.
128
+
117
129
  ### Single organisation — fine-grained PAT
118
130
 
119
131
  A fine-grained PAT is bound to one resource owner, so it works for a report
@@ -131,14 +143,15 @@ organisation and **Repository access** set to *All repositories*, then grant:
131
143
  | Secret scanning alerts | Open secret-scanning alerts |
132
144
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
133
145
 
134
- **Organization permissions** (Read-only):
146
+ **Organization permissions:**
135
147
 
136
- | Permission | Used for |
137
- | ---------- | -------- |
138
- | Administration | Organisation rulesets (detect tools enabled via a required workflow) |
148
+ | Permission | Access | Used for |
149
+ | ---------- | ------ | -------- |
150
+ | 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
151
 
140
- > A fine-grained token cannot span organisations. For a report covering more
141
- > than one org, use a classic PAT (below).
152
+ > Read-only is enough for everything except the optional ruleset coverage
153
+ > above. A fine-grained token cannot span organisations. For a report covering
154
+ > more than one org, use a classic PAT (below).
142
155
 
143
156
  ### Multiple organisations — classic PAT
144
157
 
@@ -150,7 +163,8 @@ organisations. Grant these scopes:
150
163
  | ----- | -------- |
151
164
  | `repo` | Repository data, including private repositories |
152
165
  | `security_events` | Code scanning, secret scanning, and Dependabot alerts (org-bulk and per-repo) |
153
- | `read:org` | Listing organisation repositories and reading organisation rulesets |
166
+ | `read:org` | Listing organisation repositories |
167
+ | `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
168
 
155
169
  > For organisations that enforce SSO, the PAT must be **SSO-authorised** for
156
170
  > each target organisation, or the org-level endpoints return `403` (reported as
@@ -207,7 +221,8 @@ environment-variable name, never embedded.
207
221
  "top_n_slack": 10,
208
222
  "include_archived": false,
209
223
  "include_test": false,
210
- "release_min_age_days": 28
224
+ "repo_min_age_days": 28,
225
+ "release_max_age_days": 0
211
226
  },
212
227
  "organizations": [
213
228
  {
@@ -226,12 +241,29 @@ environment-variable name, never embedded.
226
241
  `top_n` controls how many offenders are shown per signal. It is the shared
227
242
  default for all three outputs; set any of `top_n_report` (GitHub Pages),
228
243
  `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`,
244
+ individual output. Set a value to `0` to remove the limit entirely and show
245
+ every offender. Each can also be set at the CLI with `--top-n`,
230
246
  `--top-n-report`, `--top-n-cli`, and `--top-n-slack`.
231
247
 
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`.
248
+ The Releases / Tagging section has two independent freshness levers:
249
+
250
+ - `report.repo_min_age_days` (default `28`, `0` = include all) is a grace
251
+ period that omits **brand-new repositories** — those *created* within that
252
+ many days — before a release or tag is expected of them. CLI:
253
+ `--repo-min-age-days`.
254
+ - `report.release_max_age_days` (default `0` = flag everything) is the
255
+ release-staleness threshold: a repository is only flagged when its newest
256
+ release **or** tag is older than that many days (a repository with neither is
257
+ always flagged). Raise it to match your release cadence so actively released
258
+ repositories drop out of the table. CLI: `--release-max-age-days`.
259
+
260
+ The per-org `releases_exclude` (CLI `--releases-exclude`, repeatable) drops
261
+ named repositories from the section entirely.
262
+
263
+ > The former `release_min_age_days` key was a misleading name for
264
+ > `repo_min_age_days` (it gates *repository* age, not *release* age). It is
265
+ > still accepted as a deprecated alias and emits a warning; prefer
266
+ > `repo_min_age_days`.
235
267
 
236
268
  The per-org `exclude` list removes repositories from analysis entirely; they are
237
269
  reported as **excluded** (distinct from "not enabled"), so an intentional
@@ -276,10 +308,10 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
276
308
  | `output_dir` | No | — | Directory for Pages output (org mode) |
277
309
  | `pages_url` | No | — | Published Pages URL (used in the Slack link) |
278
310
  | `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`) |
311
+ | `top_n` | No | `10` | Offenders per signal across all outputs (shared default; `0` = no limit) |
312
+ | `top_n_report` | No | — | Offenders per signal in the GitHub Pages output (`0` = no limit; overrides `top_n`) |
313
+ | `top_n_cli` | No | — | Offenders per signal in the terminal output (`0` = no limit; overrides `top_n`) |
314
+ | `top_n_slack` | No | — | Offenders per signal in the Slack digest (`0` = no limit; overrides `top_n`) |
283
315
  | `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
284
316
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
285
317
  | `tool_version` | No | `0.1.0` | Published PyPI version (ignored on pull requests) |
@@ -309,6 +341,46 @@ uvx github-security-report report
309
341
  uvx github-security-report report --org lfreleng-actions
310
342
  ```
311
343
 
344
+ ## Bulk Remediation Scripts
345
+
346
+ The report ends with **nag lists** — repositories where a supported feature is
347
+ switched off. Where GitHub exposes the relevant toggle through its REST API,
348
+ the [`scripts/`](scripts/) directory ships standalone helpers that clear a whole
349
+ nag list in one pass instead of clicking through each repository's settings.
350
+ They reuse the tool's own scoping rules
351
+ ([`src/github_security_report/scope.py`](src/github_security_report/scope.py)),
352
+ so they act on exactly the repositories the report does. See
353
+ [`scripts/README.md`](scripts/README.md) for full details.
354
+
355
+ Each script is a self-contained [PEP 723](https://peps.python.org/pep-0723/)
356
+ program: `uv run` resolves its inline dependencies on the fly — no project
357
+ install required.
358
+
359
+ ### `enable_dependabot_security_updates.py`
360
+
361
+ Enables **Dependabot security updates** (and the prerequisite alerts) across an
362
+ organisation, clearing the "Dependabot: Security Updates" nag list. It reads
363
+ the current state of each repository, enables the feature where it is off, and
364
+ verifies the result.
365
+
366
+ ```bash
367
+ # An org-admin token is required (classic PAT with repo admin / admin:org).
368
+ source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
369
+
370
+ # Dry run (default): preview every change, touch nothing.
371
+ uv run scripts/enable_dependabot_security_updates.py \
372
+ --config ~/.config/github-security-report/config.json
373
+
374
+ # Apply: switch the feature on for every in-scope repository.
375
+ uv run scripts/enable_dependabot_security_updates.py \
376
+ --config ~/.config/github-security-report/config.json --apply
377
+ ```
378
+
379
+ `--config` reads the organisation name and exclusions straight from the
380
+ reporting tool's JSON config, so the script and the report never drift. The
381
+ operation is **dry-run by default** (these are privileged writes) and reversible
382
+ via `DELETE /repos/{owner}/{repo}/automated-security-fixes`.
383
+
312
384
  ## Development
313
385
 
314
386
  ```bash
@@ -42,10 +42,13 @@ tables (org mode):
42
42
  - **Dependabot** — three tables: repositories with vulnerability **alerts not
43
43
  enabled**, repositories with **security updates not enabled**, and ecosystems
44
44
  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.
45
+ - **Releases / Tagging** — repositories overdue a release or tag, ranked by
46
+ release/tag staleness (repository age never affects ordering; a repository
47
+ with no release or tag ranks highest). Repositories younger than
48
+ `repo_min_age_days` (default 28; `0` includes all) and those in
49
+ `releases_exclude` are omitted. Set `release_max_age_days` to only flag
50
+ repositories whose newest release or tag is older than that many days
51
+ (default `0` = flag every eligible repository).
49
52
 
50
53
  ## Operating modes
51
54
 
@@ -65,11 +68,20 @@ Repo mode needs nothing beyond the workflow's ephemeral `GITHUB_TOKEN`. Org mode
65
68
  needs a Personal Access Token; choose **one** of the two options below depending
66
69
  on how many organisations the report covers.
67
70
 
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
71
+ Almost all required access is **read-only**. The tool degrades any read it is
72
+ not permitted to make to an "unknown" status rather than reporting a repository
73
+ as clean, so an under-scoped token surfaces as unknowns in the report instead of
71
74
  silently wrong results — start minimal and widen if you see unknowns.
72
75
 
76
+ The **one** exception is organisation-ruleset coverage. GitHub gates the
77
+ org-rulesets endpoint behind an org-admin permission (classic `admin:org` scope,
78
+ or fine-grained Administration **write**), even though the tool only reads it.
79
+ That coverage is **optional**: it detects tools enforced through an org ruleset
80
+ (for example a required-workflow or code-scanning ruleset). Without it that one
81
+ signal is skipped and every other part of the report is unaffected, so the
82
+ minimal tokens below omit it. Grant the org-admin permission only if you want
83
+ ruleset-based tool coverage.
84
+
73
85
  ### Single organisation — fine-grained PAT
74
86
 
75
87
  A fine-grained PAT is bound to one resource owner, so it works for a report
@@ -87,14 +99,15 @@ organisation and **Repository access** set to *All repositories*, then grant:
87
99
  | Secret scanning alerts | Open secret-scanning alerts |
88
100
  | Administration | Dependabot enablement + security-updates status, and effective branch rules |
89
101
 
90
- **Organization permissions** (Read-only):
102
+ **Organization permissions:**
91
103
 
92
- | Permission | Used for |
93
- | ---------- | -------- |
94
- | Administration | Organisation rulesets (detect tools enabled via a required workflow) |
104
+ | Permission | Access | Used for |
105
+ | ---------- | ------ | -------- |
106
+ | 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
107
 
96
- > A fine-grained token cannot span organisations. For a report covering more
97
- > than one org, use a classic PAT (below).
108
+ > Read-only is enough for everything except the optional ruleset coverage
109
+ > above. A fine-grained token cannot span organisations. For a report covering
110
+ > more than one org, use a classic PAT (below).
98
111
 
99
112
  ### Multiple organisations — classic PAT
100
113
 
@@ -106,7 +119,8 @@ organisations. Grant these scopes:
106
119
  | ----- | -------- |
107
120
  | `repo` | Repository data, including private repositories |
108
121
  | `security_events` | Code scanning, secret scanning, and Dependabot alerts (org-bulk and per-repo) |
109
- | `read:org` | Listing organisation repositories and reading organisation rulesets |
122
+ | `read:org` | Listing organisation repositories |
123
+ | `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
124
 
111
125
  > For organisations that enforce SSO, the PAT must be **SSO-authorised** for
112
126
  > each target organisation, or the org-level endpoints return `403` (reported as
@@ -163,7 +177,8 @@ environment-variable name, never embedded.
163
177
  "top_n_slack": 10,
164
178
  "include_archived": false,
165
179
  "include_test": false,
166
- "release_min_age_days": 28
180
+ "repo_min_age_days": 28,
181
+ "release_max_age_days": 0
167
182
  },
168
183
  "organizations": [
169
184
  {
@@ -182,12 +197,29 @@ environment-variable name, never embedded.
182
197
  `top_n` controls how many offenders are shown per signal. It is the shared
183
198
  default for all three outputs; set any of `top_n_report` (GitHub Pages),
184
199
  `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`,
200
+ individual output. Set a value to `0` to remove the limit entirely and show
201
+ every offender. Each can also be set at the CLI with `--top-n`,
186
202
  `--top-n-report`, `--top-n-cli`, and `--top-n-slack`.
187
203
 
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`.
204
+ The Releases / Tagging section has two independent freshness levers:
205
+
206
+ - `report.repo_min_age_days` (default `28`, `0` = include all) is a grace
207
+ period that omits **brand-new repositories** — those *created* within that
208
+ many days — before a release or tag is expected of them. CLI:
209
+ `--repo-min-age-days`.
210
+ - `report.release_max_age_days` (default `0` = flag everything) is the
211
+ release-staleness threshold: a repository is only flagged when its newest
212
+ release **or** tag is older than that many days (a repository with neither is
213
+ always flagged). Raise it to match your release cadence so actively released
214
+ repositories drop out of the table. CLI: `--release-max-age-days`.
215
+
216
+ The per-org `releases_exclude` (CLI `--releases-exclude`, repeatable) drops
217
+ named repositories from the section entirely.
218
+
219
+ > The former `release_min_age_days` key was a misleading name for
220
+ > `repo_min_age_days` (it gates *repository* age, not *release* age). It is
221
+ > still accepted as a deprecated alias and emits a warning; prefer
222
+ > `repo_min_age_days`.
191
223
 
192
224
  The per-org `exclude` list removes repositories from analysis entirely; they are
193
225
  reported as **excluded** (distinct from "not enabled"), so an intentional
@@ -232,10 +264,10 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
232
264
  | `output_dir` | No | — | Directory for Pages output (org mode) |
233
265
  | `pages_url` | No | — | Published Pages URL (used in the Slack link) |
234
266
  | `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`) |
267
+ | `top_n` | No | `10` | Offenders per signal across all outputs (shared default; `0` = no limit) |
268
+ | `top_n_report` | No | — | Offenders per signal in the GitHub Pages output (`0` = no limit; overrides `top_n`) |
269
+ | `top_n_cli` | No | — | Offenders per signal in the terminal output (`0` = no limit; overrides `top_n`) |
270
+ | `top_n_slack` | No | — | Offenders per signal in the Slack digest (`0` = no limit; overrides `top_n`) |
239
271
  | `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
240
272
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
241
273
  | `tool_version` | No | `0.1.0` | Published PyPI version (ignored on pull requests) |
@@ -265,6 +297,46 @@ uvx github-security-report report
265
297
  uvx github-security-report report --org lfreleng-actions
266
298
  ```
267
299
 
300
+ ## Bulk Remediation Scripts
301
+
302
+ The report ends with **nag lists** — repositories where a supported feature is
303
+ switched off. Where GitHub exposes the relevant toggle through its REST API,
304
+ the [`scripts/`](scripts/) directory ships standalone helpers that clear a whole
305
+ nag list in one pass instead of clicking through each repository's settings.
306
+ They reuse the tool's own scoping rules
307
+ ([`src/github_security_report/scope.py`](src/github_security_report/scope.py)),
308
+ so they act on exactly the repositories the report does. See
309
+ [`scripts/README.md`](scripts/README.md) for full details.
310
+
311
+ Each script is a self-contained [PEP 723](https://peps.python.org/pep-0723/)
312
+ program: `uv run` resolves its inline dependencies on the fly — no project
313
+ install required.
314
+
315
+ ### `enable_dependabot_security_updates.py`
316
+
317
+ Enables **Dependabot security updates** (and the prerequisite alerts) across an
318
+ organisation, clearing the "Dependabot: Security Updates" nag list. It reads
319
+ the current state of each repository, enables the feature where it is off, and
320
+ verifies the result.
321
+
322
+ ```bash
323
+ # An org-admin token is required (classic PAT with repo admin / admin:org).
324
+ source ~/.secrets.github.classic.god # exports $GITHUB_TOKEN
325
+
326
+ # Dry run (default): preview every change, touch nothing.
327
+ uv run scripts/enable_dependabot_security_updates.py \
328
+ --config ~/.config/github-security-report/config.json
329
+
330
+ # Apply: switch the feature on for every in-scope repository.
331
+ uv run scripts/enable_dependabot_security_updates.py \
332
+ --config ~/.config/github-security-report/config.json --apply
333
+ ```
334
+
335
+ `--config` reads the organisation name and exclusions straight from the
336
+ reporting tool's JSON config, so the script and the report never drift. The
337
+ operation is **dry-run by default** (these are privileged writes) and reversible
338
+ via `DELETE /repos/{owner}/{repo}/automated-security-fixes`.
339
+
268
340
  ## Development
269
341
 
270
342
  ```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.0",
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.17",
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.0",
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.17",
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
@@ -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.1.0'
22
- __version_tuple__ = version_tuple = (0, 1, 0)
21
+ __version__ = version = '0.2.0'
22
+ __version_tuple__ = version_tuple = (0, 2, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -192,7 +192,8 @@ def _load_config(
192
192
  async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
193
193
  pages_url: str | None, top_n: int | None, force_notify: bool,
194
194
  slack_channel: str | None = None,
195
- release_min_age_days: int | None = None,
195
+ repo_min_age_days: int | None = None,
196
+ release_max_age_days: int | None = None,
196
197
  releases_exclude: tuple[str, ...] | None = None,
197
198
  top_n_report: int | None = None,
198
199
  top_n_cli: int | None = None,
@@ -206,8 +207,12 @@ async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
206
207
  return 2
207
208
  # CLI overrides win over config for the Releases/Tagging controls.
208
209
  report_cfg = org_cfg.report
209
- if release_min_age_days is not None:
210
- report_cfg = replace(report_cfg, release_min_age_days=release_min_age_days)
210
+ if repo_min_age_days is not None:
211
+ report_cfg = replace(report_cfg, repo_min_age_days=repo_min_age_days)
212
+ if release_max_age_days is not None:
213
+ report_cfg = replace(
214
+ report_cfg, release_max_age_days=release_max_age_days
215
+ )
211
216
  effective_cfg = org_cfg
212
217
  if releases_exclude is not None:
213
218
  effective_cfg = replace(org_cfg, releases_exclude=releases_exclude)
@@ -226,6 +231,15 @@ async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
226
231
  return top_n
227
232
  return int(getattr(org_cfg.report, attr))
228
233
 
234
+ def _most_generous(limits: list[int]) -> int:
235
+ # 0 means "no limit", so it is the most generous value of all; otherwise
236
+ # the largest positive cap wins. Without this, max() would treat 0 as
237
+ # the smallest limit and silently re-impose a cap on an org that asked
238
+ # for everything when it shares a channel with a capped org.
239
+ if any(limit <= 0 for limit in limits):
240
+ return 0
241
+ return max(limits)
242
+
229
243
  for org_cfg, org_report in pairs:
230
244
  term_render.render_org(
231
245
  org_report, console, top_n=_limit(org_cfg, top_n_cli, "cli_top_n")
@@ -276,8 +290,8 @@ async def _run_org(cfg: Config, *, console: Console, output_dir: Path | None,
276
290
  slack_render.render_payload(
277
291
  [report for _, report in items],
278
292
  channel=channel,
279
- top_n=max(
280
- _limit(oc, top_n_slack, "slack_top_n") for oc, _ in items
293
+ top_n=_most_generous(
294
+ [_limit(oc, top_n_slack, "slack_top_n") for oc, _ in items]
281
295
  ),
282
296
  pages_url=pages_url,
283
297
  )
@@ -358,13 +372,14 @@ def report(
358
372
  output_dir: str | None = typer.Option(None, "--output-dir", "-o", help="Directory for Pages output (org mode)."),
359
373
  pages_url: str | None = typer.Option(None, "--pages-url", help="GitHub Pages URL for the Slack link."),
360
374
  slack_channel: str | None = typer.Option(None, "--slack-channel", help="Slack channel ID; overrides config slack.channel (e.g. SLACK_CHANNEL_ID)."),
361
- top_n: int | None = typer.Option(None, "--top-n", help="Offenders shown per signal across all outputs (default: config, else 10). Overridden per output by the flags below."),
362
- top_n_report: int | None = typer.Option(None, "--top-n-report", help="Offenders per signal in the GitHub Pages output (overrides --top-n)."),
363
- top_n_cli: int | None = typer.Option(None, "--top-n-cli", help="Offenders per signal in the terminal output (overrides --top-n)."),
364
- top_n_slack: int | None = typer.Option(None, "--top-n-slack", help="Offenders per signal in the Slack digest (overrides --top-n)."),
375
+ top_n: int | None = typer.Option(None, "--top-n", help="Offenders shown per signal across all outputs (0 = no limit; default: config, else 10). Overridden per output by the flags below."),
376
+ top_n_report: int | None = typer.Option(None, "--top-n-report", help="Offenders per signal in the GitHub Pages output (0 = no limit; overrides --top-n)."),
377
+ top_n_cli: int | None = typer.Option(None, "--top-n-cli", help="Offenders per signal in the terminal output (0 = no limit; overrides --top-n)."),
378
+ top_n_slack: int | None = typer.Option(None, "--top-n-slack", help="Offenders per signal in the Slack digest (0 = no limit; overrides --top-n)."),
365
379
  fail_threshold: str = typer.Option("none", "--fail-threshold", help="none|low|medium|high|critical|any (repo mode)."),
366
380
  force_notify: bool = typer.Option(False, "--force-notify", help="Post to Slack regardless of report_day."),
367
- release_min_age_days: int | None = typer.Option(None, "--release-min-age-days", help="Exclude repos created within N days from Releases/Tagging (0 = include all; default: config, else 28)."),
381
+ repo_min_age_days: int | None = typer.Option(None, "--repo-min-age-days", "--release-min-age-days", help="Exclude repos created within N days from Releases/Tagging (0 = include all; default: config, else 28). --release-min-age-days is a deprecated alias."),
382
+ release_max_age_days: int | None = typer.Option(None, "--release-max-age-days", help="Flag a repo in Releases/Tagging only when its newest release or tag is older than N days (0 = flag every eligible repo; default: config, else 0)."),
368
383
  releases_exclude: list[str] | None = typer.Option(None, "--releases-exclude", help="Repository name to omit from the Releases/Tagging table (repeatable; overrides config)."),
369
384
  no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
370
385
  ) -> None:
@@ -372,22 +387,25 @@ def report(
372
387
  plain = no_color or bool(os.environ.get("CI")) or not sys.stdout.isatty()
373
388
  console = Console(no_color=plain, highlight=False)
374
389
 
375
- # Match the config schema (top_n minimum is 1): reject a non-positive
376
- # override at the boundary rather than rendering an empty/odd digest.
390
+ # Match the config schema (top_n minimum is 0): reject a negative override
391
+ # at the boundary. 0 is permitted and disables the limit (show everything).
377
392
  for name, value in (
378
393
  ("--top-n", top_n),
379
394
  ("--top-n-report", top_n_report),
380
395
  ("--top-n-cli", top_n_cli),
381
396
  ("--top-n-slack", top_n_slack),
382
397
  ):
383
- if value is not None and value < 1:
384
- console.print(f"[red]{name} must be 1 or greater[/red]")
398
+ if value is not None and value < 0:
399
+ console.print(f"[red]{name} must be 0 or greater (0 = no limit)[/red]")
385
400
  raise typer.Exit(2)
386
401
 
387
- # Match the config schema (release_min_age_days minimum is 0): reject a
388
- # negative override at the boundary.
389
- if release_min_age_days is not None and release_min_age_days < 0:
390
- console.print("[red]--release-min-age-days must be 0 or greater[/red]")
402
+ # Match the config schema (minimum is 0): reject negative overrides at the
403
+ # boundary.
404
+ if repo_min_age_days is not None and repo_min_age_days < 0:
405
+ console.print("[red]--repo-min-age-days must be 0 or greater[/red]")
406
+ raise typer.Exit(2)
407
+ if release_max_age_days is not None and release_max_age_days < 0:
408
+ console.print("[red]--release-max-age-days must be 0 or greater[/red]")
391
409
  raise typer.Exit(2)
392
410
 
393
411
  cfg = _load_config(config_file, config_data, org, token_env, console=console)
@@ -421,7 +439,8 @@ def report(
421
439
  output_dir=Path(output_dir) if output_dir else None,
422
440
  pages_url=pages_url, top_n=top_n, force_notify=force_notify,
423
441
  slack_channel=slack_channel or None,
424
- release_min_age_days=release_min_age_days,
442
+ repo_min_age_days=repo_min_age_days,
443
+ release_max_age_days=release_max_age_days,
425
444
  releases_exclude=tuple(releases_exclude) if releases_exclude else None,
426
445
  top_n_report=top_n_report,
427
446
  top_n_cli=top_n_cli,