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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
commitguard/api/http.py
ADDED
|
@@ -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
|
+
)
|