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,196 @@
1
+ """One process, one origin: webhooks, the dashboard API and the dashboard itself.
2
+
3
+ ::
4
+
5
+ /webhooks/github, /health, /ready -> GitHub App service (Phase 5, unchanged)
6
+ /api/v1/... -> DashboardApi
7
+ everything else (GET/HEAD) -> built dashboard files; unknown paths
8
+ without a file extension -> index.html
9
+ (client-side routes, deep links, refresh)
10
+
11
+ Serving the dashboard from the API's origin keeps cookies ``SameSite`` and
12
+ first-party and needs no CORS. The dashboard is a static bundle: it holds no
13
+ secrets, and its Content Security Policy allows scripts, styles, fonts and
14
+ API calls from the same origin only (no inline scripts, no ``eval``).
15
+ """
16
+
17
+ import mimetypes
18
+ from collections.abc import Callable, Iterable
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path
21
+ from wsgiref.types import StartResponse, WSGIEnvironment
22
+
23
+ from commitguard.api.app import DashboardApi
24
+ from commitguard.api.settings import DashboardSettings, Environment
25
+ from commitguard.controlplane.commands import ControlPlaneCommands
26
+ from commitguard.controlplane.identity import AuthService
27
+ from commitguard.controlplane.members import MembershipService
28
+ from commitguard.controlplane.notifications import NotificationCenter
29
+ from commitguard.controlplane.queries import DashboardQueries
30
+ from commitguard.github.app import GitHubAppService, create_wsgi_app
31
+ from commitguard.github.enforcement_status import EnforcementProbe
32
+
33
+ type WSGIApp = Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]
34
+
35
+ MAX_STATIC_FILE_BYTES = 20 * 1024 * 1024
36
+
37
+ DASHBOARD_CSP = (
38
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
39
+ "font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; "
40
+ "form-action 'self'; frame-ancestors 'none'"
41
+ )
42
+
43
+ _TYPES = {
44
+ ".html": "text/html; charset=utf-8",
45
+ ".js": "text/javascript; charset=utf-8",
46
+ ".css": "text/css; charset=utf-8",
47
+ ".json": "application/json",
48
+ ".svg": "image/svg+xml",
49
+ ".png": "image/png",
50
+ ".ico": "image/x-icon",
51
+ ".woff2": "font/woff2",
52
+ ".woff": "font/woff",
53
+ ".txt": "text/plain; charset=utf-8",
54
+ ".webmanifest": "application/manifest+json",
55
+ }
56
+
57
+
58
+ class StaticSite:
59
+ """Serves a built single-page application from a directory, read-only."""
60
+
61
+ def __init__(self, root: Path, settings: DashboardSettings) -> None:
62
+ self._root = root.resolve()
63
+ self._settings = settings
64
+
65
+ def _headers(self, path: Path) -> list[tuple[str, str]]:
66
+ content_type = _TYPES.get(path.suffix) or mimetypes.guess_type(path.name)[0]
67
+ immutable = path.parent.name == "assets"
68
+ headers = [
69
+ ("Content-Type", content_type or "application/octet-stream"),
70
+ (
71
+ "Cache-Control",
72
+ "public, max-age=31536000, immutable" if immutable else "no-cache",
73
+ ),
74
+ ("X-Content-Type-Options", "nosniff"),
75
+ ("Referrer-Policy", "no-referrer"),
76
+ ("X-Frame-Options", "DENY"),
77
+ ("Cross-Origin-Opener-Policy", "same-origin"),
78
+ ("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()"),
79
+ ("Content-Security-Policy", DASHBOARD_CSP),
80
+ ]
81
+ if self._settings.environment is Environment.PRODUCTION and self._settings.https:
82
+ headers.append(("Strict-Transport-Security", "max-age=63072000; includeSubDomains"))
83
+ return headers
84
+
85
+ def resolve(self, request_path: str) -> Path | None:
86
+ """The file for a request path, the SPA entry point, or None (404)."""
87
+ relative = request_path.lstrip("/")
88
+ if "\x00" in relative or "\\" in relative:
89
+ return None
90
+ parts = [p for p in relative.split("/") if p]
91
+ if any(p in (".", "..") or p.startswith(".") for p in parts):
92
+ return None
93
+ candidate = (self._root.joinpath(*parts) if parts else self._root / "index.html").resolve()
94
+ if not candidate.is_relative_to(self._root):
95
+ return None
96
+ if candidate.is_file():
97
+ return candidate
98
+ if parts and "." in parts[-1]:
99
+ return None # a missing asset is a 404, not the application shell
100
+ index = self._root / "index.html"
101
+ return index if index.is_file() else None
102
+
103
+ def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
104
+ method = environ.get("REQUEST_METHOD", "GET")
105
+ if method not in ("GET", "HEAD"):
106
+ start_response(
107
+ "405 Method Not Allowed",
108
+ [("Allow", "GET, HEAD"), ("Content-Type", "text/plain"), ("Content-Length", "0")],
109
+ )
110
+ return [b""]
111
+ path = self.resolve(str(environ.get("PATH_INFO", "/")))
112
+ if path is None or path.stat().st_size > MAX_STATIC_FILE_BYTES:
113
+ body = b"Not Found"
114
+ start_response(
115
+ "404 Not Found",
116
+ [
117
+ ("Content-Type", "text/plain; charset=utf-8"),
118
+ ("Content-Length", str(len(body))),
119
+ ("X-Content-Type-Options", "nosniff"),
120
+ ],
121
+ )
122
+ return [body]
123
+ data = path.read_bytes()
124
+ headers = self._headers(path)
125
+ headers.append(("Content-Length", str(len(data))))
126
+ start_response("200 OK", headers)
127
+ return [data] if method == "GET" else [b""]
128
+
129
+
130
+ def build_dashboard(
131
+ service: GitHubAppService,
132
+ settings: DashboardSettings,
133
+ *,
134
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
135
+ ) -> DashboardApi:
136
+ """Wire the dashboard API to the GitHub App service's store, client and workers."""
137
+ store = service.store
138
+ queries = DashboardQueries(store, now=now)
139
+ auth = AuthService(
140
+ store,
141
+ service.client,
142
+ service.audit,
143
+ client_id=settings.client_id,
144
+ client_secret=settings.client_secret,
145
+ redirect_uri=settings.redirect_uri,
146
+ now=now,
147
+ )
148
+ commands = ControlPlaneCommands(
149
+ store,
150
+ queries,
151
+ service.audit,
152
+ service.installations,
153
+ EnforcementProbe(service.client, now=now),
154
+ enqueue=service.queue.put,
155
+ now=now,
156
+ )
157
+ service.add_maintenance_task(auth.purge_expired)
158
+ return DashboardApi(
159
+ settings=settings,
160
+ auth=auth,
161
+ queries=queries,
162
+ commands=commands,
163
+ policies=service.policies,
164
+ members=MembershipService(store, service.audit, now=now),
165
+ notifications=NotificationCenter(
166
+ store, service.audit, service.notifications.settings, now=now
167
+ ),
168
+ governance=service.governance,
169
+ now=now,
170
+ )
171
+
172
+
173
+ def create_server_app(
174
+ service: GitHubAppService,
175
+ dashboard: DashboardApi | None = None,
176
+ ) -> WSGIApp:
177
+ webhooks = create_wsgi_app(service)
178
+ static = (
179
+ StaticSite(dashboard.settings.static_dir, dashboard.settings)
180
+ if dashboard is not None and dashboard.settings.static_dir is not None
181
+ else None
182
+ )
183
+
184
+ def application(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
185
+ path = str(environ.get("PATH_INFO", ""))
186
+ if path == "/api" or path.startswith("/api/"):
187
+ if dashboard is None:
188
+ return webhooks(environ, start_response) # JSON 404
189
+ return dashboard(environ, start_response)
190
+ if path in ("/health", "/ready") or path.startswith("/webhooks/"):
191
+ return webhooks(environ, start_response)
192
+ if static is not None:
193
+ return static(environ, start_response)
194
+ return webhooks(environ, start_response)
195
+
196
+ return application
@@ -0,0 +1,252 @@
1
+ """HTTP primitives for the dashboard API: requests, responses, errors, cookies."""
2
+
3
+ import json
4
+ from collections.abc import Iterable, Mapping
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+ from urllib.parse import parse_qsl
8
+ from wsgiref.types import WSGIEnvironment
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from commitguard.controlplane.access import AccessScope, Principal
13
+ from commitguard.controlplane.errors import ControlPlaneError
14
+
15
+ MAX_BODY_BYTES = 64 * 1024
16
+ MAX_QUERY_CHARS = 4096
17
+ MAX_QUERY_PARAMS = 32
18
+ MAX_COOKIE_HEADER_CHARS = 8192
19
+ MAX_JSON_DEPTH = 16
20
+
21
+ REASONS = {
22
+ 200: "OK",
23
+ 201: "Created",
24
+ 202: "Accepted",
25
+ 204: "No Content",
26
+ 302: "Found",
27
+ 400: "Bad Request",
28
+ 401: "Unauthorized",
29
+ 403: "Forbidden",
30
+ 404: "Not Found",
31
+ 405: "Method Not Allowed",
32
+ 409: "Conflict",
33
+ 411: "Length Required",
34
+ 413: "Payload Too Large",
35
+ 415: "Unsupported Media Type",
36
+ 422: "Unprocessable Content",
37
+ 429: "Too Many Requests",
38
+ 500: "Internal Server Error",
39
+ 502: "Bad Gateway",
40
+ 503: "Service Unavailable",
41
+ }
42
+
43
+ API_SECURITY_HEADERS: tuple[tuple[str, str], ...] = (
44
+ ("Cache-Control", "no-store"),
45
+ ("X-Content-Type-Options", "nosniff"),
46
+ ("Referrer-Policy", "no-referrer"),
47
+ ("X-Frame-Options", "DENY"),
48
+ ("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"),
49
+ ("Cross-Origin-Opener-Policy", "same-origin"),
50
+ ("Cross-Origin-Resource-Policy", "same-origin"),
51
+ )
52
+
53
+
54
+ class ApiError(Exception):
55
+ """An error returned to the client. ``message`` is safe to display."""
56
+
57
+ def __init__(
58
+ self,
59
+ status: int,
60
+ code: str,
61
+ message: str,
62
+ *,
63
+ field: str | None = None,
64
+ headers: Iterable[tuple[str, str]] = (),
65
+ details: dict[str, Any] | None = None,
66
+ ) -> None:
67
+ super().__init__(message)
68
+ self.status = status
69
+ self.code = code
70
+ self.message = message
71
+ self.field = field
72
+ self.headers = tuple(headers)
73
+ self.details = details
74
+
75
+ @classmethod
76
+ def from_control_plane(cls, exc: ControlPlaneError) -> "ApiError":
77
+ changes = getattr(exc, "changes", ())
78
+ return cls(
79
+ exc.status,
80
+ exc.code,
81
+ str(exc),
82
+ field=getattr(exc, "field", None),
83
+ details={"changes": list(changes)} if changes else None,
84
+ )
85
+
86
+
87
+ def bad_request(message: str, field: str | None = None) -> ApiError:
88
+ return ApiError(400, "VALIDATION_ERROR", message, field=field)
89
+
90
+
91
+ NOT_FOUND = "The requested resource was not found."
92
+
93
+
94
+ @dataclass
95
+ class Request:
96
+ method: str
97
+ path: str
98
+ query: Mapping[str, str]
99
+ headers: Mapping[str, str]
100
+ cookies: Mapping[str, str]
101
+ body: bytes
102
+ remote_addr: str
103
+ request_id: str
104
+ route: str = "unmatched"
105
+ params: dict[str, Any] = field(default_factory=dict)
106
+ principal: Principal | None = None
107
+ session_token: str | None = None
108
+ scope: AccessScope | None = None
109
+
110
+ def header(self, name: str) -> str | None:
111
+ return self.headers.get(name.lower())
112
+
113
+ def arg(self, name: str) -> str | None:
114
+ return self.query.get(name)
115
+
116
+ def json(self) -> dict[str, Any]:
117
+ if not self.body:
118
+ return {}
119
+ content_type = (self.header("content-type") or "").split(";", 1)[0].strip().lower()
120
+ if content_type != "application/json":
121
+ raise ApiError(415, "UNSUPPORTED_MEDIA_TYPE", "Send the request body as JSON.")
122
+ try:
123
+ document = json.loads(self.body.decode("utf-8"), object_pairs_hook=_no_duplicates)
124
+ except (UnicodeDecodeError, ValueError, RecursionError):
125
+ raise bad_request("The request body is not valid JSON.") from None
126
+ if not isinstance(document, dict):
127
+ raise bad_request("The request body must be a JSON object.")
128
+ if _depth(document) > MAX_JSON_DEPTH:
129
+ raise bad_request("The request body is nested too deeply.")
130
+ return document
131
+
132
+
133
+ def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
134
+ result: dict[str, Any] = {}
135
+ for key, value in pairs:
136
+ if key in result:
137
+ raise ValueError("duplicate key")
138
+ result[key] = value
139
+ return result
140
+
141
+
142
+ def _depth(value: Any, level: int = 0) -> int:
143
+ if level > MAX_JSON_DEPTH:
144
+ return level
145
+ if isinstance(value, dict):
146
+ return max((_depth(v, level + 1) for v in value.values()), default=level + 1)
147
+ if isinstance(value, list):
148
+ return max((_depth(v, level + 1) for v in value), default=level + 1)
149
+ return level
150
+
151
+
152
+ def parse_query(environ: WSGIEnvironment) -> dict[str, str]:
153
+ raw = environ.get("QUERY_STRING", "") or ""
154
+ if len(raw) > MAX_QUERY_CHARS:
155
+ raise bad_request("The query string is too long.")
156
+ try:
157
+ pairs = parse_qsl(raw, keep_blank_values=True, strict_parsing=False, max_num_fields=64)
158
+ except ValueError:
159
+ raise bad_request("The query string is invalid.") from None
160
+ if len(pairs) > MAX_QUERY_PARAMS:
161
+ raise bad_request("Too many query parameters.")
162
+ query: dict[str, str] = {}
163
+ for key, value in pairs:
164
+ if key in query:
165
+ raise bad_request(f"Query parameter {key[:40]} is repeated.", field=key[:40])
166
+ if any(ord(c) < 0x20 for c in key + value):
167
+ raise bad_request("The query string contains control characters.")
168
+ query[key] = value
169
+ return query
170
+
171
+
172
+ def parse_cookies(header: str | None) -> dict[str, str]:
173
+ """Parse a Cookie header without evaluating attributes; malformed parts are ignored."""
174
+ cookies: dict[str, str] = {}
175
+ if not header or len(header) > MAX_COOKIE_HEADER_CHARS:
176
+ return cookies
177
+ for part in header.split(";"):
178
+ name, sep, value = part.strip().partition("=")
179
+ if not sep or not name or not name.isascii():
180
+ continue
181
+ value = value.strip()
182
+ if value.startswith('"') and value.endswith('"') and len(value) >= 2:
183
+ value = value[1:-1]
184
+ cookies.setdefault(name, value)
185
+ return cookies
186
+
187
+
188
+ def set_cookie(
189
+ name: str,
190
+ value: str,
191
+ *,
192
+ max_age: int | None,
193
+ http_only: bool = True,
194
+ same_site: str = "Lax",
195
+ ) -> tuple[str, str]:
196
+ """A ``__Host-`` style cookie: Secure, Path=/, no Domain."""
197
+ parts = [f"{name}={value}", "Path=/", "Secure", f"SameSite={same_site}"]
198
+ if http_only:
199
+ parts.append("HttpOnly")
200
+ if max_age is not None:
201
+ parts.append(f"Max-Age={max_age}")
202
+ return ("Set-Cookie", "; ".join(parts))
203
+
204
+
205
+ def clear_cookie(name: str) -> tuple[str, str]:
206
+ return set_cookie(name, "", max_age=0)
207
+
208
+
209
+ @dataclass
210
+ class Response:
211
+ status: int
212
+ body: bytes = b""
213
+ headers: list[tuple[str, str]] = field(default_factory=list)
214
+
215
+
216
+ def _encode(value: Any) -> Any:
217
+ if isinstance(value, BaseModel):
218
+ return value.model_dump(mode="json")
219
+ if isinstance(value, list | tuple):
220
+ return [_encode(v) for v in value]
221
+ if isinstance(value, dict):
222
+ return {k: _encode(v) for k, v in value.items()}
223
+ return value
224
+
225
+
226
+ def json_body(document: Mapping[str, Any]) -> bytes:
227
+ return json.dumps(_encode(document), ensure_ascii=True, separators=(",", ":")).encode("ascii")
228
+
229
+
230
+ def ok(data: Any, meta: Mapping[str, Any] | None = None, *, status: int = 200) -> Response:
231
+ return Response(
232
+ status,
233
+ json_body({"data": data, "meta": dict(meta or {})}),
234
+ [("Content-Type", "application/json; charset=utf-8")],
235
+ )
236
+
237
+
238
+ def error_response(exc: ApiError, request_id: str) -> Response:
239
+ error: dict[str, Any] = {"code": exc.code, "message": exc.message, "request_id": request_id}
240
+ if exc.field:
241
+ error["field"] = exc.field
242
+ if exc.details:
243
+ error["details"] = exc.details
244
+ return Response(
245
+ exc.status,
246
+ json_body({"error": error}),
247
+ [("Content-Type", "application/json; charset=utf-8"), *exc.headers],
248
+ )
249
+
250
+
251
+ def redirect(location: str, headers: Iterable[tuple[str, str]] = ()) -> Response:
252
+ return Response(302, b"", [("Location", location), *headers])
@@ -0,0 +1,169 @@
1
+ """Dashboard configuration from the environment.
2
+
3
+ ====================================================== ======================================
4
+ Variable Meaning
5
+ ====================================================== ======================================
6
+ ``COMMITGUARD_DASHBOARD_URL`` public origin of the dashboard, e.g.
7
+ ``https://commitguard.example.com``;
8
+ enables the dashboard and its API
9
+ ``COMMITGUARD_GITHUB_CLIENT_ID`` the GitHub App's client ID
10
+ ``COMMITGUARD_GITHUB_CLIENT_SECRET`` the GitHub App's client secret, *or*
11
+ ``COMMITGUARD_GITHUB_CLIENT_SECRET_FILE`` a file containing it (preferred)
12
+ ``COMMITGUARD_DASHBOARD_STATIC_DIR`` optional: built dashboard (``web/dist``)
13
+ served by the same process
14
+ ``COMMITGUARD_DASHBOARD_ALLOWED_ORIGINS`` optional: comma-separated extra origins
15
+ allowed to call the API with credentials
16
+ ``COMMITGUARD_ENV`` ``production`` (default),
17
+ ``development`` or ``test``
18
+ ====================================================== ======================================
19
+
20
+ Environments differ only where it matters:
21
+
22
+ * ``production`` requires an ``https://`` dashboard URL and sends
23
+ ``Strict-Transport-Security``;
24
+ * ``development`` and ``test`` also accept ``http://localhost`` and
25
+ ``http://127.0.0.1`` URLs (browsers still honour ``Secure`` cookies there)
26
+ and omit HSTS so a local HTTP setup is not pinned to HTTPS.
27
+
28
+ Every other security control - cookie flags, CSRF, CORS allow-list, CSP,
29
+ authorization - is identical in all environments. Test suites generate their
30
+ own keys and secrets; production credentials are never needed to run them.
31
+ """
32
+
33
+ import os
34
+ from collections.abc import Mapping
35
+ from dataclasses import dataclass
36
+ from enum import StrEnum
37
+ from pathlib import Path
38
+ from urllib.parse import urlsplit
39
+
40
+ from commitguard.github.errors import AppConfigurationError
41
+ from commitguard.security.secrets import Secret, register_secret
42
+ from commitguard.utils.filesystem import read_bytes_limited
43
+
44
+ ENV_DASHBOARD_URL = "COMMITGUARD_DASHBOARD_URL"
45
+ ENV_CLIENT_ID = "COMMITGUARD_GITHUB_CLIENT_ID"
46
+ ENV_CLIENT_SECRET = "COMMITGUARD_GITHUB_CLIENT_SECRET" # noqa: S105 - variable name
47
+ ENV_CLIENT_SECRET_FILE = "COMMITGUARD_GITHUB_CLIENT_SECRET_FILE" # noqa: S105 - variable name
48
+ ENV_STATIC_DIR = "COMMITGUARD_DASHBOARD_STATIC_DIR"
49
+ ENV_ALLOWED_ORIGINS = "COMMITGUARD_DASHBOARD_ALLOWED_ORIGINS"
50
+ ENV_ENVIRONMENT = "COMMITGUARD_ENV"
51
+
52
+ CALLBACK_PATH = "/api/v1/auth/callback"
53
+ MAX_CLIENT_SECRET_BYTES = 4096
54
+
55
+
56
+ class Environment(StrEnum):
57
+ PRODUCTION = "production"
58
+ DEVELOPMENT = "development"
59
+ TEST = "test"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class DashboardSettings:
64
+ origin: str # scheme://host[:port], no path
65
+ client_id: str
66
+ client_secret: Secret
67
+ environment: Environment = Environment.PRODUCTION
68
+ static_dir: Path | None = None
69
+ allowed_origins: tuple[str, ...] = ()
70
+
71
+ @property
72
+ def redirect_uri(self) -> str:
73
+ return self.origin + CALLBACK_PATH
74
+
75
+ @property
76
+ def https(self) -> bool:
77
+ return self.origin.startswith("https://")
78
+
79
+ @property
80
+ def cors_origins(self) -> frozenset[str]:
81
+ return frozenset(self.allowed_origins)
82
+
83
+ @property
84
+ def trusted_origins(self) -> frozenset[str]:
85
+ return frozenset((self.origin, *self.allowed_origins))
86
+
87
+
88
+ def normalize_origin(value: str, environment: Environment, variable: str) -> str:
89
+ parts = urlsplit(value.strip())
90
+ local = parts.hostname in ("localhost", "127.0.0.1")
91
+ if parts.scheme not in ("https", "http") or not parts.hostname:
92
+ raise AppConfigurationError(f"{variable} must be an http(s) origin")
93
+ if parts.scheme == "http" and not (local and environment is not Environment.PRODUCTION):
94
+ raise AppConfigurationError(
95
+ f"{variable} must use https (http is only accepted for localhost outside production)"
96
+ )
97
+ if parts.username or parts.password or parts.query or parts.fragment:
98
+ raise AppConfigurationError(f"{variable} must not contain credentials, query or fragment")
99
+ if parts.path not in ("", "/"):
100
+ raise AppConfigurationError(f"{variable} must be an origin without a path")
101
+ if value.strip() == "*":
102
+ raise AppConfigurationError(f"{variable} must name explicit origins")
103
+ port = f":{parts.port}" if parts.port else ""
104
+ return f"{parts.scheme}://{parts.hostname.lower()}{port}"
105
+
106
+
107
+ def _client_secret(env: Mapping[str, str]) -> Secret:
108
+ value = env.get(ENV_CLIENT_SECRET)
109
+ path = env.get(ENV_CLIENT_SECRET_FILE)
110
+ if value and path:
111
+ raise AppConfigurationError(
112
+ f"set only one of {ENV_CLIENT_SECRET} and {ENV_CLIENT_SECRET_FILE}"
113
+ )
114
+ if path:
115
+ try:
116
+ raw = read_bytes_limited(Path(path), max_bytes=MAX_CLIENT_SECRET_BYTES)
117
+ value = raw.decode("utf-8").strip()
118
+ except (OSError, UnicodeDecodeError, ValueError):
119
+ raise AppConfigurationError(f"{ENV_CLIENT_SECRET_FILE} could not be read") from None
120
+ if not value or len(value) < 16:
121
+ raise AppConfigurationError(
122
+ f"{ENV_CLIENT_SECRET} (or {ENV_CLIENT_SECRET_FILE}) is not set or too short"
123
+ )
124
+ secret = Secret(value)
125
+ register_secret(secret)
126
+ return secret
127
+
128
+
129
+ def dashboard_enabled(env: Mapping[str, str] | None = None) -> bool:
130
+ return bool((os.environ if env is None else env).get(ENV_DASHBOARD_URL))
131
+
132
+
133
+ def load_dashboard_settings(env: Mapping[str, str] | None = None) -> DashboardSettings:
134
+ source = os.environ if env is None else env
135
+ raw_environment = source.get(ENV_ENVIRONMENT, Environment.PRODUCTION.value)
136
+ try:
137
+ environment = Environment(raw_environment)
138
+ except ValueError:
139
+ raise AppConfigurationError(
140
+ f"{ENV_ENVIRONMENT} must be production, development or test"
141
+ ) from None
142
+ url = source.get(ENV_DASHBOARD_URL)
143
+ if not url:
144
+ raise AppConfigurationError(f"{ENV_DASHBOARD_URL} is not set")
145
+ origin = normalize_origin(url, environment, ENV_DASHBOARD_URL)
146
+ client_id = source.get(ENV_CLIENT_ID, "")
147
+ if not (0 < len(client_id) <= 64) or not client_id.replace(".", "").isalnum():
148
+ raise AppConfigurationError(f"{ENV_CLIENT_ID} is not set or invalid")
149
+ static_raw = source.get(ENV_STATIC_DIR)
150
+ static_dir = None
151
+ if static_raw:
152
+ static_dir = Path(static_raw)
153
+ if not static_dir.is_absolute() or not (static_dir / "index.html").is_file():
154
+ raise AppConfigurationError(
155
+ f"{ENV_STATIC_DIR} must be an absolute path to a built dashboard (index.html)"
156
+ )
157
+ allowed = tuple(
158
+ normalize_origin(item, environment, ENV_ALLOWED_ORIGINS)
159
+ for item in source.get(ENV_ALLOWED_ORIGINS, "").split(",")
160
+ if item.strip()
161
+ )
162
+ return DashboardSettings(
163
+ origin=origin,
164
+ client_id=client_id,
165
+ client_secret=_client_secret(source),
166
+ environment=environment,
167
+ static_dir=static_dir,
168
+ allowed_origins=allowed,
169
+ )
@@ -0,0 +1,13 @@
1
+ """Audit events: a structured record of what CommitGuard decided and why.
2
+
3
+ The GitHub App records installation changes, scans, violations, policy
4
+ modifications and errors as :class:`~commitguard.audit.models.AuditEvent`
5
+ objects. Every event is written as a structured log line and, when a storage
6
+ backend is configured, appended to it (the App uses its SQLite state store).
7
+
8
+ Audit events are deliberately small: IDs, commit SHAs, rule IDs, finding
9
+ fingerprints, counts and decisions - never commit messages, author names or
10
+ e-mail addresses, file contents, tokens or keys. They are retained for a
11
+ bounded period (see docs/github-app.md). The local CLI and Git hooks do not
12
+ record audit events.
13
+ """
@@ -0,0 +1,34 @@
1
+ """Audit logger: structured log line + optional storage for every event."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from commitguard.audit.models import AuditEvent
6
+ from commitguard.audit.storage import AuditStorage
7
+ from commitguard.observability.logging import get_logger
8
+
9
+ log = get_logger("commitguard.audit")
10
+
11
+
12
+ class AuditLogger:
13
+ def __init__(self, storages: Sequence[AuditStorage] = ()) -> None:
14
+ self._storages = tuple(storages)
15
+
16
+ def record(self, event: AuditEvent) -> None:
17
+ self.log(event)
18
+ for storage in self._storages:
19
+ storage.append_audit_event(event)
20
+
21
+ @staticmethod
22
+ def log(event: AuditEvent) -> None:
23
+ log.info(
24
+ "audit",
25
+ audit_type=event.type.value,
26
+ audit_event_id=event.event_id,
27
+ actor_type=event.actor_type.value,
28
+ actor_id=event.actor_id,
29
+ installation=event.installation_id,
30
+ repository_id=event.repository_id,
31
+ head_sha=event.head_sha,
32
+ action=event.action.value if event.action else None,
33
+ data=event.data,
34
+ )