github-security-report 0.17.0__tar.gz → 0.18.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 (97) hide show
  1. github_security_report-0.17.0/README.md → github_security_report-0.18.1/PKG-INFO +155 -7
  2. github_security_report-0.17.0/PKG-INFO → github_security_report-0.18.1/README.md +111 -51
  3. {github_security_report-0.17.0 → github_security_report-0.18.1}/pyproject.toml +7 -7
  4. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/_version.py +2 -2
  5. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/app.py +11 -0
  6. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/boundary.py +8 -2
  7. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/modes.py +25 -10
  8. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/options.py +9 -7
  9. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/__init__.py +10 -7
  10. github_security_report-0.18.1/src/github_security_report/client/accounting.py +161 -0
  11. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/endpoints.py +8 -0
  12. github_security_report-0.18.1/src/github_security_report/client/failures.py +115 -0
  13. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/transport.py +41 -96
  14. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/extras.py +2 -0
  15. github_security_report-0.18.1/src/github_security_report/collect/graph.py +106 -0
  16. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/org.py +4 -60
  17. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/config/__init__.py +2 -0
  18. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/config/loader.py +28 -16
  19. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/config/models.py +13 -0
  20. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/config/schema.py +17 -0
  21. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/issues.py +73 -3
  22. {github_security_report-0.17.0 → github_security_report-0.18.1}/.gitignore +0 -0
  23. {github_security_report-0.17.0 → github_security_report-0.18.1}/LICENSE +0 -0
  24. {github_security_report-0.17.0 → github_security_report-0.18.1}/LICENSES/Apache-2.0.txt +0 -0
  25. {github_security_report-0.17.0 → github_security_report-0.18.1}/scripts/README.md +0 -0
  26. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/__init__.py +0 -0
  27. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/authors.py +0 -0
  28. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/categories/__init__.py +0 -0
  29. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/categories/keys.py +0 -0
  30. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/categories/signals.py +0 -0
  31. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/categories/tables.py +0 -0
  32. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/classify.py +0 -0
  33. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/__init__.py +0 -0
  34. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/__main__.py +0 -0
  35. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/outputs.py +0 -0
  36. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/publish.py +0 -0
  37. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/cli/serialise.py +0 -0
  38. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/alerts.py +0 -0
  39. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/batch_errors.py +0 -0
  40. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/codeql_parsers.py +0 -0
  41. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/copilot.py +0 -0
  42. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/errors.py +0 -0
  43. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/org_reads.py +0 -0
  44. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/parsers.py +0 -0
  45. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/queries.py +0 -0
  46. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/reads.py +0 -0
  47. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/reviews.py +0 -0
  48. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/client/writes.py +0 -0
  49. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/codeql/__init__.py +0 -0
  50. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/codeql/cleanup.py +0 -0
  51. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/codeql/facts.py +0 -0
  52. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/codeql/tables.py +0 -0
  53. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/__init__.py +0 -0
  54. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/codeql.py +0 -0
  55. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/context.py +0 -0
  56. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/protocols.py +0 -0
  57. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/repo.py +0 -0
  58. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/collect/scoping.py +0 -0
  59. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/config/order.py +0 -0
  60. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/gating.py +0 -0
  61. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/gitctx.py +0 -0
  62. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/layout.py +0 -0
  63. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/models.py +0 -0
  64. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/ordering.py +0 -0
  65. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/posture/__init__.py +0 -0
  66. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/posture/enablement.py +0 -0
  67. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/posture/facts.py +0 -0
  68. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/posture/releases.py +0 -0
  69. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/pulls/__init__.py +0 -0
  70. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/pulls/columns.py +0 -0
  71. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/pulls/counting.py +0 -0
  72. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/pulls/presentation.py +0 -0
  73. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/pulls/table.py +0 -0
  74. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/py.typed +0 -0
  75. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/ranking.py +0 -0
  76. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/remediate/__init__.py +0 -0
  77. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/remediate/codeql.py +0 -0
  78. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/remediate/model.py +0 -0
  79. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/render/__init__.py +0 -0
  80. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/render/html.py +0 -0
  81. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/render/markdown.py +0 -0
  82. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/render/slack.py +0 -0
  83. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/render/slack_limits.py +0 -0
  84. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/render/terminal.py +0 -0
  85. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/report/__init__.py +0 -0
  86. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/report/aggregate.py +0 -0
  87. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/report/display.py +0 -0
  88. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/report/signals.py +0 -0
  89. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/report/tables.py +0 -0
  90. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/rulesets.py +0 -0
  91. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/runner.py +0 -0
  92. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/scope.py +0 -0
  93. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/secret_patterns.py +0 -0
  94. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/severity.py +0 -0
  95. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/summary.py +0 -0
  96. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/templates/index.html.j2 +0 -0
  97. {github_security_report-0.17.0 → github_security_report-0.18.1}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,3 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: github-security-report
3
+ Version: 0.18.1
4
+ Summary: Security and quality reporting across GitHub organisations
5
+ Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
6
+ Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
7
+ Project-URL: Issues, https://github.com/lfreleng-actions/github-security-report-action/issues
8
+ Author-email: The Linux Foundation <mwatkins@linuxfoundation.org>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: codeql,dependabot,github,github-actions,linux-foundation,reporting,scorecard,secret-scanning,security,zizmor
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Security
24
+ Classifier: Topic :: Software Development :: Quality Assurance
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: httpx[http2]==0.28.1
28
+ Requires-Dist: jinja2==3.1.6
29
+ Requires-Dist: jsonschema==4.26.0
30
+ Requires-Dist: pyyaml==6.0.3
31
+ Requires-Dist: rich==15.0.0
32
+ Requires-Dist: typer==0.27.3
33
+ Provides-Extra: dev
34
+ Requires-Dist: mypy==2.4.0; extra == 'dev'
35
+ Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
36
+ Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
38
+ Requires-Dist: respx==0.23.1; extra == 'dev'
39
+ Requires-Dist: ruff==0.17.0; extra == 'dev'
40
+ Requires-Dist: syrupy==6.1.1; extra == 'dev'
41
+ Requires-Dist: types-jsonschema==4.26.0.20261006; extra == 'dev'
42
+ Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
1
45
  <!--
2
46
  # SPDX-License-Identifier: Apache-2.0
3
47
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
@@ -301,7 +345,10 @@ environment-variable name, never embedded.
301
345
  "repo_min_age_days": 28,
302
346
  "release_max_age_days": 60,
303
347
  "codeql_stale_days": 30,
348
+ "issue_age_warn_days": 30,
349
+ "issue_age_error_days": 60,
304
350
  "graph_batch": 10,
351
+ "concurrency": 9,
305
352
  "order": { "style": "auto" }
306
353
  },
307
354
  "organizations": [
@@ -793,6 +840,38 @@ with HTTP 200 and this one field null, so affected repositories are reported as
793
840
  `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
794
841
  is never presented as a clean one.
795
842
 
843
+ #### Issue colours and age thresholds
844
+
845
+ On the terminal the counts are coloured so the table reads at a glance: `Bug`
846
+ and `Untriaged` **red**, `Docs` **green**. As in the Pull Requests table, only
847
+ **non-zero** counts are coloured, so the eye lands on the rows with something
848
+ in them. The colours follow the column **name** (case-insensitively), so they
849
+ apply to a custom `issue_labels` that keeps a `Bug` or `Docs` column and simply
850
+ do not appear for one that renames them.
851
+
852
+ `Oldest` is coloured against two age thresholds, in days:
853
+
854
+ ```json
855
+ {
856
+ "report": {
857
+ "issue_age_warn_days": 30,
858
+ "issue_age_error_days": 60
859
+ },
860
+ "organizations": [{ "name": "lfreleng-actions" }]
861
+ }
862
+ ```
863
+
864
+ - **Green** while the oldest open issue is at most `issue_age_warn_days`
865
+ (default `30`) old.
866
+ - **Yellow** once it is older than `issue_age_warn_days`.
867
+ - **Red** once it is older than `issue_age_error_days` (default `60`).
868
+
869
+ An `unknown` age is never coloured. Either threshold may be set to `0` to
870
+ switch that level off; with both off the column is left uncoloured.
871
+ `issue_age_error_days` must be **strictly greater** than `issue_age_warn_days`
872
+ (or `0`), otherwise the yellow level could never be reached, so it is rejected
873
+ at load. Both are settable per organisation.
874
+
796
875
  ### Pull Requests
797
876
 
798
877
  The `pull_requests` category counts each repository's **open pull requests**,
@@ -1267,16 +1346,25 @@ size is bounded by *latency*, not by rate-limit cost: 25 repositories measured
1267
1346
  at ~9 s against `lfreleng-actions` and failed intermittently; 10 measured at
1268
1347
  ~3.5 s.
1269
1348
 
1349
+ Up to four of these queries run at once. Each takes a few seconds on GitHub's
1350
+ side, so a prefetch issued one query at a time left most of the client's
1351
+ concurrency idle; against `lfreleng-actions` it took about 50 s of a 100 s run.
1352
+
1270
1353
  `report.graph_batch` (default `10`; minimum `1`) sets the starting batch size,
1271
1354
  globally or per organisation. It is a lever for a slow day rather than a hard
1272
1355
  limit: a batch that still fails on size after the transport's own retries is
1273
- re-issued at **half** the size, and the smaller size is kept for the rest of
1274
- that organisation's collection (each organisation in a multi-org run starts
1275
- from its own configured size, since organisations differ in how much data a
1276
- repository carries). Only a single-repository query GitHub still cannot
1277
- answer, or a failure that a smaller query could not fix (a `403`, an
1278
- exhausted rate limit, or a `500`/`503` outage rather than a `502`/`504`
1279
- timeout), aborts the run with exit code `3`.
1356
+ re-issued at **half** the size, and every batch issued after that failure is
1357
+ seen uses the smaller size for the rest of that organisation's collection
1358
+ (each organisation in a multi-org run starts from its own configured size,
1359
+ since organisations differ in how much data a repository carries). The
1360
+ learned size only shrinks. Because queries overlap, the batches already in
1361
+ flight when the first failure arrives were issued at the old size, so on a
1362
+ slow day up to four oversized batches, rather than one, may fail before the
1363
+ smaller size takes effect; that bounded cost buys the overlap. Only a
1364
+ single-repository query GitHub still cannot answer, or a failure that a
1365
+ smaller query could not fix (a `403`, an exhausted rate limit, or a
1366
+ `500`/`503` outage rather than a `502`/`504` timeout), aborts the run with
1367
+ exit code `3`, and the queries still in flight are cancelled first.
1280
1368
 
1281
1369
  The value can be set three ways, in this order of precedence:
1282
1370
 
@@ -1301,6 +1389,65 @@ a larger value buys nothing except a longer first failure.
1301
1389
  > predating the flag will reject a set one with `No such option`, so set
1302
1390
  > `GSR_GRAPH_BATCH` only once the pinned release supports it.
1303
1391
 
1392
+ ### Client concurrency
1393
+
1394
+ Each organisation's run holds at most `report.concurrency` requests to the
1395
+ GitHub API open at once (default `9`; minimum `1`, maximum `32`), globally or
1396
+ per organisation. Reads from the external Scorecard API queue on a separate
1397
+ limit of four, so a slow third-party endpoint never holds a slot that a
1398
+ GitHub request is waiting for.
1399
+
1400
+ The value can be set three ways, in this order of precedence:
1401
+
1402
+ 1. `--concurrency N` on the command line (the action's `concurrency` input).
1403
+ 2. A **repository or organisation variable** passed to that input. The
1404
+ bundled `reporting.yaml` passes `vars.GSR_CONCURRENCY`, so the value can be
1405
+ adjusted from repository settings without a workflow edit or a release.
1406
+ 3. `report.concurrency` in the configuration; otherwise the built-in `9`.
1407
+
1408
+ `9` is a deliberate halfway point. GitHub documents its secondary rate limits
1409
+ as no more than 100 concurrent requests across REST and GraphQL, and no more
1410
+ than 900 points per minute for REST endpoints; this project has hit those
1411
+ limits before, so the default stays well clear of them. Measured against
1412
+ `lfreleng-actions` (143 repositories, about 1,400 requests; one run each in
1413
+ the same hour, so read ±10–15 % as GitHub latency noise), before the GraphQL
1414
+ prefetch ran concurrently:
1415
+
1416
+ <!-- markdownlint-disable MD013 -->
1417
+
1418
+ | Concurrency | Wall | Throughput | Mean time per request | Average slots busy | GitHub pushback |
1419
+ | ----------- | ---: | ---------: | --------------------: | -----------------: | --------------- |
1420
+ | 6 (former default) | 102.5 s | 13.7 req/s | 0.283 s | 3.9 / 6 | none |
1421
+ | 12 | 75.9 s | 18.5 req/s | 0.286 s | 5.3 / 12 | none |
1422
+
1423
+ <!-- markdownlint-enable MD013 -->
1424
+
1425
+ Raise it once a run shows no pushback; lower it if GitHub starts answering
1426
+ with secondary rate-limit `403`/`429` responses. To measure a change, run the
1427
+ CLI with `--verbose`: when each organisation's collection finishes it logs
1428
+ one line of what the run cost, for example
1429
+
1430
+ <!-- markdownlint-disable MD013 -->
1431
+
1432
+ ```text
1433
+ GitHub API for lfreleng-actions: 1402 requests (0 retries), 822 core points, 64 graphql points, 41.3 MiB received, 102.5 s
1434
+ ```
1435
+
1436
+ <!-- markdownlint-enable MD013 -->
1437
+
1438
+ The request count is every request the tool issued, including retries and
1439
+ attempts that never got an answer. The points come from GitHub's own
1440
+ `x-ratelimit-*` response headers rather than from counting: GitHub meters a
1441
+ token across more than one bucket per resource (alike requests seconds apart
1442
+ can carry different `x-ratelimit-reset` values with independent counters), so
1443
+ each bucket's spread between its lowest and highest reading is summed per
1444
+ resource. The lowest reading already includes that request's own cost, so the
1445
+ figure is short by the first request of each bucket — a measurement aid, not a
1446
+ bill. Nothing of this line reaches the rendered report or the step summary.
1447
+
1448
+ > As with `graph_batch`, an empty input is not passed to the tool, so set
1449
+ > `GSR_CONCURRENCY` only once the pinned release supports `--concurrency`.
1450
+
1304
1451
  ### Pass/fail severity cutoff
1305
1452
 
1306
1453
  The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
@@ -1379,6 +1526,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
1379
1526
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
1380
1527
  | `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 |
1381
1528
  | `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)) |
1529
+ | `concurrency` | No | `""` | Concurrent GitHub API requests per organisation (`1` to `32`; default: config, else `9`). Pass a repository or organisation variable such as `${{ vars.GSR_CONCURRENCY }}` to adjust it from settings (see [Client concurrency](#client-concurrency)) |
1382
1530
  | `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) |
1383
1531
  | `use_local_source` | No | `false` | Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
1384
1532
 
@@ -1,47 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: github-security-report
3
- Version: 0.17.0
4
- Summary: Security and quality reporting across GitHub organisations
5
- Project-URL: Homepage, https://github.com/lfreleng-actions/github-security-report-action
6
- Project-URL: Repository, https://github.com/lfreleng-actions/github-security-report-action
7
- Project-URL: Issues, https://github.com/lfreleng-actions/github-security-report-action/issues
8
- Author-email: The Linux Foundation <mwatkins@linuxfoundation.org>
9
- License-Expression: Apache-2.0
10
- License-File: LICENSE
11
- Keywords: codeql,dependabot,github,github-actions,linux-foundation,reporting,scorecard,secret-scanning,security,zizmor
12
- Classifier: Development Status :: 3 - Alpha
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3 :: Only
18
- Classifier: Programming Language :: Python :: 3.10
19
- Classifier: Programming Language :: Python :: 3.11
20
- Classifier: Programming Language :: Python :: 3.12
21
- Classifier: Programming Language :: Python :: 3.13
22
- Classifier: Programming Language :: Python :: 3.14
23
- Classifier: Topic :: Security
24
- Classifier: Topic :: Software Development :: Quality Assurance
25
- Classifier: Typing :: Typed
26
- Requires-Python: >=3.10
27
- Requires-Dist: httpx[http2]==0.28.1
28
- Requires-Dist: jinja2==3.1.6
29
- Requires-Dist: jsonschema==4.26.0
30
- Requires-Dist: pyyaml==6.0.3
31
- Requires-Dist: rich==15.0.0
32
- Requires-Dist: typer==0.27.2
33
- Provides-Extra: dev
34
- Requires-Dist: mypy==2.3.1; extra == 'dev'
35
- Requires-Dist: pytest-asyncio==1.4.0; extra == 'dev'
36
- Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
37
- Requires-Dist: pytest==9.1.1; extra == 'dev'
38
- Requires-Dist: respx==0.23.1; extra == 'dev'
39
- Requires-Dist: ruff==0.16.8; extra == 'dev'
40
- Requires-Dist: syrupy==6.1.1; extra == 'dev'
41
- Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
42
- Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
43
- Description-Content-Type: text/markdown
44
-
45
1
  <!--
46
2
  # SPDX-License-Identifier: Apache-2.0
47
3
  # SPDX-FileCopyrightText: 2026 The Linux Foundation
@@ -345,7 +301,10 @@ environment-variable name, never embedded.
345
301
  "repo_min_age_days": 28,
346
302
  "release_max_age_days": 60,
347
303
  "codeql_stale_days": 30,
304
+ "issue_age_warn_days": 30,
305
+ "issue_age_error_days": 60,
348
306
  "graph_batch": 10,
307
+ "concurrency": 9,
349
308
  "order": { "style": "auto" }
350
309
  },
351
310
  "organizations": [
@@ -837,6 +796,38 @@ with HTTP 200 and this one field null, so affected repositories are reported as
837
796
  `❓ Unknown` rather than counted as having no open issues — an unreadable backlog
838
797
  is never presented as a clean one.
839
798
 
799
+ #### Issue colours and age thresholds
800
+
801
+ On the terminal the counts are coloured so the table reads at a glance: `Bug`
802
+ and `Untriaged` **red**, `Docs` **green**. As in the Pull Requests table, only
803
+ **non-zero** counts are coloured, so the eye lands on the rows with something
804
+ in them. The colours follow the column **name** (case-insensitively), so they
805
+ apply to a custom `issue_labels` that keeps a `Bug` or `Docs` column and simply
806
+ do not appear for one that renames them.
807
+
808
+ `Oldest` is coloured against two age thresholds, in days:
809
+
810
+ ```json
811
+ {
812
+ "report": {
813
+ "issue_age_warn_days": 30,
814
+ "issue_age_error_days": 60
815
+ },
816
+ "organizations": [{ "name": "lfreleng-actions" }]
817
+ }
818
+ ```
819
+
820
+ - **Green** while the oldest open issue is at most `issue_age_warn_days`
821
+ (default `30`) old.
822
+ - **Yellow** once it is older than `issue_age_warn_days`.
823
+ - **Red** once it is older than `issue_age_error_days` (default `60`).
824
+
825
+ An `unknown` age is never coloured. Either threshold may be set to `0` to
826
+ switch that level off; with both off the column is left uncoloured.
827
+ `issue_age_error_days` must be **strictly greater** than `issue_age_warn_days`
828
+ (or `0`), otherwise the yellow level could never be reached, so it is rejected
829
+ at load. Both are settable per organisation.
830
+
840
831
  ### Pull Requests
841
832
 
842
833
  The `pull_requests` category counts each repository's **open pull requests**,
@@ -1311,16 +1302,25 @@ size is bounded by *latency*, not by rate-limit cost: 25 repositories measured
1311
1302
  at ~9 s against `lfreleng-actions` and failed intermittently; 10 measured at
1312
1303
  ~3.5 s.
1313
1304
 
1305
+ Up to four of these queries run at once. Each takes a few seconds on GitHub's
1306
+ side, so a prefetch issued one query at a time left most of the client's
1307
+ concurrency idle; against `lfreleng-actions` it took about 50 s of a 100 s run.
1308
+
1314
1309
  `report.graph_batch` (default `10`; minimum `1`) sets the starting batch size,
1315
1310
  globally or per organisation. It is a lever for a slow day rather than a hard
1316
1311
  limit: a batch that still fails on size after the transport's own retries is
1317
- re-issued at **half** the size, and the smaller size is kept for the rest of
1318
- that organisation's collection (each organisation in a multi-org run starts
1319
- from its own configured size, since organisations differ in how much data a
1320
- repository carries). Only a single-repository query GitHub still cannot
1321
- answer, or a failure that a smaller query could not fix (a `403`, an
1322
- exhausted rate limit, or a `500`/`503` outage rather than a `502`/`504`
1323
- timeout), aborts the run with exit code `3`.
1312
+ re-issued at **half** the size, and every batch issued after that failure is
1313
+ seen uses the smaller size for the rest of that organisation's collection
1314
+ (each organisation in a multi-org run starts from its own configured size,
1315
+ since organisations differ in how much data a repository carries). The
1316
+ learned size only shrinks. Because queries overlap, the batches already in
1317
+ flight when the first failure arrives were issued at the old size, so on a
1318
+ slow day up to four oversized batches, rather than one, may fail before the
1319
+ smaller size takes effect; that bounded cost buys the overlap. Only a
1320
+ single-repository query GitHub still cannot answer, or a failure that a
1321
+ smaller query could not fix (a `403`, an exhausted rate limit, or a
1322
+ `500`/`503` outage rather than a `502`/`504` timeout), aborts the run with
1323
+ exit code `3`, and the queries still in flight are cancelled first.
1324
1324
 
1325
1325
  The value can be set three ways, in this order of precedence:
1326
1326
 
@@ -1345,6 +1345,65 @@ a larger value buys nothing except a longer first failure.
1345
1345
  > predating the flag will reject a set one with `No such option`, so set
1346
1346
  > `GSR_GRAPH_BATCH` only once the pinned release supports it.
1347
1347
 
1348
+ ### Client concurrency
1349
+
1350
+ Each organisation's run holds at most `report.concurrency` requests to the
1351
+ GitHub API open at once (default `9`; minimum `1`, maximum `32`), globally or
1352
+ per organisation. Reads from the external Scorecard API queue on a separate
1353
+ limit of four, so a slow third-party endpoint never holds a slot that a
1354
+ GitHub request is waiting for.
1355
+
1356
+ The value can be set three ways, in this order of precedence:
1357
+
1358
+ 1. `--concurrency N` on the command line (the action's `concurrency` input).
1359
+ 2. A **repository or organisation variable** passed to that input. The
1360
+ bundled `reporting.yaml` passes `vars.GSR_CONCURRENCY`, so the value can be
1361
+ adjusted from repository settings without a workflow edit or a release.
1362
+ 3. `report.concurrency` in the configuration; otherwise the built-in `9`.
1363
+
1364
+ `9` is a deliberate halfway point. GitHub documents its secondary rate limits
1365
+ as no more than 100 concurrent requests across REST and GraphQL, and no more
1366
+ than 900 points per minute for REST endpoints; this project has hit those
1367
+ limits before, so the default stays well clear of them. Measured against
1368
+ `lfreleng-actions` (143 repositories, about 1,400 requests; one run each in
1369
+ the same hour, so read ±10–15 % as GitHub latency noise), before the GraphQL
1370
+ prefetch ran concurrently:
1371
+
1372
+ <!-- markdownlint-disable MD013 -->
1373
+
1374
+ | Concurrency | Wall | Throughput | Mean time per request | Average slots busy | GitHub pushback |
1375
+ | ----------- | ---: | ---------: | --------------------: | -----------------: | --------------- |
1376
+ | 6 (former default) | 102.5 s | 13.7 req/s | 0.283 s | 3.9 / 6 | none |
1377
+ | 12 | 75.9 s | 18.5 req/s | 0.286 s | 5.3 / 12 | none |
1378
+
1379
+ <!-- markdownlint-enable MD013 -->
1380
+
1381
+ Raise it once a run shows no pushback; lower it if GitHub starts answering
1382
+ with secondary rate-limit `403`/`429` responses. To measure a change, run the
1383
+ CLI with `--verbose`: when each organisation's collection finishes it logs
1384
+ one line of what the run cost, for example
1385
+
1386
+ <!-- markdownlint-disable MD013 -->
1387
+
1388
+ ```text
1389
+ GitHub API for lfreleng-actions: 1402 requests (0 retries), 822 core points, 64 graphql points, 41.3 MiB received, 102.5 s
1390
+ ```
1391
+
1392
+ <!-- markdownlint-enable MD013 -->
1393
+
1394
+ The request count is every request the tool issued, including retries and
1395
+ attempts that never got an answer. The points come from GitHub's own
1396
+ `x-ratelimit-*` response headers rather than from counting: GitHub meters a
1397
+ token across more than one bucket per resource (alike requests seconds apart
1398
+ can carry different `x-ratelimit-reset` values with independent counters), so
1399
+ each bucket's spread between its lowest and highest reading is summed per
1400
+ resource. The lowest reading already includes that request's own cost, so the
1401
+ figure is short by the first request of each bucket — a measurement aid, not a
1402
+ bill. Nothing of this line reaches the rendered report or the step summary.
1403
+
1404
+ > As with `graph_batch`, an empty input is not passed to the tool, so set
1405
+ > `GSR_CONCURRENCY` only once the pinned release supports `--concurrency`.
1406
+
1348
1407
  ### Pass/fail severity cutoff
1349
1408
 
1350
1409
  The severity-ranked signals (CodeQL, Scorecard, Zizmor, aislop, Dependabot
@@ -1423,6 +1482,7 @@ and the Slack **bot token** is consumed by the workflow, not the CLI.
1423
1482
  | `force_notify` | No | `false` | Post to Slack regardless of `report_day` |
1424
1483
  | `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 |
1425
1484
  | `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)) |
1485
+ | `concurrency` | No | `""` | Concurrent GitHub API requests per organisation (`1` to `32`; default: config, else `9`). Pass a repository or organisation variable such as `${{ vars.GSR_CONCURRENCY }}` to adjust it from settings (see [Client concurrency](#client-concurrency)) |
1426
1486
  | `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) |
1427
1487
  | `use_local_source` | No | `false` | Run from the checked-out source instead of PyPI (for testing unreleased code from any event) |
1428
1488
 
@@ -45,7 +45,7 @@ keywords = [
45
45
  ]
46
46
  dependencies = [
47
47
  "httpx[http2]==0.28.1",
48
- "typer==0.27.2",
48
+ "typer==0.27.3",
49
49
  "rich==15.0.0",
50
50
  "jinja2==3.1.6",
51
51
  "jsonschema==4.26.0",
@@ -64,9 +64,9 @@ dev = [
64
64
  "pytest-cov==7.1.0",
65
65
  "respx==0.23.1",
66
66
  "syrupy==6.1.1",
67
- "mypy==2.3.1",
68
- "ruff==0.16.8",
69
- "types-jsonschema==4.26.0.20260518",
67
+ "mypy==2.4.0",
68
+ "ruff==0.17.0",
69
+ "types-jsonschema==4.26.0.20261006",
70
70
  "types-PyYAML==6.0.12.20250915",
71
71
  ]
72
72
 
@@ -96,9 +96,9 @@ dev = [
96
96
  "pytest-cov==7.1.0",
97
97
  "respx==0.23.1",
98
98
  "syrupy==6.1.1",
99
- "mypy==2.3.1",
100
- "ruff==0.16.8",
101
- "types-jsonschema==4.26.0.20260518",
99
+ "mypy==2.4.0",
100
+ "ruff==0.17.0",
101
+ "types-jsonschema==4.26.0.20261006",
102
102
  "types-PyYAML==6.0.12.20250915",
103
103
  ]
104
104
 
@@ -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.17.0'
22
- __version_tuple__ = version_tuple = (0, 17, 0)
21
+ __version__ = version = '0.18.1'
22
+ __version_tuple__ = version_tuple = (0, 18, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -27,6 +27,7 @@ from github_security_report.cli.modes import (
27
27
  )
28
28
  from github_security_report.cli.options import OrgRunOptions, ReportOverrides
29
29
  from github_security_report.cli.outputs import TopNLimits
30
+ from github_security_report.config import CONCURRENCY_MAX
30
31
  from github_security_report.scope import RepoSelectionError, parse_repo_selection
31
32
 
32
33
  app = typer.Typer(
@@ -172,6 +173,11 @@ def report(
172
173
  "--graph-batch",
173
174
  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.",
174
175
  ),
176
+ concurrency: int | None = typer.Option(
177
+ None,
178
+ "--concurrency",
179
+ help=f"Concurrent GitHub API requests per organisation (1-{CONCURRENCY_MAX}; default: config, else 9). Raise it to shorten a run, lower it if GitHub's secondary rate limits push back.",
180
+ ),
175
181
  no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
176
182
  ) -> None:
177
183
  """Generate a security and quality report."""
@@ -188,6 +194,9 @@ def report(
188
194
  boundary.check_non_negative(console, "--repo-min-age-days", repo_min_age_days)
189
195
  boundary.check_non_negative(console, "--release-max-age-days", release_max_age_days)
190
196
  boundary.check_positive(console, "--graph-batch", graph_batch)
197
+ boundary.check_positive(
198
+ console, "--concurrency", concurrency, maximum=CONCURRENCY_MAX
199
+ )
191
200
  hidden = boundary.resolve_hidden(console, hide)
192
201
 
193
202
  cfg = _load_config(config_file, config_data, org, token_env, console=console)
@@ -218,6 +227,7 @@ def report(
218
227
  include_archived=True if include_archived else None,
219
228
  include_test=True if include_test else None,
220
229
  graph_batch=graph_batch,
230
+ concurrency=concurrency,
221
231
  ),
222
232
  hidden=hidden,
223
233
  )
@@ -245,6 +255,7 @@ def report(
245
255
  ("--include-archived", include_archived),
246
256
  ("--include-test", include_test),
247
257
  ("--graph-batch", graph_batch is not None),
258
+ ("--concurrency", concurrency is not None),
248
259
  )
249
260
  if supplied
250
261
  ],
@@ -41,16 +41,22 @@ 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:
44
+ def check_positive(
45
+ console: Console, name: str, value: int | None, *, maximum: int | None = None
46
+ ) -> None:
45
47
  """Reject a zero or negative override where 0 has no meaning.
46
48
 
47
49
  For a batch size, unlike a row limit, there is no "unlimited" reading of
48
50
  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.
51
+ matching the config schema's minimum for the same control. ``maximum``,
52
+ when given, mirrors the schema's ceiling for the same control.
50
53
  """
51
54
  if value is not None and value < 1:
52
55
  console.print(f"[red]{name} must be 1 or greater[/red]")
53
56
  raise typer.Exit(2)
57
+ if value is not None and maximum is not None and value > maximum:
58
+ console.print(f"[red]{name} must be {maximum} or less[/red]")
59
+ raise typer.Exit(2)
54
60
 
55
61
 
56
62
  def check_limits(console: Console, limits: Sequence[tuple[str, int | None]]) -> None:
@@ -125,7 +125,16 @@ async def _collect_reports(
125
125
  now: dt.datetime,
126
126
  overrides: ReportOverrides,
127
127
  ) -> list[OrgPair] | None:
128
- """Collect every configured org's report, or None if a token is missing."""
128
+ """Collect every configured org's report, or None if a token is missing.
129
+
130
+ Once each organisation's client closes, what its requests cost is logged
131
+ at INFO: visible with ``--verbose``, which is how a run is measured, and
132
+ silent otherwise. Nothing of it reaches the rendered report. It is logged
133
+ whether or not the collection finished: a run that aborts on a rate limit,
134
+ an outage or a rejected token is exactly the run whose request, retry and
135
+ timing figures are wanted, so the exit path logs them before the error
136
+ propagates.
137
+ """
129
138
  pairs: list[OrgPair] = []
130
139
  for org_cfg in cfg.organizations:
131
140
  token = config.resolve_token(org_cfg)
@@ -135,15 +144,21 @@ async def _collect_reports(
135
144
  )
136
145
  return None
137
146
  effective_cfg, report_cfg = overrides.apply(org_cfg)
138
- async with GitHubClient(token) as client:
139
- pairs.append(
140
- (
141
- org_cfg,
142
- await collect.collect_org(
143
- client, effective_cfg, report_cfg, generated_at=now
144
- ),
147
+ client = GitHubClient(token, concurrency=report_cfg.concurrency)
148
+ try:
149
+ async with client:
150
+ pairs.append(
151
+ (
152
+ org_cfg,
153
+ await collect.collect_org(
154
+ client, effective_cfg, report_cfg, generated_at=now
155
+ ),
156
+ )
145
157
  )
146
- )
158
+ finally:
159
+ # After the context has closed the client, so the elapsed time
160
+ # covers the whole run; the error, if any, carries on from here.
161
+ log.info("GitHub API for %s: %s", org_cfg.name, client.stats.summary())
147
162
  return pairs
148
163
 
149
164
 
@@ -204,7 +219,7 @@ async def _run_repo(
204
219
  if not token:
205
220
  console.print(f"[red]No token in ${token_env}[/red]")
206
221
  return 2
207
- async with GitHubClient(token) as client:
222
+ async with GitHubClient(token, concurrency=cfg.concurrency) as client:
208
223
  repo, signals = await collect.collect_repo(
209
224
  client, owner, repo_name, ruleset_workflows=cfg.ruleset_workflows
210
225
  )
@@ -35,9 +35,9 @@ class ReportOverrides:
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
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.
38
+ ``graph_batch`` and ``concurrency`` are operational levers rather than
39
+ report policy: they change how the requests are issued, never what the
40
+ report says, so they may move in either direction.
41
41
  """
42
42
 
43
43
  repo_min_age_days: int | None = None
@@ -47,14 +47,15 @@ class ReportOverrides:
47
47
  include_archived: bool | None = None
48
48
  include_test: bool | None = None
49
49
  graph_batch: int | None = None
50
+ concurrency: int | None = None
50
51
 
51
52
  def apply(self, org_cfg: OrgConfig) -> tuple[OrgConfig, ReportConfig]:
52
53
  """The org and report configs to collect with, overrides applied.
53
54
 
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.
55
+ The two age thresholds, the three booleans, the batch size and the
56
+ concurrency are scalars, so applying one uniformly across every
57
+ configured organisation is what a reader of the flag expects, and
58
+ matches how ``--top-n`` already behaves.
58
59
 
59
60
  ``releases_exclude`` is not scalar: it is a curated per-organisation
60
61
  list, and one flag replacing all of them loses data the config
@@ -70,6 +71,7 @@ class ReportOverrides:
70
71
  "include_archived",
71
72
  "include_test",
72
73
  "graph_batch",
74
+ "concurrency",
73
75
  ):
74
76
  value = getattr(self, name)
75
77
  if value is not None: