commitguardian 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.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,286 @@
1
+ """GitHub workflow template and static inspection.
2
+
3
+ CommitGuard cannot see repository settings (branch protection, rulesets)
4
+ without the GitHub API, which Phase 4 deliberately does not use. What it can
5
+ do locally is inspect workflow files for the properties enforcement depends on.
6
+ """
7
+
8
+ import re
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+
15
+ from commitguard.exceptions.base import UnsafeInputError
16
+ from commitguard.security.safe_yaml import load_yaml_for_inspection
17
+ from commitguard.utils.filesystem import read_text_limited
18
+
19
+ WORKFLOWS_DIR = Path(".github") / "workflows"
20
+ WORKFLOW_FILE = WORKFLOWS_DIR / "commitguard.yml"
21
+ WORKFLOW_NAME = "CommitGuard"
22
+ JOB_ID = "commitguard"
23
+ #: The status check context GitHub creates for the job (the job's ``name``).
24
+ CHECK_NAME = "commitguard"
25
+ MAX_WORKFLOW_BYTES = 512 * 1024
26
+
27
+ _REPOSITORY_RE = re.compile(r"\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})/[A-Za-z0-9._-]{1,100}\Z")
28
+ _SHA_RE = re.compile(r"\A[0-9a-f]{40}\Z")
29
+ _USES_PINNED_RE = re.compile(r"\A[^@\s]+@[0-9a-f]{40}\Z")
30
+ # "${{ ... secrets.X ... }}" in two steps: a single expression (\$\{\{[^}]*\bsecrets\.)
31
+ # rescans the same text from every "${{" and is quadratic on "${{${{${{..." (60 KB took
32
+ # 2.7 s; found by the ReDoS tests). Workflow text comes from monitored repositories.
33
+ _EXPRESSION_RE = re.compile(r"\$\{\{[^}]*")
34
+ _SECRETS_RE = re.compile(r"\bsecrets\.")
35
+
36
+ # Actions used by the template, pinned to verified commits.
37
+ CHECKOUT_ACTION = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" # v4.2.2
38
+
39
+
40
+ def render_workflow(action_repository: str, action_ref: str) -> str:
41
+ """Workflow for a repository that uses the CommitGuard Action.
42
+
43
+ ``action_ref`` must be a full commit SHA: the Action is part of the security
44
+ boundary and a movable tag or branch could be changed underneath you.
45
+ """
46
+ if not _REPOSITORY_RE.match(action_repository):
47
+ raise UnsafeInputError(f"invalid action repository {action_repository!r} (owner/name)")
48
+ if not _SHA_RE.match(action_ref):
49
+ raise UnsafeInputError("action ref must be a full 40-character commit SHA")
50
+ return f"""\
51
+ # CommitGuard server-side enforcement.
52
+ #
53
+ # This check only prevents merges once branch protection (or a ruleset) marks
54
+ # the "{CHECK_NAME}" status check as required. See the CommitGuard documentation:
55
+ # docs/github-enforcement.md.
56
+ name: {WORKFLOW_NAME}
57
+
58
+ on:
59
+ pull_request:
60
+ merge_group:
61
+ push:
62
+
63
+ # The scanner only reads Git objects: no write access and no secrets.
64
+ permissions:
65
+ contents: read
66
+
67
+ # No `concurrency: cancel-in-progress`: a push check that is cancelled by a later
68
+ # push would leave the earlier push's commits unchecked.
69
+
70
+ jobs:
71
+ {JOB_ID}:
72
+ name: {CHECK_NAME}
73
+ runs-on: ubuntu-latest
74
+ timeout-minutes: 15
75
+ steps:
76
+ - name: Check out full history
77
+ uses: {CHECKOUT_ACTION} # v4.2.2
78
+ with:
79
+ fetch-depth: 0 # base and head commits must both be present
80
+ persist-credentials: false
81
+
82
+ - name: CommitGuard
83
+ uses: {action_repository}@{action_ref}
84
+ """
85
+
86
+
87
+ class WorkflowIssueLevel(StrEnum):
88
+ OK = "ok"
89
+ WARN = "warn"
90
+ FAIL = "fail"
91
+
92
+
93
+ class WorkflowIssue(BaseModel):
94
+ model_config = ConfigDict(frozen=True, extra="forbid")
95
+
96
+ level: WorkflowIssueLevel
97
+ message: str
98
+
99
+
100
+ class WorkflowInspection(BaseModel):
101
+ model_config = ConfigDict(frozen=True, extra="forbid")
102
+
103
+ path: Path
104
+ check_names: tuple[str, ...]
105
+ issues: tuple[WorkflowIssue, ...]
106
+
107
+
108
+ def _as_list(value: Any) -> list[Any]:
109
+ if value is None:
110
+ return []
111
+ if isinstance(value, list):
112
+ return value
113
+ return [value]
114
+
115
+
116
+ def _triggers(document: dict[Any, Any]) -> set[str]:
117
+ raw = document.get("on", document.get(True)) # YAML 1.1 parses a bare `on` as True
118
+ if isinstance(raw, str):
119
+ return {raw}
120
+ if isinstance(raw, list):
121
+ return {str(item) for item in raw}
122
+ if isinstance(raw, dict):
123
+ return {str(key) for key in raw}
124
+ return set()
125
+
126
+
127
+ def _permission_issues(permissions: Any, where: str) -> list[WorkflowIssue]:
128
+ if permissions is None:
129
+ return []
130
+ if isinstance(permissions, str):
131
+ if permissions in ("read-all", "{}"):
132
+ return []
133
+ return [
134
+ WorkflowIssue(level=WorkflowIssueLevel.WARN, message=f"{where} grants {permissions}")
135
+ ]
136
+ if isinstance(permissions, dict):
137
+ writes = sorted(str(k) for k, v in permissions.items() if v == "write")
138
+ if writes:
139
+ return [
140
+ WorkflowIssue(
141
+ level=WorkflowIssueLevel.WARN,
142
+ message=f"{where} grants write access ({', '.join(writes)}); "
143
+ "CommitGuard needs only contents: read",
144
+ )
145
+ ]
146
+ return []
147
+
148
+
149
+ def _step_runs_commitguard(step: dict[Any, Any]) -> bool:
150
+ uses = str(step.get("uses", ""))
151
+ run = str(step.get("run", ""))
152
+ return "commitguard" in uses.lower() or "commitguard ci github" in run
153
+
154
+
155
+ def inspect_workflow_text(path: Path, text: str) -> WorkflowInspection | None:
156
+ """Inspect one workflow; None if it does not run CommitGuard."""
157
+ try:
158
+ document = load_yaml_for_inspection(text)
159
+ except Exception as exc: # noqa: BLE001 - any parse failure is reported, not raised
160
+ return WorkflowInspection(
161
+ path=path,
162
+ check_names=(),
163
+ issues=(
164
+ WorkflowIssue(
165
+ level=WorkflowIssueLevel.FAIL, message=f"cannot parse workflow: {exc}"
166
+ ),
167
+ ),
168
+ )
169
+ if not isinstance(document, dict) or not isinstance(document.get("jobs"), dict):
170
+ return None
171
+
172
+ issues: list[WorkflowIssue] = []
173
+ check_names: list[str] = []
174
+ for job_id, job in document["jobs"].items():
175
+ if not isinstance(job, dict):
176
+ continue
177
+ steps = [s for s in _as_list(job.get("steps")) if isinstance(s, dict)]
178
+ if not any(_step_runs_commitguard(s) for s in steps):
179
+ continue
180
+ check_names.append(str(job.get("name", job_id)))
181
+ issues.extend(_permission_issues(job.get("permissions"), f"job {job_id}"))
182
+ checkout = [s for s in steps if str(s.get("uses", "")).startswith("actions/checkout@")]
183
+ if not checkout:
184
+ issues.append(
185
+ WorkflowIssue(
186
+ level=WorkflowIssueLevel.FAIL,
187
+ message=f"job {job_id} does not check out the repository",
188
+ )
189
+ )
190
+ elif not any(
191
+ isinstance(s.get("with"), dict) and str(s["with"].get("fetch-depth")) == "0"
192
+ for s in checkout
193
+ ):
194
+ issues.append(
195
+ WorkflowIssue(
196
+ level=WorkflowIssueLevel.WARN,
197
+ message=f"job {job_id}: checkout without fetch-depth: 0; base commits may "
198
+ "be missing and the check will fail",
199
+ )
200
+ )
201
+ for step in steps:
202
+ uses = str(step.get("uses", ""))
203
+ if uses and not uses.startswith("./") and not _USES_PINNED_RE.match(uses):
204
+ issues.append(
205
+ WorkflowIssue(
206
+ level=WorkflowIssueLevel.WARN,
207
+ message=f"job {job_id}: {uses} is not pinned to a commit SHA",
208
+ )
209
+ )
210
+ if not check_names:
211
+ return None
212
+
213
+ triggers = _triggers(document)
214
+ if "pull_request_target" in triggers:
215
+ issues.append(
216
+ WorkflowIssue(
217
+ level=WorkflowIssueLevel.FAIL,
218
+ message="uses pull_request_target; use pull_request (no secrets or write token)",
219
+ )
220
+ )
221
+ if "pull_request" not in triggers:
222
+ issues.append(
223
+ WorkflowIssue(
224
+ level=WorkflowIssueLevel.WARN,
225
+ message="does not run on pull_request; pull requests are not checked",
226
+ )
227
+ )
228
+ if "merge_group" not in triggers:
229
+ issues.append(
230
+ WorkflowIssue(
231
+ level=WorkflowIssueLevel.OK,
232
+ message="no merge_group trigger (required only if you use a merge queue)",
233
+ )
234
+ )
235
+ concurrency = document.get("concurrency")
236
+ if isinstance(concurrency, dict) and concurrency.get("cancel-in-progress") is True:
237
+ issues.append(
238
+ WorkflowIssue(
239
+ level=WorkflowIssueLevel.WARN,
240
+ message="concurrency cancel-in-progress can cancel a push check whose commits "
241
+ "a later run does not re-check",
242
+ )
243
+ )
244
+ if "permissions" not in document and not all(
245
+ isinstance(j, dict) and "permissions" in j for j in document["jobs"].values()
246
+ ):
247
+ issues.append(
248
+ WorkflowIssue(
249
+ level=WorkflowIssueLevel.WARN,
250
+ message="no permissions block; the default GITHUB_TOKEN may have write access",
251
+ )
252
+ )
253
+ issues.extend(_permission_issues(document.get("permissions"), "workflow"))
254
+ if any(_SECRETS_RE.search(m.group(0)) for m in _EXPRESSION_RE.finditer(text)):
255
+ issues.append(
256
+ WorkflowIssue(
257
+ level=WorkflowIssueLevel.WARN,
258
+ message="references secrets; CommitGuard scanning needs none",
259
+ )
260
+ )
261
+ return WorkflowInspection(path=path, check_names=tuple(check_names), issues=tuple(issues))
262
+
263
+
264
+ def inspect_repository_workflows(root: Path) -> list[WorkflowInspection]:
265
+ directory = root / WORKFLOWS_DIR
266
+ if not directory.is_dir():
267
+ return []
268
+ results = []
269
+ for path in sorted(directory.iterdir()):
270
+ if path.suffix not in (".yml", ".yaml") or not path.is_file():
271
+ continue
272
+ try:
273
+ text = read_text_limited(path, max_bytes=MAX_WORKFLOW_BYTES)
274
+ except (OSError, UnsafeInputError) as exc:
275
+ results.append(
276
+ WorkflowInspection(
277
+ path=path,
278
+ check_names=(),
279
+ issues=(WorkflowIssue(level=WorkflowIssueLevel.FAIL, message=str(exc)),),
280
+ )
281
+ )
282
+ continue
283
+ inspection = inspect_workflow_text(path, text)
284
+ if inspection is not None:
285
+ results.append(inspection)
286
+ return results
@@ -0,0 +1,26 @@
1
+ """Organization governance: central policy, groups, onboarding, exceptions and rollouts.
2
+
3
+ ::
4
+
5
+ Organization (GitHub account) settings.py baseline, approval, limits
6
+ ├── repositories inventory.py discovery, onboarding, mode
7
+ ├── repository groups groups.py
8
+ ├── policies (organization / group / controlplane/policies.py immutable versions
9
+ │ repository targets) workflow.py draft -> approve -> publish
10
+ │ simulation.py historical, read-only
11
+ │ rollouts.py pilot -> stages -> active
12
+ ├── exceptions exceptions.py scoped, expiring, approved
13
+ ├── scan schedules schedules.py
14
+ ├── bulk operations bulk.py background, bounded, idempotent
15
+ └── posture, reports, analytics posture.py
16
+
17
+
18
+ GovernanceResolver (resolver.py) -> GovernanceInputs -> resolve_policy (pure)
19
+
20
+
21
+ ScanService -> PolicyEvaluator -> decision -> GitHub check
22
+
23
+ The dashboard never makes a security decision: governance decides *which*
24
+ configuration applies, the policy engine decides what a finding means, and
25
+ GitHub enforces the check.
26
+ """