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