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,222 @@
1
+ """Audit event model.
2
+
3
+ Audit events record security-relevant actions; they are not application logs.
4
+ Each has a stable ``type``, an **actor** (CommitGuard itself, GitHub, or a
5
+ signed-in dashboard user), tenant keys (``account_id`` - the GitHub
6
+ organisation or user account - ``installation_id``, ``repository_id``),
7
+ correlation IDs and a small, validated ``data`` mapping. Values are bounded
8
+ and sanitised on creation, and events are never updated after they are stored.
9
+
10
+ Notifications are a separate, typed stream (:mod:`commitguard.notifications`);
11
+ each notification records the audit correlation IDs of the change that caused it.
12
+ """
13
+
14
+ import re
15
+ import uuid
16
+ from datetime import datetime
17
+ from enum import StrEnum
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
20
+
21
+ from commitguard.core.decision import Action
22
+ from commitguard.security.sanitization import sanitize_for_terminal
23
+ from commitguard.security.secrets import redact
24
+
25
+ MAX_DATA_ITEMS = 32
26
+ MAX_DATA_STRING = 512
27
+ _KEY_RE = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z")
28
+
29
+ type AuditValue = str | int | bool | None
30
+
31
+
32
+ class ActorType(StrEnum):
33
+ SYSTEM = "system" # CommitGuard acting on its own (scans, lifecycle updates)
34
+ GITHUB = "github" # a verified GitHub webhook
35
+ USER = "user" # a signed-in dashboard user
36
+
37
+
38
+ class Actor(BaseModel):
39
+ model_config = ConfigDict(frozen=True, extra="forbid")
40
+
41
+ type: ActorType
42
+ id: int | None = None
43
+ login: str | None = Field(default=None, max_length=64)
44
+
45
+ @classmethod
46
+ def user(cls, user_id: int, login: str) -> "Actor":
47
+ return cls(type=ActorType.USER, id=user_id, login=login)
48
+
49
+
50
+ SYSTEM_ACTOR = Actor(type=ActorType.SYSTEM)
51
+ GITHUB_ACTOR = Actor(type=ActorType.GITHUB)
52
+
53
+
54
+ class AuditEventType(StrEnum):
55
+ INSTALLATION_CREATED = "installation_created"
56
+ INSTALLATION_REMOVED = "installation_removed"
57
+ INSTALLATION_SUSPENDED = "installation_suspended"
58
+ INSTALLATION_UNSUSPENDED = "installation_unsuspended"
59
+ INSTALLATION_PERMISSIONS_UPDATED = "installation_permissions_updated"
60
+ REPOSITORIES_ADDED = "repositories_added"
61
+ REPOSITORIES_REMOVED = "repositories_removed"
62
+ WEBHOOK_REJECTED = "webhook_rejected"
63
+ SCAN_QUEUED = "scan_queued"
64
+ REPOSITORY_SCANNED = "repository_scanned"
65
+ SCAN_PASSED = "scan_passed"
66
+ SCAN_FAILED = "scan_failed"
67
+ POLICY_VIOLATION = "policy_violation"
68
+ POLICY_MODIFICATION = "policy_modification"
69
+ CONFIGURATION_ERROR = "configuration_error"
70
+ SCAN_ERROR = "scan_error"
71
+ SCAN_CANCELLED = "scan_cancelled"
72
+ AUTHORIZATION_DENIED = "authorization_denied"
73
+ PULL_REQUEST_MERGED = "pull_request_merged"
74
+ # Control plane (dashboard) events.
75
+ USER_SIGNED_IN = "user_signed_in"
76
+ USER_SIGNED_OUT = "user_signed_out"
77
+ SESSION_REVOKED = "session_revoked"
78
+ MEMBER_ROLE_GRANTED = "member_role_granted"
79
+ MEMBER_ROLE_CHANGED = "member_role_changed"
80
+ MEMBER_REMOVED = "member_removed"
81
+ ORGANIZATION_POLICY_CHANGED = "organization_policy_changed"
82
+ VIOLATION_OPENED = "violation_opened"
83
+ VIOLATION_REOPENED = "violation_reopened"
84
+ VIOLATION_RESOLVED = "violation_resolved"
85
+ VIOLATION_ACKNOWLEDGED = "violation_acknowledged"
86
+ VIOLATION_ACKNOWLEDGEMENT_REMOVED = "violation_acknowledgement_removed"
87
+ REPOSITORY_MONITORING_DISABLED = "repository_monitoring_disabled"
88
+ REPOSITORY_MONITORING_ENABLED = "repository_monitoring_enabled"
89
+ REPOSITORIES_SYNCED = "repositories_synced"
90
+ ENFORCEMENT_STATUS_CHECKED = "enforcement_status_checked"
91
+ SCAN_REQUESTED = "scan_requested"
92
+ # Phase 7: executions, re-runs, merge queue, policy recovery, notifications.
93
+ SCAN_STARTED = "scan_started"
94
+ SCAN_RETRY_SCHEDULED = "scan_retry_scheduled"
95
+ CHECK_RERUN_REQUESTED = "check_rerun_requested"
96
+ CHECK_RERUN_REJECTED = "check_rerun_rejected"
97
+ MERGE_GROUP_CREATED = "merge_group_created"
98
+ MERGE_GROUP_PASSED = "merge_group_passed"
99
+ MERGE_GROUP_BLOCKED = "merge_group_blocked"
100
+ MERGE_GROUP_SCAN_FAILED = "merge_group_scan_failed"
101
+ MERGE_GROUP_DESTROYED = "merge_group_destroyed"
102
+ ORGANIZATION_POLICY_ROLLED_BACK = "organization_policy_rolled_back"
103
+ NOTIFICATION_CREATED = "notification_created"
104
+ NOTIFICATION_DELIVERED = "notification_delivered"
105
+ NOTIFICATION_DELIVERY_FAILED = "notification_delivery_failed"
106
+ NOTIFICATION_READ = "notification_read"
107
+ NOTIFICATION_SETTINGS_CHANGED = "notification_settings_changed"
108
+ NOTIFICATION_PREFERENCES_CHANGED = "notification_preferences_changed"
109
+ NOTIFICATION_WEBHOOK_ADDED = "notification_webhook_added"
110
+ NOTIFICATION_WEBHOOK_REMOVED = "notification_webhook_removed"
111
+ # Phase 8: organization governance.
112
+ ORGANIZATION_SETTINGS_CHANGED = "organization_settings_changed"
113
+ REPOSITORY_DISCOVERED = "repository_discovered"
114
+ REPOSITORY_ONBOARDED = "repository_onboarded"
115
+ REPOSITORY_EXCLUDED = "repository_excluded"
116
+ REPOSITORY_MODE_CHANGED = "repository_mode_changed"
117
+ REPOSITORY_ARCHIVED = "repository_archived"
118
+ REPOSITORY_SYNC_FAILED = "repository_sync_failed"
119
+ REPOSITORY_GROUP_CREATED = "repository_group_created"
120
+ REPOSITORY_GROUP_UPDATED = "repository_group_updated"
121
+ REPOSITORY_GROUP_ARCHIVED = "repository_group_archived"
122
+ REPOSITORY_GROUP_MEMBERS_ADDED = "repository_group_members_added"
123
+ REPOSITORY_GROUP_MEMBERS_REMOVED = "repository_group_members_removed"
124
+ POLICY_DRAFT_CREATED = "policy_draft_created"
125
+ POLICY_DRAFT_UPDATED = "policy_draft_updated"
126
+ POLICY_DRAFT_CANCELLED = "policy_draft_cancelled"
127
+ POLICY_APPROVAL_REQUESTED = "policy_approval_requested"
128
+ POLICY_APPROVED = "policy_approved"
129
+ POLICY_REJECTED = "policy_rejected"
130
+ POLICY_PUBLISHED = "policy_published"
131
+ POLICY_EMERGENCY_PUBLISHED = "policy_emergency_published"
132
+ POLICY_ROLLED_BACK = "policy_rolled_back"
133
+ POLICY_SIMULATED = "policy_simulated"
134
+ POLICY_ROLLOUT_STARTED = "policy_rollout_started"
135
+ POLICY_ROLLOUT_ADVANCED = "policy_rollout_advanced"
136
+ POLICY_ROLLOUT_PAUSED = "policy_rollout_paused"
137
+ POLICY_ROLLOUT_RESUMED = "policy_rollout_resumed"
138
+ POLICY_ROLLOUT_COMPLETED = "policy_rollout_completed"
139
+ POLICY_ROLLOUT_ROLLED_BACK = "policy_rollout_rolled_back"
140
+ POLICY_PROPAGATION_FAILED = "policy_propagation_failed"
141
+ EXCEPTION_REQUESTED = "exception_requested"
142
+ EXCEPTION_APPROVED = "exception_approved"
143
+ EXCEPTION_REJECTED = "exception_rejected"
144
+ EXCEPTION_CANCELLED = "exception_cancelled"
145
+ EXCEPTION_REVOKED = "exception_revoked"
146
+ EXCEPTION_EXPIRED = "exception_expired"
147
+ ORGANIZATION_RULES_CHANGED = "organization_rules_changed"
148
+ SCAN_SCHEDULE_CREATED = "scan_schedule_created"
149
+ SCAN_SCHEDULE_CHANGED = "scan_schedule_changed"
150
+ SCAN_SCHEDULE_DISABLED = "scan_schedule_disabled"
151
+ SCHEDULED_SCANS_QUEUED = "scheduled_scans_queued"
152
+ BULK_OPERATION_REQUESTED = "bulk_operation_requested"
153
+ BULK_OPERATION_FINISHED = "bulk_operation_finished"
154
+ BULK_OPERATION_CANCELLED = "bulk_operation_cancelled"
155
+ SECURITY_EVENT_ACKNOWLEDGED = "security_event_acknowledged"
156
+ REPORT_EXPORTED = "report_exported"
157
+
158
+
159
+ #: Security-relevant events. Notifications are not generated from this list: the
160
+ #: domain services emit typed notification events (:mod:`commitguard.notifications`)
161
+ #: in the same transaction as the state change.
162
+ SECURITY_ALERT_TYPES = frozenset(
163
+ {
164
+ AuditEventType.POLICY_VIOLATION,
165
+ AuditEventType.POLICY_MODIFICATION,
166
+ AuditEventType.ORGANIZATION_POLICY_CHANGED,
167
+ AuditEventType.ORGANIZATION_POLICY_ROLLED_BACK,
168
+ AuditEventType.INSTALLATION_REMOVED,
169
+ AuditEventType.INSTALLATION_SUSPENDED,
170
+ AuditEventType.REPOSITORY_MONITORING_DISABLED,
171
+ AuditEventType.MEMBER_ROLE_GRANTED,
172
+ AuditEventType.MEMBER_ROLE_CHANGED,
173
+ AuditEventType.MERGE_GROUP_BLOCKED,
174
+ AuditEventType.NOTIFICATION_SETTINGS_CHANGED,
175
+ AuditEventType.ORGANIZATION_SETTINGS_CHANGED,
176
+ AuditEventType.REPOSITORY_MODE_CHANGED,
177
+ AuditEventType.POLICY_PUBLISHED,
178
+ AuditEventType.POLICY_EMERGENCY_PUBLISHED,
179
+ AuditEventType.POLICY_ROLLED_BACK,
180
+ AuditEventType.POLICY_ROLLOUT_PAUSED,
181
+ AuditEventType.POLICY_PROPAGATION_FAILED,
182
+ AuditEventType.EXCEPTION_APPROVED,
183
+ AuditEventType.EXCEPTION_REVOKED,
184
+ AuditEventType.ORGANIZATION_RULES_CHANGED,
185
+ }
186
+ )
187
+
188
+
189
+ class AuditEvent(BaseModel):
190
+ model_config = ConfigDict(frozen=True, extra="forbid")
191
+
192
+ event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
193
+ type: AuditEventType
194
+ occurred_at: datetime
195
+ delivery_id: str | None = None
196
+ request_id: str | None = None # dashboard API request
197
+ job_id: str | None = None
198
+ scan_id: str | None = None
199
+ actor_type: ActorType = ActorType.SYSTEM
200
+ actor_id: int | None = None # GitHub user ID for ActorType.USER
201
+ actor_login: str | None = None
202
+ account_id: int | None = None # filled from the installation when stored
203
+ installation_id: int | None = None
204
+ repository_id: int | None = None
205
+ repository: str | None = None
206
+ head_sha: str | None = None
207
+ action: Action | None = None
208
+ data: dict[str, AuditValue] = {}
209
+
210
+ @field_validator("data")
211
+ @classmethod
212
+ def _bounded(cls, value: dict[str, AuditValue]) -> dict[str, AuditValue]:
213
+ if len(value) > MAX_DATA_ITEMS:
214
+ raise ValueError(f"audit data has more than {MAX_DATA_ITEMS} items")
215
+ clean: dict[str, AuditValue] = {}
216
+ for key, item in value.items():
217
+ if not _KEY_RE.match(key):
218
+ raise ValueError(f"invalid audit data key {key!r}")
219
+ if isinstance(item, str):
220
+ item = sanitize_for_terminal(redact(item), max_length=MAX_DATA_STRING)
221
+ clean[key] = item
222
+ return clean
@@ -0,0 +1,59 @@
1
+ """Audit storage interface.
2
+
3
+ Storage is append-only for callers; the only deletion is retention purging.
4
+ Reads are always scoped to a tenant (installation, optionally repository), so a
5
+ future dashboard or API cannot list another organisation's events by mistake.
6
+ The GitHub App's SQLite state store implements this interface; a PostgreSQL
7
+ implementation can replace it without touching the services.
8
+ """
9
+
10
+ import threading
11
+ from abc import ABC, abstractmethod
12
+ from datetime import datetime
13
+
14
+ from commitguard.audit.models import AuditEvent
15
+
16
+
17
+ class AuditStorage(ABC):
18
+ @abstractmethod
19
+ def append_audit_event(self, event: AuditEvent) -> None:
20
+ """Persist ``event``. Existing events are never updated."""
21
+
22
+ @abstractmethod
23
+ def list_audit_events(
24
+ self, *, installation_id: int, repository_id: int | None = None, limit: int = 100
25
+ ) -> list[AuditEvent]:
26
+ """Most recent events for one tenant, newest first."""
27
+
28
+ @abstractmethod
29
+ def purge_audit_events(self, before: datetime) -> int:
30
+ """Delete events older than ``before`` (retention). Returns the count removed."""
31
+
32
+
33
+ class InMemoryAuditStorage(AuditStorage):
34
+ def __init__(self) -> None:
35
+ self._lock = threading.Lock()
36
+ self._events: list[AuditEvent] = []
37
+
38
+ def append_audit_event(self, event: AuditEvent) -> None:
39
+ with self._lock:
40
+ self._events.append(event)
41
+
42
+ def list_audit_events(
43
+ self, *, installation_id: int, repository_id: int | None = None, limit: int = 100
44
+ ) -> list[AuditEvent]:
45
+ with self._lock:
46
+ matching = [
47
+ e
48
+ for e in self._events
49
+ if e.installation_id == installation_id
50
+ and (repository_id is None or e.repository_id == repository_id)
51
+ ]
52
+ return list(reversed(matching))[:limit]
53
+
54
+ def purge_audit_events(self, before: datetime) -> int:
55
+ with self._lock:
56
+ kept = [e for e in self._events if e.occurred_at >= before]
57
+ removed = len(self._events) - len(kept)
58
+ self._events = kept
59
+ return removed
@@ -0,0 +1,7 @@
1
+ """Provider-neutral CI enforcement.
2
+
3
+ CI providers (GitHub Actions today; GitLab, Bitbucket, Azure DevOps later)
4
+ translate their event data into a :class:`~commitguard.ci.context.CIContext`.
5
+ Everything after that - commit range, trusted policy source, detection, policy
6
+ evaluation - is shared and lives in :mod:`commitguard.services.ci`.
7
+ """
@@ -0,0 +1,60 @@
1
+ """Normalised CI event context. Contains only what CommitGuard needs."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+ from commitguard.security.validation import validate_git_sha
8
+
9
+
10
+ class CIProvider(StrEnum):
11
+ GITHUB = "github"
12
+
13
+
14
+ class CIEventKind(StrEnum):
15
+ PULL_REQUEST = "pull_request" # changes proposed for a base branch
16
+ PUSH = "push" # commits that already reached the server
17
+ MERGE_GROUP = "merge_group" # merge queue candidate
18
+
19
+
20
+ def _oid(value: str | None) -> str | None:
21
+ return None if value is None else validate_git_sha(value)
22
+
23
+
24
+ class CIContext(BaseModel):
25
+ """What changed, according to the CI provider.
26
+
27
+ SHAs are full object IDs. For pushes, ``before_sha``/``after_sha`` are None
28
+ when Git reports the all-zero ID (new ref / deleted ref).
29
+ """
30
+
31
+ model_config = ConfigDict(frozen=True, extra="forbid")
32
+
33
+ provider: CIProvider
34
+ event: CIEventKind
35
+ event_name: str = Field(description="Provider's own event name, for display")
36
+ repository: str | None = None # e.g. "owner/name"
37
+ ref: str | None = None # fully qualified ref that changed / PR base ref
38
+ default_branch: str | None = None
39
+ base_sha: str | None = None
40
+ head_sha: str | None = None
41
+ before_sha: str | None = None
42
+ after_sha: str | None = None
43
+ pull_request_number: int | None = Field(default=None, ge=1)
44
+ from_fork: bool = False
45
+ ref_deleted: bool = False
46
+
47
+ @field_validator("base_sha", "head_sha", "before_sha", "after_sha")
48
+ @classmethod
49
+ def _valid_oids(cls, value: str | None) -> str | None:
50
+ return _oid(value)
51
+
52
+ @model_validator(mode="after")
53
+ def _consistent(self) -> "CIContext":
54
+ needs_both = self.event in (CIEventKind.PULL_REQUEST, CIEventKind.MERGE_GROUP)
55
+ if needs_both and (self.base_sha is None or self.head_sha is None):
56
+ raise ValueError(f"{self.event.value} requires base and head commits")
57
+ push_mismatch = self.ref_deleted != (self.after_sha is None)
58
+ if self.event is CIEventKind.PUSH and push_mismatch:
59
+ raise ValueError("push: after commit must be absent exactly when the ref is deleted")
60
+ return self
@@ -0,0 +1,6 @@
1
+ """Command-line interface.
2
+
3
+ The CLI is the only layer that reads process state (CWD, argv) and writes to
4
+ the terminal. It composes the lower layers; it contains no detection or policy
5
+ logic of its own.
6
+ """
commitguard/cli/app.py ADDED
@@ -0,0 +1,74 @@
1
+ """Typer application and console entry point."""
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from commitguard import __version__
8
+ from commitguard.cli.commands import (
9
+ benchmark,
10
+ check,
11
+ ci,
12
+ dashboard,
13
+ doctor,
14
+ github,
15
+ hook,
16
+ init,
17
+ install,
18
+ policy,
19
+ report,
20
+ reproduce,
21
+ scan,
22
+ )
23
+
24
+ app = typer.Typer(
25
+ name="commitguard",
26
+ help="Git commit provenance and contribution policy enforcement.",
27
+ no_args_is_help=True,
28
+ add_completion=False,
29
+ # Never render local variables in tracebacks: they may hold secrets.
30
+ pretty_exceptions_enable=False,
31
+ pretty_exceptions_show_locals=False,
32
+ )
33
+
34
+
35
+ def _version_callback(value: bool) -> None:
36
+ if value:
37
+ typer.echo(f"commitguard {__version__}")
38
+ raise typer.Exit()
39
+
40
+
41
+ @app.callback()
42
+ def _root(
43
+ version: Annotated[
44
+ bool,
45
+ typer.Option(
46
+ "--version",
47
+ help="Show the version and exit.",
48
+ callback=_version_callback,
49
+ is_eager=True,
50
+ ),
51
+ ] = False,
52
+ ) -> None:
53
+ """CommitGuard analyses commits and enforces repository contribution policies."""
54
+
55
+
56
+ app.command("init")(init.init_command)
57
+ app.command("install")(install.install_command)
58
+ app.command("uninstall")(install.uninstall_command)
59
+ app.command("scan")(scan.scan_command)
60
+ app.command("check")(check.check_command)
61
+ app.command("doctor")(doctor.doctor_command)
62
+ app.add_typer(policy.policy_app, name="policy")
63
+ app.add_typer(hook.hook_app, name="hook")
64
+ app.add_typer(ci.ci_app, name="ci")
65
+ app.add_typer(github.github_app, name="github")
66
+ app.add_typer(dashboard.dashboard_app, name="dashboard")
67
+ app.add_typer(benchmark.benchmark_app, name="benchmark")
68
+ app.add_typer(reproduce.reproduce_app, name="reproduce")
69
+ app.add_typer(report.report_app, name="report")
70
+
71
+
72
+ def main() -> None:
73
+ """Console script entry point (``commitguard``)."""
74
+ app()
@@ -0,0 +1 @@
1
+ """CLI command implementations, one module per top-level command."""