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,128 @@
1
+ """NotificationService: the notification subsystem, wired from settings.
2
+
3
+ ::
4
+
5
+ domain services ── emit() ──> outbox ──> NotificationDispatcher ──> inbox rows
6
+ └──────────> delivery records
7
+
8
+ DeliveryWorker (retries) ▼
9
+ e-mail (SMTP) / signed webhook
10
+
11
+ The GitHub App service runs :meth:`run_once` on a background thread every few
12
+ seconds; tests call it directly. Nothing here is on the webhook request path
13
+ or the scan path: a slow or unavailable provider delays only notifications.
14
+ """
15
+
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime
19
+
20
+ from commitguard.github.storage import SqliteStateStore
21
+ from commitguard.notifications.channels.email import EmailProvider, SmtpEmailProvider
22
+ from commitguard.notifications.channels.sink import (
23
+ RecordingEmailProvider,
24
+ RecordingWebhookTransport,
25
+ )
26
+ from commitguard.notifications.channels.webhook import (
27
+ PinnedHttpsTransport,
28
+ WebhookProvider,
29
+ WebhookTransport,
30
+ )
31
+ from commitguard.notifications.dispatcher import NotificationDispatcher
32
+ from commitguard.notifications.retry import DeliveryWorker
33
+ from commitguard.notifications.settings import NotificationMode, NotificationSettings
34
+ from commitguard.observability.logging import get_logger
35
+ from commitguard.observability.metrics import Metrics
36
+ from commitguard.services.audit import AuditService
37
+
38
+ log = get_logger(__name__)
39
+
40
+ RUN_INTERVAL_SECONDS = 5.0
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class RunResult:
45
+ dispatched: int
46
+ attempted: int
47
+
48
+
49
+ class NotificationService:
50
+ def __init__(
51
+ self,
52
+ store: SqliteStateStore,
53
+ audit: AuditService,
54
+ metrics: Metrics,
55
+ settings: NotificationSettings,
56
+ *,
57
+ email: EmailProvider | None = None,
58
+ webhook_transport: WebhookTransport | None = None,
59
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
60
+ ) -> None:
61
+ self.settings = settings
62
+ self._store = store
63
+ self._now = now
64
+ if settings.mode is NotificationMode.TEST:
65
+ email = email or RecordingEmailProvider()
66
+ webhook_transport = webhook_transport or RecordingWebhookTransport()
67
+ elif settings.mode is NotificationMode.DELIVER:
68
+ if email is None and settings.smtp is not None:
69
+ email = SmtpEmailProvider(
70
+ host=settings.smtp.host,
71
+ port=settings.smtp.port,
72
+ sender=settings.smtp.sender,
73
+ security=settings.smtp.security, # type: ignore[arg-type]
74
+ username=settings.smtp.username,
75
+ password=settings.smtp.password,
76
+ )
77
+ if webhook_transport is None and settings.signing_key is not None:
78
+ webhook_transport = PinnedHttpsTransport(allow_private=not settings.production)
79
+ else:
80
+ email, webhook_transport = None, None
81
+ self.email = email if settings.email_available else None
82
+ self.webhook_transport = webhook_transport
83
+ self.webhook = (
84
+ WebhookProvider(settings.signing_key, webhook_transport)
85
+ if settings.webhook_available
86
+ and settings.signing_key is not None
87
+ and webhook_transport is not None
88
+ else None
89
+ )
90
+ self.dispatcher = NotificationDispatcher(store, audit, metrics, settings, now=now)
91
+ self.worker = DeliveryWorker(
92
+ store,
93
+ audit,
94
+ metrics,
95
+ email=self.email,
96
+ webhook=self.webhook,
97
+ dashboard_origin=settings.dashboard_origin,
98
+ now=now,
99
+ )
100
+
101
+ def run_once(self) -> RunResult:
102
+ """Fan out pending events, then attempt due deliveries."""
103
+ dispatched = self.dispatcher.dispatch_pending()
104
+ attempted = self.worker.deliver_due()
105
+ return RunResult(dispatched, attempted)
106
+
107
+ def purge_expired(self) -> int:
108
+ """Delete notification history older than the notification retention period.
109
+
110
+ Events with a delivery still pending are kept; deleting an event removes
111
+ its inbox entries and delivery records (foreign keys cascade). Audit
112
+ events about notifications follow the audit retention instead.
113
+ """
114
+ cutoff = (self._now() - self.settings.retention).timestamp()
115
+ with self._store.transaction() as db:
116
+ removed = db.execute(
117
+ "DELETE FROM notification_events WHERE last_occurred_at < ? AND dispatched_at "
118
+ "IS NOT NULL AND NOT EXISTS (SELECT 1 FROM notification_deliveries d WHERE "
119
+ "d.event_id = notification_events.event_id AND d.status = 'pending')",
120
+ (cutoff,),
121
+ ).rowcount
122
+ db.execute(
123
+ "DELETE FROM notification_webhooks WHERE removed_at IS NOT NULL AND removed_at < ?",
124
+ (cutoff,),
125
+ )
126
+ if removed:
127
+ log.info("notification_retention_purge", events=removed)
128
+ return removed
@@ -0,0 +1,167 @@
1
+ """Notification delivery configuration from the environment.
2
+
3
+ ============================================= =============================================
4
+ Variable Meaning
5
+ ============================================= =============================================
6
+ ``COMMITGUARD_NOTIFICATIONS_MODE`` ``off`` (default): in-app notifications only;
7
+ ``deliver``: e-mail and webhooks are sent;
8
+ ``test``: they are recorded in memory, never
9
+ sent (forced when ``COMMITGUARD_ENV=test``)
10
+ ``COMMITGUARD_SMTP_HOST`` SMTP relay host (enables e-mail)
11
+ ``COMMITGUARD_SMTP_PORT`` default 587
12
+ ``COMMITGUARD_SMTP_SECURITY`` ``starttls`` (default), ``tls``, or ``none``
13
+ (only for localhost outside production)
14
+ ``COMMITGUARD_SMTP_USERNAME`` optional
15
+ ``COMMITGUARD_SMTP_PASSWORD`` optional, *or*
16
+ ``COMMITGUARD_SMTP_PASSWORD_FILE`` a file containing it (preferred)
17
+ ``COMMITGUARD_SMTP_FROM`` sender address, e.g. ``commitguard@example.com``
18
+ ``COMMITGUARD_NOTIFICATION_SIGNING_KEY`` key from which webhook signing secrets are
19
+ ``COMMITGUARD_NOTIFICATION_SIGNING_KEY_FILE`` derived (enables webhooks; 32+ characters)
20
+ ``COMMITGUARD_NOTIFICATION_RETENTION_DAYS`` notifications, deliveries (default 90)
21
+ ============================================= =============================================
22
+
23
+ Links in e-mail and webhook payloads use ``COMMITGUARD_DASHBOARD_URL`` when set.
24
+ """
25
+
26
+ import os
27
+ import secrets
28
+ from collections.abc import Mapping
29
+ from dataclasses import dataclass
30
+ from datetime import timedelta
31
+ from enum import StrEnum
32
+ from pathlib import Path
33
+
34
+ from commitguard.github.errors import AppConfigurationError
35
+ from commitguard.security.secrets import Secret, register_secret
36
+ from commitguard.utils.filesystem import read_bytes_limited
37
+
38
+ ENV_MODE = "COMMITGUARD_NOTIFICATIONS_MODE"
39
+ ENV_SMTP_HOST = "COMMITGUARD_SMTP_HOST"
40
+ ENV_SMTP_PORT = "COMMITGUARD_SMTP_PORT"
41
+ ENV_SMTP_SECURITY = "COMMITGUARD_SMTP_SECURITY"
42
+ ENV_SMTP_USERNAME = "COMMITGUARD_SMTP_USERNAME"
43
+ ENV_SMTP_PASSWORD = "COMMITGUARD_SMTP_PASSWORD" # noqa: S105 - variable name
44
+ ENV_SMTP_PASSWORD_FILE = "COMMITGUARD_SMTP_PASSWORD_FILE" # noqa: S105 - variable name
45
+ ENV_SMTP_FROM = "COMMITGUARD_SMTP_FROM"
46
+ ENV_SIGNING_KEY = "COMMITGUARD_NOTIFICATION_SIGNING_KEY"
47
+ ENV_SIGNING_KEY_FILE = "COMMITGUARD_NOTIFICATION_SIGNING_KEY_FILE"
48
+ ENV_RETENTION_DAYS = "COMMITGUARD_NOTIFICATION_RETENTION_DAYS"
49
+ ENV_DASHBOARD_URL = "COMMITGUARD_DASHBOARD_URL"
50
+ ENV_ENVIRONMENT = "COMMITGUARD_ENV"
51
+
52
+ MIN_SIGNING_KEY_CHARS = 32
53
+ MAX_SECRET_BYTES = 4096
54
+ DEFAULT_RETENTION_DAYS = 90
55
+
56
+
57
+ class NotificationMode(StrEnum):
58
+ OFF = "off"
59
+ DELIVER = "deliver"
60
+ TEST = "test"
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class SmtpSettings:
65
+ host: str
66
+ port: int
67
+ security: str
68
+ sender: str
69
+ username: str | None = None
70
+ password: Secret | None = None
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class NotificationSettings:
75
+ mode: NotificationMode = NotificationMode.OFF
76
+ smtp: SmtpSettings | None = None
77
+ signing_key: Secret | None = None
78
+ retention: timedelta = timedelta(days=DEFAULT_RETENTION_DAYS)
79
+ dashboard_origin: str | None = None
80
+ production: bool = True
81
+
82
+ @property
83
+ def email_available(self) -> bool:
84
+ return self.mode is NotificationMode.TEST or (
85
+ self.mode is NotificationMode.DELIVER and self.smtp is not None
86
+ )
87
+
88
+ @property
89
+ def webhook_available(self) -> bool:
90
+ return self.mode is NotificationMode.TEST or (
91
+ self.mode is NotificationMode.DELIVER and self.signing_key is not None
92
+ )
93
+
94
+
95
+ def _secret(env: Mapping[str, str], value_var: str, file_var: str) -> Secret | None:
96
+ value, path = env.get(value_var), env.get(file_var)
97
+ if value and path:
98
+ raise AppConfigurationError(f"set only one of {value_var} and {file_var}")
99
+ if path:
100
+ try:
101
+ value = read_bytes_limited(Path(path), max_bytes=MAX_SECRET_BYTES).decode().strip()
102
+ except (OSError, UnicodeDecodeError, ValueError):
103
+ raise AppConfigurationError(f"{file_var} could not be read") from None
104
+ if not value:
105
+ return None
106
+ secret = Secret(value)
107
+ register_secret(secret)
108
+ return secret
109
+
110
+
111
+ def load_notification_settings(env: Mapping[str, str] | None = None) -> NotificationSettings:
112
+ source = os.environ if env is None else env
113
+ environment = source.get(ENV_ENVIRONMENT, "production")
114
+ raw_mode = source.get(ENV_MODE, NotificationMode.OFF.value)
115
+ try:
116
+ mode = NotificationMode(raw_mode)
117
+ except ValueError:
118
+ raise AppConfigurationError(f"{ENV_MODE} must be off, deliver or test") from None
119
+ if environment == "test" and mode is NotificationMode.DELIVER:
120
+ mode = NotificationMode.TEST # a test environment never sends real notifications
121
+ production = environment == "production"
122
+
123
+ smtp = None
124
+ host = source.get(ENV_SMTP_HOST, "").strip()
125
+ if host:
126
+ sender = source.get(ENV_SMTP_FROM, "").strip()
127
+ if "@" not in sender or any(c in sender for c in "\r\n<>"):
128
+ raise AppConfigurationError(f"{ENV_SMTP_FROM} must be a plain sender address")
129
+ port_raw = source.get(ENV_SMTP_PORT, "587")
130
+ if not port_raw.isdigit() or not 0 < int(port_raw) < 65536:
131
+ raise AppConfigurationError(f"{ENV_SMTP_PORT} must be a TCP port")
132
+ security = source.get(ENV_SMTP_SECURITY, "starttls")
133
+ if security not in ("starttls", "tls", "none"):
134
+ raise AppConfigurationError(f"{ENV_SMTP_SECURITY} must be starttls, tls or none")
135
+ if security == "none" and (production or host not in ("localhost", "127.0.0.1")):
136
+ raise AppConfigurationError(
137
+ f"{ENV_SMTP_SECURITY}=none is only allowed for localhost outside production"
138
+ )
139
+ smtp = SmtpSettings(
140
+ host=host,
141
+ port=int(port_raw),
142
+ security=security,
143
+ sender=sender,
144
+ username=source.get(ENV_SMTP_USERNAME) or None,
145
+ password=_secret(source, ENV_SMTP_PASSWORD, ENV_SMTP_PASSWORD_FILE),
146
+ )
147
+
148
+ signing_key = _secret(source, ENV_SIGNING_KEY, ENV_SIGNING_KEY_FILE)
149
+ if signing_key is not None and len(signing_key) < MIN_SIGNING_KEY_CHARS:
150
+ raise AppConfigurationError(
151
+ f"{ENV_SIGNING_KEY} must be at least {MIN_SIGNING_KEY_CHARS} characters"
152
+ )
153
+ if mode is NotificationMode.TEST and signing_key is None:
154
+ signing_key = Secret(secrets.token_hex(32)) # ephemeral: test deliveries only
155
+
156
+ retention_raw = source.get(ENV_RETENTION_DAYS, str(DEFAULT_RETENTION_DAYS))
157
+ if not retention_raw.isdigit() or not 1 <= int(retention_raw) <= 3650:
158
+ raise AppConfigurationError(f"{ENV_RETENTION_DAYS} must be between 1 and 3650")
159
+ origin = source.get(ENV_DASHBOARD_URL, "").strip().rstrip("/") or None
160
+ return NotificationSettings(
161
+ mode=mode,
162
+ smtp=smtp,
163
+ signing_key=signing_key,
164
+ retention=timedelta(days=int(retention_raw)),
165
+ dashboard_origin=origin,
166
+ production=production,
167
+ )
@@ -0,0 +1,108 @@
1
+ """Rendering notifications for each channel.
2
+
3
+ Every value that came from a repository or a user (repository names, rule
4
+ titles, reasons, logins) was sanitised when the event was created
5
+ (:func:`commitguard.notifications.models.clean`). Rendering adds only
6
+ channel-appropriate encoding:
7
+
8
+ * **in-app** - JSON through the API; the dashboard renders text, never HTML;
9
+ * **e-mail** - plain text; header values are passed to ``EmailMessage``;
10
+ * **webhook** - ``json.dumps`` with ASCII escaping, plus ``<``, ``>`` and ``&``
11
+ escaped as ``\u003c``-style sequences.
12
+
13
+ No template includes tokens, keys, session data or internal database IDs other
14
+ than the resource ID needed for the dashboard link.
15
+ """
16
+
17
+ import json
18
+ from datetime import UTC, datetime
19
+
20
+ from commitguard.notifications.channels.email import OutgoingEmail
21
+ from commitguard.notifications.models import DEFINITIONS, StoredNotificationEvent
22
+
23
+ _RESOURCE_PATHS = {
24
+ "violation": "/violations/{id}",
25
+ "scan": "/scans/{id}",
26
+ "policy": "/policies/{id}",
27
+ "installation": "/github/installations/{id}",
28
+ "repository": "/repositories/{id}",
29
+ "exception": "/organization/exceptions/{id}",
30
+ "draft": "/organization/policies/drafts/{id}",
31
+ "rollout": "/organization/policies/rollouts/{id}",
32
+ "organization": "/organization",
33
+ }
34
+
35
+
36
+ def resource_path(resource_type: str, resource_id: str) -> str | None:
37
+ template = _RESOURCE_PATHS.get(resource_type)
38
+ if template is None or not resource_id.isascii() or not resource_id.isalnum():
39
+ return None
40
+ return template.format(id=resource_id)
41
+
42
+
43
+ def render_email(
44
+ stored: StoredNotificationEvent,
45
+ *,
46
+ to: str,
47
+ organization: str,
48
+ dashboard_origin: str | None,
49
+ ) -> OutgoingEmail:
50
+ event = stored.event
51
+ definition = DEFINITIONS[event.type]
52
+ path = resource_path(event.resource_type, event.resource_id)
53
+ lines = [
54
+ event.title,
55
+ "",
56
+ event.body,
57
+ "",
58
+ f"Organization: {organization}",
59
+ f"Type: {definition.label}",
60
+ f"Severity: {event.severity.value.upper()}",
61
+ f"Time: {stored.last_occurred_at.astimezone(UTC).strftime('%Y-%m-%d %H:%M UTC')}",
62
+ ]
63
+ if stored.occurrences > 1:
64
+ lines.append(f"Occurrences: {stored.occurrences}")
65
+ if dashboard_origin and path:
66
+ lines += ["", f"Open in CommitGuard: {dashboard_origin}{path}"]
67
+ lines += [
68
+ "",
69
+ "--",
70
+ "You receive this because an administrator of this organization added this address "
71
+ "to CommitGuard notifications.",
72
+ "CommitGuard never rewrites Git history or changes repositories.",
73
+ ]
74
+ return OutgoingEmail(
75
+ to=to,
76
+ subject=f"[CommitGuard] {event.severity.value.upper()}: {event.title}",
77
+ text="\n".join(lines) + "\n",
78
+ )
79
+
80
+
81
+ def render_webhook(
82
+ stored: StoredNotificationEvent,
83
+ *,
84
+ organization: str,
85
+ repository: str | None,
86
+ dashboard_origin: str | None,
87
+ now: datetime,
88
+ ) -> bytes:
89
+ event = stored.event
90
+ path = resource_path(event.resource_type, event.resource_id)
91
+ document = {
92
+ "id": event.event_id,
93
+ "type": event.type.value,
94
+ "severity": event.severity.value,
95
+ "title": event.title,
96
+ "summary": event.body,
97
+ "organization": organization,
98
+ "repository": repository,
99
+ "occurrences": stored.occurrences,
100
+ "created_at": stored.created_at.astimezone(UTC).isoformat(),
101
+ "last_occurred_at": stored.last_occurred_at.astimezone(UTC).isoformat(),
102
+ "sent_at": now.astimezone(UTC).isoformat(),
103
+ "url": f"{dashboard_origin}{path}" if dashboard_origin and path else None,
104
+ "details": event.metadata,
105
+ }
106
+ text = json.dumps(document, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
107
+ # Still valid JSON; safe even if a receiver embeds the payload in an HTML page.
108
+ return text.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026").encode()
@@ -0,0 +1,5 @@
1
+ """Structured logging, correlation IDs and metrics for long-running services.
2
+
3
+ Used by the GitHub App service. The CLI, hooks and GitHub Action print
4
+ human-readable output instead and do not depend on this package.
5
+ """
@@ -0,0 +1,161 @@
1
+ """Structured JSON logs with correlation IDs and secret redaction.
2
+
3
+ Every record is one JSON object::
4
+
5
+ {"ts": "...", "level": "info", "logger": "commitguard.github.worker",
6
+ "event": "scan_completed", "delivery_id": "...", "job_id": "...",
7
+ "repository": "owner/name", "result": "block", "violations": 1}
8
+
9
+ * correlation fields (``delivery_id``, ``job_id``, ``scan_id``,
10
+ ``installation_id``, ``repository``) are taken from context variables, so a
11
+ webhook, its scan, the GitHub API requests and the Check Run update share IDs;
12
+ * field names that look like credentials are replaced with ``[REDACTED]``;
13
+ * every string passes through :func:`commitguard.security.secrets.redact` and
14
+ is truncated, so untrusted commit data cannot flood the log;
15
+ * exceptions are logged by type and redacted message only - never tracebacks
16
+ with local variables.
17
+ """
18
+
19
+ import json
20
+ import logging
21
+ import sys
22
+ from collections.abc import Iterator, Mapping
23
+ from contextlib import contextmanager
24
+ from contextvars import ContextVar
25
+ from datetime import UTC, datetime
26
+ from typing import Any, TextIO
27
+
28
+ from commitguard.security.secrets import REDACTED, redact
29
+
30
+ MAX_FIELD_CHARS = 1000
31
+ MAX_FIELDS = 40
32
+ CORRELATION_FIELDS = (
33
+ "request_id",
34
+ "delivery_id",
35
+ "job_id",
36
+ "scan_id",
37
+ "notification_event_id",
38
+ "installation_id",
39
+ "repository",
40
+ )
41
+ _SENSITIVE_KEY_PARTS = (
42
+ "token",
43
+ "secret",
44
+ "password",
45
+ "private_key",
46
+ "privatekey",
47
+ "authorization",
48
+ "credential",
49
+ "jwt",
50
+ "signature",
51
+ "cookie",
52
+ )
53
+
54
+ _correlation: ContextVar[Mapping[str, str | int] | None] = ContextVar(
55
+ "commitguard_correlation", default=None
56
+ )
57
+
58
+
59
+ @contextmanager
60
+ def correlation(**fields: str | int | None) -> Iterator[None]:
61
+ """Attach correlation fields to every log record emitted inside the block."""
62
+ unknown = set(fields) - set(CORRELATION_FIELDS)
63
+ if unknown:
64
+ raise ValueError(f"unknown correlation field(s): {', '.join(sorted(unknown))}")
65
+ merged = dict(_correlation.get() or {})
66
+ merged.update({k: v for k, v in fields.items() if v is not None})
67
+ token = _correlation.set(merged)
68
+ try:
69
+ yield
70
+ finally:
71
+ _correlation.reset(token)
72
+
73
+
74
+ def current_correlation() -> dict[str, str | int]:
75
+ return dict(_correlation.get() or {})
76
+
77
+
78
+ def _is_sensitive(key: str) -> bool:
79
+ lowered = key.lower()
80
+ return any(part in lowered for part in _SENSITIVE_KEY_PARTS)
81
+
82
+
83
+ def _clean(value: Any, depth: int = 0) -> Any:
84
+ if value is None or isinstance(value, bool | int | float):
85
+ return value
86
+ if isinstance(value, str):
87
+ text = redact(value)
88
+ return text if len(text) <= MAX_FIELD_CHARS else text[:MAX_FIELD_CHARS] + "...[truncated]"
89
+ if depth < 3 and isinstance(value, Mapping):
90
+ return {
91
+ str(k): (REDACTED if _is_sensitive(str(k)) else _clean(v, depth + 1))
92
+ for k, v in list(value.items())[:MAX_FIELDS]
93
+ }
94
+ if depth < 3 and isinstance(value, list | tuple | set | frozenset):
95
+ return [_clean(v, depth + 1) for v in list(value)[:MAX_FIELDS]]
96
+ return _clean(str(value), depth)
97
+
98
+
99
+ class JsonFormatter(logging.Formatter):
100
+ def format(self, record: logging.LogRecord) -> str:
101
+ document: dict[str, Any] = {
102
+ "ts": datetime.fromtimestamp(record.created, UTC).isoformat(),
103
+ "level": record.levelname.lower(),
104
+ "logger": record.name,
105
+ "event": _clean(record.getMessage()),
106
+ }
107
+ document.update(_clean(getattr(record, "correlation", None) or {}))
108
+ fields = getattr(record, "fields", None) or {}
109
+ for key, value in list(fields.items())[:MAX_FIELDS]:
110
+ if key in document:
111
+ key = f"field_{key}"
112
+ document[key] = REDACTED if _is_sensitive(key) else _clean(value)
113
+ if record.exc_info and record.exc_info[1] is not None:
114
+ exc = record.exc_info[1]
115
+ document["error_type"] = type(exc).__name__
116
+ document["error"] = _clean(str(exc))
117
+ return json.dumps(document, ensure_ascii=True, sort_keys=False, default=str)
118
+
119
+
120
+ class StructuredLogger:
121
+ """Thin wrapper: ``log.info("scan_completed", result="block", violations=1)``."""
122
+
123
+ def __init__(self, logger: logging.Logger) -> None:
124
+ self._logger = logger
125
+
126
+ def _log(
127
+ self, level: int, event: str, fields: Mapping[str, Any], exc: BaseException | None
128
+ ) -> None:
129
+ if not self._logger.isEnabledFor(level):
130
+ return
131
+ extra = {"fields": dict(fields), "correlation": current_correlation()}
132
+ exc_info = (type(exc), exc, None) if exc is not None else None
133
+ self._logger.log(level, event, extra=extra, exc_info=exc_info)
134
+
135
+ def debug(self, event: str, **fields: Any) -> None:
136
+ self._log(logging.DEBUG, event, fields, None)
137
+
138
+ def info(self, event: str, **fields: Any) -> None:
139
+ self._log(logging.INFO, event, fields, None)
140
+
141
+ def warning(self, event: str, *, exc: BaseException | None = None, **fields: Any) -> None:
142
+ self._log(logging.WARNING, event, fields, exc)
143
+
144
+ def error(self, event: str, *, exc: BaseException | None = None, **fields: Any) -> None:
145
+ self._log(logging.ERROR, event, fields, exc)
146
+
147
+
148
+ def get_logger(name: str) -> StructuredLogger:
149
+ return StructuredLogger(logging.getLogger(name))
150
+
151
+
152
+ def configure_json_logging(stream: TextIO | None = None, level: int = logging.INFO) -> None:
153
+ """Send ``commitguard.*`` logs to ``stream`` (default stderr) as JSON lines."""
154
+ handler = logging.StreamHandler(stream or sys.stderr)
155
+ handler.setFormatter(JsonFormatter())
156
+ root = logging.getLogger("commitguard")
157
+ for existing in list(root.handlers):
158
+ root.removeHandler(existing)
159
+ root.addHandler(handler)
160
+ root.setLevel(level)
161
+ root.propagate = False
@@ -0,0 +1,105 @@
1
+ """Counters for the GitHub App service.
2
+
3
+ A deliberately small abstraction: services call ``metrics.increment(name)``
4
+ and a deployment can later bridge :class:`Metrics` to Prometheus, StatsD or
5
+ OpenTelemetry. The in-memory implementation is exposed to the readiness probe
6
+ and tests only; metric labels never contain secrets or commit data.
7
+ """
8
+
9
+ import threading
10
+ from collections import Counter
11
+ from typing import Protocol
12
+
13
+ WEBHOOKS_RECEIVED = "webhooks_received"
14
+ WEBHOOKS_REJECTED = "webhooks_rejected"
15
+ WEBHOOKS_DUPLICATE = "webhooks_duplicate"
16
+ SCANS_QUEUED = "scans_queued"
17
+ SCANS_STARTED = "scans_started"
18
+ SCANS_COMPLETED = "scans_completed"
19
+ SCANS_FAILED = "scans_failed"
20
+ SCANS_CANCELLED = "scans_cancelled"
21
+ POLICY_VIOLATIONS = "policy_violations"
22
+ GITHUB_API_ERRORS = "github_api_errors"
23
+ GITHUB_RATE_LIMITS = "github_rate_limits"
24
+ GITHUB_EVENTS_RECEIVED = "github_events_received"
25
+ GITHUB_EVENTS_FAILED = "github_events_failed"
26
+ GITHUB_EVENTS_REPLAYED = "github_events_replayed"
27
+ MERGE_GROUPS_SCANNED = "merge_groups_scanned"
28
+ MERGE_GROUPS_FAILED = "merge_groups_failed"
29
+ CHECK_RERUNS = "check_reruns"
30
+ SCAN_RETRIES = "scan_retries"
31
+ POLICY_ROLLBACKS = "policy_rollbacks"
32
+ POLICY_ROLLBACK_FAILURES = "policy_rollback_failures"
33
+ NOTIFICATIONS_CREATED = "notifications_created"
34
+ NOTIFICATIONS_SENT = "notifications_sent"
35
+ NOTIFICATIONS_FAILED = "notifications_failed"
36
+ NOTIFICATION_RETRIES = "notification_retries"
37
+
38
+ KNOWN_METRICS = frozenset(
39
+ {
40
+ WEBHOOKS_RECEIVED,
41
+ WEBHOOKS_REJECTED,
42
+ WEBHOOKS_DUPLICATE,
43
+ SCANS_QUEUED,
44
+ SCANS_STARTED,
45
+ SCANS_COMPLETED,
46
+ SCANS_FAILED,
47
+ SCANS_CANCELLED,
48
+ POLICY_VIOLATIONS,
49
+ GITHUB_API_ERRORS,
50
+ GITHUB_RATE_LIMITS,
51
+ GITHUB_EVENTS_RECEIVED,
52
+ GITHUB_EVENTS_FAILED,
53
+ GITHUB_EVENTS_REPLAYED,
54
+ MERGE_GROUPS_SCANNED,
55
+ MERGE_GROUPS_FAILED,
56
+ CHECK_RERUNS,
57
+ SCAN_RETRIES,
58
+ POLICY_ROLLBACKS,
59
+ POLICY_ROLLBACK_FAILURES,
60
+ NOTIFICATIONS_CREATED,
61
+ NOTIFICATIONS_SENT,
62
+ NOTIFICATIONS_FAILED,
63
+ NOTIFICATION_RETRIES,
64
+ }
65
+ )
66
+
67
+
68
+ class Metrics(Protocol):
69
+ def increment(self, name: str, value: int = 1, **labels: str) -> None: ...
70
+
71
+
72
+ class NullMetrics:
73
+ def increment(self, name: str, value: int = 1, **labels: str) -> None:
74
+ return None
75
+
76
+
77
+ class InMemoryMetrics:
78
+ """Thread-safe counters keyed by metric name and labels."""
79
+
80
+ def __init__(self) -> None:
81
+ self._lock = threading.Lock()
82
+ self._counters: Counter[tuple[str, tuple[tuple[str, str], ...]]] = Counter()
83
+
84
+ def increment(self, name: str, value: int = 1, **labels: str) -> None:
85
+ if name not in KNOWN_METRICS:
86
+ raise ValueError(f"unknown metric {name!r}")
87
+ key = (name, tuple(sorted(labels.items())))
88
+ with self._lock:
89
+ self._counters[key] += value
90
+
91
+ def value(self, name: str, **labels: str) -> int:
92
+ """Sum of a counter over all label sets matching ``labels``."""
93
+ with self._lock:
94
+ return sum(
95
+ count
96
+ for (metric, metric_labels), count in self._counters.items()
97
+ if metric == name and set(labels.items()) <= set(metric_labels)
98
+ )
99
+
100
+ def snapshot(self) -> dict[str, int]:
101
+ with self._lock:
102
+ totals: Counter[str] = Counter()
103
+ for (metric, _), count in self._counters.items():
104
+ totals[metric] += count
105
+ return dict(sorted(totals.items()))
@@ -0,0 +1,6 @@
1
+ """Policy engine: decides what happens to findings.
2
+
3
+ Detection and policy are separate on purpose. A detector reports *that* a
4
+ commit lists an AI co-author; the repository's policy decides whether that is
5
+ allowed, merits a warning, or blocks the commit/push.
6
+ """