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,53 @@
1
+ """Which pull request webhook actions matter to CommitGuard.
2
+
3
+ ===================================== ==========================================
4
+ Action What happens
5
+ ===================================== ==========================================
6
+ ``opened``, ``synchronize``, scan the complete PR commit set
7
+ ``reopened`` (``head ^base``, trusted base policy)
8
+ ``edited`` with a base branch change scan again (different base and policy)
9
+ ``closed`` and merged record the final state (audit); no scan
10
+ ``closed`` without merge cancel queued scans; no scan
11
+ anything else (labels, reviews...) ignored: cannot change the commit set
12
+ ===================================== ==========================================
13
+
14
+ A ``synchronize`` scan always evaluates ``base..new head``, never only the
15
+ newly pushed commits, so an authoritative result covers every commit the PR
16
+ would merge.
17
+ """
18
+
19
+ from enum import StrEnum
20
+
21
+ from commitguard.github.events import PULL_REQUEST_SCAN_ACTIONS, PullRequestEvent
22
+ from commitguard.security.hashing import sha256_hex
23
+
24
+
25
+ class PullRequestDisposition(StrEnum):
26
+ SCAN = "scan"
27
+ RECORD_MERGE = "record_merge"
28
+ CLOSE = "close"
29
+ IGNORE = "ignore"
30
+
31
+
32
+ def disposition(event: PullRequestEvent) -> PullRequestDisposition:
33
+ if event.action in PULL_REQUEST_SCAN_ACTIONS:
34
+ return PullRequestDisposition.SCAN
35
+ if event.action == "edited" and event.base_changed:
36
+ return PullRequestDisposition.SCAN
37
+ if event.action == "closed":
38
+ return PullRequestDisposition.RECORD_MERGE if event.merged else PullRequestDisposition.CLOSE
39
+ return PullRequestDisposition.IGNORE
40
+
41
+
42
+ def group_key(number: int) -> str:
43
+ return f"pull_request:{int(number)}"
44
+
45
+
46
+ def branch_group_key(ref: str) -> str:
47
+ """Scan group for pushes to one branch (hashed: ref names are untrusted text)."""
48
+ return f"push:{sha256_hex(ref.encode('utf-8'))[:24]}"
49
+
50
+
51
+ def merge_group_key(head_sha: str) -> str:
52
+ """Scan group for one merge queue candidate commit (each merge group has its own SHA)."""
53
+ return f"merge_group:{head_sha}"
@@ -0,0 +1,47 @@
1
+ """Event queue abstraction between the webhook receiver and scan workers.
2
+
3
+ The webhook handler persists a :class:`~commitguard.github.storage.ScanJob`
4
+ first and then puts its ID on the queue, so the queue is only a wake-up
5
+ signal: a full queue, a crashed worker or a restart loses nothing, because
6
+ :meth:`~commitguard.github.storage.SqliteStateStore.recoverable_jobs` returns
7
+ every queued or abandoned job again. That keeps the in-process implementation
8
+ honest and lets Redis, RabbitMQ, SQS or Kafka implement the same protocol later.
9
+ """
10
+
11
+ import queue
12
+ from typing import Protocol
13
+
14
+ DEFAULT_QUEUE_SIZE = 10_000
15
+
16
+
17
+ class EventQueue(Protocol):
18
+ def put(self, job_id: str) -> bool:
19
+ """Enqueue without blocking. False if the queue is full (the job stays stored)."""
20
+ ...
21
+
22
+ def get(self, timeout: float) -> str | None:
23
+ """The next job ID, or None after ``timeout`` seconds."""
24
+ ...
25
+
26
+ def size(self) -> int: ...
27
+
28
+
29
+ class InProcessEventQueue:
30
+ def __init__(self, maxsize: int = DEFAULT_QUEUE_SIZE) -> None:
31
+ self._queue: queue.Queue[str] = queue.Queue(maxsize=maxsize)
32
+
33
+ def put(self, job_id: str) -> bool:
34
+ try:
35
+ self._queue.put_nowait(job_id)
36
+ except queue.Full:
37
+ return False
38
+ return True
39
+
40
+ def get(self, timeout: float) -> str | None:
41
+ try:
42
+ return self._queue.get(timeout=timeout)
43
+ except queue.Empty:
44
+ return None
45
+
46
+ def size(self) -> int:
47
+ return self._queue.qsize()
@@ -0,0 +1,124 @@
1
+ """Operational recovery, run by the maintenance loop.
2
+
3
+ What fails, and what happens next:
4
+
5
+ ================================= =========================================================
6
+ Failure Recovery
7
+ ================================= =========================================================
8
+ Event processing crashed The event record is ``failed`` (or ``processing`` for
9
+ (database error, process killed) longer than 10 minutes and then marked ``failed``);
10
+ GitHub's redelivery of the same delivery ID is processed
11
+ again instead of being dropped as a duplicate.
12
+ Scan could not complete: GitHub The execution is ``error`` and its check failed closed
13
+ API, network or fetch timeout when it could be published. Up to
14
+ :data:`MAX_AUTOMATIC_RETRIES` new executions (trigger
15
+ ``retry``) are scheduled after 5 and 20 minutes, only
16
+ while the scan is still the newest for its pull request,
17
+ branch or merge group, the installation is active and
18
+ monitoring is on. A retry never publishes success for
19
+ commits it did not scan.
20
+ Scan crashed on every attempt The execution becomes ``error`` after three claims, is
21
+ audited, and its check is completed as a failure.
22
+ Notification delivery failed Handled by :mod:`commitguard.notifications.retry`
23
+ (bounded retries with backoff).
24
+ ================================= =========================================================
25
+
26
+ Authorization and configuration errors are not retried automatically: they
27
+ need a human (fix permissions or configuration, then re-run the check).
28
+ """
29
+
30
+ from collections.abc import Callable
31
+ from datetime import UTC, datetime, timedelta
32
+
33
+ from commitguard.audit.models import AuditEventType
34
+ from commitguard.github.storage import JobState, ScanTrigger, SqliteStateStore
35
+ from commitguard.observability.logging import correlation, get_logger
36
+ from commitguard.observability.metrics import SCAN_RETRIES, Metrics
37
+ from commitguard.services.audit import AuditService
38
+
39
+ log = get_logger(__name__)
40
+
41
+ MAX_AUTOMATIC_RETRIES = 2
42
+ RETRY_BACKOFF = (timedelta(minutes=5), timedelta(minutes=20))
43
+ RETRY_WINDOW = timedelta(hours=24)
44
+ RETRYABLE_FAILURES = ("infrastructure", "timeout")
45
+ RECOVERY_BATCH = 50
46
+
47
+
48
+ class RecoveryService:
49
+ def __init__(
50
+ self,
51
+ store: SqliteStateStore,
52
+ audit: AuditService,
53
+ metrics: Metrics,
54
+ *,
55
+ enqueue: Callable[[str], bool],
56
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
57
+ ) -> None:
58
+ self._store = store
59
+ self._audit = audit
60
+ self._metrics = metrics
61
+ self._enqueue = enqueue
62
+ self._now = now
63
+
64
+ def run_once(self) -> dict[str, int]:
65
+ now = self._now()
66
+ stuck = self._store.fail_stuck_deliveries(now)
67
+ if stuck:
68
+ log.warning("webhook_deliveries_abandoned", count=stuck)
69
+ return {"stuck_deliveries": stuck, "scan_retries": self.retry_failed_scans()}
70
+
71
+ def retry_failed_scans(self) -> int:
72
+ now = self._now()
73
+ rows = self._store.query(
74
+ "SELECT j.job_id FROM scan_jobs j WHERE j.state = 'error' "
75
+ "AND j.failure_kind IN (?, ?) AND j.completed_at >= ? "
76
+ # still the newest execution of the newest scan of its group
77
+ "AND NOT EXISTS (SELECT 1 FROM scan_jobs n WHERE n.installation_id = j.installation_id "
78
+ "AND n.repository_id = j.repository_id AND n.group_key = j.group_key "
79
+ "AND n.sequence > j.sequence) "
80
+ "AND EXISTS (SELECT 1 FROM installations i WHERE i.installation_id = j.installation_id "
81
+ "AND i.state = 'active') "
82
+ "ORDER BY j.sequence LIMIT ?",
83
+ (*RETRYABLE_FAILURES, (now - RETRY_WINDOW).timestamp(), RECOVERY_BATCH),
84
+ )
85
+ scheduled = 0
86
+ for row in rows:
87
+ job = self._store.get_job(str(row["job_id"]))
88
+ if job is None or job.state is not JobState.ERROR or job.completed_at is None:
89
+ continue
90
+ retries = sum(
91
+ 1
92
+ for e in self._store.list_executions(
93
+ job.installation_id, job.repository.id, job.scan_key
94
+ )
95
+ if e.trigger is ScanTrigger.RETRY
96
+ )
97
+ if retries >= MAX_AUTOMATIC_RETRIES:
98
+ continue
99
+ if now - job.completed_at < RETRY_BACKOFF[retries]:
100
+ continue
101
+ if not self._store.monitoring_enabled(job.installation_id, job.repository.id):
102
+ continue
103
+ execution, created = self._store.create_execution(
104
+ job, trigger=ScanTrigger.RETRY, now=now
105
+ )
106
+ if not created:
107
+ continue
108
+ with correlation(job_id=execution.job_id, repository=job.repository.full_name):
109
+ self._audit.record(
110
+ AuditEventType.SCAN_RETRY_SCHEDULED,
111
+ installation_id=job.installation_id,
112
+ repository_id=job.repository.id,
113
+ repository=job.repository.full_name,
114
+ head_sha=job.head_sha,
115
+ job=execution.job_id,
116
+ previous_scan=job.job_id,
117
+ execution=execution.execution,
118
+ failure_kind=job.failure_kind,
119
+ retry=retries + 1,
120
+ )
121
+ self._metrics.increment(SCAN_RETRIES)
122
+ self._enqueue(execution.job_id)
123
+ scheduled += 1
124
+ return scheduled
@@ -0,0 +1,305 @@
1
+ """Metadata-only repository mirrors for server-side scanning.
2
+
3
+ The GitHub App needs the same commit objects the GitHub Action and the Git
4
+ hooks analyse, but it must never check out, build or run repository code. Each
5
+ repository gets a *bare, partial* mirror under the App's data directory::
6
+
7
+ <data_dir>/mirrors/<installation_id>/<repository_id>.git
8
+
9
+ * paths use numeric IDs only (no repository names: no traversal, no renames);
10
+ * ``git init --bare --template=``: no hooks, no work tree, nothing checked out;
11
+ * ``--filter=blob:none``: commits and trees are fetched, **file contents are
12
+ not** - only the CommitGuard configuration blobs at the commits being
13
+ evaluated are fetched explicitly by object ID;
14
+ * the exact SHAs from the (verified) event are fetched, never branch names
15
+ chosen by a payload; the default branch comes from the GitHub API;
16
+ * hardening per fetch: ``protocol.allow=never`` except the allowed protocol,
17
+ no redirects, no credential helpers, no submodules, no tags, hooks path
18
+ pointing at the null device, automatic GC and maintenance off;
19
+ * the installation token is passed as an HTTP header through ``GIT_CONFIG_*``
20
+ environment variables (not in the URL, the command line or the mirror's
21
+ config) and is registered for redaction;
22
+ * every later Git read uses ``GIT_NO_LAZY_FETCH=1``, so analysis never touches
23
+ the network.
24
+ """
25
+
26
+ import base64
27
+ import os
28
+ import shutil
29
+ import stat
30
+ import threading
31
+ import time
32
+ from collections.abc import Callable, Iterable, Sequence
33
+ from pathlib import Path
34
+ from typing import Protocol
35
+ from urllib.parse import quote, urlsplit
36
+
37
+ from commitguard.config.defaults import CONFIG_FILENAMES
38
+ from commitguard.exceptions.git import GitCommandError, GitError
39
+ from commitguard.exceptions.service import InfrastructureError, ScanError
40
+ from commitguard.git.commands import git_version, run_git
41
+ from commitguard.git.repository import Repository
42
+ from commitguard.github.errors import safe_text
43
+ from commitguard.github.identifiers import RepositoryRef
44
+ from commitguard.security.secrets import Secret, register_secret
45
+ from commitguard.security.validation import is_git_sha, validate_repository_path
46
+
47
+ MIRROR_GIT_ENV = {"GIT_NO_LAZY_FETCH": "1"}
48
+ # GIT_NO_LAZY_FETCH (Git 2.45) is what keeps analysis of a partial mirror offline;
49
+ # older Git silently ignores it, so the App refuses to run with it.
50
+ MINIMUM_MIRROR_GIT_VERSION = (2, 45)
51
+ DEFAULT_FETCH_TIMEOUT_SECONDS = 600.0
52
+ LAST_USED_MARKER = "commitguard-last-used"
53
+ _REF_NAME_MAX = 255
54
+
55
+
56
+ def _clear_read_only_and_retry(
57
+ function: Callable[[str], object], path: str, _error: BaseException
58
+ ) -> None:
59
+ """Git writes pack files read-only; on Windows they cannot be deleted until writable."""
60
+ os.chmod(path, stat.S_IWRITE)
61
+ function(path)
62
+
63
+
64
+ class FetchTimeoutError(InfrastructureError):
65
+ """Fetching commits from GitHub took longer than the configured limit."""
66
+
67
+
68
+ class RemoteLocator(Protocol):
69
+ def url_for(self, repository: RepositoryRef) -> str: ...
70
+
71
+
72
+ class GitHubRemoteLocator:
73
+ """``https://github.com/<owner>/<name>.git`` from a validated repository reference."""
74
+
75
+ def __init__(self, base_url: str = "https://github.com/") -> None:
76
+ parts = urlsplit(base_url)
77
+ if parts.scheme != "https" or not parts.hostname or parts.username or parts.query:
78
+ raise ValueError("GitHub Git base URL must be an https URL without credentials")
79
+ self._base = base_url.rstrip("/") + "/"
80
+
81
+ def url_for(self, repository: RepositoryRef) -> str:
82
+ return (
83
+ f"{self._base}{quote(repository.owner, safe='')}/{quote(repository.name, safe='')}.git"
84
+ )
85
+
86
+
87
+ def _auth_environment(url: str, token: Secret) -> dict[str, str]:
88
+ parts = urlsplit(url)
89
+ prefix = f"{parts.scheme}://{parts.netloc}/"
90
+ basic = base64.b64encode(f"x-access-token:{token.reveal()}".encode()).decode("ascii")
91
+ register_secret(basic)
92
+ return {
93
+ "GIT_CONFIG_COUNT": "1",
94
+ "GIT_CONFIG_KEY_0": f"http.{prefix}.extraHeader",
95
+ "GIT_CONFIG_VALUE_0": f"Authorization: Basic {basic}",
96
+ }
97
+
98
+
99
+ def _valid_branch(name: str) -> bool:
100
+ return (
101
+ 0 < len(name) <= _REF_NAME_MAX
102
+ and not name.startswith(("-", "/"))
103
+ and name.isprintable()
104
+ and " " not in name
105
+ and run_git(["check-ref-format", f"refs/heads/{name}"], check=False).ok
106
+ )
107
+
108
+
109
+ def require_mirror_git() -> None:
110
+ try:
111
+ version = git_version()
112
+ except GitError:
113
+ raise InfrastructureError("git is not available") from None
114
+ if version[:2] < MINIMUM_MIRROR_GIT_VERSION:
115
+ wanted = ".".join(map(str, MINIMUM_MIRROR_GIT_VERSION))
116
+ found = ".".join(map(str, version))
117
+ raise InfrastructureError(f"the GitHub App needs Git {wanted} or newer (found {found})")
118
+
119
+
120
+ class MirrorManager:
121
+ def __init__(
122
+ self,
123
+ root: Path,
124
+ locator: RemoteLocator,
125
+ *,
126
+ allowed_protocols: Sequence[str] = ("https",),
127
+ fetch_timeout: float = DEFAULT_FETCH_TIMEOUT_SECONDS,
128
+ ) -> None:
129
+ require_mirror_git()
130
+ self.root = root
131
+ self._locator = locator
132
+ self._protocols = tuple(allowed_protocols)
133
+ self._fetch_timeout = fetch_timeout
134
+ self._locks: dict[tuple[int, int], threading.Lock] = {}
135
+ self._locks_guard = threading.Lock()
136
+
137
+ def path_for(self, installation_id: int, repository_id: int) -> Path:
138
+ return self.root / str(int(installation_id)) / f"{int(repository_id)}.git"
139
+
140
+ def _lock(self, installation_id: int, repository_id: int) -> threading.Lock:
141
+ with self._locks_guard:
142
+ return self._locks.setdefault((installation_id, repository_id), threading.Lock())
143
+
144
+ def _git_options(self) -> list[str]:
145
+ options = ["-c", "protocol.allow=never"]
146
+ for protocol in self._protocols:
147
+ options += ["-c", f"protocol.{protocol}.allow=always"]
148
+ options += [
149
+ "-c", "http.followRedirects=false",
150
+ "-c", "credential.helper=",
151
+ "-c", f"core.hooksPath={os.devnull}",
152
+ "-c", "submodule.recurse=false",
153
+ "-c", "fetch.recurseSubmodules=false",
154
+ "-c", "fetch.writeCommitGraph=false",
155
+ "-c", "gc.auto=0",
156
+ "-c", "maintenance.auto=false",
157
+ ] # fmt: skip
158
+ return options
159
+
160
+ def _init(self, path: Path, url: str) -> None:
161
+ if not (path / "HEAD").is_file():
162
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
163
+ run_git(["init", "--quiet", "--bare", "--template=", "--", str(path)])
164
+ for key, value in (
165
+ ("remote.origin.url", url),
166
+ ("remote.origin.promisor", "true"),
167
+ ("remote.origin.partialclonefilter", "blob:none"),
168
+ ("core.hooksPath", os.devnull),
169
+ ("gc.auto", "0"),
170
+ ("maintenance.auto", "false"),
171
+ ):
172
+ run_git(["config", "--end-of-options", key, value], cwd=path)
173
+
174
+ def _fetch(
175
+ self, path: Path, refspecs: Sequence[str], env: dict[str, str], *, check: bool = True
176
+ ) -> bool:
177
+ args = [
178
+ *self._git_options(),
179
+ "fetch",
180
+ "--quiet",
181
+ "--no-tags",
182
+ "--no-write-fetch-head",
183
+ "--no-recurse-submodules",
184
+ "--filter=blob:none",
185
+ "origin",
186
+ *refspecs,
187
+ ]
188
+ try:
189
+ result = run_git(
190
+ args, cwd=path, check=False, timeout=self._fetch_timeout, extra_env=env
191
+ )
192
+ except GitError as exc:
193
+ if "timed out" in str(exc):
194
+ raise FetchTimeoutError("fetching commits from GitHub timed out") from None
195
+ raise InfrastructureError(f"git fetch failed: {safe_text(str(exc))}") from None
196
+ if result.ok or not check:
197
+ return result.ok
198
+ lines = result.stderr.decode("utf-8", "replace").strip().splitlines()
199
+ detail = safe_text(lines[-1]) if lines else "no details"
200
+ if any(marker in detail for marker in ("not our ref", "couldn't find remote ref")):
201
+ raise ScanError(f"a commit to scan is not available on GitHub ({detail})")
202
+ raise InfrastructureError(f"could not fetch commits from GitHub ({detail})")
203
+
204
+ def prepare(
205
+ self,
206
+ installation_id: int,
207
+ repository: RepositoryRef,
208
+ token: Secret | None,
209
+ *,
210
+ required: Sequence[str],
211
+ optional: Sequence[str] = (),
212
+ branches: Sequence[str] = (),
213
+ config_path: str | None = None,
214
+ ) -> Repository:
215
+ """Fetch commits (and config blobs) into the mirror and return it for analysis."""
216
+ for oid in (*required, *optional):
217
+ if not is_git_sha(oid):
218
+ raise ScanError("mirror fetch requires full commit ids")
219
+ if config_path is not None:
220
+ validate_repository_path(config_path)
221
+ url = self._locator.url_for(repository)
222
+ env = dict(MIRROR_GIT_ENV)
223
+ if token is not None:
224
+ env.update(_auth_environment(url, token))
225
+ path = self.path_for(installation_id, repository.id)
226
+ with self._lock(installation_id, repository.id):
227
+ try:
228
+ self._init(path, url)
229
+ except GitCommandError as exc:
230
+ raise InfrastructureError(
231
+ f"could not prepare mirror: {safe_text(str(exc))}"
232
+ ) from None
233
+ wanted = sorted(set(required))
234
+ if wanted:
235
+ self._fetch(path, [f"+{oid}:refs/commitguard/{oid}" for oid in wanted], env)
236
+ for oid in sorted(set(optional) - set(wanted)):
237
+ self._fetch(path, [f"+{oid}:refs/commitguard/{oid}"], env, check=False)
238
+ tips = []
239
+ for branch in branches:
240
+ if not _valid_branch(branch):
241
+ continue
242
+ refspec = f"+refs/heads/{branch}:refs/remotes/origin/{branch}"
243
+ if self._fetch(path, [refspec], env, check=False):
244
+ tips.append(f"refs/remotes/origin/{branch}")
245
+ blobs = self._missing_config_blobs(path, [*wanted, *optional, *tips], config_path)
246
+ if blobs:
247
+ self._fetch(path, blobs, env)
248
+ (path / LAST_USED_MARKER).touch()
249
+ return Repository(root=path, git_dir=path, git_env=MIRROR_GIT_ENV)
250
+
251
+ def _missing_config_blobs(
252
+ self, path: Path, revisions: Iterable[str], config_path: str | None
253
+ ) -> list[str]:
254
+ names = [config_path] if config_path else list(CONFIG_FILENAMES)
255
+ missing: set[str] = set()
256
+ for revision in revisions:
257
+ listed = run_git(
258
+ ["ls-tree", "-z", "--end-of-options", revision, "--", *names],
259
+ cwd=path,
260
+ check=False,
261
+ extra_env=MIRROR_GIT_ENV,
262
+ )
263
+ if not listed.ok:
264
+ continue # optional commit that could not be fetched
265
+ for entry in filter(None, listed.stdout.split(b"\x00")):
266
+ meta = entry.partition(b"\t")[0].decode("ascii", "replace").split()
267
+ if len(meta) == 3 and meta[1] == "blob" and is_git_sha(meta[2]):
268
+ exists = run_git(
269
+ ["cat-file", "-e", meta[2]], cwd=path, check=False, extra_env=MIRROR_GIT_ENV
270
+ )
271
+ if not exists.ok:
272
+ missing.add(meta[2])
273
+ return sorted(missing)
274
+
275
+ def _remove(self, target: Path) -> None:
276
+ root = self.root.resolve()
277
+ resolved = target.resolve()
278
+ if resolved == root or not resolved.is_relative_to(root) or target.is_symlink():
279
+ raise InfrastructureError("refusing to remove a path outside the mirror directory")
280
+ if resolved.exists():
281
+ shutil.rmtree(resolved, onexc=_clear_read_only_and_retry)
282
+
283
+ def remove_repository(self, installation_id: int, repository_id: int) -> None:
284
+ with self._lock(installation_id, repository_id):
285
+ self._remove(self.path_for(installation_id, repository_id))
286
+
287
+ def remove_installation(self, installation_id: int) -> None:
288
+ self._remove(self.root / str(int(installation_id)))
289
+
290
+ def purge_unused(self, older_than_seconds: float) -> int:
291
+ """Remove mirrors not used for ``older_than_seconds`` (retention)."""
292
+ if not self.root.is_dir():
293
+ return 0
294
+ cutoff = time.time() - older_than_seconds
295
+ removed = 0
296
+ for installation_dir in self.root.iterdir():
297
+ if not installation_dir.name.isdigit() or installation_dir.is_symlink():
298
+ continue
299
+ for mirror in installation_dir.glob("*.git"):
300
+ marker = mirror / LAST_USED_MARKER
301
+ used = marker.stat().st_mtime if marker.exists() else mirror.stat().st_mtime
302
+ if used < cutoff:
303
+ self._remove(mirror)
304
+ removed += 1
305
+ return removed
@@ -0,0 +1,52 @@
1
+ """A small threaded HTTP server for development and single-host deployments.
2
+
3
+ It serves the WSGI application from :mod:`commitguard.github.app` on a local
4
+ address (default ``127.0.0.1``). It does not terminate TLS: expose it to
5
+ GitHub only through a TLS-terminating reverse proxy or a development tunnel.
6
+ Access logs are structured, contain no query strings or headers, and every
7
+ connection has a socket timeout.
8
+ """
9
+
10
+ from collections.abc import Callable, Iterable
11
+ from socketserver import ThreadingMixIn
12
+ from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server
13
+ from wsgiref.types import StartResponse, WSGIEnvironment
14
+
15
+ from commitguard.observability.logging import get_logger
16
+
17
+ log = get_logger(__name__)
18
+
19
+ SOCKET_TIMEOUT_SECONDS = 30
20
+
21
+
22
+ class _ThreadingWSGIServer(ThreadingMixIn, WSGIServer):
23
+ daemon_threads = True
24
+ allow_reuse_address = True
25
+
26
+
27
+ class _QuietHandler(WSGIRequestHandler):
28
+ timeout = SOCKET_TIMEOUT_SECONDS
29
+
30
+ def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
31
+ log.info(
32
+ "http_request",
33
+ method=self.command,
34
+ path=(self.path or "").split("?", 1)[0][:200],
35
+ status=str(code),
36
+ )
37
+
38
+ def log_message(self, format: str, *args: object) -> None:
39
+ log.debug("http_server_message")
40
+
41
+
42
+ def serve(
43
+ application: Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]],
44
+ *,
45
+ host: str = "127.0.0.1",
46
+ port: int = 8080,
47
+ ) -> None:
48
+ with make_server(
49
+ host, port, application, server_class=_ThreadingWSGIServer, handler_class=_QuietHandler
50
+ ) as httpd:
51
+ log.info("http_server_listening", host=host, port=port)
52
+ httpd.serve_forever()