tollgate 1.0.4__py3-none-any.whl → 1.4.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.
- tollgate/__init__.py +36 -3
- tollgate/anomaly_detector.py +396 -0
- tollgate/audit.py +90 -1
- tollgate/backends/__init__.py +37 -0
- tollgate/backends/redis_store.py +411 -0
- tollgate/backends/sqlite_store.py +458 -0
- tollgate/circuit_breaker.py +206 -0
- tollgate/context_monitor.py +292 -0
- tollgate/exceptions.py +20 -0
- tollgate/grants.py +46 -0
- tollgate/manifest_signing.py +90 -0
- tollgate/network_guard.py +114 -0
- tollgate/policy.py +37 -0
- tollgate/policy_testing.py +360 -0
- tollgate/rate_limiter.py +162 -0
- tollgate/registry.py +225 -2
- tollgate/tower.py +184 -12
- tollgate/types.py +21 -1
- tollgate/verification.py +81 -0
- tollgate-1.4.0.dist-info/METADATA +393 -0
- tollgate-1.4.0.dist-info/RECORD +33 -0
- tollgate-1.4.0.dist-info/entry_points.txt +2 -0
- tollgate-1.0.4.dist-info/METADATA +0 -144
- tollgate-1.0.4.dist-info/RECORD +0 -21
- {tollgate-1.0.4.dist-info → tollgate-1.4.0.dist-info}/WHEEL +0 -0
- {tollgate-1.0.4.dist-info → tollgate-1.4.0.dist-info}/licenses/LICENSE +0 -0
tollgate/__init__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from .anomaly_detector import AnomalyAlert, AnomalyDetector
|
|
1
2
|
from .approvals import (
|
|
2
3
|
ApprovalStore,
|
|
3
4
|
Approver,
|
|
@@ -7,15 +8,24 @@ from .approvals import (
|
|
|
7
8
|
InMemoryApprovalStore,
|
|
8
9
|
compute_request_hash,
|
|
9
10
|
)
|
|
10
|
-
from .audit import AuditSink, JsonlAuditSink
|
|
11
|
+
from .audit import AuditSink, CompositeAuditSink, JsonlAuditSink, WebhookAuditSink
|
|
12
|
+
from .circuit_breaker import CircuitBreaker, CircuitState, InMemoryCircuitBreaker
|
|
13
|
+
from .context_monitor import ContextIntegrityMonitor, VerificationResult
|
|
11
14
|
from .exceptions import (
|
|
12
15
|
TollgateApprovalDenied,
|
|
16
|
+
TollgateConstraintViolation,
|
|
13
17
|
TollgateDeferred,
|
|
14
18
|
TollgateDenied,
|
|
15
19
|
TollgateError,
|
|
20
|
+
TollgateRateLimited,
|
|
16
21
|
)
|
|
17
|
-
from .grants import InMemoryGrantStore
|
|
22
|
+
from .grants import GrantStore, InMemoryGrantStore
|
|
18
23
|
from .helpers import guard, wrap_tool
|
|
24
|
+
from .manifest_signing import sign_manifest, verify_manifest, get_manifest_hash
|
|
25
|
+
from .network_guard import NetworkGuard
|
|
26
|
+
from .policy_testing import PolicyTestRunner, PolicyTestRunResult
|
|
27
|
+
from .rate_limiter import InMemoryRateLimiter, RateLimiter
|
|
28
|
+
from .verification import make_verifier, sign_agent_context, verify_agent_context
|
|
19
29
|
from .policy import PolicyEvaluator, YamlPolicyEvaluator
|
|
20
30
|
from .registry import ToolRegistry
|
|
21
31
|
from .tower import ControlTower
|
|
@@ -33,7 +43,7 @@ from .types import (
|
|
|
33
43
|
ToolRequest,
|
|
34
44
|
)
|
|
35
45
|
|
|
36
|
-
__version__ = "1.0
|
|
46
|
+
__version__ = "1.4.0"
|
|
37
47
|
|
|
38
48
|
__all__ = [
|
|
39
49
|
"ControlTower",
|
|
@@ -45,6 +55,7 @@ __all__ = [
|
|
|
45
55
|
"DecisionType",
|
|
46
56
|
"Effect",
|
|
47
57
|
"Grant",
|
|
58
|
+
"GrantStore",
|
|
48
59
|
"AuditEvent",
|
|
49
60
|
"Outcome",
|
|
50
61
|
"ApprovalOutcome",
|
|
@@ -57,6 +68,8 @@ __all__ = [
|
|
|
57
68
|
"compute_request_hash",
|
|
58
69
|
"AuditSink",
|
|
59
70
|
"JsonlAuditSink",
|
|
71
|
+
"CompositeAuditSink",
|
|
72
|
+
"WebhookAuditSink",
|
|
60
73
|
"ToolRegistry",
|
|
61
74
|
"PolicyEvaluator",
|
|
62
75
|
"YamlPolicyEvaluator",
|
|
@@ -65,6 +78,26 @@ __all__ = [
|
|
|
65
78
|
"TollgateDenied",
|
|
66
79
|
"TollgateApprovalDenied",
|
|
67
80
|
"TollgateDeferred",
|
|
81
|
+
"TollgateRateLimited",
|
|
82
|
+
"TollgateConstraintViolation",
|
|
83
|
+
"RateLimiter",
|
|
84
|
+
"InMemoryRateLimiter",
|
|
85
|
+
"CircuitBreaker",
|
|
86
|
+
"InMemoryCircuitBreaker",
|
|
87
|
+
"CircuitState",
|
|
88
|
+
"NetworkGuard",
|
|
89
|
+
"sign_manifest",
|
|
90
|
+
"verify_manifest",
|
|
91
|
+
"get_manifest_hash",
|
|
92
|
+
"sign_agent_context",
|
|
93
|
+
"verify_agent_context",
|
|
94
|
+
"make_verifier",
|
|
95
|
+
"PolicyTestRunner",
|
|
96
|
+
"PolicyTestRunResult",
|
|
97
|
+
"ContextIntegrityMonitor",
|
|
98
|
+
"VerificationResult",
|
|
99
|
+
"AnomalyDetector",
|
|
100
|
+
"AnomalyAlert",
|
|
68
101
|
"wrap_tool",
|
|
69
102
|
"guard",
|
|
70
103
|
]
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Anomaly detection on Tollgate audit event streams.
|
|
2
|
+
|
|
3
|
+
Consumes audit events and computes baseline call frequency per
|
|
4
|
+
(agent, tool) pair. Detects anomalies using z-score deviation on
|
|
5
|
+
sliding windows.
|
|
6
|
+
|
|
7
|
+
This is a complementary layer that plugs into Tollgate's AuditSink
|
|
8
|
+
protocol. It can operate as both a real-time detector (via the AuditSink
|
|
9
|
+
interface) and a batch analyzer (via ``analyze()``).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
|
|
13
|
+
from tollgate.anomaly_detector import AnomalyDetector
|
|
14
|
+
|
|
15
|
+
detector = AnomalyDetector(
|
|
16
|
+
window_seconds=300, # 5-minute sliding window
|
|
17
|
+
z_score_threshold=3.0, # Alert at 3 standard deviations
|
|
18
|
+
min_samples=10, # Need 10+ data points before alerting
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Use as an AuditSink (real-time)
|
|
22
|
+
composite = CompositeAuditSink([jsonl_sink, detector])
|
|
23
|
+
tower = ControlTower(..., audit=composite)
|
|
24
|
+
|
|
25
|
+
# Or analyze batch data
|
|
26
|
+
alerts = detector.analyze()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import math
|
|
31
|
+
import time
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from .types import AuditEvent, Outcome
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("tollgate.anomaly_detector")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AnomalyAlert:
|
|
43
|
+
"""An anomaly alert generated by the detector."""
|
|
44
|
+
|
|
45
|
+
alert_type: str # "rate_spike", "error_burst", "unusual_tool", "deny_surge"
|
|
46
|
+
agent_id: str
|
|
47
|
+
tool: str | None
|
|
48
|
+
severity: str # "low", "medium", "high", "critical"
|
|
49
|
+
message: str
|
|
50
|
+
z_score: float | None = None
|
|
51
|
+
current_rate: float = 0.0
|
|
52
|
+
baseline_rate: float = 0.0
|
|
53
|
+
timestamp: float = 0.0
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"alert_type": self.alert_type,
|
|
58
|
+
"agent_id": self.agent_id,
|
|
59
|
+
"tool": self.tool,
|
|
60
|
+
"severity": self.severity,
|
|
61
|
+
"message": self.message,
|
|
62
|
+
"z_score": self.z_score,
|
|
63
|
+
"current_rate": self.current_rate,
|
|
64
|
+
"baseline_rate": self.baseline_rate,
|
|
65
|
+
"timestamp": self.timestamp,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _SlidingWindow:
|
|
70
|
+
"""A time-based sliding window counter."""
|
|
71
|
+
|
|
72
|
+
__slots__ = ("_timestamps", "_window_seconds")
|
|
73
|
+
|
|
74
|
+
def __init__(self, window_seconds: float):
|
|
75
|
+
self._timestamps: list[float] = []
|
|
76
|
+
self._window_seconds = window_seconds
|
|
77
|
+
|
|
78
|
+
def add(self, ts: float | None = None):
|
|
79
|
+
self._timestamps.append(ts or time.time())
|
|
80
|
+
|
|
81
|
+
def _prune(self, now: float):
|
|
82
|
+
cutoff = now - self._window_seconds
|
|
83
|
+
# Binary search would be faster but list is typically small
|
|
84
|
+
self._timestamps = [t for t in self._timestamps if t > cutoff]
|
|
85
|
+
|
|
86
|
+
def count(self, now: float | None = None) -> int:
|
|
87
|
+
now = now or time.time()
|
|
88
|
+
self._prune(now)
|
|
89
|
+
return len(self._timestamps)
|
|
90
|
+
|
|
91
|
+
def rate(self, now: float | None = None) -> float:
|
|
92
|
+
"""Events per second in the current window."""
|
|
93
|
+
now = now or time.time()
|
|
94
|
+
self._prune(now)
|
|
95
|
+
if not self._timestamps:
|
|
96
|
+
return 0.0
|
|
97
|
+
return len(self._timestamps) / self._window_seconds
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _BaselineTracker:
|
|
101
|
+
"""Tracks historical rates to compute mean and standard deviation."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, max_samples: int = 100):
|
|
104
|
+
self._samples: list[float] = []
|
|
105
|
+
self._max_samples = max_samples
|
|
106
|
+
|
|
107
|
+
def add_sample(self, rate: float):
|
|
108
|
+
self._samples.append(rate)
|
|
109
|
+
if len(self._samples) > self._max_samples:
|
|
110
|
+
self._samples.pop(0)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def count(self) -> int:
|
|
114
|
+
return len(self._samples)
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def mean(self) -> float:
|
|
118
|
+
if not self._samples:
|
|
119
|
+
return 0.0
|
|
120
|
+
return sum(self._samples) / len(self._samples)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def std_dev(self) -> float:
|
|
124
|
+
if len(self._samples) < 2:
|
|
125
|
+
return 0.0
|
|
126
|
+
m = self.mean
|
|
127
|
+
variance = sum((x - m) ** 2 for x in self._samples) / (len(self._samples) - 1)
|
|
128
|
+
return math.sqrt(variance)
|
|
129
|
+
|
|
130
|
+
def z_score(self, value: float) -> float | None:
|
|
131
|
+
"""Compute z-score for a value. Returns None if insufficient data."""
|
|
132
|
+
if self.count < 2 or self.std_dev == 0:
|
|
133
|
+
return None
|
|
134
|
+
return (value - self.mean) / self.std_dev
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AnomalyDetector:
|
|
138
|
+
"""Real-time anomaly detection on Tollgate audit streams.
|
|
139
|
+
|
|
140
|
+
Implements the AuditSink protocol so it can be used directly with
|
|
141
|
+
CompositeAuditSink. Detects:
|
|
142
|
+
|
|
143
|
+
- **Rate spikes**: Unusual call frequency per (agent, tool)
|
|
144
|
+
- **Error bursts**: Sudden increase in FAILED outcomes
|
|
145
|
+
- **Deny surges**: Unusual number of BLOCKED/DENIED outcomes
|
|
146
|
+
- **Unusual tools**: Agent calling tools they haven't used before
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
window_seconds: Sliding window duration for rate calculation.
|
|
150
|
+
z_score_threshold: Number of standard deviations to trigger an alert.
|
|
151
|
+
min_samples: Minimum baseline samples before alerting.
|
|
152
|
+
alert_callback: Optional callback invoked for each alert.
|
|
153
|
+
baseline_interval: Seconds between baseline rate samples.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
window_seconds: float = 300.0,
|
|
160
|
+
z_score_threshold: float = 3.0,
|
|
161
|
+
min_samples: int = 10,
|
|
162
|
+
alert_callback: Any | None = None,
|
|
163
|
+
baseline_interval: float = 60.0,
|
|
164
|
+
):
|
|
165
|
+
self._window_seconds = window_seconds
|
|
166
|
+
self._z_threshold = z_score_threshold
|
|
167
|
+
self._min_samples = min_samples
|
|
168
|
+
self._alert_callback = alert_callback
|
|
169
|
+
self._baseline_interval = baseline_interval
|
|
170
|
+
|
|
171
|
+
# Per (agent, tool) sliding windows
|
|
172
|
+
self._call_windows: dict[str, _SlidingWindow] = defaultdict(
|
|
173
|
+
lambda: _SlidingWindow(window_seconds)
|
|
174
|
+
)
|
|
175
|
+
# Per agent error windows
|
|
176
|
+
self._error_windows: dict[str, _SlidingWindow] = defaultdict(
|
|
177
|
+
lambda: _SlidingWindow(window_seconds)
|
|
178
|
+
)
|
|
179
|
+
# Per agent deny windows
|
|
180
|
+
self._deny_windows: dict[str, _SlidingWindow] = defaultdict(
|
|
181
|
+
lambda: _SlidingWindow(window_seconds)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Baseline trackers
|
|
185
|
+
self._call_baselines: dict[str, _BaselineTracker] = defaultdict(
|
|
186
|
+
_BaselineTracker
|
|
187
|
+
)
|
|
188
|
+
self._error_baselines: dict[str, _BaselineTracker] = defaultdict(
|
|
189
|
+
_BaselineTracker
|
|
190
|
+
)
|
|
191
|
+
self._deny_baselines: dict[str, _BaselineTracker] = defaultdict(
|
|
192
|
+
_BaselineTracker
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Track known tool usage per agent
|
|
196
|
+
self._known_tools: dict[str, set[str]] = defaultdict(set)
|
|
197
|
+
|
|
198
|
+
# Last baseline sample timestamp
|
|
199
|
+
self._last_baseline_sample: float = 0.0
|
|
200
|
+
|
|
201
|
+
# Accumulated alerts
|
|
202
|
+
self._alerts: list[AnomalyAlert] = []
|
|
203
|
+
|
|
204
|
+
def _key(self, agent_id: str, tool: str) -> str:
|
|
205
|
+
return f"{agent_id}:{tool}"
|
|
206
|
+
|
|
207
|
+
def emit(self, event: AuditEvent):
|
|
208
|
+
"""AuditSink protocol — process an audit event."""
|
|
209
|
+
now = time.time()
|
|
210
|
+
agent_id = event.agent.agent_id
|
|
211
|
+
tool = event.tool_request.tool
|
|
212
|
+
key = self._key(agent_id, tool)
|
|
213
|
+
|
|
214
|
+
# Record in sliding windows
|
|
215
|
+
self._call_windows[key].add(now)
|
|
216
|
+
|
|
217
|
+
if event.outcome == Outcome.FAILED:
|
|
218
|
+
self._error_windows[agent_id].add(now)
|
|
219
|
+
|
|
220
|
+
if event.outcome in (Outcome.BLOCKED, Outcome.APPROVAL_DENIED):
|
|
221
|
+
self._deny_windows[agent_id].add(now)
|
|
222
|
+
|
|
223
|
+
# Check for unusual tool usage
|
|
224
|
+
if tool not in self._known_tools[agent_id]:
|
|
225
|
+
if self._known_tools[agent_id]: # Has history
|
|
226
|
+
alert = AnomalyAlert(
|
|
227
|
+
alert_type="unusual_tool",
|
|
228
|
+
agent_id=agent_id,
|
|
229
|
+
tool=tool,
|
|
230
|
+
severity="medium",
|
|
231
|
+
message=(
|
|
232
|
+
f"Agent '{agent_id}' called tool '{tool}' for the first "
|
|
233
|
+
f"time (known tools: {sorted(self._known_tools[agent_id])})"
|
|
234
|
+
),
|
|
235
|
+
timestamp=now,
|
|
236
|
+
)
|
|
237
|
+
self._fire_alert(alert)
|
|
238
|
+
self._known_tools[agent_id].add(tool)
|
|
239
|
+
|
|
240
|
+
# Periodically sample baselines
|
|
241
|
+
if now - self._last_baseline_sample >= self._baseline_interval:
|
|
242
|
+
self._sample_baselines(now)
|
|
243
|
+
self._last_baseline_sample = now
|
|
244
|
+
|
|
245
|
+
# Check for anomalies
|
|
246
|
+
self._check_rate_anomaly(agent_id, tool, now)
|
|
247
|
+
self._check_error_anomaly(agent_id, now)
|
|
248
|
+
self._check_deny_anomaly(agent_id, now)
|
|
249
|
+
|
|
250
|
+
def _sample_baselines(self, now: float):
|
|
251
|
+
"""Sample current rates into baseline trackers."""
|
|
252
|
+
for key, window in self._call_windows.items():
|
|
253
|
+
rate = window.rate(now)
|
|
254
|
+
self._call_baselines[key].add_sample(rate)
|
|
255
|
+
|
|
256
|
+
for agent_id, window in self._error_windows.items():
|
|
257
|
+
rate = window.rate(now)
|
|
258
|
+
self._error_baselines[agent_id].add_sample(rate)
|
|
259
|
+
|
|
260
|
+
for agent_id, window in self._deny_windows.items():
|
|
261
|
+
rate = window.rate(now)
|
|
262
|
+
self._deny_baselines[agent_id].add_sample(rate)
|
|
263
|
+
|
|
264
|
+
def _check_rate_anomaly(self, agent_id: str, tool: str, now: float):
|
|
265
|
+
key = self._key(agent_id, tool)
|
|
266
|
+
baseline = self._call_baselines[key]
|
|
267
|
+
|
|
268
|
+
if baseline.count < self._min_samples:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
current_rate = self._call_windows[key].rate(now)
|
|
272
|
+
z = baseline.z_score(current_rate)
|
|
273
|
+
|
|
274
|
+
if z is not None and z > self._z_threshold:
|
|
275
|
+
severity = "high" if z > self._z_threshold * 2 else "medium"
|
|
276
|
+
alert = AnomalyAlert(
|
|
277
|
+
alert_type="rate_spike",
|
|
278
|
+
agent_id=agent_id,
|
|
279
|
+
tool=tool,
|
|
280
|
+
severity=severity,
|
|
281
|
+
message=(
|
|
282
|
+
f"Rate spike for {agent_id}/{tool}: "
|
|
283
|
+
f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
|
|
284
|
+
f"z-score: {z:.1f})"
|
|
285
|
+
),
|
|
286
|
+
z_score=z,
|
|
287
|
+
current_rate=current_rate,
|
|
288
|
+
baseline_rate=baseline.mean,
|
|
289
|
+
timestamp=now,
|
|
290
|
+
)
|
|
291
|
+
self._fire_alert(alert)
|
|
292
|
+
|
|
293
|
+
def _check_error_anomaly(self, agent_id: str, now: float):
|
|
294
|
+
baseline = self._error_baselines[agent_id]
|
|
295
|
+
|
|
296
|
+
if baseline.count < self._min_samples:
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
current_rate = self._error_windows[agent_id].rate(now)
|
|
300
|
+
z = baseline.z_score(current_rate)
|
|
301
|
+
|
|
302
|
+
if z is not None and z > self._z_threshold:
|
|
303
|
+
severity = "high" if z > self._z_threshold * 2 else "medium"
|
|
304
|
+
alert = AnomalyAlert(
|
|
305
|
+
alert_type="error_burst",
|
|
306
|
+
agent_id=agent_id,
|
|
307
|
+
tool=None,
|
|
308
|
+
severity=severity,
|
|
309
|
+
message=(
|
|
310
|
+
f"Error burst for {agent_id}: "
|
|
311
|
+
f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
|
|
312
|
+
f"z-score: {z:.1f})"
|
|
313
|
+
),
|
|
314
|
+
z_score=z,
|
|
315
|
+
current_rate=current_rate,
|
|
316
|
+
baseline_rate=baseline.mean,
|
|
317
|
+
timestamp=now,
|
|
318
|
+
)
|
|
319
|
+
self._fire_alert(alert)
|
|
320
|
+
|
|
321
|
+
def _check_deny_anomaly(self, agent_id: str, now: float):
|
|
322
|
+
baseline = self._deny_baselines[agent_id]
|
|
323
|
+
|
|
324
|
+
if baseline.count < self._min_samples:
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
current_rate = self._deny_windows[agent_id].rate(now)
|
|
328
|
+
z = baseline.z_score(current_rate)
|
|
329
|
+
|
|
330
|
+
if z is not None and z > self._z_threshold:
|
|
331
|
+
severity = "critical" if z > self._z_threshold * 2 else "high"
|
|
332
|
+
alert = AnomalyAlert(
|
|
333
|
+
alert_type="deny_surge",
|
|
334
|
+
agent_id=agent_id,
|
|
335
|
+
tool=None,
|
|
336
|
+
severity=severity,
|
|
337
|
+
message=(
|
|
338
|
+
f"Deny surge for {agent_id}: "
|
|
339
|
+
f"{current_rate:.2f}/s (baseline: {baseline.mean:.2f}/s, "
|
|
340
|
+
f"z-score: {z:.1f})"
|
|
341
|
+
),
|
|
342
|
+
z_score=z,
|
|
343
|
+
current_rate=current_rate,
|
|
344
|
+
baseline_rate=baseline.mean,
|
|
345
|
+
timestamp=now,
|
|
346
|
+
)
|
|
347
|
+
self._fire_alert(alert)
|
|
348
|
+
|
|
349
|
+
def _fire_alert(self, alert: AnomalyAlert):
|
|
350
|
+
"""Record and dispatch an alert."""
|
|
351
|
+
self._alerts.append(alert)
|
|
352
|
+
logger.warning("Anomaly detected: %s", alert.message)
|
|
353
|
+
|
|
354
|
+
if self._alert_callback is not None:
|
|
355
|
+
try:
|
|
356
|
+
self._alert_callback(alert)
|
|
357
|
+
except Exception:
|
|
358
|
+
logger.exception("Alert callback failed")
|
|
359
|
+
|
|
360
|
+
def get_alerts(self, since: float | None = None) -> list[AnomalyAlert]:
|
|
361
|
+
"""Get accumulated alerts, optionally filtered by timestamp."""
|
|
362
|
+
if since is None:
|
|
363
|
+
return list(self._alerts)
|
|
364
|
+
return [a for a in self._alerts if a.timestamp >= since]
|
|
365
|
+
|
|
366
|
+
def get_stats(self) -> dict[str, Any]:
|
|
367
|
+
"""Get current detector statistics for monitoring."""
|
|
368
|
+
now = time.time()
|
|
369
|
+
stats: dict[str, Any] = {
|
|
370
|
+
"total_alerts": len(self._alerts),
|
|
371
|
+
"agents_tracked": len(self._known_tools),
|
|
372
|
+
"tools_per_agent": {
|
|
373
|
+
agent: len(tools) for agent, tools in self._known_tools.items()
|
|
374
|
+
},
|
|
375
|
+
"current_rates": {},
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for key, window in self._call_windows.items():
|
|
379
|
+
stats["current_rates"][key] = {
|
|
380
|
+
"rate": window.rate(now),
|
|
381
|
+
"count": window.count(now),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return stats
|
|
385
|
+
|
|
386
|
+
def clear(self):
|
|
387
|
+
"""Reset all state."""
|
|
388
|
+
self._call_windows.clear()
|
|
389
|
+
self._error_windows.clear()
|
|
390
|
+
self._deny_windows.clear()
|
|
391
|
+
self._call_baselines.clear()
|
|
392
|
+
self._error_baselines.clear()
|
|
393
|
+
self._deny_baselines.clear()
|
|
394
|
+
self._known_tools.clear()
|
|
395
|
+
self._alerts.clear()
|
|
396
|
+
self._last_baseline_sample = 0.0
|
tollgate/audit.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
from typing import Protocol
|
|
6
|
+
from urllib.request import Request, urlopen
|
|
4
7
|
|
|
5
|
-
from .types import AuditEvent
|
|
8
|
+
from .types import AuditEvent, Outcome
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
class AuditSink(Protocol):
|
|
@@ -45,3 +48,89 @@ class JsonlAuditSink:
|
|
|
45
48
|
|
|
46
49
|
def __del__(self):
|
|
47
50
|
self.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CompositeAuditSink:
|
|
54
|
+
"""Chains multiple AuditSink implementations together.
|
|
55
|
+
|
|
56
|
+
Example:
|
|
57
|
+
sink = CompositeAuditSink([
|
|
58
|
+
JsonlAuditSink("audit.jsonl"),
|
|
59
|
+
WebhookAuditSink("https://hooks.slack.com/..."),
|
|
60
|
+
])
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, sinks: list[AuditSink]):
|
|
64
|
+
if not sinks:
|
|
65
|
+
raise ValueError("CompositeAuditSink requires at least one sink.")
|
|
66
|
+
self._sinks = list(sinks)
|
|
67
|
+
|
|
68
|
+
def emit(self, event: AuditEvent) -> None:
|
|
69
|
+
"""Emit an event to all registered sinks."""
|
|
70
|
+
for sink in self._sinks:
|
|
71
|
+
try:
|
|
72
|
+
sink.emit(event)
|
|
73
|
+
except Exception:
|
|
74
|
+
# Never let one sink failure block others
|
|
75
|
+
logging.getLogger("tollgate.audit").exception(
|
|
76
|
+
"AuditSink failed: %s", type(sink).__name__
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WebhookAuditSink:
|
|
81
|
+
"""Fire-and-forget HTTP POST on security-relevant audit events.
|
|
82
|
+
|
|
83
|
+
By default, alerts are sent for BLOCKED, APPROVAL_DENIED, FAILED,
|
|
84
|
+
and TIMEOUT outcomes. Customise via ``alert_outcomes``.
|
|
85
|
+
|
|
86
|
+
The webhook payload is the full AuditEvent dict as JSON. Requests are
|
|
87
|
+
dispatched on a daemon thread so they never block the execution path.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# Outcomes that trigger a webhook by default
|
|
91
|
+
DEFAULT_ALERT_OUTCOMES = frozenset(
|
|
92
|
+
{Outcome.BLOCKED, Outcome.APPROVAL_DENIED, Outcome.FAILED, Outcome.TIMEOUT}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
webhook_url: str,
|
|
98
|
+
*,
|
|
99
|
+
alert_outcomes: frozenset[Outcome] | None = None,
|
|
100
|
+
timeout_seconds: float = 5.0,
|
|
101
|
+
headers: dict[str, str] | None = None,
|
|
102
|
+
):
|
|
103
|
+
if not webhook_url:
|
|
104
|
+
raise ValueError("webhook_url must not be empty.")
|
|
105
|
+
self.webhook_url = webhook_url
|
|
106
|
+
self.alert_outcomes = alert_outcomes or self.DEFAULT_ALERT_OUTCOMES
|
|
107
|
+
self.timeout_seconds = timeout_seconds
|
|
108
|
+
self._headers = {"Content-Type": "application/json"}
|
|
109
|
+
if headers:
|
|
110
|
+
self._headers.update(headers)
|
|
111
|
+
self._logger = logging.getLogger("tollgate.audit.webhook")
|
|
112
|
+
|
|
113
|
+
def emit(self, event: AuditEvent) -> None:
|
|
114
|
+
"""Send a webhook if the event outcome warrants an alert."""
|
|
115
|
+
if event.outcome not in self.alert_outcomes:
|
|
116
|
+
return # Not an alertable event — skip silently
|
|
117
|
+
|
|
118
|
+
# Fire-and-forget on a daemon thread to avoid blocking execution
|
|
119
|
+
thread = threading.Thread(
|
|
120
|
+
target=self._send, args=(event,), daemon=True
|
|
121
|
+
)
|
|
122
|
+
thread.start()
|
|
123
|
+
|
|
124
|
+
def _send(self, event: AuditEvent) -> None:
|
|
125
|
+
"""Synchronous HTTP POST (runs on background thread)."""
|
|
126
|
+
try:
|
|
127
|
+
body = json.dumps(event.to_dict(), ensure_ascii=False).encode("utf-8")
|
|
128
|
+
req = Request(
|
|
129
|
+
self.webhook_url,
|
|
130
|
+
data=body,
|
|
131
|
+
headers=self._headers,
|
|
132
|
+
method="POST",
|
|
133
|
+
)
|
|
134
|
+
urlopen(req, timeout=self.timeout_seconds) # noqa: S310
|
|
135
|
+
except Exception:
|
|
136
|
+
self._logger.exception("Webhook delivery failed: %s", self.webhook_url)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Persistent backends for Tollgate stores.
|
|
2
|
+
|
|
3
|
+
Provides drop-in replacements for InMemoryGrantStore and InMemoryApprovalStore
|
|
4
|
+
with persistent storage:
|
|
5
|
+
|
|
6
|
+
- SQLiteGrantStore / SQLiteApprovalStore — zero extra dependencies
|
|
7
|
+
- RedisGrantStore / RedisApprovalStore — requires ``redis[hiredis]``
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
|
|
11
|
+
# SQLite (zero deps, good for single-process)
|
|
12
|
+
from tollgate.backends import SQLiteGrantStore, SQLiteApprovalStore
|
|
13
|
+
|
|
14
|
+
grant_store = SQLiteGrantStore("tollgate_grants.db")
|
|
15
|
+
approval_store = SQLiteApprovalStore("tollgate_approvals.db")
|
|
16
|
+
|
|
17
|
+
# Redis (multi-process, multi-host)
|
|
18
|
+
from tollgate.backends import RedisGrantStore, RedisApprovalStore
|
|
19
|
+
|
|
20
|
+
grant_store = RedisGrantStore(redis_url="redis://localhost:6379/0")
|
|
21
|
+
approval_store = RedisApprovalStore(redis_url="redis://localhost:6379/0")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .sqlite_store import SQLiteApprovalStore, SQLiteGrantStore
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"SQLiteGrantStore",
|
|
28
|
+
"SQLiteApprovalStore",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Conditionally export Redis backends if redis is available
|
|
32
|
+
try:
|
|
33
|
+
from .redis_store import RedisApprovalStore, RedisGrantStore
|
|
34
|
+
|
|
35
|
+
__all__ += ["RedisGrantStore", "RedisApprovalStore"]
|
|
36
|
+
except ImportError:
|
|
37
|
+
pass
|