github-security-report 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,13 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Security and quality reporting across GitHub organisations."""
4
+
5
+ from __future__ import annotations
6
+
7
+ try: # pragma: no cover - resolved at build time by hatch-vcs
8
+ from github_security_report._version import __version__
9
+ except ImportError: # pragma: no cover - editable/source checkout without build
10
+ __version__ = "0.0.0+unknown"
11
+
12
+
13
+ __all__ = ["__version__"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,208 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+ """Classification: raw API facts -> four-state RepoSignal results.
4
+
5
+ Pure, transport-free logic encoding every Phase 0 finding
6
+ (``docs/phase0-findings.md``):
7
+
8
+ - the single code-scanning feed is partitioned by ``tool.name`` into CodeQL,
9
+ Scorecard and zizmor -- counts are filtered per tool;
10
+ - CodeQL/Scorecard/zizmor enablement is the presence of that tool in
11
+ ``code-scanning/analyses`` (not ``default-setup``); a 404 on code scanning
12
+ means it is disabled entirely;
13
+ - secret scanning 404 = disabled, 200 [] = enabled-clean;
14
+ - Dependabot ``hasVulnerabilityAlertsEnabled == false`` = disabled;
15
+ - Scorecard prefers the external aggregate score, else code-scanning findings;
16
+ - 403 / indeterminate -> the unknown bucket, never clean or nag.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ from github_security_report import severity
24
+ from github_security_report.models import (
25
+ Repo,
26
+ RepoSignal,
27
+ RepoState,
28
+ SeverityCounts,
29
+ SignalType,
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class RepoFacts:
35
+ """Raw per-repository facts gathered by the client, pre-classification."""
36
+
37
+ repo: Repo
38
+ # Code scanning (covers CodeQL, Scorecard, zizmor).
39
+ code_scanning_status: int = 200 # 200 ok, 404 disabled, 403 forbidden
40
+ code_scanning_tools: set[str] = field(default_factory=set) # analyses tool names
41
+ code_scanning_alerts: list[dict] = field(default_factory=list) # all tools
42
+ code_scanning_alerts_status: int = 200 # status of the alert read (200 ok)
43
+ # Secret scanning.
44
+ secret_scanning_status: int = 200 # 200 enabled, 404 disabled, 403 forbidden
45
+ secret_scanning_open: int = 0
46
+ secret_scanning_open_status: int = 200 # status of the open-count read
47
+ # Dependabot.
48
+ dependabot_enabled: bool | None = None # None = indeterminate
49
+ dependabot_alerts: list[dict] = field(default_factory=list)
50
+ dependabot_alerts_status: int = 200 # status of the alert read
51
+ # Scorecard (external API).
52
+ scorecard_status: int = 404 # 200 has score, 404 none, 403 forbidden
53
+ scorecard_score: float | None = None
54
+ # Signals (by value) enforced for this repo via an org/branch ruleset
55
+ # (e.g. zizmor required by a central workflow), even with no per-repo file.
56
+ ruleset_signals: set[str] = field(default_factory=set)
57
+
58
+
59
+ # --------------------------------------------------------------------------- #
60
+ # Counting helpers
61
+ # --------------------------------------------------------------------------- #
62
+ def count_code_scanning(alerts: list[dict], tool_name: str) -> SeverityCounts:
63
+ """Sum open code-scanning alerts for a single tool, by severity."""
64
+ counts = SeverityCounts()
65
+ for alert in alerts:
66
+ if (alert.get("tool") or {}).get("name") != tool_name:
67
+ continue
68
+ rule = alert.get("rule") or {}
69
+ sev = severity.from_code_scanning(
70
+ rule.get("security_severity_level"), rule.get("severity")
71
+ )
72
+ counts.add(sev)
73
+ return counts
74
+
75
+
76
+ def count_dependabot(alerts: list[dict]) -> SeverityCounts:
77
+ """Sum open Dependabot alerts by advisory severity."""
78
+ counts = SeverityCounts()
79
+ for alert in alerts:
80
+ advisory = alert.get("security_advisory") or {}
81
+ vuln = alert.get("security_vulnerability") or {}
82
+ sev = severity.from_name(advisory.get("severity") or vuln.get("severity"))
83
+ counts.add(sev or severity.Severity.LOW)
84
+ return counts
85
+
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # Per-signal classification
89
+ # --------------------------------------------------------------------------- #
90
+ def _code_scanning_tool_signal(
91
+ facts: RepoFacts, signal: SignalType, tool_name: str
92
+ ) -> RepoSignal:
93
+ """Shared four-state logic for the code-scanning-derived signals.
94
+
95
+ A tool is enabled when its analyses are present, OR when an org/branch
96
+ ruleset enforces it for this repo (a central required workflow) -- the
97
+ latter prevents falsely nagging repos whose tool runs from a ruleset.
98
+ """
99
+ repo = facts.repo
100
+ covered = signal.value in facts.ruleset_signals
101
+ # An indeterminate code-scanning probe (403 forbidden, a 5xx, or a synthetic
102
+ # 0 from a transport failure) is unknown, not a nag -- unless a ruleset
103
+ # already proves the tool is enabled for this repo.
104
+ if not covered and facts.code_scanning_status not in (200, 404):
105
+ detail = (
106
+ "insufficient permission"
107
+ if facts.code_scanning_status == 403
108
+ else "indeterminate"
109
+ )
110
+ return RepoSignal(repo, signal, RepoState.UNKNOWN, detail=detail)
111
+ enabled = covered or (
112
+ facts.code_scanning_status == 200 and tool_name in facts.code_scanning_tools
113
+ )
114
+ if not enabled:
115
+ detail = "code scanning disabled" if facts.code_scanning_status == 404 else f"{tool_name} not enabled"
116
+ return RepoSignal(repo, signal, RepoState.NAG, detail=detail)
117
+ counts = count_code_scanning(facts.code_scanning_alerts, tool_name)
118
+ # Enabled with no findings is only "clean" when the alert read succeeded;
119
+ # an unreadable sweep (e.g. org-bulk 403/5xx) must not masquerade as clean.
120
+ if counts.total == 0 and facts.code_scanning_alerts_status != 200:
121
+ return RepoSignal(repo, signal, RepoState.UNKNOWN, detail="alert data unavailable")
122
+ state = RepoState.OFFENDER if counts.total else RepoState.CLEAN
123
+ return RepoSignal(repo, signal, state, counts=counts)
124
+
125
+
126
+ def classify_codeql(facts: RepoFacts) -> RepoSignal:
127
+ return _code_scanning_tool_signal(facts, SignalType.CODEQL, "CodeQL")
128
+
129
+
130
+ def classify_zizmor(facts: RepoFacts) -> RepoSignal:
131
+ return _code_scanning_tool_signal(facts, SignalType.ZIZMOR, "zizmor")
132
+
133
+
134
+ def classify_scorecard(facts: RepoFacts) -> RepoSignal:
135
+ """Scorecard: prefer the external aggregate score, else code-scanning findings."""
136
+ repo = facts.repo
137
+ counts = count_code_scanning(facts.code_scanning_alerts, "Scorecard")
138
+ has_cs = "Scorecard" in facts.code_scanning_tools and facts.code_scanning_status == 200
139
+
140
+ if facts.scorecard_status == 200 and facts.scorecard_score is not None:
141
+ # A perfect 10 with no findings is clean; anything else is an offender.
142
+ clean = facts.scorecard_score >= 10.0 and counts.total == 0
143
+ state = RepoState.CLEAN if clean else RepoState.OFFENDER
144
+ return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts, score=facts.scorecard_score)
145
+ if has_cs:
146
+ if counts.total == 0 and facts.code_scanning_alerts_status != 200:
147
+ return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="alert data unavailable")
148
+ state = RepoState.OFFENDER if counts.total else RepoState.CLEAN
149
+ return RepoSignal(repo, SignalType.SCORECARD, state, counts=counts)
150
+ # An indeterminate external request (a 403 forbidden/blocked, a 5xx
151
+ # including the synthetic 503 a transport failure produces, or a forbidden
152
+ # code-scanning probe) is unknown, not a definitive nag. Only a clean 404
153
+ # with no code-scanning Scorecard data means "no results".
154
+ if facts.code_scanning_status == 403 or facts.scorecard_status not in (200, 404):
155
+ return RepoSignal(repo, SignalType.SCORECARD, RepoState.UNKNOWN, detail="indeterminate")
156
+ return RepoSignal(repo, SignalType.SCORECARD, RepoState.NAG, detail="no Scorecard results")
157
+
158
+
159
+ def classify_secret_scanning(facts: RepoFacts) -> RepoSignal:
160
+ repo = facts.repo
161
+ if facts.secret_scanning_status == 403:
162
+ return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="insufficient permission")
163
+ if facts.secret_scanning_status == 404:
164
+ return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.NAG, detail="secret scanning disabled")
165
+ # Flat open count; stored as HIGH so ranking (more == worse) works and the
166
+ # repo-mode gate treats leaked secrets as serious, without conflating them
167
+ # with CRITICAL code findings (which would make --fail-threshold critical
168
+ # trip on any secret alert). Rendered as a single total because
169
+ # SignalType.uses_severity_columns is False for secret scanning.
170
+ counts = SeverityCounts(high=facts.secret_scanning_open)
171
+ # Positive evidence of leaked secrets is actionable even when the read was
172
+ # incomplete (a later page failed) or the enablement probe was indeterminate.
173
+ if facts.secret_scanning_open:
174
+ return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.OFFENDER, counts=counts)
175
+ # A zero count is only "clean" when both the enablement probe and the
176
+ # open-count read succeeded; an indeterminate status (5xx) or an unreadable
177
+ # sweep cannot confirm the repo is clean.
178
+ if facts.secret_scanning_status != 200 or facts.secret_scanning_open_status != 200:
179
+ return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.UNKNOWN, detail="alert data unavailable")
180
+ return RepoSignal(repo, SignalType.SECRET_SCANNING, RepoState.CLEAN, counts=counts)
181
+
182
+
183
+ def classify_dependabot(facts: RepoFacts) -> RepoSignal:
184
+ repo = facts.repo
185
+ if facts.dependabot_enabled is None:
186
+ return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="indeterminate")
187
+ if facts.dependabot_enabled is False:
188
+ return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.NAG, detail="Dependabot alerts disabled")
189
+ counts = count_dependabot(facts.dependabot_alerts)
190
+ # Enabled with no alerts is only "clean" when the alert read succeeded.
191
+ if counts.total == 0 and facts.dependabot_alerts_status != 200:
192
+ return RepoSignal(repo, SignalType.DEPENDABOT, RepoState.UNKNOWN, detail="alert data unavailable")
193
+ state = RepoState.OFFENDER if counts.total else RepoState.CLEAN
194
+ return RepoSignal(repo, SignalType.DEPENDABOT, state, counts=counts)
195
+
196
+
197
+ _CLASSIFIERS = (
198
+ classify_codeql,
199
+ classify_scorecard,
200
+ classify_zizmor,
201
+ classify_dependabot,
202
+ classify_secret_scanning,
203
+ )
204
+
205
+
206
+ def classify_repo(facts: RepoFacts) -> list[RepoSignal]:
207
+ """Classify a repository across all five signals."""
208
+ return [classifier(facts) for classifier in _CLASSIFIERS]