github-security-report 0.8.0__tar.gz → 0.10.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 (33) hide show
  1. {github_security_report-0.8.0 → github_security_report-0.10.0}/PKG-INFO +3 -3
  2. {github_security_report-0.8.0 → github_security_report-0.10.0}/pyproject.toml +7 -3
  3. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/_version.py +2 -2
  4. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/categories.py +17 -8
  5. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/models.py +65 -14
  6. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/severity.py +26 -9
  7. {github_security_report-0.8.0 → github_security_report-0.10.0}/.gitignore +0 -0
  8. {github_security_report-0.8.0 → github_security_report-0.10.0}/LICENSE +0 -0
  9. {github_security_report-0.8.0 → github_security_report-0.10.0}/LICENSES/Apache-2.0.txt +0 -0
  10. {github_security_report-0.8.0 → github_security_report-0.10.0}/README.md +0 -0
  11. {github_security_report-0.8.0 → github_security_report-0.10.0}/scripts/README.md +0 -0
  12. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/__init__.py +0 -0
  13. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/classify.py +0 -0
  14. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/cli.py +0 -0
  15. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/client.py +0 -0
  16. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/collect.py +0 -0
  17. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/config.py +0 -0
  18. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/gating.py +0 -0
  19. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/gitctx.py +0 -0
  20. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/posture.py +0 -0
  21. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/py.typed +0 -0
  22. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/remediate.py +0 -0
  23. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/render/__init__.py +0 -0
  24. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/render/html.py +0 -0
  25. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/render/markdown.py +0 -0
  26. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/render/slack.py +0 -0
  27. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/render/terminal.py +0 -0
  28. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/report.py +0 -0
  29. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/rulesets.py +0 -0
  30. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/runner.py +0 -0
  31. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/scope.py +0 -0
  32. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/templates/index.html.j2 +0 -0
  33. {github_security_report-0.8.0 → github_security_report-0.10.0}/src/github_security_report/templates/report.html.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-security-report
3
- Version: 0.8.0
3
+ Version: 0.10.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
@@ -29,14 +29,14 @@ 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.26.8
32
+ Requires-Dist: typer==0.27.0
33
33
  Provides-Extra: dev
34
34
  Requires-Dist: mypy==2.3.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
37
  Requires-Dist: pytest==9.1.1; extra == 'dev'
38
38
  Requires-Dist: respx==0.23.1; extra == 'dev'
39
- Requires-Dist: ruff==0.15.21; extra == 'dev'
39
+ Requires-Dist: ruff==0.15.22; extra == 'dev'
40
40
  Requires-Dist: syrupy==5.5.3; extra == 'dev'
41
41
  Requires-Dist: types-jsonschema==4.26.0.20260518; extra == 'dev'
42
42
  Requires-Dist: types-pyyaml==6.0.12.20250915; extra == 'dev'
@@ -45,7 +45,7 @@ keywords = [
45
45
  ]
46
46
  dependencies = [
47
47
  "httpx[http2]==0.28.1",
48
- "typer==0.26.8",
48
+ "typer==0.27.0",
49
49
  "rich==15.0.0",
50
50
  "jinja2==3.1.6",
51
51
  "jsonschema==4.26.0",
@@ -65,7 +65,7 @@ dev = [
65
65
  "respx==0.23.1",
66
66
  "syrupy==5.5.3",
67
67
  "mypy==2.3.0",
68
- "ruff==0.15.21",
68
+ "ruff==0.15.22",
69
69
  "types-jsonschema==4.26.0.20260518",
70
70
  "types-PyYAML==6.0.12.20250915",
71
71
  ]
@@ -97,7 +97,7 @@ dev = [
97
97
  "respx==0.23.1",
98
98
  "syrupy==5.5.3",
99
99
  "mypy==2.3.0",
100
- "ruff==0.15.21",
100
+ "ruff==0.15.22",
101
101
  "types-jsonschema==4.26.0.20260518",
102
102
  "types-PyYAML==6.0.12.20250915",
103
103
  ]
@@ -136,6 +136,10 @@ skip_empty = true
136
136
 
137
137
  [tool.mypy]
138
138
  python_version = "3.10"
139
+ # The whole first-party tree, so a bare 'mypy' checks what the pre-commit hook
140
+ # checks. Tests are deliberately included: they are held to the same standard
141
+ # as the package.
142
+ files = ["src", "tests", "scripts"]
139
143
  warn_return_any = true
140
144
  warn_unused_configs = true
141
145
  disallow_untyped_defs = false
@@ -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.8.0'
22
- __version_tuple__ = version_tuple = (0, 8, 0)
21
+ __version__ = version = '0.10.0'
22
+ __version_tuple__ = version_tuple = (0, 10, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -67,9 +67,9 @@ class CategoryMeta:
67
67
  # A repository fails (appears as an offender) only when it carries a finding
68
68
  # at or above this rung; findings below it fold into the clean count. The
69
69
  # global default is MEDIUM, so Low and Informational findings pass; a
70
- # category may lower it (Zizmor uses LOW, so only Informational passes).
71
- # Meaningful only for the severity-ranked signals; binary categories ignore
72
- # it. Overridable per category via the JSON config.
70
+ # category may lower it (Zizmor uses INFORMATIONAL, so every finding
71
+ # counts). Meaningful only for the severity-ranked signals; binary
72
+ # categories ignore it. Overridable per category via the JSON config.
73
73
  fail_severity: Severity = Severity.MEDIUM
74
74
 
75
75
 
@@ -93,7 +93,8 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
93
93
  url="https://github.com/ossf/scorecard",
94
94
  description=(
95
95
  "OpenSSF Scorecard supply-chain health scores (a lower score is "
96
- "weaker), ranked weakest-first."
96
+ "weaker). Ranked by the worst severity rung present in the table "
97
+ "(most findings at that rung first), then weakest score first."
97
98
  ),
98
99
  ),
99
100
  CategoryKey.ZIZMOR: CategoryMeta(
@@ -106,10 +107,18 @@ _CATEGORIES: dict[CategoryKey, CategoryMeta] = {
106
107
  "Zizmor static analysis of GitHub Actions workflows, ranked "
107
108
  "worst-first by severity."
108
109
  ),
109
- # zizmor emits its Low findings at SARIF level "note", which
110
- # normalises to LOW (see severity.py), so any zizmor finding fails --
111
- # matching the ruleset-enforced PR gate that blocks on note-and-above.
112
- fail_severity=Severity.LOW,
110
+ # The organisation scan pipeline runs zizmor with an
111
+ # 'informational' floor, so every finding it can report reaches the
112
+ # SARIF. Match that here: any zizmor finding counts, at any
113
+ # severity. This mirrors the ruleset-enforced PR gate, which blocks
114
+ # on any finding regardless of level.
115
+ #
116
+ # zizmor emits both Low and Informational findings at SARIF level
117
+ # "note", and the code-scanning alerts API exposes only that level
118
+ # (not zizmor's own severity property), so the two are
119
+ # indistinguishable here. Cutting at INFORMATIONAL sidesteps the
120
+ # ambiguity: both surface either way.
121
+ fail_severity=Severity.INFORMATIONAL,
113
122
  ),
114
123
  CategoryKey.AISLOP: CategoryMeta(
115
124
  key=CategoryKey.AISLOP,
@@ -5,8 +5,8 @@
5
5
  Encodes the Phase 0 design (see ``docs/BRIEF.md`` and
6
6
  ``docs/phase0-findings.md``): the six ranked signals, the four-state per-report
7
7
  classification, severity counts with hierarchical worst-first ordering, and the
8
- ranking rules (alert tables sort by severity descending; Scorecard by score
9
- ascending).
8
+ ranking rules (alert tables sort by severity descending; Scorecard by its worst
9
+ populated severity rung descending, then by score ascending).
10
10
  """
11
11
 
12
12
  from __future__ import annotations
@@ -16,7 +16,7 @@ from dataclasses import dataclass, field
16
16
  from enum import Enum
17
17
 
18
18
  from github_security_report.categories import CategoryKey, CategoryMeta, category_meta
19
- from github_security_report.severity import Severity
19
+ from github_security_report.severity import RUNGS_WORST_FIRST, Severity
20
20
 
21
21
 
22
22
  class SignalType(str, Enum):
@@ -66,7 +66,13 @@ class SignalType(str, Enum):
66
66
 
67
67
  @property
68
68
  def sort_ascending(self) -> bool:
69
- """Scorecard ranks by score ascending (lower == worse); others descend."""
69
+ """Whether lower is worse for this signal's primary metric.
70
+
71
+ True only for Scorecard, whose aggregate score runs the opposite way to
72
+ a finding count (lower == weaker). The score is Scorecard's *secondary*
73
+ key: ``rank_offenders`` leads on the worst severity rung present in the
74
+ table and uses the score to order repositories within that rung.
75
+ """
70
76
  return self is SignalType.SCORECARD
71
77
 
72
78
 
@@ -177,6 +183,22 @@ class SeverityCounts:
177
183
  def total(self) -> int:
178
184
  return self.critical + self.high + self.medium + self.low + self.informational
179
185
 
186
+ def at(self, rung: Severity) -> int:
187
+ """The count at exactly one severity rung.
188
+
189
+ Attribute access rather than a ``by_rung`` lookup, so the ranking hot
190
+ path does not build a dict per comparison.
191
+ """
192
+ if rung is Severity.CRITICAL:
193
+ return self.critical
194
+ if rung is Severity.HIGH:
195
+ return self.high
196
+ if rung is Severity.MEDIUM:
197
+ return self.medium
198
+ if rung is Severity.LOW:
199
+ return self.low
200
+ return self.informational
201
+
180
202
  def at_or_above(self, cutoff: Severity) -> int:
181
203
  """Count of findings whose severity is at least ``cutoff``.
182
204
 
@@ -185,14 +207,12 @@ class SeverityCounts:
185
207
  category's ``fail_severity`` cutoff. Findings below the cutoff (e.g.
186
208
  informational-only) do not count towards a failure.
187
209
  """
188
- by_rung = {
189
- Severity.CRITICAL: self.critical,
190
- Severity.HIGH: self.high,
191
- Severity.MEDIUM: self.medium,
192
- Severity.LOW: self.low,
193
- Severity.INFORMATIONAL: self.informational,
194
- }
195
- return sum(count for rung, count in by_rung.items() if rung >= cutoff)
210
+ return sum(count for rung, count in self.by_rung.items() if rung >= cutoff)
211
+
212
+ @property
213
+ def by_rung(self) -> dict[Severity, int]:
214
+ """Per-severity counts keyed by rung, in worst-first iteration order."""
215
+ return {rung: self.at(rung) for rung in RUNGS_WORST_FIRST}
196
216
 
197
217
  @property
198
218
  def weighted(self) -> int:
@@ -236,12 +256,41 @@ class RepoSignal:
236
256
  return self.state is RepoState.OFFENDER
237
257
 
238
258
 
259
+ # The rungs eligible to lead the Scorecard ordering, worst-first.
260
+ # ``INFORMATIONAL`` is deliberately absent: it is the non-actionable rung, so it
261
+ # never displaces the score as the primary key.
262
+ LEAD_RUNGS: tuple[Severity, ...] = tuple(
263
+ rung for rung in RUNGS_WORST_FIRST if rung is not Severity.INFORMATIONAL
264
+ )
265
+
266
+
267
+ def lead_rung(offenders: list[RepoSignal]) -> Severity | None:
268
+ """The worst severity rung any offender actually carries.
269
+
270
+ Returns ``None`` when no offender carries a finding at Low or above, in
271
+ which case there is no severity tier worth leading on.
272
+ """
273
+ return next(
274
+ (rung for rung in LEAD_RUNGS if any(s.counts.at(rung) for s in offenders)),
275
+ None,
276
+ )
277
+
278
+
239
279
  def rank_offenders(signals: list[RepoSignal]) -> list[RepoSignal]:
240
280
  """Sort offenders worst-first for a single signal.
241
281
 
242
282
  Alert-based signals sort by the hierarchical severity key descending, with
243
- total as a tiebreaker. Scorecard sorts by aggregate score ascending (lower
244
- == worse). Repo name breaks remaining ties, ascending.
283
+ total as a tiebreaker.
284
+
285
+ Scorecard sorts on two tiers: the count at the worst severity rung present
286
+ anywhere in the table (descending), then the aggregate score (ascending,
287
+ lower == worse). The leading rung cascades -- Critical, else High, else
288
+ Medium, else Low -- so the rung that actually discriminates between
289
+ repositories leads, and a lone Critical can never be buried mid-table by a
290
+ weaker repository with a lower score. When no offender carries a finding at
291
+ Low or above, the score alone orders the table.
292
+
293
+ Repo name breaks remaining ties, ascending.
245
294
 
246
295
  Numeric components are negated so the whole sort runs ascending (no
247
296
  ``reverse=True``); that keeps the name tiebreaker correctly ascending even
@@ -252,9 +301,11 @@ def rank_offenders(signals: list[RepoSignal]) -> list[RepoSignal]:
252
301
  return []
253
302
  signal = offenders[0].signal
254
303
  if signal.sort_ascending:
304
+ rung = lead_rung(offenders)
255
305
  return sorted(
256
306
  offenders,
257
307
  key=lambda s: (
308
+ -s.counts.at(rung) if rung is not None else 0,
258
309
  s.score if s.score is not None else float("inf"),
259
310
  s.repo.name,
260
311
  ),
@@ -19,10 +19,13 @@ directly.
19
19
  The ``note`` mapping mirrors zizmor's own SARIF encoder, which emits both its
20
20
  Low and Informational findings at SARIF level ``note`` (Medium -> ``warning``,
21
21
  High -> ``error``). The organisation scan pipeline runs zizmor with
22
- ``--min-severity low``, so informational findings never reach the uploaded
23
- SARIF: every ``note`` alert in code scanning is a genuine Low finding, and the
24
- ruleset-enforced PR gate blocks on it. Mapping ``note`` below LOW would
25
- (and previously did) under-state the estate's posture relative to that gate.
22
+ ``--min-severity informational``, so both tiers reach the uploaded SARIF and a
23
+ ``note`` alert may be either. The code-scanning alerts API exposes only the
24
+ SARIF level -- not the ``zizmor/severity`` property carried in the raw SARIF --
25
+ so the two cannot be separated here. ``note`` therefore stays at LOW rather
26
+ than INFORMATIONAL, which errs towards over- rather than under-stating the
27
+ estate's posture; the zizmor category cuts at INFORMATIONAL (see
28
+ ``categories.py``) so both tiers are reported regardless of where this maps.
26
29
  """
27
30
 
28
31
  from __future__ import annotations
@@ -49,6 +52,18 @@ class Severity(IntEnum):
49
52
  return self.name.lower()
50
53
 
51
54
 
55
+ # The canonical worst-first rung order, for the severity columns and for every
56
+ # worst-first ranking. Declared once so display order and ranking order cannot
57
+ # drift apart.
58
+ RUNGS_WORST_FIRST: tuple[Severity, ...] = (
59
+ Severity.CRITICAL,
60
+ Severity.HIGH,
61
+ Severity.MEDIUM,
62
+ Severity.LOW,
63
+ Severity.INFORMATIONAL,
64
+ )
65
+
66
+
52
67
  # Direct names on the security-severity scale (CodeQL, Scorecard, Dependabot).
53
68
  _SECURITY_NAMES: dict[str, Severity] = {
54
69
  "critical": Severity.CRITICAL,
@@ -59,11 +74,13 @@ _SECURITY_NAMES: dict[str, Severity] = {
59
74
  }
60
75
 
61
76
  # SARIF level -> security scale, used only as a fallback (zizmor, aislop).
62
- # zizmor's SARIF encoder emits Low AND Informational findings as ``note``, but
63
- # the scan pipeline's --min-severity low floor keeps informational findings out
64
- # of the SARIF entirely, so a ``note`` alert is a genuine Low finding (matching
65
- # the ruleset-enforced PR gate, which blocks on note-and-above). aislop uses
66
- # the same three levels. The rare ``none`` level stays at INFORMATIONAL.
77
+ # zizmor's SARIF encoder emits Low AND Informational findings as ``note``, and
78
+ # the scan pipeline's --min-severity informational floor lets both through, so
79
+ # a ``note`` alert may be either tier. The alerts API does not expose zizmor's
80
+ # own severity property, so they are indistinguishable here; ``note`` maps to
81
+ # LOW to avoid under-stating, and the zizmor category's INFORMATIONAL cutoff
82
+ # ensures both are reported. aislop uses the same three levels. The rare
83
+ # ``none`` level stays at INFORMATIONAL.
67
84
  _SARIF_LEVEL_NAMES: dict[str, Severity] = {
68
85
  "error": Severity.HIGH,
69
86
  "warning": Severity.MEDIUM,