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.
Files changed (60) hide show
  1. devguard/INTEGRATION_SUMMARY.md +121 -0
  2. devguard/__init__.py +3 -0
  3. devguard/__main__.py +6 -0
  4. devguard/checkers/__init__.py +41 -0
  5. devguard/checkers/api_usage.py +523 -0
  6. devguard/checkers/aws_cost.py +331 -0
  7. devguard/checkers/aws_iam.py +284 -0
  8. devguard/checkers/base.py +25 -0
  9. devguard/checkers/container.py +137 -0
  10. devguard/checkers/domain.py +189 -0
  11. devguard/checkers/firecrawl.py +117 -0
  12. devguard/checkers/fly.py +225 -0
  13. devguard/checkers/github.py +210 -0
  14. devguard/checkers/npm.py +327 -0
  15. devguard/checkers/npm_security.py +244 -0
  16. devguard/checkers/redteam.py +290 -0
  17. devguard/checkers/secret.py +279 -0
  18. devguard/checkers/swarm.py +376 -0
  19. devguard/checkers/tailscale.py +143 -0
  20. devguard/checkers/tailsnitch.py +303 -0
  21. devguard/checkers/tavily.py +179 -0
  22. devguard/checkers/vercel.py +192 -0
  23. devguard/cli.py +1510 -0
  24. devguard/cli_helpers.py +189 -0
  25. devguard/config.py +249 -0
  26. devguard/core.py +293 -0
  27. devguard/dashboard.py +715 -0
  28. devguard/discovery.py +363 -0
  29. devguard/http_client.py +142 -0
  30. devguard/llm_service.py +481 -0
  31. devguard/mcp_server.py +259 -0
  32. devguard/metrics.py +144 -0
  33. devguard/models.py +208 -0
  34. devguard/reporting.py +1571 -0
  35. devguard/sarif.py +295 -0
  36. devguard/scripts/ANALYSIS_SUMMARY.md +141 -0
  37. devguard/scripts/README.md +221 -0
  38. devguard/scripts/auto_fix_recommendations.py +145 -0
  39. devguard/scripts/generate_npmignore.py +175 -0
  40. devguard/scripts/generate_security_report.py +324 -0
  41. devguard/scripts/prepublish_check.sh +29 -0
  42. devguard/scripts/redteam_npm_packages.py +1262 -0
  43. devguard/scripts/review_all_repos.py +300 -0
  44. devguard/spec.py +617 -0
  45. devguard/sweeps/__init__.py +23 -0
  46. devguard/sweeps/ai_editor_config_audit.py +697 -0
  47. devguard/sweeps/cargo_publish_audit.py +655 -0
  48. devguard/sweeps/dependency_audit.py +419 -0
  49. devguard/sweeps/gitignore_audit.py +336 -0
  50. devguard/sweeps/local_dev.py +260 -0
  51. devguard/sweeps/local_dirty_worktree_secrets.py +521 -0
  52. devguard/sweeps/project_flaudit.py +636 -0
  53. devguard/sweeps/public_github_secrets.py +680 -0
  54. devguard/sweeps/publish_audit.py +478 -0
  55. devguard/sweeps/ssh_key_audit.py +327 -0
  56. devguard/utils.py +174 -0
  57. devguard-0.2.0.dist-info/METADATA +225 -0
  58. devguard-0.2.0.dist-info/RECORD +60 -0
  59. devguard-0.2.0.dist-info/WHEEL +4 -0
  60. 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)