github-security-report 0.14.0__tar.gz → 0.14.1__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 (80) hide show
  1. {github_security_report-0.14.0 → github_security_report-0.14.1}/PKG-INFO +66 -4
  2. {github_security_report-0.14.0 → github_security_report-0.14.1}/README.md +64 -2
  3. {github_security_report-0.14.0 → github_security_report-0.14.1}/pyproject.toml +10 -1
  4. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/app.py +8 -0
  6. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/boundary.py +12 -0
  7. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/options.py +10 -3
  8. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/__init__.py +6 -2
  9. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/alerts.py +2 -1
  10. github_security_report-0.14.1/src/github_security_report/client/batch_errors.py +247 -0
  11. github_security_report-0.14.1/src/github_security_report/client/errors.py +79 -0
  12. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/org_reads.py +15 -95
  13. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/queries.py +2 -2
  14. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/transport.py +1 -32
  15. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/__init__.py +6 -1
  16. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/context.py +0 -4
  17. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/org.py +51 -9
  18. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/loader.py +1 -0
  19. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/models.py +13 -0
  20. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/schema.py +8 -0
  21. {github_security_report-0.14.0 → github_security_report-0.14.1}/.gitignore +0 -0
  22. {github_security_report-0.14.0 → github_security_report-0.14.1}/LICENSE +0 -0
  23. {github_security_report-0.14.0 → github_security_report-0.14.1}/LICENSES/Apache-2.0.txt +0 -0
  24. {github_security_report-0.14.0 → github_security_report-0.14.1}/scripts/README.md +0 -0
  25. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/__init__.py +0 -0
  26. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/authors.py +0 -0
  27. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/categories.py +0 -0
  28. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/classify.py +0 -0
  29. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/__init__.py +0 -0
  30. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/__main__.py +0 -0
  31. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/modes.py +0 -0
  32. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/outputs.py +0 -0
  33. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/publish.py +0 -0
  34. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/cli/serialise.py +0 -0
  35. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/copilot.py +0 -0
  36. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/endpoints.py +0 -0
  37. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/parsers.py +0 -0
  38. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/reads.py +0 -0
  39. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/client/writes.py +0 -0
  40. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/extras.py +0 -0
  41. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/protocols.py +0 -0
  42. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/collect/repo.py +0 -0
  43. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/__init__.py +0 -0
  44. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/config/order.py +0 -0
  45. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/gating.py +0 -0
  46. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/gitctx.py +0 -0
  47. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/issues.py +0 -0
  48. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/layout.py +0 -0
  49. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/models.py +0 -0
  50. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/ordering.py +0 -0
  51. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/__init__.py +0 -0
  52. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/enablement.py +0 -0
  53. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/facts.py +0 -0
  54. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/posture/releases.py +0 -0
  55. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/__init__.py +0 -0
  56. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/columns.py +0 -0
  57. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/counting.py +0 -0
  58. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/presentation.py +0 -0
  59. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/pulls/table.py +0 -0
  60. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/py.typed +0 -0
  61. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/remediate.py +0 -0
  62. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/__init__.py +0 -0
  63. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/html.py +0 -0
  64. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/markdown.py +0 -0
  65. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/slack.py +0 -0
  66. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/slack_limits.py +0 -0
  67. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/render/terminal.py +0 -0
  68. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/__init__.py +0 -0
  69. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/aggregate.py +0 -0
  70. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/display.py +0 -0
  71. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/signals.py +0 -0
  72. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/report/tables.py +0 -0
  73. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/rulesets.py +0 -0
  74. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/runner.py +0 -0
  75. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/scope.py +0 -0
  76. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/secret_patterns.py +0 -0
  77. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/severity.py +0 -0
  78. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/summary.py +0 -0
  79. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/templates/index.html.j2 +0 -0
  80. {github_security_report-0.14.0 → github_security_report-0.14.1}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github-security-report
3
- Version: 0.14.0
3
+ Version: 0.14.1
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
@@ -29,7 +29,7 @@ Requires-Dist: jinja2==3.1.6
29
29
  Requires-Dist: jsonschema==4.26.0
30
30
  Requires-Dist: pyyaml==6.0.3
31
31
  Requires-Dist: rich==15.0.0
32
- Requires-Dist: typer==0.27.1
32
+ Requires-Dist: typer==0.27.2
33
33
  Provides-Extra: dev
34
34
  Requires-Dist: mypy==2.3.1; extra == 'dev'
35
35
  Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
@@ -302,6 +302,7 @@ environment-variable name, never embedded.
302
302
  "include_test": false,
303
303
  "repo_min_age_days": 28,
304
304
  "release_max_age_days": 60,
305
+ "graph_batch": 10,
305
306
  "order": { "style": "auto" }
306
307
  },
307
308
  "organizations": [
@@ -1127,6 +1128,55 @@ Gating decides **collection**; the per-category render toggles above decide
1127
1128
  **presentation**. A skipped section still renders (as the one-line notice)
1128
1129
  unless its category is also disabled.
1129
1130
 
1131
+ ### GraphQL prefetch batch size
1132
+
1133
+ The releases/tags, Dependabot-enablement, open-issues and pull-request data
1134
+ for every in-scope repository comes from one aliased GraphQL query per batch
1135
+ of repositories rather than a round-trip per repository. GitHub bounds how
1136
+ much work a single GraphQL query may do — roughly ten seconds of execution —
1137
+ and reports a breach in one of three shapes, each after the query has already
1138
+ run for the full limit: an **HTTP 502** (or 504) gateway timeout from the
1139
+ edge, an HTTP 200 carrying no `data`, or an HTTP 200 whose `errors` say
1140
+ `Resource limits for this query exceeded` with the unresolved fields nulled.
1141
+ Each aliased repository adds a few hundred milliseconds, so the batch
1142
+ size is bounded by *latency*, not by rate-limit cost: 25 repositories measured
1143
+ at ~9 s against `lfreleng-actions` and failed intermittently; 10 measured at
1144
+ ~3.5 s.
1145
+
1146
+ `report.graph_batch` (default `10`; minimum `1`) sets the starting batch size,
1147
+ globally or per organisation. It is a lever for a slow day rather than a hard
1148
+ limit: a batch that still fails on size after the transport's own retries is
1149
+ re-issued at **half** the size, and the smaller size is kept for the rest of
1150
+ that organisation's collection (each organisation in a multi-org run starts
1151
+ from its own configured size, since organisations differ in how much data a
1152
+ repository carries). Only a single-repository query GitHub still cannot
1153
+ answer, or a failure that a smaller query could not fix (a `403`, an
1154
+ exhausted rate limit, or a `500`/`503` outage rather than a `502`/`504`
1155
+ timeout), aborts the run with exit code `3`.
1156
+
1157
+ The value can be set three ways, in this order of precedence:
1158
+
1159
+ 1. `--graph-batch N` on the command line (the action's `graph_batch` input).
1160
+ 2. A **repository or organisation variable** passed to that input. The
1161
+ bundled `reporting.yaml` passes `vars.GSR_GRAPH_BATCH`, so the size can be
1162
+ adjusted from repository settings without a workflow edit or a release;
1163
+ a caller's own workflow can do the same. (The `vars` context is not
1164
+ available inside a composite action, so the action cannot read it itself.)
1165
+ 3. `report.graph_batch` in the configuration; otherwise the built-in `10`.
1166
+
1167
+ Batching changes how many requests carry the data, not how many GraphQL nodes
1168
+ are resolved, so the node work is the same whatever the batch size. A smaller
1169
+ batch does cost slightly more of the rate-limit budget — each query is charged
1170
+ at least one point, and per-query cost rounding adds a little when one query
1171
+ becomes several — and a few more requests (a 124-repository organisation is 13
1172
+ queries at 10, 5 at 25). Against the 5,000-point hourly budget that is noise;
1173
+ a larger value buys nothing except a longer first failure.
1174
+
1175
+ > The flag and input are recent additions. An empty input is not passed to
1176
+ > the tool, so an unset variable cannot break a run; but a pinned tool version
1177
+ > predating the flag will reject a set one with `No such option`, so set
1178
+ > `GSR_GRAPH_BATCH` only once the pinned release supports it.
1179
+
1130
1180
  ### Pass/fail severity cutoff
1131
1181
 
1132
1182
  The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
@@ -1204,6 +1254,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
1204
1254
  | `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
1205
1255
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
1206
1256
  | `hide` | No | `""` | Category keys to suppress on every output (space- or comma-separated). Overrides config, and is one-way: it cannot re-enable a disabled category |
1257
+ | `graph_batch` | No | `""` | Repositories per batched GraphQL prefetch query (minimum `1`; default: config, else `10`). Pass a repository or organisation variable such as `${{ vars.GSR_GRAPH_BATCH }}` to adjust it from settings. A batch that still fails is halved automatically, so this sets the starting size (see [GraphQL prefetch batch size](#graphql-prefetch-batch-size)) |
1207
1258
  | `tool_version` | No | `""` | Published PyPI version to install. Empty (the default) uses the Dependabot-managed pin in `.github/runtime-pin/requirements.txt`; set a specific version to override. Ignored on pull requests or when `use_local_source` is `true` (both run from source) |
1208
1259
  | `use_local_source` | No | `false` | Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
1209
1260
 
@@ -1247,7 +1298,7 @@ API in the same way.
1247
1298
  | `0` | The report ran. |
1248
1299
  | `1` | Repo mode only: findings met or exceeded `--fail-threshold`. |
1249
1300
  | `2` | Usage or configuration error (bad flag, unreadable config). |
1250
- | `3` | The GitHub API was unreachable after the retry budget. |
1301
+ | `3` | The GitHub API was unusable after the retry budget (see below). |
1251
1302
  | `4` | GitHub rejected the credentials (HTTP 401). |
1252
1303
 
1253
1304
  Codes `3` and `4` are **aborts, not reports**: nothing is written and no Pages
@@ -1258,8 +1309,19 @@ section `No data` or `All Clean` — and a scheduled job would then publish it
1258
1309
  over the last good one. Reporting false data is worse than reporting none, so
1259
1310
  the run stops at the first rejected request.
1260
1311
 
1312
+ Code `3` covers a GitHub API that could not be reached at all, and a GraphQL
1313
+ prefetch that failed as a whole: either a size-shaped failure (`502`/`504`
1314
+ timeout, `200` with no data, resource limits exceeded) that persisted even
1315
+ after the batch had been halved down to one repository, or a failure that
1316
+ halving could not have fixed (`403`, an exhausted GraphQL rate limit, a
1317
+ `500`/`503` outage), which aborts at once. The message reports the status or
1318
+ cause and ends with the matching next step — a smaller `--graph-batch`, retry
1319
+ later, wait for the rate-limit budget to reset, or check the token's
1320
+ permissions — so read it rather than assuming "retry later" (see
1321
+ [GraphQL prefetch batch size](#graphql-prefetch-batch-size)).
1322
+
1261
1323
  The two are separate codes because the remedy differs: `4` means rotate or fix
1262
- the token, `3` means retry later.
1324
+ the token, `3` usually means retry later.
1263
1325
 
1264
1326
  ## Remediation
1265
1327
 
@@ -258,6 +258,7 @@ environment-variable name, never embedded.
258
258
  "include_test": false,
259
259
  "repo_min_age_days": 28,
260
260
  "release_max_age_days": 60,
261
+ "graph_batch": 10,
261
262
  "order": { "style": "auto" }
262
263
  },
263
264
  "organizations": [
@@ -1083,6 +1084,55 @@ Gating decides **collection**; the per-category render toggles above decide
1083
1084
  **presentation**. A skipped section still renders (as the one-line notice)
1084
1085
  unless its category is also disabled.
1085
1086
 
1087
+ ### GraphQL prefetch batch size
1088
+
1089
+ The releases/tags, Dependabot-enablement, open-issues and pull-request data
1090
+ for every in-scope repository comes from one aliased GraphQL query per batch
1091
+ of repositories rather than a round-trip per repository. GitHub bounds how
1092
+ much work a single GraphQL query may do — roughly ten seconds of execution —
1093
+ and reports a breach in one of three shapes, each after the query has already
1094
+ run for the full limit: an **HTTP 502** (or 504) gateway timeout from the
1095
+ edge, an HTTP 200 carrying no `data`, or an HTTP 200 whose `errors` say
1096
+ `Resource limits for this query exceeded` with the unresolved fields nulled.
1097
+ Each aliased repository adds a few hundred milliseconds, so the batch
1098
+ size is bounded by *latency*, not by rate-limit cost: 25 repositories measured
1099
+ at ~9 s against `lfreleng-actions` and failed intermittently; 10 measured at
1100
+ ~3.5 s.
1101
+
1102
+ `report.graph_batch` (default `10`; minimum `1`) sets the starting batch size,
1103
+ globally or per organisation. It is a lever for a slow day rather than a hard
1104
+ limit: a batch that still fails on size after the transport's own retries is
1105
+ re-issued at **half** the size, and the smaller size is kept for the rest of
1106
+ that organisation's collection (each organisation in a multi-org run starts
1107
+ from its own configured size, since organisations differ in how much data a
1108
+ repository carries). Only a single-repository query GitHub still cannot
1109
+ answer, or a failure that a smaller query could not fix (a `403`, an
1110
+ exhausted rate limit, or a `500`/`503` outage rather than a `502`/`504`
1111
+ timeout), aborts the run with exit code `3`.
1112
+
1113
+ The value can be set three ways, in this order of precedence:
1114
+
1115
+ 1. `--graph-batch N` on the command line (the action's `graph_batch` input).
1116
+ 2. A **repository or organisation variable** passed to that input. The
1117
+ bundled `reporting.yaml` passes `vars.GSR_GRAPH_BATCH`, so the size can be
1118
+ adjusted from repository settings without a workflow edit or a release;
1119
+ a caller's own workflow can do the same. (The `vars` context is not
1120
+ available inside a composite action, so the action cannot read it itself.)
1121
+ 3. `report.graph_batch` in the configuration; otherwise the built-in `10`.
1122
+
1123
+ Batching changes how many requests carry the data, not how many GraphQL nodes
1124
+ are resolved, so the node work is the same whatever the batch size. A smaller
1125
+ batch does cost slightly more of the rate-limit budget — each query is charged
1126
+ at least one point, and per-query cost rounding adds a little when one query
1127
+ becomes several — and a few more requests (a 124-repository organisation is 13
1128
+ queries at 10, 5 at 25). Against the 5,000-point hourly budget that is noise;
1129
+ a larger value buys nothing except a longer first failure.
1130
+
1131
+ > The flag and input are recent additions. An empty input is not passed to
1132
+ > the tool, so an unset variable cannot break a run; but a pinned tool version
1133
+ > predating the flag will reject a set one with `No such option`, so set
1134
+ > `GSR_GRAPH_BATCH` only once the pinned release supports it.
1135
+
1086
1136
  ### Pass/fail severity cutoff
1087
1137
 
1088
1138
  The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
@@ -1160,6 +1210,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
1160
1210
  | `fail_threshold` | No | `none` | `none`/`low`/`medium`/`high`/`critical`/`any` (repo mode) |
1161
1211
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
1162
1212
  | `hide` | No | `""` | Category keys to suppress on every output (space- or comma-separated). Overrides config, and is one-way: it cannot re-enable a disabled category |
1213
+ | `graph_batch` | No | `""` | Repositories per batched GraphQL prefetch query (minimum `1`; default: config, else `10`). Pass a repository or organisation variable such as `${{ vars.GSR_GRAPH_BATCH }}` to adjust it from settings. A batch that still fails is halved automatically, so this sets the starting size (see [GraphQL prefetch batch size](#graphql-prefetch-batch-size)) |
1163
1214
  | `tool_version` | No | `""` | Published PyPI version to install. Empty (the default) uses the Dependabot-managed pin in `.github/runtime-pin/requirements.txt`; set a specific version to override. Ignored on pull requests or when `use_local_source` is `true` (both run from source) |
1164
1215
  | `use_local_source` | No | `false` | Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
1165
1216
 
@@ -1203,7 +1254,7 @@ API in the same way.
1203
1254
  | `0` | The report ran. |
1204
1255
  | `1` | Repo mode only: findings met or exceeded `--fail-threshold`. |
1205
1256
  | `2` | Usage or configuration error (bad flag, unreadable config). |
1206
- | `3` | The GitHub API was unreachable after the retry budget. |
1257
+ | `3` | The GitHub API was unusable after the retry budget (see below). |
1207
1258
  | `4` | GitHub rejected the credentials (HTTP 401). |
1208
1259
 
1209
1260
  Codes `3` and `4` are **aborts, not reports**: nothing is written and no Pages
@@ -1214,8 +1265,19 @@ section `No data` or `All Clean` — and a scheduled job would then publish it
1214
1265
  over the last good one. Reporting false data is worse than reporting none, so
1215
1266
  the run stops at the first rejected request.
1216
1267
 
1268
+ Code `3` covers a GitHub API that could not be reached at all, and a GraphQL
1269
+ prefetch that failed as a whole: either a size-shaped failure (`502`/`504`
1270
+ timeout, `200` with no data, resource limits exceeded) that persisted even
1271
+ after the batch had been halved down to one repository, or a failure that
1272
+ halving could not have fixed (`403`, an exhausted GraphQL rate limit, a
1273
+ `500`/`503` outage), which aborts at once. The message reports the status or
1274
+ cause and ends with the matching next step — a smaller `--graph-batch`, retry
1275
+ later, wait for the rate-limit budget to reset, or check the token's
1276
+ permissions — so read it rather than assuming "retry later" (see
1277
+ [GraphQL prefetch batch size](#graphql-prefetch-batch-size)).
1278
+
1217
1279
  The two are separate codes because the remedy differs: `4` means rotate or fix
1218
- the token, `3` means retry later.
1280
+ the token, `3` usually means retry later.
1219
1281
 
1220
1282
  ## Remediation
1221
1283
 
@@ -45,7 +45,7 @@ keywords = [
45
45
  ]
46
46
  dependencies = [
47
47
  "httpx[http2]==0.28.1",
48
- "typer==0.27.1",
48
+ "typer==0.27.2",
49
49
  "rich==15.0.0",
50
50
  "jinja2==3.1.6",
51
51
  "jsonschema==4.26.0",
@@ -129,6 +129,15 @@ markers = [
129
129
  [tool.coverage.run]
130
130
  source = ["github_security_report"]
131
131
  omit = ["tests/*"]
132
+ # Keep the transient coverage database out of the working tree.
133
+ # coverage.py resolves a relative data_file against the current
134
+ # directory, so the default '.coverage' lands wherever pytest runs and
135
+ # can be picked up by tests that inspect on-disk content. TEMP is
136
+ # set on Windows, where /tmp does not exist; elsewhere it is normally
137
+ # unset and the /tmp fallback applies. An explicit value also
138
+ # satisfies python-test-action's data_file configuration check.
139
+ # COVERAGE_FILE (env) still takes precedence when set.
140
+ data_file = "${TEMP-/tmp}/.coverage.github-security-report"
132
141
 
133
142
  [tool.coverage.report]
134
143
  show_missing = true
@@ -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.14.0'
22
- __version_tuple__ = version_tuple = (0, 14, 0)
21
+ __version__ = version = '0.14.1'
22
+ __version_tuple__ = version_tuple = (0, 14, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -166,6 +166,11 @@ def report(
166
166
  "--include-test",
167
167
  help="Analyse test repositories, which are excluded by default.",
168
168
  ),
169
+ graph_batch: int | None = typer.Option(
170
+ None,
171
+ "--graph-batch",
172
+ help="Repositories per batched GraphQL prefetch query (minimum 1; default: config, else 10). GitHub bounds how long one query may run, so a batch that still fails is halved automatically; this sets the starting size.",
173
+ ),
169
174
  no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
170
175
  ) -> None:
171
176
  """Generate a security and quality report."""
@@ -181,6 +186,7 @@ def report(
181
186
  )
182
187
  boundary.check_non_negative(console, "--repo-min-age-days", repo_min_age_days)
183
188
  boundary.check_non_negative(console, "--release-max-age-days", release_max_age_days)
189
+ boundary.check_positive(console, "--graph-batch", graph_batch)
184
190
  hidden = boundary.resolve_hidden(console, hide)
185
191
 
186
192
  cfg = _load_config(config_file, config_data, org, token_env, console=console)
@@ -210,6 +216,7 @@ def report(
210
216
  gating=False if no_gating else None,
211
217
  include_archived=True if include_archived else None,
212
218
  include_test=True if include_test else None,
219
+ graph_batch=graph_batch,
213
220
  ),
214
221
  hidden=hidden,
215
222
  )
@@ -236,6 +243,7 @@ def report(
236
243
  ("--no-gating", no_gating),
237
244
  ("--include-archived", include_archived),
238
245
  ("--include-test", include_test),
246
+ ("--graph-batch", graph_batch is not None),
239
247
  )
240
248
  if supplied
241
249
  ],
@@ -41,6 +41,18 @@ def check_non_negative(console: Console, name: str, value: int | None) -> None:
41
41
  raise typer.Exit(2)
42
42
 
43
43
 
44
+ def check_positive(console: Console, name: str, value: int | None) -> None:
45
+ """Reject a zero or negative override where 0 has no meaning.
46
+
47
+ For a batch size, unlike a row limit, there is no "unlimited" reading of
48
+ 0 -- a query carrying no repositories is not a query -- so the floor is 1,
49
+ matching the config schema's minimum for the same control.
50
+ """
51
+ if value is not None and value < 1:
52
+ console.print(f"[red]{name} must be 1 or greater[/red]")
53
+ raise typer.Exit(2)
54
+
55
+
44
56
  def check_limits(console: Console, limits: Sequence[tuple[str, int | None]]) -> None:
45
57
  """Reject a negative row limit, naming the flag and the 0 convention."""
46
58
  for name, value in limits:
@@ -34,6 +34,10 @@ class ReportOverrides:
34
34
  not on, and ``--include-archived`` / ``--include-test`` can widen the scope
35
35
  but not narrow it, so a flag can loosen what the configuration asked for
36
36
  without being able to tighten it behind the operator's back.
37
+
38
+ ``graph_batch`` is an operational lever rather than report policy: it
39
+ changes how the GraphQL prefetch is issued, never what the report says, so
40
+ it may move in either direction.
37
41
  """
38
42
 
39
43
  repo_min_age_days: int | None = None
@@ -42,13 +46,15 @@ class ReportOverrides:
42
46
  gating: bool | None = None
43
47
  include_archived: bool | None = None
44
48
  include_test: bool | None = None
49
+ graph_batch: int | None = None
45
50
 
46
51
  def apply(self, org_cfg: OrgConfig) -> tuple[OrgConfig, ReportConfig]:
47
52
  """The org and report configs to collect with, overrides applied.
48
53
 
49
- The two age thresholds and the three booleans are scalar policy, so
50
- applying one uniformly across every configured organisation is what a
51
- reader of the flag expects, and matches how ``--top-n`` already behaves.
54
+ The two age thresholds, the three booleans and the batch size are
55
+ scalar policy, so applying one uniformly across every configured
56
+ organisation is what a reader of the flag expects, and matches how
57
+ ``--top-n`` already behaves.
52
58
 
53
59
  ``releases_exclude`` is not scalar: it is a curated per-organisation
54
60
  list, and one flag replacing all of them loses data the config
@@ -63,6 +69,7 @@ class ReportOverrides:
63
69
  "gating",
64
70
  "include_archived",
65
71
  "include_test",
72
+ "graph_batch",
66
73
  ):
67
74
  value = getattr(self, name)
68
75
  if value is not None:
@@ -35,6 +35,11 @@ from github_security_report.client.endpoints import (
35
35
  SCORECARD_API,
36
36
  _https_endpoint,
37
37
  )
38
+ from github_security_report.client.errors import (
39
+ AuthError,
40
+ GraphBatchError,
41
+ NetworkError,
42
+ )
38
43
  from github_security_report.client.org_reads import OrgReadClient
39
44
  from github_security_report.client.parsers import (
40
45
  _last_published,
@@ -52,8 +57,6 @@ from github_security_report.client.queries import (
52
57
  )
53
58
  from github_security_report.client.reads import ReadClient
54
59
  from github_security_report.client.transport import (
55
- AuthError,
56
- NetworkError,
57
60
  Transport,
58
61
  _endpoint_diagnostics,
59
62
  )
@@ -72,6 +75,7 @@ __all__ = [
72
75
  "AlertReads",
73
76
  "AuthError",
74
77
  "GitHubClient",
78
+ "GraphBatchError",
75
79
  "NetworkError",
76
80
  "OrgReadClient",
77
81
  "ReadClient",
@@ -27,7 +27,8 @@ import logging
27
27
  from typing import NamedTuple
28
28
 
29
29
  from github_security_report.client.endpoints import BULK_KINDS
30
- from github_security_report.client.transport import AuthError, NetworkError, Transport
30
+ from github_security_report.client.errors import AuthError, NetworkError
31
+ from github_security_report.client.transport import Transport
31
32
  from github_security_report.secret_patterns import (
32
33
  GENERIC_SECRET_TYPES,
33
34
  PATTERN_CONFIG_PATH,
@@ -0,0 +1,247 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Reading the ``errors`` array of a batched GraphQL query.
4
+
5
+ GitHub answers a partially-failed aliased query with HTTP 200: the readable
6
+ aliases populated, the rest ``null`` or missing individual fields, and an
7
+ ``errors`` array explaining why. Deciding what those entries mean for each
8
+ repository -- unreadable, unreadable in one isolable field, or a symptom of the
9
+ whole batch being too large -- is a policy of its own, kept here so the client
10
+ that issues the query stays about issuing it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from github_security_report.client.errors import GraphBatchError
16
+
17
+ # Fields whose failure can be isolated to the data they feed instead of failing
18
+ # the whole repository. Only fields the model already carries a dedicated
19
+ # "unknown" for qualify: ``pullRequests`` has ``open_pull_requests=None``, which
20
+ # every pull-request table already renders as unknown, so a token that cannot
21
+ # read pull requests loses that one table rather than the repository's releases,
22
+ # issues, tags and Dependabot posture as well.
23
+ _ISOLABLE_FIELDS = frozenset({"pullRequests"})
24
+
25
+ # The message GitHub attaches to every error when a query is cut short for
26
+ # exceeding its execution budget. Unlike a field the token cannot read, this is
27
+ # a property of the query's size: the same repositories resolve in full when
28
+ # asked for in smaller batches, so it is reported as a batch failure to split
29
+ # rather than as repositories to mark unknown.
30
+ _RESOURCE_LIMIT_MESSAGE = "Resource limits for this query exceeded"
31
+
32
+ # The statuses GitHub's edge answers with when a GraphQL query runs past its
33
+ # time limit: 502 is the shape observed against lfreleng-actions (arriving
34
+ # ~10.7 s after each request), and 504 is the gateway's own timeout status. A
35
+ # 500 or 503 is an outage rather than a verdict on the query's size; splitting
36
+ # would hand each smaller batch a fresh retry budget against a service that is
37
+ # down, multiplying requests and delaying an abort that smaller queries cannot
38
+ # avert.
39
+ _QUERY_TIMEOUT_STATUSES = frozenset({502, 504})
40
+
41
+ # The ``type`` GitHub puts on every error when a GraphQL rate limit is spent.
42
+ # Two spellings are in use -- ``RATE_LIMITED`` for the hourly point budget and
43
+ # ``RATE_LIMIT`` (with ``code: graphql_rate_limit``) for the secondary limit on
44
+ # concurrent or bursty queries -- so the prefix is matched. Either arrives as
45
+ # HTTP 200 with a ``null`` ``data`` object, the same shape as a timed-out query,
46
+ # and is the one such response that a smaller batch cannot fix: every retry
47
+ # would burn another request against an exhausted budget.
48
+ _RATE_LIMIT_TYPE_PREFIX = "RATE_LIMIT"
49
+
50
+
51
+ def _exceeded_resource_limits(errors: object) -> bool:
52
+ """Whether ``errors`` shows GitHub cut the query short for its size."""
53
+ if not isinstance(errors, list):
54
+ return False
55
+ return any(
56
+ isinstance(e, dict) and _RESOURCE_LIMIT_MESSAGE in str(e.get("message", ""))
57
+ for e in errors
58
+ )
59
+
60
+
61
+ def _rate_limited(errors: object) -> bool:
62
+ """Whether ``errors`` shows a GraphQL rate limit is exhausted."""
63
+ if not isinstance(errors, list):
64
+ return False
65
+ return any(
66
+ isinstance(e, dict)
67
+ and str(e.get("type", "")).startswith(_RATE_LIMIT_TYPE_PREFIX)
68
+ for e in errors
69
+ )
70
+
71
+
72
+ def _batch_response(
73
+ org: str, count: int, *, status: int, body: dict | None
74
+ ) -> tuple[dict, list]:
75
+ """The ``(data, errors)`` of a batched query, or the failure to abort on.
76
+
77
+ Raises :class:`GraphBatchError` when the query failed as a whole -- for
78
+ every one of the ``count`` repositories -- and says whether the caller may
79
+ retry them in smaller batches. Four shapes are told apart, in this order:
80
+
81
+ * A non-200 ``status`` (``body`` is then ``None``): a 502 or 504 is
82
+ GitHub's edge giving up on a query that ran too long, which a smaller
83
+ batch may avoid; any other status -- a 403/429 that outlived the
84
+ transport's retry budget, or a 500/503 outage -- would recur at any size.
85
+ * ``RATE_LIMITED`` or ``RATE_LIMIT`` in ``errors``: a GraphQL rate limit
86
+ (hourly points or the secondary burst limit) is spent, so a retry in any
87
+ batch size would only burn requests against it.
88
+ * "Resource limits exceeded" in ``errors``: GitHub ran out of execution
89
+ budget part-way through and nulled the rest. Those nulls are a symptom of
90
+ the batch's size, not per-repository read failures, so the batch is
91
+ failed for splitting rather than (typically most of) it marked unknown.
92
+ * No ``data`` object at all: with the rate limit ruled out, this is how
93
+ GitHub reports a query it could not finish in time.
94
+
95
+ Anything else is a response the caller can read alias by alias; a missing
96
+ or malformed ``errors`` array is returned as an empty list.
97
+
98
+ Each message ends with the remedy that fits the failure: a batch of
99
+ several repositories can be retried smaller, but by the time a failure
100
+ escapes the adaptive collector the batch is usually a single repository,
101
+ and advising a smaller batch then would be advice that cannot be followed;
102
+ a permission error was never retried, so it must not claim a retry budget
103
+ was spent.
104
+ """
105
+ if body is None:
106
+ raise GraphBatchError(
107
+ f"GraphQL prefetch for {org} failed with HTTP {status}; aborting "
108
+ "because the release/tag, Dependabot-enablement and open-issues "
109
+ f"data for {count} repositories would otherwise be fabricated "
110
+ f"from defaults (e.g. reported as never released). "
111
+ f"{_status_remedy(status, count)}",
112
+ status=status,
113
+ splittable=status in _QUERY_TIMEOUT_STATUSES,
114
+ )
115
+ errors = body.get("errors")
116
+ if _rate_limited(errors):
117
+ raise GraphBatchError(
118
+ f"GraphQL prefetch for {org} was refused: a GraphQL API rate "
119
+ f"limit is exhausted; aborting rather than reporting "
120
+ f"{count} repositories from fabricated defaults. "
121
+ "Retry once the budget resets.",
122
+ status=status,
123
+ splittable=False,
124
+ reason="rate limited",
125
+ )
126
+ if _exceeded_resource_limits(errors):
127
+ raise GraphBatchError(
128
+ f"GraphQL prefetch for {org} exceeded GitHub's resource limits "
129
+ f"for a single query across {count} repositories; "
130
+ "aborting rather than reporting the unresolved fields as "
131
+ f"unknown. {_size_remedy(count)}",
132
+ status=status,
133
+ splittable=True,
134
+ reason="resource limits exceeded",
135
+ )
136
+ data = body.get("data")
137
+ if not isinstance(data, dict):
138
+ raise GraphBatchError(
139
+ f"GraphQL prefetch for {org} returned no data for any of "
140
+ f"{count} repositories; aborting rather than reporting "
141
+ f"fabricated defaults. {_size_remedy(count)} "
142
+ f"errors={errors!r}",
143
+ status=status,
144
+ splittable=True,
145
+ reason="HTTP 200 with no data",
146
+ )
147
+ return data, errors if isinstance(errors, list) else []
148
+
149
+
150
+ def _size_remedy(count: int) -> str:
151
+ """The operator's next step after a size-shaped failure of ``count`` repos."""
152
+ if count > 1:
153
+ return "Retry with a smaller --graph-batch."
154
+ return (
155
+ "This repository's query exceeds GitHub's limits on its own; retry "
156
+ "later, or exclude the repository if it persists."
157
+ )
158
+
159
+
160
+ def _status_remedy(status: int, count: int) -> str:
161
+ """The operator's next step after a non-200 answer to a batched query.
162
+
163
+ Mirrors the transport's own handling of each status: a gateway timeout
164
+ and a 5xx outage both outlived the retry budget, whereas a 403 is handed
165
+ back at once when it carries no rate-limit headers (a genuine permission
166
+ error) and only after the budget when it does, so it is described as
167
+ either without claiming a delay that may not have happened.
168
+ """
169
+ if status in _QUERY_TIMEOUT_STATUSES:
170
+ return (
171
+ "GitHub timed the query out on every retry, which points at its "
172
+ f"size. {_size_remedy(count)}"
173
+ )
174
+ if status == 403:
175
+ return (
176
+ "HTTP 403 is either a permission error (check that the token can "
177
+ "read this organisation's repositories) or a secondary rate limit "
178
+ "that outlived the retry budget (retry later)."
179
+ )
180
+ if status == 429:
181
+ return "Rate limited beyond the retry budget; retry later."
182
+ if status >= 500:
183
+ return (
184
+ "GitHub answered with a server error on every retry, which is an "
185
+ "outage rather than a problem with the query; retry later."
186
+ )
187
+ return "Check the GitHub API status and retry."
188
+
189
+
190
+ def _alias_errors(errors: object, alias_count: int) -> tuple[set[str], set[str]]:
191
+ """Alias keys implicated by a batched query's ``errors`` array.
192
+
193
+ Returns ``(unreadable, pull_requests_only)``: aliases that must be failed
194
+ wholesale, and aliases whose only failures were confined to fields in
195
+ :data:`_ISOLABLE_FIELDS`.
196
+
197
+ GitHub reports a *field-level* failure with HTTP 200: the alias is still a
198
+ populated dictionary, the field that failed is null, and an ``errors``
199
+ entry carries its path (e.g. ``["r3", "latestRelease"]``). Parsing such a
200
+ node would convert a read failure into a confident negative -- a nulled
201
+ ``latestRelease`` is indistinguishable from "never released" -- so the
202
+ whole alias is treated as unreadable rather than partially trusted.
203
+
204
+ The alias is failed wholesale rather than per field, because a per-field
205
+ flag would have to be threaded through every table to be honest about which
206
+ half of a row is trustworthy, whereas one unknown repository is already a
207
+ state every table renders correctly. The exception is a field the model
208
+ *already* carries a dedicated unknown for: failing the whole repository for
209
+ one of those would let an optional, permission-sensitive section take the
210
+ rest of the report down with it -- a token without pull-request access would
211
+ lose its releases, issues and Dependabot posture too.
212
+
213
+ An error whose path names no alias cannot be attributed, so it implicates
214
+ every alias in the batch: with no way to tell which repositories it
215
+ touched, treating any of them as successfully read would be a guess.
216
+
217
+ An error *nested* inside an isolable field is classified by that field, and
218
+ deliberately so. ``reviewThreads`` is non-null in GitHub's schema
219
+ (``PullRequestReviewThreadConnection!``), so a resolver failure there does
220
+ not null the connection: it propagates up to the nearest nullable ancestor,
221
+ which is the pull-request node itself. The node arrives as ``null`` and
222
+ carries none of its facts, so ignoring the error would silently drop that
223
+ pull request from every column while ``totalCount`` still counted it --
224
+ understating the breakdown with nothing to say so. Failing the connection
225
+ reports the repository as unknown instead, which every table renders
226
+ correctly.
227
+ """
228
+ all_aliases = {f"r{i}" for i in range(alias_count)}
229
+ if not isinstance(errors, list):
230
+ return set(), set()
231
+ unreadable: set[str] = set()
232
+ isolated: set[str] = set()
233
+ for entry in errors:
234
+ path = entry.get("path") if isinstance(entry, dict) else None
235
+ if not isinstance(path, list) or not path:
236
+ return all_aliases, set()
237
+ head = path[0]
238
+ if not isinstance(head, str) or head not in all_aliases:
239
+ return all_aliases, set()
240
+ field = path[1] if len(path) > 1 else None
241
+ if isinstance(field, str) and field in _ISOLABLE_FIELDS:
242
+ isolated.add(head)
243
+ else:
244
+ unreadable.add(head)
245
+ # An alias with failures on both sides is unreadable: the isolable one is
246
+ # the lesser problem, and the other still poisons the rest of the node.
247
+ return unreadable, isolated - unreadable