security-use 0.1.1__py3-none-any.whl → 0.2.9__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.
- security_use/__init__.py +9 -1
- security_use/auth/__init__.py +16 -0
- security_use/auth/client.py +223 -0
- security_use/auth/config.py +177 -0
- security_use/auth/oauth.py +317 -0
- security_use/cli.py +699 -34
- security_use/compliance/__init__.py +10 -0
- security_use/compliance/mapper.py +275 -0
- security_use/compliance/models.py +50 -0
- security_use/dependency_scanner.py +76 -30
- security_use/fixers/iac_fixer.py +173 -95
- security_use/iac/rules/azure.py +246 -0
- security_use/iac/rules/gcp.py +255 -0
- security_use/iac/rules/kubernetes.py +429 -0
- security_use/iac/rules/registry.py +56 -0
- security_use/parsers/__init__.py +18 -0
- security_use/parsers/base.py +2 -0
- security_use/parsers/composer.py +101 -0
- security_use/parsers/conda.py +97 -0
- security_use/parsers/dotnet.py +89 -0
- security_use/parsers/gradle.py +90 -0
- security_use/parsers/maven.py +108 -0
- security_use/parsers/npm.py +196 -0
- security_use/parsers/yarn.py +108 -0
- security_use/reporter.py +29 -1
- security_use/sbom/__init__.py +10 -0
- security_use/sbom/generator.py +340 -0
- security_use/sbom/models.py +40 -0
- security_use/scanner.py +15 -2
- security_use/sensor/__init__.py +125 -0
- security_use/sensor/alert_queue.py +207 -0
- security_use/sensor/config.py +217 -0
- security_use/sensor/dashboard_alerter.py +246 -0
- security_use/sensor/detector.py +415 -0
- security_use/sensor/endpoint_analyzer.py +339 -0
- security_use/sensor/middleware.py +521 -0
- security_use/sensor/models.py +140 -0
- security_use/sensor/webhook.py +227 -0
- security_use-0.2.9.dist-info/METADATA +531 -0
- security_use-0.2.9.dist-info/RECORD +60 -0
- security_use-0.2.9.dist-info/licenses/LICENSE +21 -0
- security_use-0.1.1.dist-info/METADATA +0 -92
- security_use-0.1.1.dist-info/RECORD +0 -30
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/WHEEL +0 -0
- {security_use-0.1.1.dist-info → security_use-0.2.9.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Framework middleware adapters for security monitoring."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
from urllib.parse import parse_qs
|
|
8
|
+
|
|
9
|
+
from .alert_queue import get_alert_queue
|
|
10
|
+
from .config import SensorConfig, create_config
|
|
11
|
+
from .dashboard_alerter import DashboardAlerter
|
|
12
|
+
from .detector import AttackDetector
|
|
13
|
+
from .models import ActionTaken, RequestData
|
|
14
|
+
from .webhook import WebhookAlerter
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SecurityMiddleware:
|
|
20
|
+
"""ASGI middleware for FastAPI/Starlette security monitoring.
|
|
21
|
+
|
|
22
|
+
Usage with dashboard (recommended):
|
|
23
|
+
from fastapi import FastAPI
|
|
24
|
+
from security_use.sensor import SecurityMiddleware
|
|
25
|
+
|
|
26
|
+
app = FastAPI()
|
|
27
|
+
app.add_middleware(
|
|
28
|
+
SecurityMiddleware,
|
|
29
|
+
api_key="su_...", # Or set SECURITY_USE_API_KEY env var
|
|
30
|
+
block_on_detection=True,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
Usage with auto-detection of vulnerable endpoints:
|
|
34
|
+
app.add_middleware(
|
|
35
|
+
SecurityMiddleware,
|
|
36
|
+
auto_detect_vulnerable=True,
|
|
37
|
+
project_path="./",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
Legacy usage with webhook:
|
|
41
|
+
app.add_middleware(
|
|
42
|
+
SecurityMiddleware,
|
|
43
|
+
webhook_url="https://your-webhook.com/alerts",
|
|
44
|
+
)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
app: Any,
|
|
50
|
+
api_key: Optional[str] = None,
|
|
51
|
+
webhook_url: Optional[str] = None,
|
|
52
|
+
block_on_detection: bool = True,
|
|
53
|
+
excluded_paths: Optional[list[str]] = None,
|
|
54
|
+
watch_paths: Optional[list[str]] = None,
|
|
55
|
+
auto_detect_vulnerable: bool = False,
|
|
56
|
+
project_path: Optional[str] = None,
|
|
57
|
+
enabled_detectors: Optional[list[str]] = None,
|
|
58
|
+
rate_limit_threshold: int = 100,
|
|
59
|
+
config: Optional[SensorConfig] = None,
|
|
60
|
+
):
|
|
61
|
+
"""Initialize the security middleware.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
app: The ASGI application.
|
|
65
|
+
api_key: SecurityUse API key for dashboard alerting.
|
|
66
|
+
webhook_url: URL to send alerts to (legacy).
|
|
67
|
+
block_on_detection: Return 403 on attack detection.
|
|
68
|
+
excluded_paths: Paths to skip monitoring.
|
|
69
|
+
watch_paths: Only monitor these paths (None = all).
|
|
70
|
+
auto_detect_vulnerable: Auto-detect vulnerable endpoints.
|
|
71
|
+
project_path: Project path for auto-detection.
|
|
72
|
+
enabled_detectors: List of detector types to enable.
|
|
73
|
+
rate_limit_threshold: Requests per minute per IP.
|
|
74
|
+
config: Optional pre-configured SensorConfig.
|
|
75
|
+
"""
|
|
76
|
+
self.app = app
|
|
77
|
+
|
|
78
|
+
if config:
|
|
79
|
+
self.config = config
|
|
80
|
+
else:
|
|
81
|
+
self.config = create_config(
|
|
82
|
+
api_key=api_key,
|
|
83
|
+
webhook_url=webhook_url,
|
|
84
|
+
block_on_detection=block_on_detection,
|
|
85
|
+
excluded_paths=excluded_paths,
|
|
86
|
+
watch_paths=watch_paths,
|
|
87
|
+
auto_detect_vulnerable=auto_detect_vulnerable,
|
|
88
|
+
project_path=project_path,
|
|
89
|
+
enabled_detectors=enabled_detectors,
|
|
90
|
+
rate_limit_threshold=rate_limit_threshold,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Auto-detect vulnerable endpoints if requested
|
|
94
|
+
if self.config.auto_detect_vulnerable and self.config.project_path:
|
|
95
|
+
self._detect_vulnerable_endpoints()
|
|
96
|
+
|
|
97
|
+
self.detector = AttackDetector(
|
|
98
|
+
enabled_detectors=self.config.enabled_detectors,
|
|
99
|
+
rate_limit_threshold=self.config.rate_limit_threshold,
|
|
100
|
+
rate_limit_window=self.config.rate_limit_window,
|
|
101
|
+
rate_limit_cleanup_interval=self.config.rate_limit_cleanup_interval,
|
|
102
|
+
rate_limit_max_ips=self.config.rate_limit_max_ips,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Set up alerters based on config
|
|
106
|
+
self.dashboard_alerter: Optional[DashboardAlerter] = None
|
|
107
|
+
self.webhook_alerter: Optional[WebhookAlerter] = None
|
|
108
|
+
|
|
109
|
+
if self.config.alert_mode in ("dashboard", "both"):
|
|
110
|
+
self.dashboard_alerter = DashboardAlerter(
|
|
111
|
+
api_key=self.config.api_key,
|
|
112
|
+
dashboard_url=self.config.dashboard_url,
|
|
113
|
+
timeout=self.config.webhook_timeout,
|
|
114
|
+
retry_count=self.config.webhook_retry_count,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if self.config.alert_mode in ("webhook", "both") and self.config.webhook_url:
|
|
118
|
+
self.webhook_alerter = WebhookAlerter(
|
|
119
|
+
webhook_url=self.config.webhook_url,
|
|
120
|
+
retry_count=self.config.webhook_retry_count,
|
|
121
|
+
timeout=self.config.webhook_timeout,
|
|
122
|
+
headers=self.config.webhook_headers,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Log configuration
|
|
126
|
+
if self.config.watch_paths:
|
|
127
|
+
logger.info(f"SecurityMiddleware monitoring {len(self.config.watch_paths)} paths")
|
|
128
|
+
else:
|
|
129
|
+
logger.info("SecurityMiddleware monitoring all paths")
|
|
130
|
+
|
|
131
|
+
def _detect_vulnerable_endpoints(self) -> None:
|
|
132
|
+
"""Auto-detect vulnerable endpoints from project scan."""
|
|
133
|
+
try:
|
|
134
|
+
from .endpoint_analyzer import VulnerableEndpointDetector
|
|
135
|
+
|
|
136
|
+
detector = VulnerableEndpointDetector()
|
|
137
|
+
paths = detector.get_watch_paths(self.config.project_path)
|
|
138
|
+
|
|
139
|
+
if paths:
|
|
140
|
+
# Merge with existing watch_paths
|
|
141
|
+
existing = set(self.config.watch_paths or [])
|
|
142
|
+
self.config.watch_paths = list(existing | set(paths))
|
|
143
|
+
logger.info(f"Auto-detected {len(paths)} vulnerable endpoints to monitor")
|
|
144
|
+
else:
|
|
145
|
+
logger.info("No vulnerable endpoints detected")
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.warning(f"Failed to auto-detect vulnerable endpoints: {e}")
|
|
149
|
+
|
|
150
|
+
async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
|
|
151
|
+
"""ASGI interface."""
|
|
152
|
+
if scope["type"] != "http":
|
|
153
|
+
await self.app(scope, receive, send)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Extract request data
|
|
157
|
+
path = scope.get("path", "/")
|
|
158
|
+
|
|
159
|
+
# Check if path should be monitored
|
|
160
|
+
if not self.config.should_monitor_path(path):
|
|
161
|
+
await self.app(scope, receive, send)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# Buffer the body and create a replay receive function
|
|
165
|
+
body, receive_wrapper = await self._buffer_body(receive)
|
|
166
|
+
|
|
167
|
+
# Build request data for analysis
|
|
168
|
+
request_data = self._build_request_data(scope, body)
|
|
169
|
+
|
|
170
|
+
# Analyze for attacks
|
|
171
|
+
events = self.detector.analyze_request(request_data)
|
|
172
|
+
|
|
173
|
+
if events:
|
|
174
|
+
action = (
|
|
175
|
+
ActionTaken.BLOCKED
|
|
176
|
+
if self.config.block_on_detection
|
|
177
|
+
else ActionTaken.LOGGED
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Send alerts asynchronously
|
|
181
|
+
for event in events:
|
|
182
|
+
if self.dashboard_alerter:
|
|
183
|
+
asyncio.create_task(self.dashboard_alerter.send_alert(event, action))
|
|
184
|
+
if self.webhook_alerter:
|
|
185
|
+
asyncio.create_task(self.webhook_alerter.send_alert(event, action))
|
|
186
|
+
|
|
187
|
+
if self.config.block_on_detection:
|
|
188
|
+
# Return 403 Forbidden
|
|
189
|
+
await self._send_blocked_response(send, events[0])
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
# Continue to application with replay receive that provides the buffered body
|
|
193
|
+
await self.app(scope, receive_wrapper, send)
|
|
194
|
+
|
|
195
|
+
async def _buffer_body(self, receive: Callable) -> tuple[bytes, Callable]:
|
|
196
|
+
"""Buffer the entire request body and create a replay receive function.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of (full_body, receive_wrapper) where receive_wrapper
|
|
200
|
+
can be passed to the app to replay the body.
|
|
201
|
+
"""
|
|
202
|
+
body_parts = []
|
|
203
|
+
|
|
204
|
+
# Read all body chunks
|
|
205
|
+
while True:
|
|
206
|
+
message = await receive()
|
|
207
|
+
if message["type"] == "http.request":
|
|
208
|
+
body_parts.append(message.get("body", b""))
|
|
209
|
+
if not message.get("more_body", False):
|
|
210
|
+
break
|
|
211
|
+
elif message["type"] == "http.disconnect":
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
full_body = b"".join(body_parts)
|
|
215
|
+
|
|
216
|
+
# Handle large bodies - truncate for analysis if needed
|
|
217
|
+
max_body_size = getattr(self.config, "max_body_size", 1024 * 1024)
|
|
218
|
+
analysis_body = full_body[:max_body_size] if len(full_body) > max_body_size else full_body
|
|
219
|
+
|
|
220
|
+
# Create a receive function that replays the buffered body
|
|
221
|
+
body_sent = False
|
|
222
|
+
|
|
223
|
+
async def receive_wrapper() -> dict:
|
|
224
|
+
nonlocal body_sent
|
|
225
|
+
if not body_sent:
|
|
226
|
+
body_sent = True
|
|
227
|
+
return {
|
|
228
|
+
"type": "http.request",
|
|
229
|
+
"body": full_body,
|
|
230
|
+
"more_body": False,
|
|
231
|
+
}
|
|
232
|
+
# After body is sent, wait for disconnect or return empty
|
|
233
|
+
return {
|
|
234
|
+
"type": "http.request",
|
|
235
|
+
"body": b"",
|
|
236
|
+
"more_body": False,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return analysis_body, receive_wrapper
|
|
240
|
+
|
|
241
|
+
def _build_request_data(self, scope: dict, body: bytes) -> RequestData:
|
|
242
|
+
"""Build request data from ASGI scope and buffered body."""
|
|
243
|
+
method = scope.get("method", "GET")
|
|
244
|
+
path = scope.get("path", "/")
|
|
245
|
+
query_string = scope.get("query_string", b"").decode("utf-8")
|
|
246
|
+
|
|
247
|
+
# Parse query params
|
|
248
|
+
query_params = {}
|
|
249
|
+
if query_string:
|
|
250
|
+
parsed = parse_qs(query_string)
|
|
251
|
+
query_params = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
|
|
252
|
+
|
|
253
|
+
# Extract headers
|
|
254
|
+
headers = {}
|
|
255
|
+
for key, value in scope.get("headers", []):
|
|
256
|
+
headers[key.decode("utf-8").lower()] = value.decode("utf-8")
|
|
257
|
+
|
|
258
|
+
# Get client IP
|
|
259
|
+
client = scope.get("client")
|
|
260
|
+
source_ip = client[0] if client else "unknown"
|
|
261
|
+
|
|
262
|
+
# Check for forwarded IP
|
|
263
|
+
if "x-forwarded-for" in headers:
|
|
264
|
+
source_ip = headers["x-forwarded-for"].split(",")[0].strip()
|
|
265
|
+
elif "x-real-ip" in headers:
|
|
266
|
+
source_ip = headers["x-real-ip"]
|
|
267
|
+
|
|
268
|
+
return RequestData(
|
|
269
|
+
method=method,
|
|
270
|
+
path=path,
|
|
271
|
+
query_params=query_params,
|
|
272
|
+
headers=headers,
|
|
273
|
+
body=body.decode("utf-8", errors="replace") if body else None,
|
|
274
|
+
source_ip=source_ip,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def _send_blocked_response(self, send: Callable, event: Any) -> None:
|
|
278
|
+
"""Send a 403 Forbidden response."""
|
|
279
|
+
body = b'{"error": "Request blocked due to security policy"}'
|
|
280
|
+
|
|
281
|
+
await send(
|
|
282
|
+
{
|
|
283
|
+
"type": "http.response.start",
|
|
284
|
+
"status": 403,
|
|
285
|
+
"headers": [
|
|
286
|
+
(b"content-type", b"application/json"),
|
|
287
|
+
(b"content-length", str(len(body)).encode()),
|
|
288
|
+
],
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
await send(
|
|
292
|
+
{
|
|
293
|
+
"type": "http.response.body",
|
|
294
|
+
"body": body,
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class FlaskSecurityMiddleware:
|
|
300
|
+
"""WSGI middleware for Flask security monitoring.
|
|
301
|
+
|
|
302
|
+
Usage with dashboard (recommended):
|
|
303
|
+
from flask import Flask
|
|
304
|
+
from security_use.sensor import FlaskSecurityMiddleware
|
|
305
|
+
|
|
306
|
+
app = Flask(__name__)
|
|
307
|
+
app.wsgi_app = FlaskSecurityMiddleware(
|
|
308
|
+
app.wsgi_app,
|
|
309
|
+
api_key="su_...", # Or set SECURITY_USE_API_KEY env var
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
Usage with auto-detection:
|
|
313
|
+
app.wsgi_app = FlaskSecurityMiddleware(
|
|
314
|
+
app.wsgi_app,
|
|
315
|
+
auto_detect_vulnerable=True,
|
|
316
|
+
project_path="./",
|
|
317
|
+
)
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
def __init__(
|
|
321
|
+
self,
|
|
322
|
+
app: Any,
|
|
323
|
+
api_key: Optional[str] = None,
|
|
324
|
+
webhook_url: Optional[str] = None,
|
|
325
|
+
block_on_detection: bool = True,
|
|
326
|
+
excluded_paths: Optional[list[str]] = None,
|
|
327
|
+
watch_paths: Optional[list[str]] = None,
|
|
328
|
+
auto_detect_vulnerable: bool = False,
|
|
329
|
+
project_path: Optional[str] = None,
|
|
330
|
+
enabled_detectors: Optional[list[str]] = None,
|
|
331
|
+
rate_limit_threshold: int = 100,
|
|
332
|
+
config: Optional[SensorConfig] = None,
|
|
333
|
+
):
|
|
334
|
+
"""Initialize the Flask security middleware.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
app: The WSGI application.
|
|
338
|
+
api_key: SecurityUse API key for dashboard alerting.
|
|
339
|
+
webhook_url: URL to send alerts to (legacy).
|
|
340
|
+
block_on_detection: Return 403 on attack detection.
|
|
341
|
+
excluded_paths: Paths to skip monitoring.
|
|
342
|
+
watch_paths: Only monitor these paths (None = all).
|
|
343
|
+
auto_detect_vulnerable: Auto-detect vulnerable endpoints.
|
|
344
|
+
project_path: Project path for auto-detection.
|
|
345
|
+
enabled_detectors: List of detector types to enable.
|
|
346
|
+
rate_limit_threshold: Requests per minute per IP.
|
|
347
|
+
config: Optional pre-configured SensorConfig.
|
|
348
|
+
"""
|
|
349
|
+
self.app = app
|
|
350
|
+
|
|
351
|
+
if config:
|
|
352
|
+
self.config = config
|
|
353
|
+
else:
|
|
354
|
+
self.config = create_config(
|
|
355
|
+
api_key=api_key,
|
|
356
|
+
webhook_url=webhook_url,
|
|
357
|
+
block_on_detection=block_on_detection,
|
|
358
|
+
excluded_paths=excluded_paths,
|
|
359
|
+
watch_paths=watch_paths,
|
|
360
|
+
auto_detect_vulnerable=auto_detect_vulnerable,
|
|
361
|
+
project_path=project_path,
|
|
362
|
+
enabled_detectors=enabled_detectors,
|
|
363
|
+
rate_limit_threshold=rate_limit_threshold,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# Auto-detect vulnerable endpoints if requested
|
|
367
|
+
if self.config.auto_detect_vulnerable and self.config.project_path:
|
|
368
|
+
self._detect_vulnerable_endpoints()
|
|
369
|
+
|
|
370
|
+
self.detector = AttackDetector(
|
|
371
|
+
enabled_detectors=self.config.enabled_detectors,
|
|
372
|
+
rate_limit_threshold=self.config.rate_limit_threshold,
|
|
373
|
+
rate_limit_window=self.config.rate_limit_window,
|
|
374
|
+
rate_limit_cleanup_interval=self.config.rate_limit_cleanup_interval,
|
|
375
|
+
rate_limit_max_ips=self.config.rate_limit_max_ips,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Set up alerters based on config
|
|
379
|
+
self.dashboard_alerter: Optional[DashboardAlerter] = None
|
|
380
|
+
self.webhook_alerter: Optional[WebhookAlerter] = None
|
|
381
|
+
|
|
382
|
+
if self.config.alert_mode in ("dashboard", "both"):
|
|
383
|
+
self.dashboard_alerter = DashboardAlerter(
|
|
384
|
+
api_key=self.config.api_key,
|
|
385
|
+
dashboard_url=self.config.dashboard_url,
|
|
386
|
+
timeout=self.config.webhook_timeout,
|
|
387
|
+
retry_count=self.config.webhook_retry_count,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if self.config.alert_mode in ("webhook", "both") and self.config.webhook_url:
|
|
391
|
+
self.webhook_alerter = WebhookAlerter(
|
|
392
|
+
webhook_url=self.config.webhook_url,
|
|
393
|
+
retry_count=self.config.webhook_retry_count,
|
|
394
|
+
timeout=self.config.webhook_timeout,
|
|
395
|
+
headers=self.config.webhook_headers,
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Get shared alert queue for non-blocking alert delivery
|
|
399
|
+
self._alert_queue = get_alert_queue()
|
|
400
|
+
|
|
401
|
+
# Log configuration
|
|
402
|
+
if self.config.watch_paths:
|
|
403
|
+
logger.info(f"FlaskSecurityMiddleware monitoring {len(self.config.watch_paths)} paths")
|
|
404
|
+
else:
|
|
405
|
+
logger.info("FlaskSecurityMiddleware monitoring all paths")
|
|
406
|
+
|
|
407
|
+
def _detect_vulnerable_endpoints(self) -> None:
|
|
408
|
+
"""Auto-detect vulnerable endpoints from project scan."""
|
|
409
|
+
try:
|
|
410
|
+
from .endpoint_analyzer import VulnerableEndpointDetector
|
|
411
|
+
|
|
412
|
+
detector = VulnerableEndpointDetector()
|
|
413
|
+
paths = detector.get_watch_paths(self.config.project_path)
|
|
414
|
+
|
|
415
|
+
if paths:
|
|
416
|
+
existing = set(self.config.watch_paths or [])
|
|
417
|
+
self.config.watch_paths = list(existing | set(paths))
|
|
418
|
+
logger.info(f"Auto-detected {len(paths)} vulnerable endpoints to monitor")
|
|
419
|
+
else:
|
|
420
|
+
logger.info("No vulnerable endpoints detected")
|
|
421
|
+
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.warning(f"Failed to auto-detect vulnerable endpoints: {e}")
|
|
424
|
+
|
|
425
|
+
def __call__(self, environ: dict, start_response: Callable) -> Any:
|
|
426
|
+
"""WSGI interface."""
|
|
427
|
+
path = environ.get("PATH_INFO", "/")
|
|
428
|
+
|
|
429
|
+
# Check if path should be monitored
|
|
430
|
+
if not self.config.should_monitor_path(path):
|
|
431
|
+
return self.app(environ, start_response)
|
|
432
|
+
|
|
433
|
+
request_data = self._extract_request_data(environ)
|
|
434
|
+
|
|
435
|
+
# Analyze for attacks
|
|
436
|
+
events = self.detector.analyze_request(request_data)
|
|
437
|
+
|
|
438
|
+
if events:
|
|
439
|
+
action = (
|
|
440
|
+
ActionTaken.BLOCKED
|
|
441
|
+
if self.config.block_on_detection
|
|
442
|
+
else ActionTaken.LOGGED
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# Queue alerts for background sending (non-blocking)
|
|
446
|
+
for event in events:
|
|
447
|
+
if self.dashboard_alerter:
|
|
448
|
+
self._alert_queue.enqueue(event, action, self.dashboard_alerter)
|
|
449
|
+
if self.webhook_alerter:
|
|
450
|
+
self._alert_queue.enqueue(event, action, self.webhook_alerter)
|
|
451
|
+
|
|
452
|
+
if self.config.block_on_detection:
|
|
453
|
+
# Return 403 Forbidden
|
|
454
|
+
return self._blocked_response(start_response)
|
|
455
|
+
|
|
456
|
+
return self.app(environ, start_response)
|
|
457
|
+
|
|
458
|
+
def _extract_request_data(self, environ: dict) -> RequestData:
|
|
459
|
+
"""Extract request data from WSGI environ."""
|
|
460
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
461
|
+
path = environ.get("PATH_INFO", "/")
|
|
462
|
+
query_string = environ.get("QUERY_STRING", "")
|
|
463
|
+
|
|
464
|
+
# Parse query params
|
|
465
|
+
query_params = {}
|
|
466
|
+
if query_string:
|
|
467
|
+
parsed = parse_qs(query_string)
|
|
468
|
+
query_params = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
|
|
469
|
+
|
|
470
|
+
# Extract headers
|
|
471
|
+
headers = {}
|
|
472
|
+
for key, value in environ.items():
|
|
473
|
+
if key.startswith("HTTP_"):
|
|
474
|
+
header_name = key[5:].lower().replace("_", "-")
|
|
475
|
+
headers[header_name] = value
|
|
476
|
+
elif key in ("CONTENT_TYPE", "CONTENT_LENGTH"):
|
|
477
|
+
headers[key.lower().replace("_", "-")] = value
|
|
478
|
+
|
|
479
|
+
# Get client IP
|
|
480
|
+
source_ip = environ.get("REMOTE_ADDR", "unknown")
|
|
481
|
+
if "HTTP_X_FORWARDED_FOR" in environ:
|
|
482
|
+
source_ip = environ["HTTP_X_FORWARDED_FOR"].split(",")[0].strip()
|
|
483
|
+
elif "HTTP_X_REAL_IP" in environ:
|
|
484
|
+
source_ip = environ["HTTP_X_REAL_IP"]
|
|
485
|
+
|
|
486
|
+
# Read body
|
|
487
|
+
body = None
|
|
488
|
+
content_length = environ.get("CONTENT_LENGTH")
|
|
489
|
+
if content_length:
|
|
490
|
+
try:
|
|
491
|
+
length = int(content_length)
|
|
492
|
+
if length > 0:
|
|
493
|
+
wsgi_input = environ.get("wsgi.input")
|
|
494
|
+
if wsgi_input:
|
|
495
|
+
body_bytes = wsgi_input.read(length)
|
|
496
|
+
body = body_bytes.decode("utf-8", errors="replace")
|
|
497
|
+
# Reset stream for the application
|
|
498
|
+
environ["wsgi.input"] = BytesIO(body_bytes)
|
|
499
|
+
except (ValueError, TypeError):
|
|
500
|
+
pass
|
|
501
|
+
|
|
502
|
+
return RequestData(
|
|
503
|
+
method=method,
|
|
504
|
+
path=path,
|
|
505
|
+
query_params=query_params,
|
|
506
|
+
headers=headers,
|
|
507
|
+
body=body,
|
|
508
|
+
source_ip=source_ip,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _blocked_response(self, start_response: Callable) -> list[bytes]:
|
|
512
|
+
"""Return a 403 Forbidden response."""
|
|
513
|
+
body = b'{"error": "Request blocked due to security policy"}'
|
|
514
|
+
start_response(
|
|
515
|
+
"403 Forbidden",
|
|
516
|
+
[
|
|
517
|
+
("Content-Type", "application/json"),
|
|
518
|
+
("Content-Length", str(len(body))),
|
|
519
|
+
],
|
|
520
|
+
)
|
|
521
|
+
return [body]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Data models for security sensor events and alerts."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AttackType(Enum):
|
|
11
|
+
"""Types of attacks that can be detected."""
|
|
12
|
+
|
|
13
|
+
SQL_INJECTION = "sql_injection"
|
|
14
|
+
XSS = "xss"
|
|
15
|
+
PATH_TRAVERSAL = "path_traversal"
|
|
16
|
+
COMMAND_INJECTION = "command_injection"
|
|
17
|
+
RATE_LIMIT_EXCEEDED = "rate_limit_exceeded"
|
|
18
|
+
SUSPICIOUS_HEADER = "suspicious_header"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ActionTaken(Enum):
|
|
22
|
+
"""Action taken in response to detected threat."""
|
|
23
|
+
|
|
24
|
+
LOGGED = "logged"
|
|
25
|
+
BLOCKED = "blocked"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class RequestData:
|
|
30
|
+
"""Normalized HTTP request data for analysis."""
|
|
31
|
+
|
|
32
|
+
method: str
|
|
33
|
+
path: str
|
|
34
|
+
query_params: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
body: Optional[str] = None
|
|
37
|
+
source_ip: str = "unknown"
|
|
38
|
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MatchedPattern:
|
|
43
|
+
"""Details about a matched attack pattern."""
|
|
44
|
+
|
|
45
|
+
pattern: str
|
|
46
|
+
location: str # "path", "query", "body", "header"
|
|
47
|
+
field: Optional[str] = None # Specific field name if applicable
|
|
48
|
+
matched_value: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SecurityEvent:
|
|
53
|
+
"""Represents a detected security event."""
|
|
54
|
+
|
|
55
|
+
event_type: AttackType
|
|
56
|
+
severity: str # "CRITICAL", "HIGH", "MEDIUM", "LOW"
|
|
57
|
+
timestamp: datetime
|
|
58
|
+
source_ip: str
|
|
59
|
+
path: str
|
|
60
|
+
method: str
|
|
61
|
+
matched_pattern: MatchedPattern
|
|
62
|
+
request_headers: dict[str, str] = field(default_factory=dict)
|
|
63
|
+
request_body: Optional[str] = None
|
|
64
|
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
65
|
+
confidence: float = 0.9
|
|
66
|
+
description: str = ""
|
|
67
|
+
|
|
68
|
+
def to_dict(self) -> dict:
|
|
69
|
+
"""Convert to dictionary for JSON serialization."""
|
|
70
|
+
return {
|
|
71
|
+
"event_type": self.event_type.value,
|
|
72
|
+
"severity": self.severity,
|
|
73
|
+
"timestamp": self.timestamp.isoformat(),
|
|
74
|
+
"source_ip": self.source_ip,
|
|
75
|
+
"path": self.path,
|
|
76
|
+
"method": self.method,
|
|
77
|
+
"matched_pattern": {
|
|
78
|
+
"pattern": self.matched_pattern.pattern,
|
|
79
|
+
"location": self.matched_pattern.location,
|
|
80
|
+
"field": self.matched_pattern.field,
|
|
81
|
+
"matched_value": self.matched_pattern.matched_value,
|
|
82
|
+
},
|
|
83
|
+
"request_id": self.request_id,
|
|
84
|
+
"confidence": self.confidence,
|
|
85
|
+
"description": self.description,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class AlertPayload:
|
|
91
|
+
"""Webhook alert payload format."""
|
|
92
|
+
|
|
93
|
+
version: str = "1.0"
|
|
94
|
+
event_id: str = field(default_factory=lambda: f"evt_{uuid.uuid4().hex[:12]}")
|
|
95
|
+
event_type: str = "security_alert"
|
|
96
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
97
|
+
alert: Optional[SecurityEvent] = None
|
|
98
|
+
action_taken: ActionTaken = ActionTaken.LOGGED
|
|
99
|
+
|
|
100
|
+
def to_dict(self) -> dict:
|
|
101
|
+
"""Convert to webhook payload format."""
|
|
102
|
+
if self.alert is None:
|
|
103
|
+
raise ValueError("Alert cannot be None")
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
"version": self.version,
|
|
107
|
+
"event": {
|
|
108
|
+
"id": self.event_id,
|
|
109
|
+
"type": self.event_type,
|
|
110
|
+
"timestamp": self.timestamp.isoformat() + "Z",
|
|
111
|
+
},
|
|
112
|
+
"alert": {
|
|
113
|
+
"type": self.alert.event_type.value,
|
|
114
|
+
"severity": self.alert.severity,
|
|
115
|
+
"confidence": self.alert.confidence,
|
|
116
|
+
"description": self.alert.description,
|
|
117
|
+
},
|
|
118
|
+
"request": {
|
|
119
|
+
"method": self.alert.method,
|
|
120
|
+
"path": self.alert.path,
|
|
121
|
+
"source_ip": self.alert.source_ip,
|
|
122
|
+
"headers": self.alert.request_headers,
|
|
123
|
+
},
|
|
124
|
+
"matched": {
|
|
125
|
+
"pattern": self.alert.matched_pattern.pattern,
|
|
126
|
+
"location": self.alert.matched_pattern.location,
|
|
127
|
+
"field": self.alert.matched_pattern.field,
|
|
128
|
+
},
|
|
129
|
+
"action_taken": self.action_taken.value,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class AlertResponse:
|
|
135
|
+
"""Response from webhook alert attempt."""
|
|
136
|
+
|
|
137
|
+
success: bool
|
|
138
|
+
webhook_status: int
|
|
139
|
+
retry_count: int
|
|
140
|
+
error_message: Optional[str] = None
|