zugashield 1.0.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.
- zugashield/__init__.py +539 -0
- zugashield/audit.py +152 -0
- zugashield/config.py +136 -0
- zugashield/integrations/__init__.py +1 -0
- zugashield/integrations/approval.py +72 -0
- zugashield/integrations/fastapi.py +74 -0
- zugashield/layers/__init__.py +1 -0
- zugashield/layers/anomaly_detector.py +271 -0
- zugashield/layers/exfiltration_guard.py +448 -0
- zugashield/layers/llm_judge.py +179 -0
- zugashield/layers/memory_sentinel.py +490 -0
- zugashield/layers/perimeter.py +244 -0
- zugashield/layers/prompt_armor.py +1174 -0
- zugashield/layers/tool_guard.py +477 -0
- zugashield/layers/wallet_fortress.py +389 -0
- zugashield/multimodal.py +226 -0
- zugashield/signatures/ascii_art.json +259 -0
- zugashield/signatures/catalog_version.json +72 -0
- zugashield/signatures/exfiltration.json +293 -0
- zugashield/signatures/memory_poisoning.json +328 -0
- zugashield/signatures/prompt_injection.json +423 -0
- zugashield/signatures/tool_exploitation.json +269 -0
- zugashield/signatures/unicode_smuggling.json +360 -0
- zugashield/signatures/wallet_attacks.json +327 -0
- zugashield/threat_catalog.py +266 -0
- zugashield/types.py +199 -0
- zugashield-1.0.0.dist-info/METADATA +266 -0
- zugashield-1.0.0.dist-info/RECORD +33 -0
- zugashield-1.0.0.dist-info/WHEEL +4 -0
- zugashield-1.0.0.dist-info/entry_points.txt +2 -0
- zugashield-1.0.0.dist-info/licenses/LICENSE +21 -0
- zugashield_mcp/__init__.py +3 -0
- zugashield_mcp/server.py +290 -0
zugashield/__init__.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ZugaShield - AI Agent Security System
|
|
3
|
+
=======================================
|
|
4
|
+
|
|
5
|
+
7-layer defense system for AI agent threats:
|
|
6
|
+
|
|
7
|
+
Layer 1: Perimeter ─── HTTP middleware, request validation
|
|
8
|
+
Layer 2: Prompt Armor ── Injection detection, input sanitization
|
|
9
|
+
Layer 3: Tool Guard ──── Tool execution gating, parameter validation
|
|
10
|
+
Layer 4: Memory Sentinel ── Memory content validation, poison detection
|
|
11
|
+
Layer 5: Exfiltration Guard ── Output DLP, secret detection
|
|
12
|
+
Layer 6: Anomaly Detector ── Behavioral baselines, chain attack detection
|
|
13
|
+
Layer 7: Wallet Fortress ── Transaction approval, address validation
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from zugashield import ZugaShield
|
|
17
|
+
|
|
18
|
+
shield = ZugaShield()
|
|
19
|
+
decision = await shield.check_prompt("user message", context={})
|
|
20
|
+
if decision.is_blocked:
|
|
21
|
+
return "Blocked by ZugaShield"
|
|
22
|
+
|
|
23
|
+
Architecture:
|
|
24
|
+
- Modeled after uBlock Origin: curated threat catalog + layered defenses
|
|
25
|
+
- Zero required dependencies - works out of the box
|
|
26
|
+
- Configurable via environment variables (ZUGASHIELD_*)
|
|
27
|
+
- All layers run asynchronously with <15ms total fast-path overhead
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
from zugashield.types import (
|
|
37
|
+
AnomalyScore,
|
|
38
|
+
MemoryTrust,
|
|
39
|
+
ShieldDecision,
|
|
40
|
+
ShieldVerdict,
|
|
41
|
+
ThreatCategory,
|
|
42
|
+
ThreatDetection,
|
|
43
|
+
ThreatLevel,
|
|
44
|
+
ToolPolicy,
|
|
45
|
+
allow_decision,
|
|
46
|
+
block_decision,
|
|
47
|
+
)
|
|
48
|
+
from zugashield.config import ShieldConfig
|
|
49
|
+
from zugashield.threat_catalog import ThreatCatalog
|
|
50
|
+
from zugashield.audit import ShieldAuditLogger
|
|
51
|
+
|
|
52
|
+
from zugashield.layers.perimeter import PerimeterLayer
|
|
53
|
+
from zugashield.layers.prompt_armor import PromptArmorLayer
|
|
54
|
+
from zugashield.layers.tool_guard import ToolGuardLayer
|
|
55
|
+
from zugashield.layers.memory_sentinel import MemorySentinelLayer
|
|
56
|
+
from zugashield.layers.exfiltration_guard import ExfiltrationGuardLayer
|
|
57
|
+
from zugashield.layers.anomaly_detector import AnomalyDetectorLayer
|
|
58
|
+
from zugashield.layers.wallet_fortress import WalletFortressLayer
|
|
59
|
+
from zugashield.layers.llm_judge import LLMJudgeLayer
|
|
60
|
+
from zugashield.multimodal import MultimodalScanner
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
# Approval provider interface for Human-in-the-Loop integration
|
|
65
|
+
_approval_provider = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def set_approval_provider(provider: Any) -> None:
|
|
69
|
+
"""Set an external approval provider for HIL integration."""
|
|
70
|
+
global _approval_provider
|
|
71
|
+
_approval_provider = provider
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_approval_provider() -> Any:
|
|
75
|
+
"""Get the current approval provider (or None)."""
|
|
76
|
+
return _approval_provider
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ZugaShield:
|
|
80
|
+
"""
|
|
81
|
+
Main facade for the ZugaShield security system.
|
|
82
|
+
|
|
83
|
+
Provides a clean API for each integration point.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, config: Optional[ShieldConfig] = None) -> None:
|
|
87
|
+
self._config = config or ShieldConfig.from_env()
|
|
88
|
+
self._catalog = ThreatCatalog()
|
|
89
|
+
self._audit = ShieldAuditLogger()
|
|
90
|
+
|
|
91
|
+
# Initialize all layers
|
|
92
|
+
self.perimeter = PerimeterLayer(self._config, self._catalog)
|
|
93
|
+
self.prompt_armor = PromptArmorLayer(self._config, self._catalog)
|
|
94
|
+
self.tool_guard = ToolGuardLayer(self._config, self._catalog)
|
|
95
|
+
self.memory_sentinel = MemorySentinelLayer(self._config, self._catalog)
|
|
96
|
+
self.exfiltration_guard = ExfiltrationGuardLayer(self._config, self._catalog)
|
|
97
|
+
self.anomaly_detector = AnomalyDetectorLayer(self._config)
|
|
98
|
+
self.wallet_fortress = WalletFortressLayer(self._config, self._catalog)
|
|
99
|
+
self.multimodal = MultimodalScanner(self._config, self._catalog)
|
|
100
|
+
self.llm_judge = LLMJudgeLayer(self._config)
|
|
101
|
+
|
|
102
|
+
logger.info(
|
|
103
|
+
"[ZugaShield] Initialized: %d signatures loaded, %d layers active",
|
|
104
|
+
self._catalog._total_signatures,
|
|
105
|
+
sum(
|
|
106
|
+
[
|
|
107
|
+
self._config.perimeter_enabled,
|
|
108
|
+
self._config.prompt_armor_enabled,
|
|
109
|
+
self._config.tool_guard_enabled,
|
|
110
|
+
self._config.memory_sentinel_enabled,
|
|
111
|
+
self._config.exfiltration_guard_enabled,
|
|
112
|
+
self._config.anomaly_detector_enabled,
|
|
113
|
+
self._config.wallet_fortress_enabled,
|
|
114
|
+
]
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def enabled(self) -> bool:
|
|
120
|
+
return self._config.enabled
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def config(self) -> ShieldConfig:
|
|
124
|
+
return self._config
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def catalog(self) -> ThreatCatalog:
|
|
128
|
+
return self._catalog
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def audit(self) -> ShieldAuditLogger:
|
|
132
|
+
return self._audit
|
|
133
|
+
|
|
134
|
+
# =========================================================================
|
|
135
|
+
# Layer 1: Perimeter (HTTP middleware)
|
|
136
|
+
# =========================================================================
|
|
137
|
+
|
|
138
|
+
async def check_request(
|
|
139
|
+
self,
|
|
140
|
+
path: str,
|
|
141
|
+
method: str = "GET",
|
|
142
|
+
content_length: int = 0,
|
|
143
|
+
body: Optional[str] = None,
|
|
144
|
+
headers: Optional[Dict[str, str]] = None,
|
|
145
|
+
client_ip: str = "unknown",
|
|
146
|
+
) -> ShieldDecision:
|
|
147
|
+
"""Check an incoming HTTP request (Layer 1)."""
|
|
148
|
+
if not self._config.enabled:
|
|
149
|
+
return allow_decision("shield_disabled")
|
|
150
|
+
|
|
151
|
+
decision = await self.perimeter.check(
|
|
152
|
+
path=path,
|
|
153
|
+
method=method,
|
|
154
|
+
content_length=content_length,
|
|
155
|
+
body=body,
|
|
156
|
+
headers=headers,
|
|
157
|
+
client_ip=client_ip,
|
|
158
|
+
)
|
|
159
|
+
self._audit.log(decision, {"path": path, "method": method, "client_ip": client_ip})
|
|
160
|
+
self._feed_anomaly(decision)
|
|
161
|
+
return decision
|
|
162
|
+
|
|
163
|
+
# =========================================================================
|
|
164
|
+
# Layer 2: Prompt Armor (injection defense)
|
|
165
|
+
# =========================================================================
|
|
166
|
+
|
|
167
|
+
async def check_prompt(
|
|
168
|
+
self,
|
|
169
|
+
user_message: str,
|
|
170
|
+
context: Optional[Dict] = None,
|
|
171
|
+
) -> ShieldDecision:
|
|
172
|
+
"""Check user message for prompt injection (Layer 2)."""
|
|
173
|
+
if not self._config.enabled:
|
|
174
|
+
return allow_decision("shield_disabled")
|
|
175
|
+
|
|
176
|
+
decision = await self.prompt_armor.check(user_message, context)
|
|
177
|
+
|
|
178
|
+
# Optional LLM judge escalation for ambiguous cases
|
|
179
|
+
if self.llm_judge.should_escalate(decision):
|
|
180
|
+
decision = await self.llm_judge.judge(user_message, decision)
|
|
181
|
+
|
|
182
|
+
self._audit.log(decision, {"source": "prompt", **(context or {})})
|
|
183
|
+
self._feed_anomaly(decision)
|
|
184
|
+
return decision
|
|
185
|
+
|
|
186
|
+
# =========================================================================
|
|
187
|
+
# Layer 3: Tool Guard (execution gating)
|
|
188
|
+
# =========================================================================
|
|
189
|
+
|
|
190
|
+
async def check_tool_call(
|
|
191
|
+
self,
|
|
192
|
+
tool_name: str,
|
|
193
|
+
params: Dict[str, Any],
|
|
194
|
+
session_id: str = "default",
|
|
195
|
+
) -> ShieldDecision:
|
|
196
|
+
"""Check a tool call before execution (Layer 3)."""
|
|
197
|
+
if not self._config.enabled:
|
|
198
|
+
return allow_decision("shield_disabled")
|
|
199
|
+
|
|
200
|
+
decision = await self.tool_guard.check(tool_name, params, session_id)
|
|
201
|
+
self._audit.log(decision, {"tool": tool_name, "session_id": session_id})
|
|
202
|
+
self._feed_anomaly(decision, session_id)
|
|
203
|
+
return decision
|
|
204
|
+
|
|
205
|
+
# =========================================================================
|
|
206
|
+
# Layer 4: Memory Sentinel (write + read paths)
|
|
207
|
+
# =========================================================================
|
|
208
|
+
|
|
209
|
+
async def check_memory_write(
|
|
210
|
+
self,
|
|
211
|
+
content: str,
|
|
212
|
+
memory_type: str = "",
|
|
213
|
+
importance: str = "",
|
|
214
|
+
source: str = "unknown",
|
|
215
|
+
user_id: str = "default",
|
|
216
|
+
tags: Optional[List[str]] = None,
|
|
217
|
+
) -> ShieldDecision:
|
|
218
|
+
"""Check memory content before storage (Layer 4 write)."""
|
|
219
|
+
if not self._config.enabled:
|
|
220
|
+
return allow_decision("shield_disabled")
|
|
221
|
+
|
|
222
|
+
decision = await self.memory_sentinel.check_write(
|
|
223
|
+
content=content,
|
|
224
|
+
memory_type=memory_type,
|
|
225
|
+
importance=importance,
|
|
226
|
+
source=source,
|
|
227
|
+
user_id=user_id,
|
|
228
|
+
tags=tags,
|
|
229
|
+
)
|
|
230
|
+
self._audit.log(decision, {"source": source, "memory_type": memory_type})
|
|
231
|
+
self._feed_anomaly(decision)
|
|
232
|
+
return decision
|
|
233
|
+
|
|
234
|
+
async def check_memory_recall(
|
|
235
|
+
self,
|
|
236
|
+
memories: List[Dict[str, Any]],
|
|
237
|
+
) -> ShieldDecision:
|
|
238
|
+
"""Check recalled memories before prompt injection (Layer 4 read)."""
|
|
239
|
+
if not self._config.enabled:
|
|
240
|
+
return allow_decision("shield_disabled")
|
|
241
|
+
|
|
242
|
+
decision = await self.memory_sentinel.check_recall(memories)
|
|
243
|
+
self._audit.log(decision, {"memory_count": len(memories)})
|
|
244
|
+
return decision
|
|
245
|
+
|
|
246
|
+
# =========================================================================
|
|
247
|
+
# Layer 4b: RAG Document Pre-Ingestion Scanning
|
|
248
|
+
# =========================================================================
|
|
249
|
+
|
|
250
|
+
async def check_document(
|
|
251
|
+
self,
|
|
252
|
+
content: str,
|
|
253
|
+
source: str = "external",
|
|
254
|
+
document_type: str = "",
|
|
255
|
+
) -> ShieldDecision:
|
|
256
|
+
"""Check an external document before RAG ingestion (Layer 4)."""
|
|
257
|
+
if not self._config.enabled:
|
|
258
|
+
return allow_decision("shield_disabled")
|
|
259
|
+
|
|
260
|
+
decision = await self.memory_sentinel.check_document(
|
|
261
|
+
content=content,
|
|
262
|
+
source=source,
|
|
263
|
+
document_type=document_type,
|
|
264
|
+
)
|
|
265
|
+
self._audit.log(decision, {"source": source, "document_type": document_type})
|
|
266
|
+
self._feed_anomaly(decision)
|
|
267
|
+
return decision
|
|
268
|
+
|
|
269
|
+
# =========================================================================
|
|
270
|
+
# Layer 5: Exfiltration Guard (output DLP)
|
|
271
|
+
# =========================================================================
|
|
272
|
+
|
|
273
|
+
async def check_output(
|
|
274
|
+
self,
|
|
275
|
+
output: str,
|
|
276
|
+
context: Optional[Dict] = None,
|
|
277
|
+
) -> ShieldDecision:
|
|
278
|
+
"""Check LLM response or tool output for data leakage (Layer 5)."""
|
|
279
|
+
if not self._config.enabled:
|
|
280
|
+
return allow_decision("shield_disabled")
|
|
281
|
+
|
|
282
|
+
decision = await self.exfiltration_guard.check(output, context)
|
|
283
|
+
self._audit.log(decision, context)
|
|
284
|
+
self._feed_anomaly(decision)
|
|
285
|
+
return decision
|
|
286
|
+
|
|
287
|
+
# =========================================================================
|
|
288
|
+
# Multimodal: Image-Based Injection Detection
|
|
289
|
+
# =========================================================================
|
|
290
|
+
|
|
291
|
+
async def check_image(
|
|
292
|
+
self,
|
|
293
|
+
image_path: Optional[str] = None,
|
|
294
|
+
alt_text: Optional[str] = None,
|
|
295
|
+
ocr_text: Optional[str] = None,
|
|
296
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
297
|
+
) -> ShieldDecision:
|
|
298
|
+
"""Check an image for injection payloads (multimodal defense)."""
|
|
299
|
+
if not self._config.enabled:
|
|
300
|
+
return allow_decision("shield_disabled")
|
|
301
|
+
|
|
302
|
+
decision = await self.multimodal.check_image(
|
|
303
|
+
image_path=image_path,
|
|
304
|
+
alt_text=alt_text,
|
|
305
|
+
ocr_text=ocr_text,
|
|
306
|
+
metadata=metadata,
|
|
307
|
+
)
|
|
308
|
+
self._audit.log(decision, {"source": "image"})
|
|
309
|
+
self._feed_anomaly(decision)
|
|
310
|
+
return decision
|
|
311
|
+
|
|
312
|
+
# =========================================================================
|
|
313
|
+
# Layer 7: Wallet Fortress (crypto protection)
|
|
314
|
+
# =========================================================================
|
|
315
|
+
|
|
316
|
+
async def check_transaction(
|
|
317
|
+
self,
|
|
318
|
+
tx_type: str = "send",
|
|
319
|
+
to_address: str = "",
|
|
320
|
+
amount: float = 0.0,
|
|
321
|
+
amount_usd: float = 0.0,
|
|
322
|
+
contract_data: Optional[str] = None,
|
|
323
|
+
function_sig: Optional[str] = None,
|
|
324
|
+
) -> ShieldDecision:
|
|
325
|
+
"""Check a wallet transaction (Layer 7)."""
|
|
326
|
+
if not self._config.enabled:
|
|
327
|
+
return allow_decision("shield_disabled")
|
|
328
|
+
|
|
329
|
+
decision = await self.wallet_fortress.check(
|
|
330
|
+
tx_type=tx_type,
|
|
331
|
+
to_address=to_address,
|
|
332
|
+
amount=amount,
|
|
333
|
+
amount_usd=amount_usd,
|
|
334
|
+
contract_data=contract_data,
|
|
335
|
+
function_sig=function_sig,
|
|
336
|
+
)
|
|
337
|
+
self._audit.log(decision, {"tx_type": tx_type, "amount_usd": amount_usd})
|
|
338
|
+
self._feed_anomaly(decision)
|
|
339
|
+
return decision
|
|
340
|
+
|
|
341
|
+
# =========================================================================
|
|
342
|
+
# Cross-layer: Anomaly Detection (Layer 6)
|
|
343
|
+
# =========================================================================
|
|
344
|
+
|
|
345
|
+
def get_session_risk(self, session_id: str = "default") -> AnomalyScore:
|
|
346
|
+
"""Get current anomaly score for a session."""
|
|
347
|
+
return self.anomaly_detector.get_session_score(session_id)
|
|
348
|
+
|
|
349
|
+
def get_audit_log(self, limit: int = 100, layer: Optional[str] = None) -> List[Dict]:
|
|
350
|
+
"""Get recent audit events."""
|
|
351
|
+
return self._audit.get_recent(limit=limit, layer=layer)
|
|
352
|
+
|
|
353
|
+
def get_dashboard_data(self) -> Dict:
|
|
354
|
+
"""Get aggregated data for the security dashboard."""
|
|
355
|
+
return {
|
|
356
|
+
"enabled": self._config.enabled,
|
|
357
|
+
"strict_mode": self._config.strict_mode,
|
|
358
|
+
"catalog": self._catalog.get_stats(),
|
|
359
|
+
"audit": self._audit.get_stats(),
|
|
360
|
+
"layers": {
|
|
361
|
+
"perimeter": self.perimeter.get_stats(),
|
|
362
|
+
"prompt_armor": self.prompt_armor.get_stats(),
|
|
363
|
+
"tool_guard": self.tool_guard.get_stats(),
|
|
364
|
+
"memory_sentinel": self.memory_sentinel.get_stats(),
|
|
365
|
+
"exfiltration_guard": self.exfiltration_guard.get_stats(),
|
|
366
|
+
"anomaly_detector": self.anomaly_detector.get_stats(),
|
|
367
|
+
"wallet_fortress": self.wallet_fortress.get_stats(),
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
# =========================================================================
|
|
372
|
+
# Tool Definition Scanning (MCP injection defense)
|
|
373
|
+
# =========================================================================
|
|
374
|
+
|
|
375
|
+
def scan_tool_definitions(
|
|
376
|
+
self,
|
|
377
|
+
tools: List[Dict[str, Any]],
|
|
378
|
+
) -> List[Dict[str, Any]]:
|
|
379
|
+
"""
|
|
380
|
+
Scan tool definitions for injection payloads before sending to LLM.
|
|
381
|
+
|
|
382
|
+
Addresses CVE-2025-53773: malicious instructions hidden in tool
|
|
383
|
+
descriptions, parameter descriptions, or tool metadata.
|
|
384
|
+
"""
|
|
385
|
+
if not self._config.enabled or not self._config.prompt_armor_enabled:
|
|
386
|
+
return tools
|
|
387
|
+
|
|
388
|
+
clean_tools = []
|
|
389
|
+
injection_keywords = re.compile(
|
|
390
|
+
r"(?:ignore|override|bypass|disable)\s+(?:all\s+)?(?:previous|prior|safety|security|filter|restriction|instruction|rule|guideline)",
|
|
391
|
+
re.I,
|
|
392
|
+
)
|
|
393
|
+
hidden_instruction = re.compile(
|
|
394
|
+
r"(?:actually|really|instead|secretly|hidden)\s*[,:]?\s*(?:you\s+)?(?:should|must|will|need\s+to)\s+(?:ignore|override|execute|bypass|follow\s+these)",
|
|
395
|
+
re.I,
|
|
396
|
+
)
|
|
397
|
+
role_override = re.compile(
|
|
398
|
+
r"(?:you\s+are\s+now|act\s+as|pretend\s+to\s+be|switch\s+to)\s+(?:a\s+)?(?:unrestricted|unfiltered|jailbroken|evil|DAN)",
|
|
399
|
+
re.I,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
for tool in tools:
|
|
403
|
+
flagged = False
|
|
404
|
+
tool_name = tool.get("name", "unknown")
|
|
405
|
+
|
|
406
|
+
texts_to_scan = []
|
|
407
|
+
if "description" in tool:
|
|
408
|
+
texts_to_scan.append(("description", tool["description"]))
|
|
409
|
+
|
|
410
|
+
schema = tool.get("input_schema", {})
|
|
411
|
+
for param_name, param_def in schema.get("properties", {}).items():
|
|
412
|
+
if "description" in param_def:
|
|
413
|
+
texts_to_scan.append((f"param:{param_name}", param_def["description"]))
|
|
414
|
+
|
|
415
|
+
for field_name, text in texts_to_scan:
|
|
416
|
+
for pattern, desc in [
|
|
417
|
+
(injection_keywords, "injection keyword"),
|
|
418
|
+
(hidden_instruction, "hidden instruction"),
|
|
419
|
+
(role_override, "role override"),
|
|
420
|
+
]:
|
|
421
|
+
match = pattern.search(text)
|
|
422
|
+
if match:
|
|
423
|
+
flagged = True
|
|
424
|
+
threat = ThreatDetection(
|
|
425
|
+
category=ThreatCategory.TOOL_EXPLOITATION,
|
|
426
|
+
level=ThreatLevel.CRITICAL,
|
|
427
|
+
verdict=ShieldVerdict.BLOCK,
|
|
428
|
+
description=f"Injection in tool definition '{tool_name}' field '{field_name}': {desc}",
|
|
429
|
+
evidence=match.group(0)[:200],
|
|
430
|
+
layer="tool_definition_scanner",
|
|
431
|
+
confidence=0.88,
|
|
432
|
+
suggested_action=f"Remove poisoned tool '{tool_name}' from definitions",
|
|
433
|
+
signature_id="TDS-INJECT",
|
|
434
|
+
)
|
|
435
|
+
self._audit.log(
|
|
436
|
+
ShieldDecision(
|
|
437
|
+
verdict=ShieldVerdict.BLOCK,
|
|
438
|
+
threats_detected=[threat],
|
|
439
|
+
layer="tool_definition_scanner",
|
|
440
|
+
elapsed_ms=0.0,
|
|
441
|
+
),
|
|
442
|
+
{"tool_name": tool_name, "field": field_name},
|
|
443
|
+
)
|
|
444
|
+
logger.warning(
|
|
445
|
+
"[ZugaShield] BLOCKED poisoned tool definition: %s (field=%s, match=%s)",
|
|
446
|
+
tool_name,
|
|
447
|
+
field_name,
|
|
448
|
+
match.group(0)[:80],
|
|
449
|
+
)
|
|
450
|
+
break
|
|
451
|
+
if flagged:
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
if not flagged:
|
|
455
|
+
clean_tools.append(tool)
|
|
456
|
+
|
|
457
|
+
removed = len(tools) - len(clean_tools)
|
|
458
|
+
if removed:
|
|
459
|
+
logger.warning(
|
|
460
|
+
"[ZugaShield] Removed %d poisoned tool definition(s) from %d total",
|
|
461
|
+
removed,
|
|
462
|
+
len(tools),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return clean_tools
|
|
466
|
+
|
|
467
|
+
# =========================================================================
|
|
468
|
+
# Internal helpers
|
|
469
|
+
# =========================================================================
|
|
470
|
+
|
|
471
|
+
def _feed_anomaly(self, decision: ShieldDecision, session_id: str = "default") -> None:
|
|
472
|
+
"""Feed detection events to the anomaly detector for correlation."""
|
|
473
|
+
for threat in decision.threats_detected:
|
|
474
|
+
self.anomaly_detector.record_event(session_id, threat)
|
|
475
|
+
|
|
476
|
+
def get_version_state(self) -> Dict[str, Any]:
|
|
477
|
+
"""Snapshot current shield configuration for versioning."""
|
|
478
|
+
return {
|
|
479
|
+
"enabled_layers": {
|
|
480
|
+
"perimeter": self._config.perimeter_enabled,
|
|
481
|
+
"prompt_armor": self._config.prompt_armor_enabled,
|
|
482
|
+
"tool_guard": self._config.tool_guard_enabled,
|
|
483
|
+
"memory_sentinel": self._config.memory_sentinel_enabled,
|
|
484
|
+
"exfiltration_guard": self._config.exfiltration_guard_enabled,
|
|
485
|
+
"anomaly_detector": self._config.anomaly_detector_enabled,
|
|
486
|
+
"wallet_fortress": self._config.wallet_fortress_enabled,
|
|
487
|
+
},
|
|
488
|
+
"config": {
|
|
489
|
+
"fail_open": self._config.fail_open if hasattr(self._config, "fail_open") else True,
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# =============================================================================
|
|
495
|
+
# Singleton
|
|
496
|
+
# =============================================================================
|
|
497
|
+
|
|
498
|
+
_shield: Optional[ZugaShield] = None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def get_zugashield() -> ZugaShield:
|
|
502
|
+
"""Get or create the singleton ZugaShield instance."""
|
|
503
|
+
global _shield
|
|
504
|
+
if _shield is None:
|
|
505
|
+
_shield = ZugaShield()
|
|
506
|
+
return _shield
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def reset_zugashield() -> None:
|
|
510
|
+
"""Reset the singleton (for testing)."""
|
|
511
|
+
global _shield
|
|
512
|
+
_shield = None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
__all__ = [
|
|
516
|
+
# Facade
|
|
517
|
+
"ZugaShield",
|
|
518
|
+
"get_zugashield",
|
|
519
|
+
"reset_zugashield",
|
|
520
|
+
"set_approval_provider",
|
|
521
|
+
"get_approval_provider",
|
|
522
|
+
# Types
|
|
523
|
+
"AnomalyScore",
|
|
524
|
+
"MemoryTrust",
|
|
525
|
+
"ShieldDecision",
|
|
526
|
+
"ShieldVerdict",
|
|
527
|
+
"ThreatCategory",
|
|
528
|
+
"ThreatDetection",
|
|
529
|
+
"ThreatLevel",
|
|
530
|
+
"ToolPolicy",
|
|
531
|
+
"allow_decision",
|
|
532
|
+
"block_decision",
|
|
533
|
+
# Config
|
|
534
|
+
"ShieldConfig",
|
|
535
|
+
# Catalog
|
|
536
|
+
"ThreatCatalog",
|
|
537
|
+
# Audit
|
|
538
|
+
"ShieldAuditLogger",
|
|
539
|
+
]
|
zugashield/audit.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ZugaShield - Audit Logger
|
|
3
|
+
===========================
|
|
4
|
+
|
|
5
|
+
Security event logging and forensics.
|
|
6
|
+
Records all shield decisions for:
|
|
7
|
+
- Real-time dashboard display
|
|
8
|
+
- Post-incident forensics
|
|
9
|
+
- False positive analysis
|
|
10
|
+
- Performance monitoring
|
|
11
|
+
|
|
12
|
+
Uses in-memory ring buffer with optional database persistence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from collections import deque
|
|
19
|
+
from dataclasses import asdict, dataclass
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from typing import Any, Dict, List, Optional
|
|
22
|
+
|
|
23
|
+
from zugashield.types import (
|
|
24
|
+
ShieldDecision,
|
|
25
|
+
ShieldVerdict,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Maximum events in memory
|
|
31
|
+
_MAX_EVENTS = 10000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AuditEvent:
|
|
36
|
+
"""A single audit log entry."""
|
|
37
|
+
|
|
38
|
+
timestamp: str
|
|
39
|
+
layer: str
|
|
40
|
+
verdict: str
|
|
41
|
+
threat_count: int
|
|
42
|
+
max_level: str
|
|
43
|
+
elapsed_ms: float
|
|
44
|
+
details: Dict[str, Any]
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> Dict:
|
|
47
|
+
return asdict(self)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ShieldAuditLogger:
|
|
51
|
+
"""
|
|
52
|
+
Audit logger for ZugaShield events.
|
|
53
|
+
|
|
54
|
+
Maintains an in-memory ring buffer of recent events
|
|
55
|
+
and provides query/filter capabilities for the dashboard.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, max_events: int = _MAX_EVENTS) -> None:
|
|
59
|
+
self._events: deque = deque(maxlen=max_events)
|
|
60
|
+
self._counters = {
|
|
61
|
+
"total_checks": 0,
|
|
62
|
+
"total_blocks": 0,
|
|
63
|
+
"total_challenges": 0,
|
|
64
|
+
"total_sanitizations": 0,
|
|
65
|
+
"total_allows": 0,
|
|
66
|
+
}
|
|
67
|
+
self._layer_stats: Dict[str, Dict[str, int]] = {}
|
|
68
|
+
|
|
69
|
+
def log(self, decision: ShieldDecision, context: Optional[Dict] = None) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Log a shield decision.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
decision: The ShieldDecision from any layer
|
|
75
|
+
context: Optional additional context (session_id, user_id, etc.)
|
|
76
|
+
"""
|
|
77
|
+
self._counters["total_checks"] += 1
|
|
78
|
+
|
|
79
|
+
if decision.verdict == ShieldVerdict.BLOCK:
|
|
80
|
+
self._counters["total_blocks"] += 1
|
|
81
|
+
elif decision.verdict == ShieldVerdict.QUARANTINE:
|
|
82
|
+
self._counters["total_blocks"] += 1
|
|
83
|
+
elif decision.verdict == ShieldVerdict.CHALLENGE:
|
|
84
|
+
self._counters["total_challenges"] += 1
|
|
85
|
+
elif decision.verdict == ShieldVerdict.SANITIZE:
|
|
86
|
+
self._counters["total_sanitizations"] += 1
|
|
87
|
+
else:
|
|
88
|
+
self._counters["total_allows"] += 1
|
|
89
|
+
|
|
90
|
+
# Track per-layer stats
|
|
91
|
+
layer = decision.layer
|
|
92
|
+
if layer not in self._layer_stats:
|
|
93
|
+
self._layer_stats[layer] = {"checks": 0, "blocks": 0, "threats": 0}
|
|
94
|
+
self._layer_stats[layer]["checks"] += 1
|
|
95
|
+
self._layer_stats[layer]["threats"] += decision.threat_count
|
|
96
|
+
if decision.is_blocked:
|
|
97
|
+
self._layer_stats[layer]["blocks"] += 1
|
|
98
|
+
|
|
99
|
+
# Only log non-allow events to the ring buffer (saves space)
|
|
100
|
+
if decision.verdict != ShieldVerdict.ALLOW:
|
|
101
|
+
details = {
|
|
102
|
+
"threats": [
|
|
103
|
+
{
|
|
104
|
+
"category": t.category.value,
|
|
105
|
+
"level": t.level.value,
|
|
106
|
+
"description": t.description,
|
|
107
|
+
"evidence": t.evidence[:100],
|
|
108
|
+
"confidence": t.confidence,
|
|
109
|
+
"signature_id": t.signature_id,
|
|
110
|
+
}
|
|
111
|
+
for t in decision.threats_detected
|
|
112
|
+
],
|
|
113
|
+
}
|
|
114
|
+
if context:
|
|
115
|
+
details["context"] = context
|
|
116
|
+
|
|
117
|
+
event = AuditEvent(
|
|
118
|
+
timestamp=datetime.utcnow().isoformat() + "Z",
|
|
119
|
+
layer=layer,
|
|
120
|
+
verdict=decision.verdict.value,
|
|
121
|
+
threat_count=decision.threat_count,
|
|
122
|
+
max_level=decision.max_threat_level.value,
|
|
123
|
+
elapsed_ms=round(decision.elapsed_ms, 2),
|
|
124
|
+
details=details,
|
|
125
|
+
)
|
|
126
|
+
self._events.append(event)
|
|
127
|
+
|
|
128
|
+
# Log to standard logger for syslog/file collection
|
|
129
|
+
if decision.is_blocked:
|
|
130
|
+
logger.warning(
|
|
131
|
+
"[ShieldAudit] BLOCKED by %s: %s (threats=%d, %.1fms)",
|
|
132
|
+
layer,
|
|
133
|
+
decision.threats_detected[0].description if decision.threats_detected else "unknown",
|
|
134
|
+
decision.threat_count,
|
|
135
|
+
decision.elapsed_ms,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def get_recent(self, limit: int = 100, layer: Optional[str] = None) -> List[Dict]:
|
|
139
|
+
"""Get recent audit events."""
|
|
140
|
+
events = list(self._events)
|
|
141
|
+
if layer:
|
|
142
|
+
events = [e for e in events if e.layer == layer]
|
|
143
|
+
return [e.to_dict() for e in events[-limit:]]
|
|
144
|
+
|
|
145
|
+
def get_stats(self) -> Dict:
|
|
146
|
+
"""Get overall audit statistics."""
|
|
147
|
+
return {
|
|
148
|
+
"counters": dict(self._counters),
|
|
149
|
+
"layer_stats": dict(self._layer_stats),
|
|
150
|
+
"buffer_size": len(self._events),
|
|
151
|
+
"buffer_capacity": self._events.maxlen,
|
|
152
|
+
}
|