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