devguard 0.2.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.
- devguard/INTEGRATION_SUMMARY.md +121 -0
- devguard/__init__.py +3 -0
- devguard/__main__.py +6 -0
- devguard/checkers/__init__.py +41 -0
- devguard/checkers/api_usage.py +523 -0
- devguard/checkers/aws_cost.py +331 -0
- devguard/checkers/aws_iam.py +284 -0
- devguard/checkers/base.py +25 -0
- devguard/checkers/container.py +137 -0
- devguard/checkers/domain.py +189 -0
- devguard/checkers/firecrawl.py +117 -0
- devguard/checkers/fly.py +225 -0
- devguard/checkers/github.py +210 -0
- devguard/checkers/npm.py +327 -0
- devguard/checkers/npm_security.py +244 -0
- devguard/checkers/redteam.py +290 -0
- devguard/checkers/secret.py +279 -0
- devguard/checkers/swarm.py +376 -0
- devguard/checkers/tailscale.py +143 -0
- devguard/checkers/tailsnitch.py +303 -0
- devguard/checkers/tavily.py +179 -0
- devguard/checkers/vercel.py +192 -0
- devguard/cli.py +1510 -0
- devguard/cli_helpers.py +189 -0
- devguard/config.py +249 -0
- devguard/core.py +293 -0
- devguard/dashboard.py +715 -0
- devguard/discovery.py +363 -0
- devguard/http_client.py +142 -0
- devguard/llm_service.py +481 -0
- devguard/mcp_server.py +259 -0
- devguard/metrics.py +144 -0
- devguard/models.py +208 -0
- devguard/reporting.py +1571 -0
- devguard/sarif.py +295 -0
- devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
- devguard/scripts/README.md +221 -0
- devguard/scripts/auto_fix_recommendations.py +145 -0
- devguard/scripts/generate_npmignore.py +175 -0
- devguard/scripts/generate_security_report.py +324 -0
- devguard/scripts/prepublish_check.sh +29 -0
- devguard/scripts/redteam_npm_packages.py +1262 -0
- devguard/scripts/review_all_repos.py +300 -0
- devguard/spec.py +617 -0
- devguard/sweeps/__init__.py +23 -0
- devguard/sweeps/ai_editor_config_audit.py +697 -0
- devguard/sweeps/cargo_publish_audit.py +655 -0
- devguard/sweeps/dependency_audit.py +419 -0
- devguard/sweeps/gitignore_audit.py +336 -0
- devguard/sweeps/local_dev.py +260 -0
- devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
- devguard/sweeps/project_flaudit.py +636 -0
- devguard/sweeps/public_github_secrets.py +680 -0
- devguard/sweeps/publish_audit.py +478 -0
- devguard/sweeps/ssh_key_audit.py +327 -0
- devguard/utils.py +174 -0
- devguard-0.2.0.dist-info/METADATA +225 -0
- devguard-0.2.0.dist-info/RECORD +60 -0
- devguard-0.2.0.dist-info/WHEEL +4 -0
- devguard-0.2.0.dist-info/entry_points.txt +2 -0
devguard/dashboard.py
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
"""Web dashboard for Guardian monitoring."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException, Request, Security, status
|
|
12
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
13
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
14
|
+
from fastapi.security import APIKeyHeader
|
|
15
|
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
|
16
|
+
from slowapi.errors import RateLimitExceeded
|
|
17
|
+
|
|
18
|
+
from devguard.config import get_settings
|
|
19
|
+
from devguard.core import Guardian
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_real_client_ip(request: Request) -> str:
|
|
25
|
+
"""Get real client IP, respecting proxy headers.
|
|
26
|
+
|
|
27
|
+
Priority:
|
|
28
|
+
1. Fly-Client-IP (Fly.io)
|
|
29
|
+
2. CF-Connecting-IP (Cloudflare)
|
|
30
|
+
3. X-Real-IP (nginx)
|
|
31
|
+
4. X-Forwarded-For (first IP in chain)
|
|
32
|
+
5. request.client.host (direct connection)
|
|
33
|
+
"""
|
|
34
|
+
# Fly.io sets this header
|
|
35
|
+
fly_ip = request.headers.get("Fly-Client-IP")
|
|
36
|
+
if fly_ip:
|
|
37
|
+
return fly_ip
|
|
38
|
+
|
|
39
|
+
# Cloudflare sets this header
|
|
40
|
+
cf_ip = request.headers.get("CF-Connecting-IP")
|
|
41
|
+
if cf_ip:
|
|
42
|
+
return cf_ip
|
|
43
|
+
|
|
44
|
+
# Common proxy header
|
|
45
|
+
real_ip = request.headers.get("X-Real-IP")
|
|
46
|
+
if real_ip:
|
|
47
|
+
return real_ip
|
|
48
|
+
|
|
49
|
+
# X-Forwarded-For can contain multiple IPs: client, proxy1, proxy2
|
|
50
|
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
51
|
+
if forwarded_for:
|
|
52
|
+
# First IP is the original client
|
|
53
|
+
return forwarded_for.split(",")[0].strip()
|
|
54
|
+
|
|
55
|
+
# Fall back to direct connection
|
|
56
|
+
if request.client:
|
|
57
|
+
return request.client.host
|
|
58
|
+
|
|
59
|
+
return "unknown"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Rate limiting with proxy-aware IP detection
|
|
63
|
+
limiter = Limiter(key_func=get_real_client_ip)
|
|
64
|
+
|
|
65
|
+
# API Key authentication
|
|
66
|
+
API_KEY_NAME = "X-API-Key"
|
|
67
|
+
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
|
68
|
+
|
|
69
|
+
# Session configuration
|
|
70
|
+
SESSION_TIMEOUT_SECONDS = 24 * 60 * 60 # 24 hours
|
|
71
|
+
SESSION_COOKIE_NAME = "devguard_session"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_session_secret() -> bytes:
|
|
75
|
+
"""Get or derive the session signing secret."""
|
|
76
|
+
settings = get_settings()
|
|
77
|
+
# Use dashboard API key as base for session secret
|
|
78
|
+
# If not set, use a per-process random secret (sessions won't survive restarts)
|
|
79
|
+
if settings.dashboard_api_key:
|
|
80
|
+
return hashlib.sha256(settings.dashboard_api_key.encode()).digest()
|
|
81
|
+
else:
|
|
82
|
+
# Generate a random secret for development (not persistent)
|
|
83
|
+
if not hasattr(_get_session_secret, "_dev_secret"):
|
|
84
|
+
_get_session_secret._dev_secret = secrets.token_bytes(32)
|
|
85
|
+
logger.warning("Using ephemeral session secret (development mode)")
|
|
86
|
+
return _get_session_secret._dev_secret
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _sign_session(data: str) -> str:
|
|
90
|
+
"""Create a signed session token."""
|
|
91
|
+
secret = _get_session_secret()
|
|
92
|
+
signature = hmac.new(secret, data.encode(), hashlib.sha256).hexdigest()
|
|
93
|
+
return f"{data}.{signature}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _verify_signed_session(token: str) -> str | None:
|
|
97
|
+
"""Verify a signed session token and return the data if valid."""
|
|
98
|
+
if "." not in token:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
data, signature = token.rsplit(".", 1)
|
|
103
|
+
secret = _get_session_secret()
|
|
104
|
+
expected_sig = hmac.new(secret, data.encode(), hashlib.sha256).hexdigest()
|
|
105
|
+
|
|
106
|
+
if not hmac.compare_digest(signature, expected_sig):
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
return data
|
|
110
|
+
except Exception:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def create_session_token() -> str:
|
|
115
|
+
"""Create a new signed session token with expiry."""
|
|
116
|
+
expiry = int(time.time()) + SESSION_TIMEOUT_SECONDS
|
|
117
|
+
session_id = secrets.token_urlsafe(16)
|
|
118
|
+
data = f"{session_id}:{expiry}"
|
|
119
|
+
return _sign_session(data)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def verify_session(request: Request) -> str:
|
|
123
|
+
"""Verify session cookie (stateless, signed)."""
|
|
124
|
+
session_token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
125
|
+
if not session_token:
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
128
|
+
detail="Session required",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
data = _verify_signed_session(session_token)
|
|
132
|
+
if not data:
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
135
|
+
detail="Invalid session",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
session_id, expiry_str = data.split(":", 1)
|
|
140
|
+
expiry = int(expiry_str)
|
|
141
|
+
|
|
142
|
+
if time.time() > expiry:
|
|
143
|
+
raise HTTPException(
|
|
144
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
145
|
+
detail="Session expired",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return session_id
|
|
149
|
+
except (ValueError, TypeError):
|
|
150
|
+
raise HTTPException(
|
|
151
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
152
|
+
detail="Invalid session format",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def verify_api_key(api_key: str = Security(api_key_header)) -> str:
|
|
157
|
+
"""Verify API key from header."""
|
|
158
|
+
settings = get_settings()
|
|
159
|
+
expected_key = settings.dashboard_api_key
|
|
160
|
+
if not expected_key:
|
|
161
|
+
logger.warning("DASHBOARD_API_KEY not set - allowing all access (development mode)")
|
|
162
|
+
return "dev"
|
|
163
|
+
if not api_key or api_key != expected_key:
|
|
164
|
+
raise HTTPException(
|
|
165
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
166
|
+
detail="Invalid or missing API key",
|
|
167
|
+
)
|
|
168
|
+
return api_key
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@asynccontextmanager
|
|
172
|
+
async def lifespan(app: FastAPI):
|
|
173
|
+
"""Lifespan context manager for startup/shutdown."""
|
|
174
|
+
logger.info("Guardian dashboard server starting")
|
|
175
|
+
yield
|
|
176
|
+
logger.info("Guardian dashboard server shutting down")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
app = FastAPI(
|
|
180
|
+
title="Guardian Dashboard",
|
|
181
|
+
lifespan=lifespan,
|
|
182
|
+
docs_url=None,
|
|
183
|
+
redoc_url=None,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Add rate limiting
|
|
187
|
+
app.state.limiter = limiter
|
|
188
|
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
189
|
+
|
|
190
|
+
# CORS configuration
|
|
191
|
+
settings = get_settings()
|
|
192
|
+
allowed_origins = settings.allowed_origins
|
|
193
|
+
if not allowed_origins:
|
|
194
|
+
allowed_origins = []
|
|
195
|
+
|
|
196
|
+
app.add_middleware(
|
|
197
|
+
CORSMiddleware,
|
|
198
|
+
allow_origins=allowed_origins,
|
|
199
|
+
allow_credentials=True,
|
|
200
|
+
allow_methods=["*"],
|
|
201
|
+
allow_headers=["*"],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Security headers middleware
|
|
206
|
+
@app.middleware("http")
|
|
207
|
+
async def security_headers_middleware(request: Request, call_next):
|
|
208
|
+
"""Add security headers to all responses."""
|
|
209
|
+
response = await call_next(request)
|
|
210
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
211
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
212
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
213
|
+
if settings.environment == "production":
|
|
214
|
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
215
|
+
return response
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.get("/", response_class=HTMLResponse)
|
|
219
|
+
@limiter.limit("30/minute")
|
|
220
|
+
async def dashboard(request: Request):
|
|
221
|
+
"""Main dashboard page."""
|
|
222
|
+
csrf_token = secrets.token_urlsafe(32)
|
|
223
|
+
html = get_dashboard_html(csrf_token)
|
|
224
|
+
return HTMLResponse(content=html)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.get("/config", response_class=HTMLResponse)
|
|
228
|
+
@limiter.limit("30/minute")
|
|
229
|
+
async def config_page(request: Request):
|
|
230
|
+
"""Configuration browser page."""
|
|
231
|
+
html = """
|
|
232
|
+
<!DOCTYPE html>
|
|
233
|
+
<html lang="en">
|
|
234
|
+
<head>
|
|
235
|
+
<meta charset="UTF-8">
|
|
236
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
237
|
+
<title>Guardian Configuration</title>
|
|
238
|
+
<style>
|
|
239
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
240
|
+
body {
|
|
241
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
242
|
+
background: #0a0a0a;
|
|
243
|
+
color: #e0e0e0;
|
|
244
|
+
padding: 20px;
|
|
245
|
+
}
|
|
246
|
+
.container { max-width: 1200px; margin: 0 auto; }
|
|
247
|
+
h1 { color: #4a9eff; margin-bottom: 20px; }
|
|
248
|
+
.nav { margin-bottom: 20px; }
|
|
249
|
+
.nav a { color: #4a9eff; margin-right: 20px; text-decoration: none; }
|
|
250
|
+
.nav a:hover { text-decoration: underline; }
|
|
251
|
+
.section { background: #1a1a1a; padding: 20px; margin-bottom: 20px; border-radius: 8px; }
|
|
252
|
+
.section h2 { color: #4a9eff; margin-bottom: 15px; }
|
|
253
|
+
.config-item { display: flex; justify-content: space-between; padding: 10px; border-bottom: 1px solid #2a2a2a; }
|
|
254
|
+
.config-item:last-child { border-bottom: none; }
|
|
255
|
+
.config-label { color: #888; }
|
|
256
|
+
.config-value { color: #fff; font-weight: bold; }
|
|
257
|
+
.status-ok { color: #44ff44; }
|
|
258
|
+
.status-error { color: #ff4444; }
|
|
259
|
+
.refresh-btn {
|
|
260
|
+
background: #4a9eff;
|
|
261
|
+
color: #000;
|
|
262
|
+
border: none;
|
|
263
|
+
padding: 10px 20px;
|
|
264
|
+
border-radius: 4px;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
margin-top: 20px;
|
|
267
|
+
}
|
|
268
|
+
.refresh-btn:hover { background: #5ab0ff; }
|
|
269
|
+
</style>
|
|
270
|
+
</head>
|
|
271
|
+
<body>
|
|
272
|
+
<div class="container">
|
|
273
|
+
<h1>⚙️ Guardian Configuration</h1>
|
|
274
|
+
<div class="nav">
|
|
275
|
+
<a href="/">Dashboard</a>
|
|
276
|
+
<a href="/config">Configuration</a>
|
|
277
|
+
</div>
|
|
278
|
+
<div id="config"></div>
|
|
279
|
+
<button class="refresh-btn" onclick="loadConfig()">Refresh</button>
|
|
280
|
+
</div>
|
|
281
|
+
<script>
|
|
282
|
+
async function loadConfig() {
|
|
283
|
+
try {
|
|
284
|
+
const response = await fetch('/api/config');
|
|
285
|
+
const config = await response.json();
|
|
286
|
+
renderConfig(config);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
document.getElementById('config').innerHTML =
|
|
289
|
+
'<div class="section"><p style="color: #ff4444;">Error loading config</p></div>';
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function renderConfig(config) {
|
|
294
|
+
let html = '';
|
|
295
|
+
|
|
296
|
+
// Services
|
|
297
|
+
html += '<div class="section"><h2>API Keys & Services</h2>';
|
|
298
|
+
for (const [service, data] of Object.entries(config.services)) {
|
|
299
|
+
const status = data.configured ? '<span class="status-ok">✓ Configured</span>' :
|
|
300
|
+
'<span class="status-error">✗ Not configured</span>';
|
|
301
|
+
html += `<div class="config-item">
|
|
302
|
+
<span class="config-label">${service}</span>
|
|
303
|
+
<span class="config-value">${status}</span>
|
|
304
|
+
</div>`;
|
|
305
|
+
}
|
|
306
|
+
html += '</div>';
|
|
307
|
+
|
|
308
|
+
// Monitoring
|
|
309
|
+
html += '<div class="section"><h2>Monitoring Configuration</h2>';
|
|
310
|
+
for (const [key, value] of Object.entries(config.monitoring)) {
|
|
311
|
+
html += `<div class="config-item">
|
|
312
|
+
<span class="config-label">${key.replace(/_/g, ' ')}</span>
|
|
313
|
+
<span class="config-value">${value}</span>
|
|
314
|
+
</div>`;
|
|
315
|
+
}
|
|
316
|
+
html += '</div>';
|
|
317
|
+
|
|
318
|
+
// Dashboard
|
|
319
|
+
html += '<div class="section"><h2>Dashboard Configuration</h2>';
|
|
320
|
+
for (const [key, value] of Object.entries(config.dashboard)) {
|
|
321
|
+
const displayValue = typeof value === 'boolean' ? (value ? 'Yes' : 'No') : value;
|
|
322
|
+
html += `<div class="config-item">
|
|
323
|
+
<span class="config-label">${key.replace(/_/g, ' ')}</span>
|
|
324
|
+
<span class="config-value">${displayValue}</span>
|
|
325
|
+
</div>`;
|
|
326
|
+
}
|
|
327
|
+
html += '</div>';
|
|
328
|
+
|
|
329
|
+
// Environment
|
|
330
|
+
html += `<div class="section"><h2>Environment</h2>
|
|
331
|
+
<div class="config-item">
|
|
332
|
+
<span class="config-label">Mode</span>
|
|
333
|
+
<span class="config-value">${config.environment}</span>
|
|
334
|
+
</div>
|
|
335
|
+
</div>`;
|
|
336
|
+
|
|
337
|
+
document.getElementById('config').innerHTML = html;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Load on page load
|
|
341
|
+
loadConfig();
|
|
342
|
+
</script>
|
|
343
|
+
</body>
|
|
344
|
+
</html>
|
|
345
|
+
"""
|
|
346
|
+
return HTMLResponse(content=html)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def get_dashboard_html(csrf_token: str) -> str:
|
|
350
|
+
"""Generate dashboard HTML."""
|
|
351
|
+
return """
|
|
352
|
+
<!DOCTYPE html>
|
|
353
|
+
<html lang="en">
|
|
354
|
+
<head>
|
|
355
|
+
<meta charset="UTF-8">
|
|
356
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
357
|
+
<title>Guardian Dashboard</title>
|
|
358
|
+
<style>
|
|
359
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
360
|
+
body {
|
|
361
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
362
|
+
Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
363
|
+
background: #0a0a0a;
|
|
364
|
+
color: #e0e0e0;
|
|
365
|
+
padding: 20px;
|
|
366
|
+
line-height: 1.6;
|
|
367
|
+
}
|
|
368
|
+
.container {
|
|
369
|
+
max-width: 1200px;
|
|
370
|
+
margin: 0 auto;
|
|
371
|
+
}
|
|
372
|
+
h1 {
|
|
373
|
+
color: #4a9eff;
|
|
374
|
+
margin-bottom: 30px;
|
|
375
|
+
}
|
|
376
|
+
.summary {
|
|
377
|
+
display: grid;
|
|
378
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
379
|
+
gap: 20px;
|
|
380
|
+
margin-bottom: 30px;
|
|
381
|
+
}
|
|
382
|
+
.card {
|
|
383
|
+
background: #1a1a1a;
|
|
384
|
+
border: 1px solid #2a2a2a;
|
|
385
|
+
border-radius: 8px;
|
|
386
|
+
padding: 20px;
|
|
387
|
+
}
|
|
388
|
+
.card h3 {
|
|
389
|
+
color: #888;
|
|
390
|
+
font-size: 14px;
|
|
391
|
+
margin-bottom: 10px;
|
|
392
|
+
text-transform: uppercase;
|
|
393
|
+
}
|
|
394
|
+
.card .value {
|
|
395
|
+
font-size: 32px;
|
|
396
|
+
font-weight: bold;
|
|
397
|
+
color: #4a9eff;
|
|
398
|
+
}
|
|
399
|
+
.card .critical { color: #ff4444; }
|
|
400
|
+
.card .warning { color: #ffaa00; }
|
|
401
|
+
.card .healthy { color: #44ff44; }
|
|
402
|
+
.checks {
|
|
403
|
+
margin-top: 30px;
|
|
404
|
+
}
|
|
405
|
+
.check-item {
|
|
406
|
+
background: #1a1a1a;
|
|
407
|
+
border: 1px solid #2a2a2a;
|
|
408
|
+
border-radius: 8px;
|
|
409
|
+
padding: 15px;
|
|
410
|
+
margin-bottom: 15px;
|
|
411
|
+
}
|
|
412
|
+
.check-header {
|
|
413
|
+
display: flex;
|
|
414
|
+
justify-content: space-between;
|
|
415
|
+
align-items: center;
|
|
416
|
+
margin-bottom: 10px;
|
|
417
|
+
}
|
|
418
|
+
.check-type {
|
|
419
|
+
font-weight: bold;
|
|
420
|
+
color: #4a9eff;
|
|
421
|
+
}
|
|
422
|
+
.check-status {
|
|
423
|
+
padding: 4px 12px;
|
|
424
|
+
border-radius: 4px;
|
|
425
|
+
font-size: 12px;
|
|
426
|
+
font-weight: bold;
|
|
427
|
+
}
|
|
428
|
+
.status-success { background: #44ff44; color: #000; }
|
|
429
|
+
.status-error { background: #ff4444; color: #fff; }
|
|
430
|
+
.refresh-info {
|
|
431
|
+
color: #666;
|
|
432
|
+
font-size: 12px;
|
|
433
|
+
margin-top: 20px;
|
|
434
|
+
text-align: center;
|
|
435
|
+
}
|
|
436
|
+
.error { color: #ff4444; }
|
|
437
|
+
</style>
|
|
438
|
+
</head>
|
|
439
|
+
<body>
|
|
440
|
+
<div class="container">
|
|
441
|
+
<h1>🛡️ Guardian Dashboard</h1>
|
|
442
|
+
<div style="margin-bottom: 20px;">
|
|
443
|
+
<a href="/" style="color: #4a9eff; margin-right: 20px;">Dashboard</a>
|
|
444
|
+
<a href="/config" style="color: #4a9eff;">Configuration</a>
|
|
445
|
+
</div>
|
|
446
|
+
<div class="summary" id="summary"></div>
|
|
447
|
+
<div class="checks" id="checks"></div>
|
|
448
|
+
<div class="refresh-info">Auto-refreshing every 30 seconds</div>
|
|
449
|
+
</div>
|
|
450
|
+
<script>
|
|
451
|
+
async function fetchData() {
|
|
452
|
+
try {
|
|
453
|
+
const response = await fetch('/api/report');
|
|
454
|
+
if (!response.ok) throw new Error('Failed to fetch');
|
|
455
|
+
const data = await response.json();
|
|
456
|
+
updateDashboard(data);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
console.error('Error fetching data:', error);
|
|
459
|
+
const errorMsg = 'Error loading data. Check console for details.';
|
|
460
|
+
document.getElementById('checks').innerHTML =
|
|
461
|
+
`<div class="check-item error">${errorMsg}</div>`;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function updateDashboard(data) {
|
|
466
|
+
const summary = data.summary || {};
|
|
467
|
+
const checks = data.checks || [];
|
|
468
|
+
|
|
469
|
+
// Update summary
|
|
470
|
+
const summaryHtml = `
|
|
471
|
+
<div class="card">
|
|
472
|
+
<h3>Total Checks</h3>
|
|
473
|
+
<div class="value">${summary.total_checks || 0}</div>
|
|
474
|
+
</div>
|
|
475
|
+
<div class="card">
|
|
476
|
+
<h3>Vulnerabilities</h3>
|
|
477
|
+
<div class="value ${summary.total_vulnerabilities > 0 ? 'critical' : 'healthy'}">
|
|
478
|
+
${summary.total_vulnerabilities || 0}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="card">
|
|
482
|
+
<h3>Critical</h3>
|
|
483
|
+
<div class="value critical">${summary.critical_vulnerabilities || 0}</div>
|
|
484
|
+
</div>
|
|
485
|
+
<div class="card">
|
|
486
|
+
<h3>Unhealthy Deployments</h3>
|
|
487
|
+
<div class="value ${summary.unhealthy_deployments > 0 ? 'warning' : 'healthy'}">
|
|
488
|
+
${summary.unhealthy_deployments || 0}
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
<div class="card">
|
|
492
|
+
<h3>Total Cost (USD)</h3>
|
|
493
|
+
<div class="value ${summary.total_cost_usd > 0 ? 'warning' : 'healthy'}">
|
|
494
|
+
$${(summary.total_cost_usd || 0).toFixed(2)}
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
`;
|
|
498
|
+
document.getElementById('summary').innerHTML = summaryHtml;
|
|
499
|
+
|
|
500
|
+
// Update checks
|
|
501
|
+
const checksHtml = checks.map(check => `
|
|
502
|
+
<div class="check-item">
|
|
503
|
+
<div class="check-header">
|
|
504
|
+
<span class="check-type">${check.check_type.toUpperCase()}</span>
|
|
505
|
+
<span class="check-status ${check.success ? 'status-success' : 'status-error'}">
|
|
506
|
+
${check.success ? '✓ Success' : '✗ Failed'}
|
|
507
|
+
</span>
|
|
508
|
+
</div>
|
|
509
|
+
${check.errors.length > 0 ?
|
|
510
|
+
`<div class="error">Errors: ${check.errors.join(', ')}</div>` : ''}
|
|
511
|
+
${check.cost_metrics && check.cost_metrics.length > 0 ?
|
|
512
|
+
`<div style="margin-top: 10px; padding: 10px; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #4a9eff;">
|
|
513
|
+
<strong style="color: #4a9eff;">Cost Metrics:</strong>
|
|
514
|
+
${check.cost_metrics.map(m => {
|
|
515
|
+
const amount = m.amount || 0;
|
|
516
|
+
const usage = m.usage || 0;
|
|
517
|
+
const limit = m.limit || 0;
|
|
518
|
+
const usagePct = m.usage_percent || 0;
|
|
519
|
+
const limitStr = limit > 0 ? limit.toLocaleString() : 'N/A';
|
|
520
|
+
return `
|
|
521
|
+
<div style="margin-top: 8px; padding: 8px; background: #0f0f0f; border-radius: 3px;">
|
|
522
|
+
<strong style="color: #fff;">${m.service.toUpperCase()}</strong><br/>
|
|
523
|
+
Cost: <span style="color: ${amount > 0 ? '#ffaa44' : '#888'}">$${amount.toFixed(2)}</span> |
|
|
524
|
+
Usage: <span style="color: ${usagePct > 80 ? '#ff4444' : usagePct > 50 ? '#ffaa44' : '#44ff44'}">${usage.toLocaleString()} / ${limitStr}</span>
|
|
525
|
+
<span style="color: #888;">(${usagePct.toFixed(1)}%)</span>
|
|
526
|
+
</div>
|
|
527
|
+
`;
|
|
528
|
+
}).join('')}
|
|
529
|
+
</div>` :
|
|
530
|
+
check.errors.length === 0 ?
|
|
531
|
+
`<div style="margin-top: 10px; padding: 8px; background: #1a1a1a; border-radius: 4px; color: #888; font-size: 12px;">
|
|
532
|
+
No cost metrics available
|
|
533
|
+
</div>` : ''}
|
|
534
|
+
${check.metadata && Object.keys(check.metadata).length > 0 ?
|
|
535
|
+
`<div style="margin-top: 10px; font-size: 12px; color: #888;">
|
|
536
|
+
${Object.entries(check.metadata).map(([k, v]) => `${k}: ${v}`).join(' | ')}
|
|
537
|
+
</div>` : ''}
|
|
538
|
+
</div>
|
|
539
|
+
`).join('');
|
|
540
|
+
document.getElementById('checks').innerHTML = checksHtml;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Initial load
|
|
544
|
+
fetchData();
|
|
545
|
+
|
|
546
|
+
// Auto-refresh every 30 seconds
|
|
547
|
+
setInterval(fetchData, 30000);
|
|
548
|
+
</script>
|
|
549
|
+
</body>
|
|
550
|
+
</html>
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@app.post("/api/login")
|
|
555
|
+
@limiter.limit("5/minute")
|
|
556
|
+
async def login(request: Request, api_key: str):
|
|
557
|
+
"""Login with API key and create session."""
|
|
558
|
+
settings = get_settings()
|
|
559
|
+
expected_key = settings.dashboard_api_key
|
|
560
|
+
|
|
561
|
+
if not expected_key:
|
|
562
|
+
logger.warning("DASHBOARD_API_KEY not set - allowing login (development mode)")
|
|
563
|
+
elif api_key != expected_key:
|
|
564
|
+
raise HTTPException(
|
|
565
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
566
|
+
detail="Invalid API key",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Create signed session token (stateless - works across replicas)
|
|
570
|
+
session_token = create_session_token()
|
|
571
|
+
|
|
572
|
+
response = JSONResponse({"status": "success"})
|
|
573
|
+
response.set_cookie(
|
|
574
|
+
key=SESSION_COOKIE_NAME,
|
|
575
|
+
value=session_token,
|
|
576
|
+
httponly=True,
|
|
577
|
+
secure=settings.environment == "production",
|
|
578
|
+
samesite="lax",
|
|
579
|
+
max_age=SESSION_TIMEOUT_SECONDS,
|
|
580
|
+
)
|
|
581
|
+
return response
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@app.get("/api/config")
|
|
585
|
+
@limiter.limit("30/minute")
|
|
586
|
+
async def get_config(request: Request):
|
|
587
|
+
"""Get current configuration (sanitized - no secrets)."""
|
|
588
|
+
settings = get_settings()
|
|
589
|
+
|
|
590
|
+
# Return config without exposing secrets
|
|
591
|
+
config_dict = {
|
|
592
|
+
"services": {
|
|
593
|
+
"github": {"configured": bool(settings.github_token)},
|
|
594
|
+
"vercel": {"configured": bool(settings.vercel_token)},
|
|
595
|
+
"fly": {"configured": bool(settings.fly_api_token)},
|
|
596
|
+
"snyk": {"configured": bool(settings.snyk_token)},
|
|
597
|
+
"firecrawl": {"configured": bool(settings.firecrawl_api_key)},
|
|
598
|
+
"tavily": {"configured": bool(settings.tavily_api_key)},
|
|
599
|
+
"anthropic": {"configured": bool(settings.anthropic_api_key)},
|
|
600
|
+
"openrouter": {"configured": bool(settings.openrouter_api_key)},
|
|
601
|
+
},
|
|
602
|
+
"monitoring": {
|
|
603
|
+
"npm_packages_count": len(settings.npm_packages_to_monitor)
|
|
604
|
+
if settings.npm_packages_to_monitor
|
|
605
|
+
else 0,
|
|
606
|
+
"github_repos_count": len(settings.github_repos_to_monitor)
|
|
607
|
+
if settings.github_repos_to_monitor
|
|
608
|
+
else 0,
|
|
609
|
+
"vercel_projects_count": len(settings.vercel_projects_to_monitor)
|
|
610
|
+
if settings.vercel_projects_to_monitor
|
|
611
|
+
else 0,
|
|
612
|
+
"fly_apps_count": len(settings.fly_apps_to_monitor)
|
|
613
|
+
if settings.fly_apps_to_monitor
|
|
614
|
+
else 0,
|
|
615
|
+
"check_interval_seconds": settings.check_interval_seconds,
|
|
616
|
+
},
|
|
617
|
+
"dashboard": {
|
|
618
|
+
"enabled": settings.dashboard_enabled,
|
|
619
|
+
"host": settings.dashboard_host,
|
|
620
|
+
"port": settings.dashboard_port,
|
|
621
|
+
"api_key_set": bool(settings.dashboard_api_key),
|
|
622
|
+
"metrics_enabled": settings.metrics_enabled,
|
|
623
|
+
},
|
|
624
|
+
"environment": settings.environment,
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return JSONResponse(config_dict)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@app.get("/api/report")
|
|
631
|
+
@limiter.limit("30/minute")
|
|
632
|
+
async def get_report(request: Request):
|
|
633
|
+
"""Get current monitoring report."""
|
|
634
|
+
settings = get_settings()
|
|
635
|
+
guardian = Guardian(settings)
|
|
636
|
+
report = await guardian.run_checks()
|
|
637
|
+
|
|
638
|
+
# Convert to dict for JSON response
|
|
639
|
+
report_dict = {
|
|
640
|
+
"generated_at": report.generated_at.isoformat(),
|
|
641
|
+
"summary": report.summary,
|
|
642
|
+
"checks": [
|
|
643
|
+
{
|
|
644
|
+
"check_type": check.check_type,
|
|
645
|
+
"timestamp": check.timestamp.isoformat(),
|
|
646
|
+
"success": check.success,
|
|
647
|
+
"vulnerabilities_count": len(check.vulnerabilities),
|
|
648
|
+
"deployments_count": len(check.deployments),
|
|
649
|
+
"repository_alerts_count": len(check.repository_alerts),
|
|
650
|
+
"errors": check.errors,
|
|
651
|
+
"cost_metrics": [
|
|
652
|
+
{
|
|
653
|
+
"service": cost.service,
|
|
654
|
+
"period": cost.period,
|
|
655
|
+
"amount": cost.amount if cost.amount is not None else 0.0,
|
|
656
|
+
"usage": cost.usage if cost.usage is not None else 0.0,
|
|
657
|
+
"limit": cost.limit if cost.limit is not None else 0.0,
|
|
658
|
+
"usage_percent": cost.usage_percent
|
|
659
|
+
if cost.usage_percent is not None
|
|
660
|
+
else 0.0,
|
|
661
|
+
"metadata": cost.metadata,
|
|
662
|
+
}
|
|
663
|
+
for cost in check.cost_metrics
|
|
664
|
+
],
|
|
665
|
+
"metadata": check.metadata,
|
|
666
|
+
}
|
|
667
|
+
for check in report.checks
|
|
668
|
+
],
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return JSONResponse(report_dict)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
@app.get("/health")
|
|
675
|
+
async def health():
|
|
676
|
+
"""Health check endpoint."""
|
|
677
|
+
return JSONResponse({"status": "healthy"})
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
@app.get("/metrics")
|
|
681
|
+
async def metrics():
|
|
682
|
+
"""Prometheus metrics endpoint."""
|
|
683
|
+
from fastapi.responses import Response
|
|
684
|
+
|
|
685
|
+
from devguard.metrics import get_metrics
|
|
686
|
+
|
|
687
|
+
return Response(content=get_metrics(), media_type="text/plain")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def run_dashboard(host: str | None = None, port: int | None = None) -> None:
|
|
691
|
+
"""Run the dashboard server."""
|
|
692
|
+
import uvicorn
|
|
693
|
+
|
|
694
|
+
settings = get_settings()
|
|
695
|
+
dashboard_host = host or settings.dashboard_host
|
|
696
|
+
dashboard_port = port or settings.dashboard_port
|
|
697
|
+
|
|
698
|
+
# Use PORT env var if set (for Fly.io, etc.)
|
|
699
|
+
env_port = os.getenv("PORT")
|
|
700
|
+
if env_port:
|
|
701
|
+
dashboard_port = int(env_port)
|
|
702
|
+
|
|
703
|
+
# Start Prometheus metrics server if enabled
|
|
704
|
+
if settings.metrics_enabled:
|
|
705
|
+
try:
|
|
706
|
+
from devguard.metrics import start_metrics_server
|
|
707
|
+
|
|
708
|
+
start_metrics_server(port=settings.metrics_port)
|
|
709
|
+
logger.info(f"Prometheus metrics server started on port {settings.metrics_port}")
|
|
710
|
+
except Exception as e:
|
|
711
|
+
logger.warning(f"Failed to start metrics server: {e}")
|
|
712
|
+
|
|
713
|
+
logger.info(f"Starting Guardian dashboard on {dashboard_host}:{dashboard_port}")
|
|
714
|
+
logger.info(f"Metrics endpoint: http://{dashboard_host}:{dashboard_port}/metrics")
|
|
715
|
+
uvicorn.run(app, host=dashboard_host, port=dashboard_port)
|