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,663 @@
|
|
|
1
|
+
"""Scheduled scans: re-evaluate default branches on a daily or weekly schedule.
|
|
2
|
+
|
|
3
|
+
A schedule targets the organization, a repository group or one repository and
|
|
4
|
+
runs at a local time in a time zone (the organization's by default)::
|
|
5
|
+
|
|
6
|
+
scan_schedules "Production nightly", group Production, daily 02:00 UTC
|
|
7
|
+
│ due (next_run_at <= now)
|
|
8
|
+
▼
|
|
9
|
+
scan_schedule_runs one row per (schedule, slot) - a slot runs at most once
|
|
10
|
+
│ bounded batches per maintenance tick
|
|
11
|
+
▼
|
|
12
|
+
DefaultBranchScanner GitHub reports the default branch head (installation token)
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
scan_jobs (trigger "scheduled") -> the normal worker: authorization, check ownership,
|
|
16
|
+
current effective policy, fail-closed errors
|
|
17
|
+
|
|
18
|
+
Scheduled scans use the existing scan service and worker; there is no other
|
|
19
|
+
scanner. They are:
|
|
20
|
+
|
|
21
|
+
* **idempotent** - a run is unique per schedule and slot, and each repository's
|
|
22
|
+
job key includes the schedule and slot, so a restarted or concurrent
|
|
23
|
+
maintenance loop never queues the same scan twice;
|
|
24
|
+
* **bounded** - at most :data:`JOBS_PER_TICK` repositories are processed per
|
|
25
|
+
maintenance pass, and nothing is queued while more than :data:`MAX_BACKLOG`
|
|
26
|
+
scans wait in the queue, so a schedule over thousands of repositories never
|
|
27
|
+
floods the workers or GitHub's API;
|
|
28
|
+
* **observable** - each run records repositories covered, queued, skipped (with
|
|
29
|
+
reasons) and failed, and ends ``completed``, ``partial`` or ``failed``.
|
|
30
|
+
|
|
31
|
+
Skipped, not scanned: archived repositories, repositories the GitHub App can no
|
|
32
|
+
longer access, paused monitoring, and a default branch head that was already
|
|
33
|
+
scanned with the current effective policy. The first scheduled scan of a
|
|
34
|
+
branch has no earlier trusted commit, so - like a push of a new default branch -
|
|
35
|
+
its repository configuration comes from built-in defaults while organization
|
|
36
|
+
governance still applies (see :mod:`commitguard.services.ci`).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import json
|
|
40
|
+
import sqlite3
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
from datetime import UTC, datetime, timedelta
|
|
43
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
44
|
+
|
|
45
|
+
from pydantic import BaseModel, ConfigDict
|
|
46
|
+
|
|
47
|
+
from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEventType
|
|
48
|
+
from commitguard.ci.context import CIContext, CIEventKind, CIProvider
|
|
49
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
50
|
+
from commitguard.controlplane.errors import ConflictError, InputValidationError, NotFoundError
|
|
51
|
+
from commitguard.controlplane.policies import PolicyTarget, PolicyTargetType, target_label
|
|
52
|
+
from commitguard.controlplane.views import PolicyTargetView
|
|
53
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
54
|
+
from commitguard.github.checks import APP_PUSH_CHECK_NAME
|
|
55
|
+
from commitguard.github.client import GitHubClient
|
|
56
|
+
from commitguard.github.identifiers import RepositoryRef
|
|
57
|
+
from commitguard.github.installations import InstallationService
|
|
58
|
+
from commitguard.github.pull_requests import branch_group_key
|
|
59
|
+
from commitguard.github.storage import NewScanJob, SqliteStateStore
|
|
60
|
+
from commitguard.governance.cache import group_member_ids
|
|
61
|
+
from commitguard.governance.common import (
|
|
62
|
+
MAX_NAME_CHARS,
|
|
63
|
+
account_repositories,
|
|
64
|
+
dt,
|
|
65
|
+
is_hex_id,
|
|
66
|
+
new_id,
|
|
67
|
+
req_dt,
|
|
68
|
+
require,
|
|
69
|
+
text,
|
|
70
|
+
ts,
|
|
71
|
+
visible_repository_ids,
|
|
72
|
+
)
|
|
73
|
+
from commitguard.governance.inventory import governance_state
|
|
74
|
+
from commitguard.governance.resolver import GovernanceResolver
|
|
75
|
+
from commitguard.governance.settings import load_settings
|
|
76
|
+
from commitguard.governance.workflow import parse_target
|
|
77
|
+
from commitguard.observability.logging import get_logger
|
|
78
|
+
from commitguard.security.hashing import fingerprint
|
|
79
|
+
from commitguard.services.audit import AuditService
|
|
80
|
+
|
|
81
|
+
log = get_logger(__name__)
|
|
82
|
+
|
|
83
|
+
JOBS_PER_TICK = 50
|
|
84
|
+
MAX_BACKLOG = 200
|
|
85
|
+
MAX_SCHEDULES_PER_ORGANIZATION = 100
|
|
86
|
+
CADENCES = ("daily", "weekly")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ScheduleRunView(BaseModel):
|
|
90
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
91
|
+
|
|
92
|
+
id: str
|
|
93
|
+
slot: datetime
|
|
94
|
+
state: str
|
|
95
|
+
started_at: datetime
|
|
96
|
+
completed_at: datetime | None
|
|
97
|
+
repositories: int
|
|
98
|
+
queued: int
|
|
99
|
+
skipped: int
|
|
100
|
+
failed: int
|
|
101
|
+
detail: dict[str, int]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ScanScheduleView(BaseModel):
|
|
105
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
106
|
+
|
|
107
|
+
id: str
|
|
108
|
+
organization_id: int
|
|
109
|
+
name: str
|
|
110
|
+
target: PolicyTargetView
|
|
111
|
+
cadence: str
|
|
112
|
+
hour: int
|
|
113
|
+
minute: int
|
|
114
|
+
weekday: int | None
|
|
115
|
+
timezone: str
|
|
116
|
+
enabled: bool
|
|
117
|
+
next_run_at: datetime | None
|
|
118
|
+
revision: int
|
|
119
|
+
repositories_covered: int
|
|
120
|
+
last_run: ScheduleRunView | None
|
|
121
|
+
created_by: str | None
|
|
122
|
+
created_at: datetime
|
|
123
|
+
updated_by: str | None
|
|
124
|
+
updated_at: datetime
|
|
125
|
+
can_manage: bool
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def next_occurrence(
|
|
129
|
+
cadence: str, hour: int, minute: int, weekday: int | None, timezone: str, after: datetime
|
|
130
|
+
) -> datetime:
|
|
131
|
+
"""The first local ``hour:minute`` (on ``weekday`` for weekly) strictly after ``after``."""
|
|
132
|
+
zone = ZoneInfo(timezone)
|
|
133
|
+
local = after.astimezone(zone)
|
|
134
|
+
for offset in range(0, 15):
|
|
135
|
+
day = (local + timedelta(days=offset)).date()
|
|
136
|
+
if cadence == "weekly" and day.weekday() != weekday:
|
|
137
|
+
continue
|
|
138
|
+
candidate = datetime(day.year, day.month, day.day, hour, minute, tzinfo=zone)
|
|
139
|
+
if candidate > local:
|
|
140
|
+
return candidate.astimezone(UTC)
|
|
141
|
+
raise ValueError("no occurrence found") # pragma: no cover - 15 days cover any week
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _schedule_fields(
|
|
145
|
+
cadence: object, hour: object, minute: object, weekday: object, timezone: object
|
|
146
|
+
) -> tuple[str, int, int, int | None, str]:
|
|
147
|
+
if cadence not in CADENCES:
|
|
148
|
+
raise InputValidationError("cadence must be daily or weekly", field="cadence")
|
|
149
|
+
assert isinstance(cadence, str) # noqa: S101 - checked above
|
|
150
|
+
|
|
151
|
+
def bounded(name: str, value: object, upper: int) -> int:
|
|
152
|
+
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= upper:
|
|
153
|
+
raise InputValidationError(f"{name} must be between 0 and {upper}", field=name)
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
clean_hour = bounded("hour", hour, 23)
|
|
157
|
+
clean_minute = bounded("minute", minute, 59)
|
|
158
|
+
clean_weekday = bounded("weekday", weekday, 6) if cadence == "weekly" else None
|
|
159
|
+
if not isinstance(timezone, str) or len(timezone) > 64:
|
|
160
|
+
raise InputValidationError("timezone must be an IANA time zone", field="timezone")
|
|
161
|
+
try:
|
|
162
|
+
ZoneInfo(timezone)
|
|
163
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
164
|
+
raise InputValidationError("unknown time zone", field="timezone") from None
|
|
165
|
+
return cadence, clean_hour, clean_minute, clean_weekday, timezone
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class DefaultBranchScanner:
|
|
169
|
+
"""Queue a scan of a repository's current default branch head."""
|
|
170
|
+
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
store: SqliteStateStore,
|
|
174
|
+
installations: InstallationService,
|
|
175
|
+
client: GitHubClient,
|
|
176
|
+
resolver: GovernanceResolver,
|
|
177
|
+
enqueue: Callable[[str], bool],
|
|
178
|
+
*,
|
|
179
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
180
|
+
) -> None:
|
|
181
|
+
self._store = store
|
|
182
|
+
self._installations = installations
|
|
183
|
+
self._client = client
|
|
184
|
+
self._resolver = resolver
|
|
185
|
+
self._enqueue = enqueue
|
|
186
|
+
self._now = now
|
|
187
|
+
|
|
188
|
+
def queue(
|
|
189
|
+
self,
|
|
190
|
+
account_id: int,
|
|
191
|
+
repository_id: int,
|
|
192
|
+
*,
|
|
193
|
+
key: tuple[str, ...],
|
|
194
|
+
requested_by: str | None,
|
|
195
|
+
schedule_id: str | None = None,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""``queued``, ``skipped:<reason>``; raises when GitHub cannot be reached."""
|
|
198
|
+
repository = account_repositories(self._store, account_id).get(repository_id)
|
|
199
|
+
if repository is None:
|
|
200
|
+
return "skipped:not_found"
|
|
201
|
+
if repository.archived:
|
|
202
|
+
return "skipped:archived"
|
|
203
|
+
if not repository.connected:
|
|
204
|
+
return "skipped:disconnected"
|
|
205
|
+
if not self._store.monitoring_enabled(repository.installation_id, repository_id):
|
|
206
|
+
return "skipped:monitoring_paused"
|
|
207
|
+
if governance_state(self._store, account_id, repository_id).onboarding == "excluded":
|
|
208
|
+
return "skipped:excluded"
|
|
209
|
+
ref = RepositoryRef(id=repository_id, owner=repository.owner, name=repository.name)
|
|
210
|
+
authorized = self._installations.authorize(repository.installation_id, ref)
|
|
211
|
+
branch = authorized.default_branch
|
|
212
|
+
if not branch:
|
|
213
|
+
return "skipped:no_default_branch"
|
|
214
|
+
info = self._client.get_branch(authorized.token.token, authorized.repository, branch)
|
|
215
|
+
if info.commit is None:
|
|
216
|
+
return "skipped:no_default_branch"
|
|
217
|
+
head = info.commit.sha
|
|
218
|
+
branch_ref = f"refs/heads/{branch}"
|
|
219
|
+
group = branch_group_key(branch_ref)
|
|
220
|
+
previous = self._store.query(
|
|
221
|
+
"SELECT head_sha, governance_fingerprint FROM scan_jobs WHERE installation_id = ? "
|
|
222
|
+
"AND repository_id = ? AND group_key = ? AND state IN ('passed', 'failed') "
|
|
223
|
+
"ORDER BY sequence DESC LIMIT 1",
|
|
224
|
+
(repository.installation_id, repository_id, group),
|
|
225
|
+
)
|
|
226
|
+
before = str(previous[0]["head_sha"]) if previous else None
|
|
227
|
+
if previous and before == head:
|
|
228
|
+
current = self._resolver.for_repository(account_id, repository_id).fingerprint
|
|
229
|
+
if previous[0]["governance_fingerprint"] == current:
|
|
230
|
+
return "skipped:unchanged"
|
|
231
|
+
before = None # re-evaluate the branch head under the changed policy
|
|
232
|
+
context = CIContext(
|
|
233
|
+
provider=CIProvider.GITHUB,
|
|
234
|
+
event=CIEventKind.PUSH,
|
|
235
|
+
event_name="schedule",
|
|
236
|
+
repository=authorized.repository.full_name,
|
|
237
|
+
ref=branch_ref,
|
|
238
|
+
default_branch=branch,
|
|
239
|
+
before_sha=before if before != head else None,
|
|
240
|
+
after_sha=head,
|
|
241
|
+
)
|
|
242
|
+
job, created = self._store.create_job(
|
|
243
|
+
NewScanJob(
|
|
244
|
+
job_key=fingerprint(["scheduled", *key, str(repository_id), head]),
|
|
245
|
+
installation_id=repository.installation_id,
|
|
246
|
+
repository=authorized.repository,
|
|
247
|
+
delivery_id=None,
|
|
248
|
+
event="scheduled",
|
|
249
|
+
group_key=group,
|
|
250
|
+
head_sha=head,
|
|
251
|
+
check_name=APP_PUSH_CHECK_NAME,
|
|
252
|
+
pull_request_number=None,
|
|
253
|
+
context=context,
|
|
254
|
+
requested_by=requested_by,
|
|
255
|
+
),
|
|
256
|
+
self._now(),
|
|
257
|
+
)
|
|
258
|
+
if not created:
|
|
259
|
+
return "skipped:already_queued"
|
|
260
|
+
if schedule_id is not None:
|
|
261
|
+
with self._store.transaction() as db:
|
|
262
|
+
db.execute(
|
|
263
|
+
"UPDATE scan_jobs SET schedule_id = ? WHERE job_id = ?",
|
|
264
|
+
(schedule_id, job.job_id),
|
|
265
|
+
)
|
|
266
|
+
self._enqueue(job.job_id) # a full queue is recovered by maintenance
|
|
267
|
+
return "queued"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ScanScheduleService:
|
|
271
|
+
def __init__(
|
|
272
|
+
self,
|
|
273
|
+
store: SqliteStateStore,
|
|
274
|
+
audit: AuditService,
|
|
275
|
+
scanner: DefaultBranchScanner | None,
|
|
276
|
+
*,
|
|
277
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
278
|
+
) -> None:
|
|
279
|
+
self._store = store
|
|
280
|
+
self._audit = audit
|
|
281
|
+
self._scanner = scanner
|
|
282
|
+
self._now = now
|
|
283
|
+
|
|
284
|
+
# -- scope ------------------------------------------------------------ #
|
|
285
|
+
def _scope(self, db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> list[int]:
|
|
286
|
+
repositories = sorted(account_repositories(db, account_id))
|
|
287
|
+
if target.type is PolicyTargetType.GROUP:
|
|
288
|
+
members = set(group_member_ids(db, account_id, target.id))
|
|
289
|
+
return [r for r in repositories if r in members]
|
|
290
|
+
if target.type is PolicyTargetType.REPOSITORY:
|
|
291
|
+
return [r for r in repositories if r == int(target.id)]
|
|
292
|
+
return repositories
|
|
293
|
+
|
|
294
|
+
# -- views ------------------------------------------------------------ #
|
|
295
|
+
def _row(self, schedule_id: str) -> sqlite3.Row:
|
|
296
|
+
if not is_hex_id(schedule_id):
|
|
297
|
+
raise NotFoundError()
|
|
298
|
+
rows = self._store.query(
|
|
299
|
+
"SELECT * FROM scan_schedules WHERE schedule_id = ?", (schedule_id,)
|
|
300
|
+
)
|
|
301
|
+
if not rows:
|
|
302
|
+
raise NotFoundError()
|
|
303
|
+
return rows[0]
|
|
304
|
+
|
|
305
|
+
@staticmethod
|
|
306
|
+
def _run_view(row: sqlite3.Row) -> ScheduleRunView:
|
|
307
|
+
detail = json.loads(str(row["detail"] or "{}"))
|
|
308
|
+
return ScheduleRunView(
|
|
309
|
+
id=str(row["run_id"]),
|
|
310
|
+
slot=req_dt(row["slot"]),
|
|
311
|
+
state=str(row["state"]),
|
|
312
|
+
started_at=req_dt(row["started_at"]),
|
|
313
|
+
completed_at=dt(row["completed_at"]),
|
|
314
|
+
repositories=int(row["repositories"]),
|
|
315
|
+
queued=int(row["queued"]),
|
|
316
|
+
skipped=int(row["skipped"]),
|
|
317
|
+
failed=int(row["failed"]),
|
|
318
|
+
detail={str(k): int(v) for k, v in detail.items() if isinstance(v, int)},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _view(self, row: sqlite3.Row, principal: Principal) -> ScanScheduleView:
|
|
322
|
+
account_id = int(row["account_id"])
|
|
323
|
+
target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
|
|
324
|
+
with self._store.transaction() as db:
|
|
325
|
+
label = target_label(db, account_id, target) or "(removed)"
|
|
326
|
+
covered = len(self._scope(db, account_id, target))
|
|
327
|
+
runs = self._store.query(
|
|
328
|
+
"SELECT * FROM scan_schedule_runs WHERE schedule_id = ? ORDER BY slot DESC LIMIT 1",
|
|
329
|
+
(row["schedule_id"],),
|
|
330
|
+
)
|
|
331
|
+
return ScanScheduleView(
|
|
332
|
+
id=str(row["schedule_id"]),
|
|
333
|
+
organization_id=account_id,
|
|
334
|
+
name=str(row["name"]),
|
|
335
|
+
target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
|
|
336
|
+
cadence=str(row["cadence"]),
|
|
337
|
+
hour=int(row["hour"]),
|
|
338
|
+
minute=int(row["minute"]),
|
|
339
|
+
weekday=row["weekday"],
|
|
340
|
+
timezone=str(row["timezone"]),
|
|
341
|
+
enabled=bool(row["enabled"]),
|
|
342
|
+
next_run_at=dt(row["next_run_at"]) if row["enabled"] else None,
|
|
343
|
+
revision=int(row["revision"]),
|
|
344
|
+
repositories_covered=covered,
|
|
345
|
+
last_run=self._run_view(runs[0]) if runs else None,
|
|
346
|
+
created_by=row["created_by_login"],
|
|
347
|
+
created_at=req_dt(row["created_at"]),
|
|
348
|
+
updated_by=row["updated_by_login"],
|
|
349
|
+
updated_at=req_dt(row["updated_at"]),
|
|
350
|
+
can_manage=principal.can(Permission.SECURITY_MANAGE, account_id),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def list_schedules(self, principal: Principal, account_id: int) -> list[ScanScheduleView]:
|
|
354
|
+
require(principal, Permission.SECURITY_READ, account_id)
|
|
355
|
+
rows = self._store.query(
|
|
356
|
+
"SELECT * FROM scan_schedules WHERE account_id = ? ORDER BY created_at LIMIT ?",
|
|
357
|
+
(account_id, MAX_SCHEDULES_PER_ORGANIZATION),
|
|
358
|
+
)
|
|
359
|
+
return [self._view(row, principal) for row in rows]
|
|
360
|
+
|
|
361
|
+
def get(
|
|
362
|
+
self, principal: Principal, schedule_id: str
|
|
363
|
+
) -> tuple[ScanScheduleView, list[ScheduleRunView]]:
|
|
364
|
+
row = self._row(schedule_id)
|
|
365
|
+
require(principal, Permission.SECURITY_READ, int(row["account_id"]))
|
|
366
|
+
runs = self._store.query(
|
|
367
|
+
"SELECT * FROM scan_schedule_runs WHERE schedule_id = ? ORDER BY slot DESC LIMIT 20",
|
|
368
|
+
(schedule_id,),
|
|
369
|
+
)
|
|
370
|
+
return self._view(row, principal), [self._run_view(r) for r in runs]
|
|
371
|
+
|
|
372
|
+
# -- writes ----------------------------------------------------------- #
|
|
373
|
+
def create(
|
|
374
|
+
self, principal: Principal, account_id: int, body: dict[str, object]
|
|
375
|
+
) -> ScanScheduleView:
|
|
376
|
+
require(principal, Permission.SECURITY_MANAGE, account_id)
|
|
377
|
+
target = parse_target(body.get("target_type"), body.get("target_id"))
|
|
378
|
+
with self._store.transaction() as db:
|
|
379
|
+
if target_label(db, account_id, target) is None:
|
|
380
|
+
raise NotFoundError()
|
|
381
|
+
if target.type is PolicyTargetType.REPOSITORY and int(target.id) not in (
|
|
382
|
+
visible_repository_ids(self._store, principal, account_id)
|
|
383
|
+
):
|
|
384
|
+
raise NotFoundError()
|
|
385
|
+
name = text(body.get("name"), "name", limit=MAX_NAME_CHARS, required=True)
|
|
386
|
+
timezone = body.get("timezone") or load_settings(self._store, account_id).settings.timezone
|
|
387
|
+
cadence, hour, minute, weekday, zone = _schedule_fields(
|
|
388
|
+
body.get("cadence"),
|
|
389
|
+
body.get("hour"),
|
|
390
|
+
body.get("minute", 0),
|
|
391
|
+
body.get("weekday"),
|
|
392
|
+
timezone,
|
|
393
|
+
)
|
|
394
|
+
enabled = body.get("enabled", True)
|
|
395
|
+
if not isinstance(enabled, bool):
|
|
396
|
+
raise InputValidationError("enabled must be true or false", field="enabled")
|
|
397
|
+
now = self._now()
|
|
398
|
+
schedule_id = new_id()
|
|
399
|
+
next_run = next_occurrence(cadence, hour, minute, weekday, zone, now)
|
|
400
|
+
with self._store.transaction() as db:
|
|
401
|
+
count = db.execute(
|
|
402
|
+
"SELECT COUNT(*) AS n FROM scan_schedules WHERE account_id = ?", (account_id,)
|
|
403
|
+
).fetchone()["n"]
|
|
404
|
+
if int(count) >= MAX_SCHEDULES_PER_ORGANIZATION:
|
|
405
|
+
raise ConflictError(
|
|
406
|
+
f"An organization can have at most {MAX_SCHEDULES_PER_ORGANIZATION} schedules."
|
|
407
|
+
)
|
|
408
|
+
db.execute(
|
|
409
|
+
"INSERT INTO scan_schedules (schedule_id, account_id, target_type, target_id, "
|
|
410
|
+
"name, cadence, hour, minute, weekday, timezone, enabled, next_run_at, created_at, "
|
|
411
|
+
"created_by_login, updated_at, updated_by_login) "
|
|
412
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
413
|
+
(
|
|
414
|
+
schedule_id,
|
|
415
|
+
account_id,
|
|
416
|
+
target.type.value,
|
|
417
|
+
target.id,
|
|
418
|
+
name,
|
|
419
|
+
cadence,
|
|
420
|
+
hour,
|
|
421
|
+
minute,
|
|
422
|
+
weekday,
|
|
423
|
+
zone,
|
|
424
|
+
1 if enabled else 0,
|
|
425
|
+
ts(next_run),
|
|
426
|
+
ts(now),
|
|
427
|
+
principal.login,
|
|
428
|
+
ts(now),
|
|
429
|
+
principal.login,
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
stored = self._store.insert_audit_event(
|
|
433
|
+
db,
|
|
434
|
+
self._audit.build(
|
|
435
|
+
AuditEventType.SCAN_SCHEDULE_CREATED,
|
|
436
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
437
|
+
account_id=account_id,
|
|
438
|
+
schedule=schedule_id,
|
|
439
|
+
name=name,
|
|
440
|
+
target_type=target.type.value,
|
|
441
|
+
target_id=target.id or None,
|
|
442
|
+
cadence=cadence,
|
|
443
|
+
time=f"{hour:02d}:{minute:02d} {zone}",
|
|
444
|
+
enabled=enabled,
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
self._audit.log_stored(stored)
|
|
448
|
+
return self._view(self._row(schedule_id), principal)
|
|
449
|
+
|
|
450
|
+
def update(
|
|
451
|
+
self, principal: Principal, schedule_id: str, body: dict[str, object]
|
|
452
|
+
) -> ScanScheduleView:
|
|
453
|
+
row = self._row(schedule_id)
|
|
454
|
+
account_id = int(row["account_id"])
|
|
455
|
+
require(principal, Permission.SECURITY_MANAGE, account_id)
|
|
456
|
+
expected = body.get("expected_revision")
|
|
457
|
+
if not isinstance(expected, int) or isinstance(expected, bool):
|
|
458
|
+
raise InputValidationError(
|
|
459
|
+
"expected_revision must be an integer", field="expected_revision"
|
|
460
|
+
)
|
|
461
|
+
name = (
|
|
462
|
+
text(body.get("name"), "name", limit=MAX_NAME_CHARS, required=True)
|
|
463
|
+
if "name" in body
|
|
464
|
+
else row["name"]
|
|
465
|
+
)
|
|
466
|
+
cadence, hour, minute, weekday, zone = _schedule_fields(
|
|
467
|
+
body.get("cadence", row["cadence"]),
|
|
468
|
+
body.get("hour", row["hour"]),
|
|
469
|
+
body.get("minute", row["minute"]),
|
|
470
|
+
body.get("weekday", row["weekday"]),
|
|
471
|
+
body.get("timezone", row["timezone"]),
|
|
472
|
+
)
|
|
473
|
+
enabled = body.get("enabled", bool(row["enabled"]))
|
|
474
|
+
if not isinstance(enabled, bool):
|
|
475
|
+
raise InputValidationError("enabled must be true or false", field="enabled")
|
|
476
|
+
now = self._now()
|
|
477
|
+
next_run = next_occurrence(cadence, hour, minute, weekday, zone, now)
|
|
478
|
+
with self._store.transaction() as db:
|
|
479
|
+
changed = db.execute(
|
|
480
|
+
"UPDATE scan_schedules SET name = ?, cadence = ?, hour = ?, minute = ?, "
|
|
481
|
+
"weekday = ?, timezone = ?, enabled = ?, next_run_at = ?, revision = revision + 1, "
|
|
482
|
+
"updated_at = ?, updated_by_login = ? WHERE schedule_id = ? AND revision = ?",
|
|
483
|
+
(
|
|
484
|
+
name,
|
|
485
|
+
cadence,
|
|
486
|
+
hour,
|
|
487
|
+
minute,
|
|
488
|
+
weekday,
|
|
489
|
+
zone,
|
|
490
|
+
1 if enabled else 0,
|
|
491
|
+
ts(next_run),
|
|
492
|
+
ts(now),
|
|
493
|
+
principal.login,
|
|
494
|
+
schedule_id,
|
|
495
|
+
expected,
|
|
496
|
+
),
|
|
497
|
+
).rowcount
|
|
498
|
+
if changed != 1:
|
|
499
|
+
raise ConflictError("The schedule was changed by someone else. Reload first.")
|
|
500
|
+
stored = self._store.insert_audit_event(
|
|
501
|
+
db,
|
|
502
|
+
self._audit.build(
|
|
503
|
+
AuditEventType.SCAN_SCHEDULE_DISABLED
|
|
504
|
+
if bool(row["enabled"]) and not enabled
|
|
505
|
+
else AuditEventType.SCAN_SCHEDULE_CHANGED,
|
|
506
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
507
|
+
account_id=account_id,
|
|
508
|
+
schedule=schedule_id,
|
|
509
|
+
name=str(name),
|
|
510
|
+
cadence=cadence,
|
|
511
|
+
time=f"{hour:02d}:{minute:02d} {zone}",
|
|
512
|
+
enabled=enabled,
|
|
513
|
+
),
|
|
514
|
+
)
|
|
515
|
+
self._audit.log_stored(stored)
|
|
516
|
+
return self._view(self._row(schedule_id), principal)
|
|
517
|
+
|
|
518
|
+
def disable(self, principal: Principal, schedule_id: str) -> ScanScheduleView:
|
|
519
|
+
row = self._row(schedule_id)
|
|
520
|
+
return self.update(
|
|
521
|
+
principal,
|
|
522
|
+
schedule_id,
|
|
523
|
+
{"expected_revision": int(row["revision"]), "enabled": False},
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# -- background ------------------------------------------------------- #
|
|
527
|
+
def run_due(self) -> dict[str, int]:
|
|
528
|
+
"""Start due runs and process running ones within the per-tick budget."""
|
|
529
|
+
now = self._now()
|
|
530
|
+
started = 0
|
|
531
|
+
for row in self._store.query(
|
|
532
|
+
"SELECT * FROM scan_schedules WHERE enabled = 1 AND next_run_at <= ? "
|
|
533
|
+
"ORDER BY next_run_at LIMIT 50",
|
|
534
|
+
(ts(now),),
|
|
535
|
+
):
|
|
536
|
+
slot = float(row["next_run_at"])
|
|
537
|
+
following = next_occurrence(
|
|
538
|
+
row["cadence"], row["hour"], row["minute"], row["weekday"], row["timezone"], now
|
|
539
|
+
)
|
|
540
|
+
with self._store.transaction() as db:
|
|
541
|
+
db.execute(
|
|
542
|
+
"UPDATE scan_schedules SET next_run_at = ? WHERE schedule_id = ? "
|
|
543
|
+
"AND next_run_at = ?",
|
|
544
|
+
(ts(following), row["schedule_id"], slot),
|
|
545
|
+
)
|
|
546
|
+
target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
|
|
547
|
+
repositories = self._scope(db, int(row["account_id"]), target)
|
|
548
|
+
cursor = db.execute(
|
|
549
|
+
"INSERT OR IGNORE INTO scan_schedule_runs (run_id, schedule_id, account_id, "
|
|
550
|
+
"slot, state, started_at, repositories, detail) "
|
|
551
|
+
"VALUES (?, ?, ?, ?, 'running', ?, ?, '{}')",
|
|
552
|
+
(
|
|
553
|
+
new_id(),
|
|
554
|
+
row["schedule_id"],
|
|
555
|
+
row["account_id"],
|
|
556
|
+
slot,
|
|
557
|
+
ts(now),
|
|
558
|
+
len(repositories),
|
|
559
|
+
),
|
|
560
|
+
)
|
|
561
|
+
started += cursor.rowcount
|
|
562
|
+
processed = self._process_runs()
|
|
563
|
+
return {"started": started, **processed}
|
|
564
|
+
|
|
565
|
+
def _process_runs(self) -> dict[str, int]:
|
|
566
|
+
totals = {"queued": 0, "skipped": 0, "failed": 0}
|
|
567
|
+
backlog = int(
|
|
568
|
+
self._store.query("SELECT COUNT(*) AS n FROM scan_jobs WHERE state = 'queued'")[0]["n"]
|
|
569
|
+
)
|
|
570
|
+
budget = JOBS_PER_TICK if backlog < MAX_BACKLOG else 0
|
|
571
|
+
if budget == 0:
|
|
572
|
+
log.info("scheduled_scans_deferred", backlog=backlog)
|
|
573
|
+
for run in self._store.query(
|
|
574
|
+
"SELECT r.*, s.target_type, s.target_id, s.name FROM scan_schedule_runs r JOIN "
|
|
575
|
+
"scan_schedules s ON s.schedule_id = r.schedule_id WHERE r.state = 'running' "
|
|
576
|
+
"ORDER BY r.started_at LIMIT 20"
|
|
577
|
+
):
|
|
578
|
+
account_id = int(run["account_id"])
|
|
579
|
+
target = PolicyTarget(PolicyTargetType(run["target_type"]), str(run["target_id"]))
|
|
580
|
+
with self._store.transaction() as db:
|
|
581
|
+
repositories = self._scope(db, account_id, target)
|
|
582
|
+
position = int(run["cursor"])
|
|
583
|
+
detail: dict[str, int] = json.loads(str(run["detail"] or "{}"))
|
|
584
|
+
counts = {k: int(run[k]) for k in ("queued", "skipped", "failed")}
|
|
585
|
+
while position < len(repositories) and budget > 0:
|
|
586
|
+
repository_id = repositories[position]
|
|
587
|
+
position += 1
|
|
588
|
+
budget -= 1
|
|
589
|
+
try:
|
|
590
|
+
outcome = (
|
|
591
|
+
self._scanner.queue(
|
|
592
|
+
account_id,
|
|
593
|
+
repository_id,
|
|
594
|
+
key=(str(run["schedule_id"]), str(run["slot"])),
|
|
595
|
+
requested_by=f"schedule:{run['name']}"[:64],
|
|
596
|
+
schedule_id=str(run["schedule_id"]),
|
|
597
|
+
)
|
|
598
|
+
if self._scanner is not None
|
|
599
|
+
else "skipped:no_scanner"
|
|
600
|
+
)
|
|
601
|
+
except (CommitGuardError, OSError) as exc:
|
|
602
|
+
log.warning(
|
|
603
|
+
"scheduled_scan_failed",
|
|
604
|
+
repository_id=repository_id,
|
|
605
|
+
error_type=type(exc).__name__,
|
|
606
|
+
)
|
|
607
|
+
outcome = "failed"
|
|
608
|
+
if outcome == "queued":
|
|
609
|
+
counts["queued"] += 1
|
|
610
|
+
elif outcome == "failed":
|
|
611
|
+
counts["failed"] += 1
|
|
612
|
+
else:
|
|
613
|
+
counts["skipped"] += 1
|
|
614
|
+
reason = outcome.split(":", 1)[-1]
|
|
615
|
+
detail[reason] = detail.get(reason, 0) + 1
|
|
616
|
+
finished = position >= len(repositories)
|
|
617
|
+
state = "running"
|
|
618
|
+
if finished:
|
|
619
|
+
if counts["failed"] and not counts["queued"] and not counts["skipped"]:
|
|
620
|
+
state = "failed"
|
|
621
|
+
elif counts["failed"]:
|
|
622
|
+
state = "partial"
|
|
623
|
+
else:
|
|
624
|
+
state = "completed"
|
|
625
|
+
with self._store.transaction() as db:
|
|
626
|
+
db.execute(
|
|
627
|
+
"UPDATE scan_schedule_runs SET cursor = ?, queued = ?, skipped = ?, "
|
|
628
|
+
"failed = ?, "
|
|
629
|
+
"detail = ?, state = ?, completed_at = ? WHERE run_id = ?",
|
|
630
|
+
(
|
|
631
|
+
position,
|
|
632
|
+
counts["queued"],
|
|
633
|
+
counts["skipped"],
|
|
634
|
+
counts["failed"],
|
|
635
|
+
json.dumps(detail, sort_keys=True),
|
|
636
|
+
state,
|
|
637
|
+
ts(self._now()) if finished else None,
|
|
638
|
+
run["run_id"],
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
if finished:
|
|
642
|
+
stored = self._store.insert_audit_event(
|
|
643
|
+
db,
|
|
644
|
+
self._audit.build(
|
|
645
|
+
AuditEventType.SCHEDULED_SCANS_QUEUED,
|
|
646
|
+
actor=SYSTEM_ACTOR,
|
|
647
|
+
account_id=account_id,
|
|
648
|
+
schedule=str(run["schedule_id"]),
|
|
649
|
+
run=str(run["run_id"]),
|
|
650
|
+
result=state,
|
|
651
|
+
repositories=len(repositories),
|
|
652
|
+
queued=counts["queued"],
|
|
653
|
+
skipped=counts["skipped"],
|
|
654
|
+
failed=counts["failed"],
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
if finished:
|
|
658
|
+
self._audit.log_stored(stored)
|
|
659
|
+
for key in totals:
|
|
660
|
+
totals[key] += counts[key] - int(run[key])
|
|
661
|
+
if budget <= 0:
|
|
662
|
+
break
|
|
663
|
+
return totals
|