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,2 @@
1
+ """Notifications: typed events emitted with domain changes, delivered in-app, by e-mail
2
+ and to signed webhooks. See :mod:`commitguard.notifications.service`."""
@@ -0,0 +1 @@
1
+ """Notification channels: in-app (database), e-mail (SMTP) and signed webhooks."""
@@ -0,0 +1,22 @@
1
+ """What every external delivery channel returns and raises."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ class DeliveryError(Exception):
7
+ """A delivery attempt failed.
8
+
9
+ ``code`` is a short, non-sensitive label stored with the delivery record
10
+ (never a provider response body). ``permanent`` failures are not retried.
11
+ """
12
+
13
+ def __init__(self, code: str, *, permanent: bool = False) -> None:
14
+ super().__init__(code)
15
+ self.code = code[:64]
16
+ self.permanent = permanent
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class DeliveryReceipt:
21
+ provider: str
22
+ provider_message_id: str | None
@@ -0,0 +1,110 @@
1
+ """E-mail channel.
2
+
3
+ :class:`EmailProvider` is the interface the notification domain depends on;
4
+ :class:`SmtpEmailProvider` is the implementation shipped in this phase (any
5
+ SMTP relay, including those of SES, SendGrid or Resend). Provider credentials
6
+ come from the environment or a secret file (:mod:`commitguard.notifications.settings`),
7
+ never from repository or organization configuration.
8
+
9
+ Messages are **plain text** only: repository names, rule titles and other
10
+ repository-controlled values are never interpreted as HTML. Header values are
11
+ set through :class:`email.message.EmailMessage`, which rejects line breaks, so
12
+ untrusted text cannot inject headers. ``Message-ID`` is derived from the
13
+ delivery's idempotency key, so a retried message can be recognised as the same
14
+ message by the relay and by recipients' mail systems.
15
+ """
16
+
17
+ import smtplib
18
+ import ssl
19
+ from dataclasses import dataclass
20
+ from email.message import EmailMessage
21
+ from email.utils import formatdate
22
+ from typing import Literal, Protocol
23
+
24
+ from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
25
+ from commitguard.security.secrets import Secret
26
+
27
+ SMTP_TIMEOUT_SECONDS = 15.0
28
+
29
+ type SmtpSecurity = Literal["starttls", "tls", "none"]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class OutgoingEmail:
34
+ to: str
35
+ subject: str
36
+ text: str
37
+
38
+
39
+ def build_message(sender: str, email: OutgoingEmail, idempotency_key: str) -> EmailMessage:
40
+ message = EmailMessage()
41
+ message["From"] = sender
42
+ message["To"] = email.to
43
+ message["Subject"] = " ".join(email.subject.split())[:200]
44
+ message["Date"] = formatdate(usegmt=True)
45
+ domain = sender.rsplit("@", 1)[-1] if "@" in sender else "commitguard.invalid"
46
+ message["Message-ID"] = f"<{idempotency_key[:64]}@{domain}>"
47
+ message["Auto-Submitted"] = "auto-generated"
48
+ message.set_content(email.text, subtype="plain", charset="utf-8")
49
+ return message
50
+
51
+
52
+ class EmailProvider(Protocol):
53
+ name: str
54
+
55
+ def send(self, email: OutgoingEmail, *, idempotency_key: str) -> DeliveryReceipt: ...
56
+
57
+
58
+ class SmtpEmailProvider:
59
+ name = "smtp"
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ host: str,
65
+ port: int,
66
+ sender: str,
67
+ security: SmtpSecurity = "starttls",
68
+ username: str | None = None,
69
+ password: Secret | None = None,
70
+ timeout: float = SMTP_TIMEOUT_SECONDS,
71
+ ) -> None:
72
+ self._host = host
73
+ self._port = port
74
+ self._sender = sender
75
+ self._security = security
76
+ self._username = username
77
+ self._password = password
78
+ self._timeout = timeout
79
+
80
+ def send(self, email: OutgoingEmail, *, idempotency_key: str) -> DeliveryReceipt:
81
+ message = build_message(self._sender, email, idempotency_key)
82
+ context = ssl.create_default_context()
83
+ try:
84
+ client: smtplib.SMTP
85
+ if self._security == "tls":
86
+ client = smtplib.SMTP_SSL(
87
+ self._host, self._port, timeout=self._timeout, context=context
88
+ )
89
+ else:
90
+ client = smtplib.SMTP(self._host, self._port, timeout=self._timeout)
91
+ with client:
92
+ if self._security == "starttls":
93
+ client.starttls(context=context)
94
+ if self._username and self._password:
95
+ client.login(self._username, self._password.reveal())
96
+ refused = client.send_message(message)
97
+ except smtplib.SMTPRecipientsRefused:
98
+ raise DeliveryError("recipient_refused", permanent=True) from None
99
+ except smtplib.SMTPAuthenticationError:
100
+ raise DeliveryError("smtp_authentication_failed") from None
101
+ except smtplib.SMTPResponseException as exc:
102
+ permanent = 500 <= exc.smtp_code < 600
103
+ raise DeliveryError(f"smtp_{exc.smtp_code}", permanent=permanent) from None
104
+ except TimeoutError:
105
+ raise DeliveryError("smtp_timeout") from None
106
+ except (smtplib.SMTPException, ssl.SSLError, OSError) as exc:
107
+ raise DeliveryError(f"smtp_unavailable_{type(exc).__name__}"[:64]) from None
108
+ if refused:
109
+ raise DeliveryError("recipient_refused", permanent=True)
110
+ return DeliveryReceipt(self.name, str(message["Message-ID"]))
@@ -0,0 +1,74 @@
1
+ """In-app channel: who receives a notification in their CommitGuard inbox.
2
+
3
+ Recipients are computed when the event is dispatched, from the organization's
4
+ current members:
5
+
6
+ * the member's role must grant the type's permission (for example
7
+ ``github:manage`` for installation disconnects), and the owner of a personal
8
+ (user-account) installation is always included;
9
+ * the type must be enabled for the member (mandatory types always are).
10
+
11
+ Repository visibility on GitHub is per session (reported by GitHub at
12
+ sign-in), so it is applied when notifications are *read*: a notification about
13
+ a repository is only listed for a session that can see that repository, and the
14
+ role check is applied again at read time. A member who lost their role or
15
+ their GitHub access stops seeing notifications immediately.
16
+ """
17
+
18
+ import sqlite3
19
+
20
+ from commitguard.controlplane.access import Role
21
+ from commitguard.notifications.models import NotificationType, TypeDefinition
22
+ from commitguard.notifications.preferences import (
23
+ OrganizationSettings,
24
+ UserPreferences,
25
+ in_app_enabled,
26
+ )
27
+
28
+
29
+ def eligible_members(
30
+ db: sqlite3.Connection,
31
+ account_id: int,
32
+ definition: TypeDefinition,
33
+ ) -> set[int]:
34
+ users = {
35
+ int(row["user_id"])
36
+ for row in db.execute(
37
+ "SELECT user_id, role FROM memberships WHERE account_id = ?", (int(account_id),)
38
+ ).fetchall()
39
+ if row["role"] in {r.value for r in Role}
40
+ and definition.permission in Role(row["role"]).permissions
41
+ }
42
+ personal = db.execute(
43
+ "SELECT 1 FROM installations WHERE account_id = ? AND account_type = 'User' LIMIT 1",
44
+ (int(account_id),),
45
+ ).fetchone()
46
+ if personal is not None:
47
+ users.add(int(account_id)) # a user account's GitHub user ID is its account ID
48
+ return users
49
+
50
+
51
+ def recipients(
52
+ db: sqlite3.Connection,
53
+ account_id: int,
54
+ definition: TypeDefinition,
55
+ organization: OrganizationSettings,
56
+ ) -> list[int]:
57
+ muted = {
58
+ int(row["user_id"])
59
+ for row in db.execute(
60
+ "SELECT user_id FROM notification_user_preferences WHERE account_id = ? "
61
+ "AND type = ? AND in_app = 0",
62
+ (int(account_id), definition.type.value),
63
+ ).fetchall()
64
+ }
65
+ result = []
66
+ for user_id in sorted(eligible_members(db, account_id, definition)):
67
+ preferences = UserPreferences(
68
+ user_id,
69
+ account_id,
70
+ frozenset({definition.type}) if user_id in muted else frozenset[NotificationType](),
71
+ )
72
+ if in_app_enabled(definition, organization, preferences):
73
+ result.append(user_id)
74
+ return result
@@ -0,0 +1,58 @@
1
+ """Test mode: channels that record deliveries instead of sending them.
2
+
3
+ With ``COMMITGUARD_NOTIFICATIONS_MODE=test`` (and always when
4
+ ``COMMITGUARD_ENV=test``) e-mail and webhook deliveries go through the full
5
+ pipeline - outbox, preferences, delivery records, retries, audit - but the
6
+ final hop is replaced by these in-memory sinks, so a test or staging
7
+ environment can never reach a real mailbox or endpoint.
8
+ """
9
+
10
+ import json
11
+ import threading
12
+ from dataclasses import dataclass
13
+
14
+ from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
15
+ from commitguard.notifications.channels.email import OutgoingEmail
16
+ from commitguard.notifications.channels.webhook import WebhookRequest
17
+
18
+ MAX_RECORDED = 1000
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RecordedEmail:
23
+ email: OutgoingEmail
24
+ idempotency_key: str
25
+
26
+
27
+ class RecordingEmailProvider:
28
+ name = "test-sink"
29
+
30
+ def __init__(self) -> None:
31
+ self._lock = threading.Lock()
32
+ self.sent: list[RecordedEmail] = []
33
+ self.failure: DeliveryError | None = None # tests simulate an outage
34
+
35
+ def send(self, email: OutgoingEmail, *, idempotency_key: str) -> DeliveryReceipt:
36
+ with self._lock:
37
+ if self.failure is not None:
38
+ raise self.failure
39
+ self.sent.append(RecordedEmail(email, idempotency_key))
40
+ del self.sent[:-MAX_RECORDED]
41
+ return DeliveryReceipt(self.name, f"test-{idempotency_key[:24]}")
42
+
43
+
44
+ class RecordingWebhookTransport:
45
+ def __init__(self) -> None:
46
+ self._lock = threading.Lock()
47
+ self.requests: list[WebhookRequest] = []
48
+ self.status = 200
49
+
50
+ def post(self, request: WebhookRequest, *, timeout: float) -> int:
51
+ with self._lock:
52
+ self.requests.append(request)
53
+ del self.requests[:-MAX_RECORDED]
54
+ return self.status
55
+
56
+ def payloads(self) -> list[dict[str, object]]:
57
+ with self._lock:
58
+ return [json.loads(r.body) for r in self.requests]
@@ -0,0 +1,233 @@
1
+ """Signed outbound webhooks.
2
+
3
+ Request::
4
+
5
+ POST <endpoint URL> (HTTPS; redirects are not followed)
6
+ Content-Type: application/json
7
+ User-Agent: CommitGuard-Notifications
8
+ X-CommitGuard-Event: policy_rolled_back
9
+ X-CommitGuard-Delivery: <idempotency key> (same value on every retry)
10
+ X-CommitGuard-Timestamp: 1767225600 (Unix seconds)
11
+ X-CommitGuard-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>
12
+
13
+ Receivers verify the signature over the timestamp *and* the raw body with
14
+ :func:`verify_signature` (or the equivalent in their language), reject
15
+ timestamps outside a small window (default five minutes) to stop replays, and
16
+ use ``X-CommitGuard-Delivery`` to discard a retried delivery they already
17
+ processed.
18
+
19
+ Signing secrets: each endpoint has its own secret, derived from the
20
+ deployment's signing key (``COMMITGUARD_NOTIFICATION_SIGNING_KEY``) and the
21
+ endpoint ID with HMAC. Nothing secret is stored in the database; the secret is
22
+ shown once, when an administrator adds the endpoint. A tenant's secret reveals
23
+ nothing about another tenant's.
24
+
25
+ Network safety: the endpoint host is resolved at send time and the connection
26
+ is made to the resolved address itself (TLS still verifies the certificate for
27
+ the host name), so a DNS change between the address check and the connection
28
+ cannot redirect the request. Loopback, private, link-local and other
29
+ non-public addresses are refused in production.
30
+ """
31
+
32
+ import hashlib
33
+ import hmac
34
+ import http.client
35
+ import ipaddress
36
+ import socket
37
+ import ssl
38
+ from collections.abc import Callable, Mapping
39
+ from dataclasses import dataclass
40
+ from typing import Protocol
41
+ from urllib.parse import urlsplit
42
+
43
+ from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
44
+ from commitguard.security.secrets import Secret
45
+
46
+ WEBHOOK_TIMEOUT_SECONDS = 10.0
47
+ SIGNATURE_TOLERANCE_SECONDS = 300
48
+ MAX_URL_CHARS = 2048
49
+ SIGNATURE_VERSION = "v1"
50
+ SECRET_PREFIX = "whsec_" # noqa: S105 - display prefix, not a secret
51
+
52
+
53
+ def endpoint_secret(signing_key: Secret, endpoint_id: str) -> Secret:
54
+ digest = hmac.new(
55
+ signing_key.reveal().encode("utf-8"),
56
+ b"commitguard-webhook-endpoint\x1f" + endpoint_id.encode("ascii"),
57
+ hashlib.sha256,
58
+ ).hexdigest()
59
+ return Secret(SECRET_PREFIX + digest)
60
+
61
+
62
+ def sign(secret: Secret, timestamp: int, body: bytes) -> str:
63
+ mac = hmac.new(secret.reveal().encode("utf-8"), f"{timestamp}.".encode() + body, hashlib.sha256)
64
+ return f"{SIGNATURE_VERSION}={mac.hexdigest()}"
65
+
66
+
67
+ def verify_signature(
68
+ secret: Secret,
69
+ *,
70
+ timestamp: str | None,
71
+ signature: str | None,
72
+ body: bytes,
73
+ now: float,
74
+ tolerance: int = SIGNATURE_TOLERANCE_SECONDS,
75
+ ) -> bool:
76
+ """Receiver-side check: authentic body, fresh timestamp (replay window)."""
77
+ if not timestamp or not signature or not timestamp.isascii() or not timestamp.isdigit():
78
+ return False
79
+ sent = int(timestamp)
80
+ if abs(now - sent) > tolerance:
81
+ return False
82
+ expected = sign(secret, sent, body)
83
+ return hmac.compare_digest(expected.encode("ascii"), signature.strip().encode("ascii"))
84
+
85
+
86
+ class WebhookUrlError(ValueError):
87
+ pass
88
+
89
+
90
+ def validate_webhook_url(url: object, *, allow_insecure_local: bool) -> str:
91
+ """An endpoint URL administrators may register."""
92
+ if not isinstance(url, str) or not url or len(url) > MAX_URL_CHARS:
93
+ raise WebhookUrlError("the webhook URL must be an https:// URL")
94
+ if any(ord(c) < 0x21 or ord(c) == 0x7F for c in url):
95
+ raise WebhookUrlError("the webhook URL contains invalid characters")
96
+ parts = urlsplit(url)
97
+ host = parts.hostname
98
+ local = host in ("localhost", "127.0.0.1", "::1")
99
+ if parts.scheme != "https" and not (parts.scheme == "http" and local and allow_insecure_local):
100
+ raise WebhookUrlError("the webhook URL must use https")
101
+ if not host or parts.username or parts.password or parts.fragment:
102
+ raise WebhookUrlError("the webhook URL must not contain credentials or a fragment")
103
+ try:
104
+ _ = parts.port
105
+ except ValueError:
106
+ raise WebhookUrlError("the webhook URL has an invalid port") from None
107
+ if not allow_insecure_local:
108
+ try:
109
+ address = ipaddress.ip_address(host)
110
+ except ValueError:
111
+ address = None
112
+ if local or (address is not None and not address.is_global):
113
+ raise WebhookUrlError("the webhook URL must point to a public host")
114
+ return url
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class WebhookRequest:
119
+ url: str
120
+ body: bytes
121
+ headers: Mapping[str, str]
122
+
123
+
124
+ class WebhookTransport(Protocol):
125
+ def post(self, request: WebhookRequest, *, timeout: float) -> int:
126
+ """Send the request and return the HTTP status (redirects are not followed)."""
127
+ ...
128
+
129
+
130
+ type Resolver = Callable[[str, int], list[str]]
131
+
132
+
133
+ def _resolve(host: str, port: int) -> list[str]:
134
+ return sorted(
135
+ {str(info[4][0]) for info in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)}
136
+ )
137
+
138
+
139
+ class PinnedHttpsTransport:
140
+ """HTTPS POST to the address that passed the network check (no DNS rebinding)."""
141
+
142
+ def __init__(self, *, allow_private: bool = False, resolver: Resolver = _resolve) -> None:
143
+ self._allow_private = allow_private
144
+ self._resolver = resolver
145
+
146
+ def post(self, request: WebhookRequest, *, timeout: float) -> int:
147
+ parts = urlsplit(request.url)
148
+ host = parts.hostname or ""
149
+ secure = parts.scheme == "https"
150
+ port = parts.port or (443 if secure else 80)
151
+ try:
152
+ addresses = self._resolver(host, port)
153
+ except OSError:
154
+ raise DeliveryError("webhook_dns_failed") from None
155
+ allowed = [a for a in addresses if self._allow_private or ipaddress.ip_address(a).is_global]
156
+ if not allowed:
157
+ raise DeliveryError("webhook_address_refused", permanent=True)
158
+ address = allowed[0]
159
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
160
+ connection: http.client.HTTPConnection
161
+ if secure:
162
+ connection = _PinnedHTTPSConnection(host, port, address, timeout)
163
+ else:
164
+ connection = http.client.HTTPConnection(address, port, timeout=timeout)
165
+ try:
166
+ connection.request("POST", path, body=request.body, headers=dict(request.headers))
167
+ response = connection.getresponse()
168
+ response.read(65536)
169
+ return int(response.status)
170
+ except TimeoutError:
171
+ raise DeliveryError("webhook_timeout") from None
172
+ except (ssl.SSLError, http.client.HTTPException, OSError) as exc:
173
+ raise DeliveryError(f"webhook_unreachable_{type(exc).__name__}"[:64]) from None
174
+ finally:
175
+ connection.close()
176
+
177
+
178
+ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
179
+ def __init__(self, host: str, port: int, address: str, timeout: float) -> None:
180
+ super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
181
+ self._address = address
182
+
183
+ def connect(self) -> None:
184
+ sock = socket.create_connection((self._address, self.port), self.timeout)
185
+ context = ssl.create_default_context()
186
+ self.sock = context.wrap_socket(sock, server_hostname=self.host)
187
+
188
+
189
+ class WebhookProvider:
190
+ name = "webhook"
191
+
192
+ def __init__(
193
+ self,
194
+ signing_key: Secret,
195
+ transport: WebhookTransport,
196
+ *,
197
+ timeout: float = WEBHOOK_TIMEOUT_SECONDS,
198
+ ) -> None:
199
+ self._signing_key = signing_key
200
+ self._transport = transport
201
+ self._timeout = timeout
202
+
203
+ def secret_for(self, endpoint_id: str) -> Secret:
204
+ return endpoint_secret(self._signing_key, endpoint_id)
205
+
206
+ def send(
207
+ self,
208
+ *,
209
+ url: str,
210
+ endpoint_id: str,
211
+ event_type: str,
212
+ body: bytes,
213
+ idempotency_key: str,
214
+ timestamp: int,
215
+ ) -> DeliveryReceipt:
216
+ headers = {
217
+ "Content-Type": "application/json",
218
+ "User-Agent": "CommitGuard-Notifications",
219
+ "X-CommitGuard-Event": event_type,
220
+ "X-CommitGuard-Delivery": idempotency_key,
221
+ "X-CommitGuard-Timestamp": str(timestamp),
222
+ "X-CommitGuard-Signature": sign(self.secret_for(endpoint_id), timestamp, body),
223
+ }
224
+ status = self._transport.post(
225
+ WebhookRequest(url=url, body=body, headers=headers), timeout=self._timeout
226
+ )
227
+ if 200 <= status < 300:
228
+ return DeliveryReceipt(self.name, idempotency_key)
229
+ if 300 <= status < 400:
230
+ raise DeliveryError("webhook_redirect_refused", permanent=True)
231
+ if status in (408, 425, 429) or status >= 500:
232
+ raise DeliveryError(f"webhook_http_{status}")
233
+ raise DeliveryError(f"webhook_http_{status}", permanent=True)
@@ -0,0 +1,57 @@
1
+ """Notification deduplication.
2
+
3
+ Two mechanisms keep one underlying event from producing many notifications:
4
+
5
+ 1. **Idempotent emission.** Every notification event has a *storage key*: its
6
+ domain identity (``dedup_key``) plus, for types with a coalescing window, the
7
+ window it falls in. The outbox has a unique constraint on
8
+ ``(account_id, storage key)``. Emitting the same key again - a replayed
9
+ webhook, a retried job, twenty commits of one pull request detected in one
10
+ scan - updates the existing event (``occurrences`` + 1, newest text) instead
11
+ of inserting another one, and it creates no new e-mail or webhook deliveries.
12
+ 2. **Idempotent delivery.** Each delivery has an idempotency key derived from
13
+ the event, the channel and the destination, also unique in the database, and
14
+ sent to the provider (``Message-ID`` for e-mail, ``X-CommitGuard-Delivery``
15
+ for webhooks) so a retry after an ambiguous failure can be recognised.
16
+
17
+ Domain keys, per type:
18
+
19
+ ============================= ==================================================
20
+ ``critical_violation``, installation, repository, pull request / branch /
21
+ ``high_violation`` merge group, rule - one notification per rule per
22
+ change within an hour, however many commits
23
+ ``policy_changed``, organization and the new version number
24
+ ``policy_rolled_back``
25
+ ``installation_disconnected`` installation (within an hour: flapping
26
+ ``installation_reconnected`` suspend/unsuspend does not repeat the alert)
27
+ ``merge_queue_failure`` repository and merge group commit
28
+ ``check_rerun_failed`` the failed execution
29
+ ============================= ==================================================
30
+ """
31
+
32
+ from collections.abc import Iterable
33
+ from datetime import datetime
34
+
35
+ from commitguard.notifications.models import DEFINITIONS, NotificationType
36
+ from commitguard.security.hashing import fingerprint
37
+
38
+
39
+ def domain_key(notification_type: NotificationType, *parts: str | int) -> str:
40
+ return ":".join([notification_type.value, *(str(p) for p in parts)])
41
+
42
+
43
+ def storage_key(notification_type: NotificationType, dedup_key: str, occurred_at: datetime) -> str:
44
+ """The outbox uniqueness key: the domain key, bucketed by the coalescing window."""
45
+ window = DEFINITIONS[notification_type].coalesce_seconds
46
+ if not window:
47
+ return dedup_key
48
+ bucket = int(occurred_at.timestamp() // window)
49
+ return f"{dedup_key}@{bucket}"
50
+
51
+
52
+ def delivery_idempotency_key(event_id: str, channel: str, destination: str) -> str:
53
+ return fingerprint(["notification-delivery", event_id, channel, destination])
54
+
55
+
56
+ def unique_sorted(values: Iterable[str]) -> list[str]:
57
+ return sorted(set(values))