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/reporting.py ADDED
@@ -0,0 +1,1571 @@
1
+ """Reporting and alerting functionality."""
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from devguard.config import Settings
10
+ from devguard.models import GuardianReport
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Lazy import to avoid dependency if not using LLM features
15
+ _llm_service = None
16
+
17
+
18
+ def _get_llm_service(settings: Settings):
19
+ """Get LLM service instance (lazy import)."""
20
+ global _llm_service
21
+ if _llm_service is None and getattr(settings, "email_llm_enabled", False):
22
+ try:
23
+ # Try shared LLM service first (ops/agent/llm_service.py)
24
+ from devguard.utils import import_llm_service
25
+
26
+ LLMService = import_llm_service()
27
+ if LLMService:
28
+ _llm_service = LLMService(settings)
29
+ else:
30
+ # Fallback to local LLM service
31
+ from devguard.llm_service import LLMService
32
+
33
+ _llm_service = LLMService(settings)
34
+ except (ImportError, ValueError):
35
+ # Fallback to local LLM service
36
+ try:
37
+ from devguard.llm_service import LLMService
38
+
39
+ _llm_service = LLMService(settings)
40
+ except ImportError:
41
+ logger.debug("LLM service dependencies not available")
42
+ return _llm_service
43
+
44
+
45
+ class Reporter:
46
+ """Handle reporting and alerting."""
47
+
48
+ def __init__(self, settings: Settings):
49
+ """Initialize reporter with settings."""
50
+ self.settings = settings
51
+ self._email_history: list[dict[str, Any]] = []
52
+
53
+ async def report(self, report: GuardianReport) -> None:
54
+ """Generate and send reports."""
55
+ # Print to console
56
+ self._print_report(report)
57
+
58
+ # Send webhook if configured
59
+ if self.settings.alert_webhook_url:
60
+ await self._send_webhook(report)
61
+
62
+ # Send email if configured
63
+ if self.settings.alert_email:
64
+ await self._send_email(report)
65
+
66
+ def _print_report(self, report: GuardianReport) -> None:
67
+ """Print report to console."""
68
+ from rich.console import Console
69
+ from rich.table import Table
70
+
71
+ console = Console()
72
+
73
+ console.print("\n[bold blue]Guardian Report[/bold blue]")
74
+ console.print(f"Generated at: {report.generated_at.isoformat()}\n")
75
+
76
+ # Summary table
77
+ summary_table = Table(title="Summary")
78
+ summary_table.add_column("Metric", style="cyan")
79
+ summary_table.add_column("Value", style="magenta")
80
+
81
+ for key, value in report.summary.items():
82
+ summary_table.add_row(key.replace("_", " ").title(), str(value))
83
+
84
+ console.print(summary_table)
85
+ console.print()
86
+
87
+ # Critical vulnerabilities
88
+ critical_vulns = report.get_critical_vulnerabilities()
89
+ if critical_vulns:
90
+ console.print(f"[bold red]Critical Vulnerabilities: {len(critical_vulns)}[/bold red]")
91
+ for vuln in critical_vulns[:10]: # Show first 10
92
+ console.print(
93
+ f" • {vuln.package_name}@{vuln.package_version} "
94
+ f"({vuln.severity.value}) - {vuln.summary or 'No summary'}"
95
+ )
96
+ console.print()
97
+
98
+ # Unhealthy deployments
99
+ unhealthy = report.get_unhealthy_deployments()
100
+ if unhealthy:
101
+ console.print(f"[bold red]Unhealthy Deployments: {len(unhealthy)}[/bold red]")
102
+ for deployment in unhealthy:
103
+ console.print(
104
+ f" • {deployment.platform}/{deployment.project_name}: "
105
+ f"{deployment.status.value}"
106
+ )
107
+ if deployment.error_message:
108
+ console.print(f" Error: {deployment.error_message}")
109
+ console.print()
110
+
111
+ # Open repository alerts
112
+ open_alerts = report.get_open_repository_alerts()
113
+ if open_alerts:
114
+ console.print(f"[bold yellow]Open Repository Alerts: {len(open_alerts)}[/bold yellow]")
115
+ for alert in open_alerts[:10]: # Show first 10
116
+ console.print(
117
+ f" • {alert.repository}: {alert.severity.value} - "
118
+ f"{alert.security_advisory.get('summary', 'No summary')}"
119
+ )
120
+ console.print()
121
+
122
+ # Check results
123
+ for check in report.checks:
124
+ status_style = "green" if check.success else "red"
125
+ status_icon = "✓" if check.success else "✗"
126
+ console.print(
127
+ f"[{status_style}]{status_icon}[/{status_style}] "
128
+ f"{check.check_type}: {len(check.errors)} errors"
129
+ )
130
+
131
+ async def _send_webhook(self, report: GuardianReport) -> None:
132
+ """Send report to webhook URL."""
133
+ if not self.settings.alert_webhook_url:
134
+ return
135
+
136
+ from devguard.http_client import create_client, retry_with_backoff
137
+
138
+ payload = self._report_to_dict(report)
139
+
140
+ try:
141
+
142
+ async def send():
143
+ async with create_client() as client:
144
+ response = await client.post(
145
+ self.settings.alert_webhook_url,
146
+ json=payload,
147
+ )
148
+ response.raise_for_status()
149
+ return response
150
+
151
+ await retry_with_backoff(send, max_retries=2)
152
+ except Exception as e:
153
+ # Log but don't fail the entire report
154
+ logger.warning(f"Failed to send webhook: {str(e)}")
155
+
156
+ def _has_actionable_issues(self, report: GuardianReport) -> bool:
157
+ """Check if report contains issues that warrant an email."""
158
+ critical_vulns = len(report.get_critical_vulnerabilities())
159
+ unhealthy = len(report.get_unhealthy_deployments())
160
+ failed_checks = report.summary.get("failed_checks", 0)
161
+ critical_findings = len(report.get_critical_findings())
162
+ high_findings = len(report.get_high_findings())
163
+
164
+ return (
165
+ critical_vulns > 0
166
+ or unhealthy > 0
167
+ or failed_checks > 0
168
+ or critical_findings > 0
169
+ or high_findings > 0
170
+ )
171
+
172
+ def _get_thread_id_file(self) -> str:
173
+ """Get path to thread ID storage file."""
174
+ if self.settings.email_thread_id_file:
175
+ return self.settings.email_thread_id_file
176
+ # Default to .devguard-email-thread in current directory
177
+ return os.path.join(os.getcwd(), ".devguard-email-thread")
178
+
179
+ def _get_last_message_id(self) -> str | None:
180
+ """Retrieve the last message ID from storage."""
181
+ thread_file = self._get_thread_id_file()
182
+ try:
183
+ if os.path.exists(thread_file):
184
+ with open(thread_file) as f:
185
+ return f.read().strip() or None
186
+ except Exception as e:
187
+ logger.debug(f"Could not read thread ID file: {e}")
188
+ return None
189
+
190
+ def _save_message_id(self, message_id: str) -> None:
191
+ """Save the message ID for threading."""
192
+ thread_file = self._get_thread_id_file()
193
+ try:
194
+ os.makedirs(os.path.dirname(thread_file) or ".", exist_ok=True)
195
+ with open(thread_file, "w") as f:
196
+ f.write(message_id)
197
+ except Exception as e:
198
+ logger.debug(f"Could not write thread ID file: {e}")
199
+
200
+ def _get_email_history_file(self) -> str:
201
+ """Get path to email history storage file."""
202
+ if self.settings.email_history_file:
203
+ return self.settings.email_history_file
204
+ # Default to .devguard-email-history.json in current directory
205
+ return os.path.join(os.getcwd(), ".devguard-email-history.json")
206
+
207
+ def _load_email_history(self) -> list[dict[str, Any]]:
208
+ """Load email history from storage."""
209
+ history_file = self._get_email_history_file()
210
+ try:
211
+ if os.path.exists(history_file):
212
+ with open(history_file) as f:
213
+ return json.load(f)
214
+ except Exception as e:
215
+ logger.debug(f"Could not read email history file: {e}")
216
+ return []
217
+
218
+ def _save_email_history(self, history: list[dict[str, Any]]) -> None:
219
+ """Save email history to storage."""
220
+ history_file = self._get_email_history_file()
221
+ try:
222
+ os.makedirs(os.path.dirname(history_file) or ".", exist_ok=True)
223
+ # Keep only last 100 emails to prevent file from growing too large
224
+ trimmed_history = history[-100:]
225
+ with open(history_file, "w") as f:
226
+ json.dump(trimmed_history, f, indent=2, default=str)
227
+ except Exception as e:
228
+ logger.debug(f"Could not write email history file: {e}")
229
+
230
+ def _record_email_history(
231
+ self,
232
+ report: GuardianReport,
233
+ message_id: str,
234
+ subject: str,
235
+ in_reply_to: str | None,
236
+ llm_decision: dict[str, Any] | None = None,
237
+ ) -> None:
238
+ """Record email in history for agent introspection."""
239
+ history = self._load_email_history()
240
+
241
+ # Extract key information for agent decision-making
242
+ critical_vulns = report.get_critical_vulnerabilities()
243
+ high_findings = report.get_high_findings()
244
+ critical_findings = report.get_critical_findings()
245
+ unhealthy = report.get_unhealthy_deployments()
246
+
247
+ entry = {
248
+ "timestamp": datetime.now(UTC).isoformat(),
249
+ "message_id": message_id,
250
+ "subject": subject,
251
+ "in_reply_to": in_reply_to,
252
+ "summary": {
253
+ "critical_vulnerabilities": len(critical_vulns),
254
+ "high_findings": len(high_findings),
255
+ "critical_findings": len(critical_findings),
256
+ "unhealthy_deployments": len(unhealthy),
257
+ "total_vulnerabilities": report.summary.get("total_vulnerabilities", 0),
258
+ "failed_checks": report.summary.get("failed_checks", 0),
259
+ "total_checks": report.summary.get("total_checks", 0),
260
+ },
261
+ "issues": {
262
+ "critical_vulns": [
263
+ {
264
+ "package": f"{v.package_name}@{v.package_version}",
265
+ "severity": v.severity.value,
266
+ "cve": v.cve_id,
267
+ }
268
+ for v in critical_vulns[:5] # Top 5 for brevity
269
+ ],
270
+ "critical_findings": [
271
+ {
272
+ "title": f.title,
273
+ "resource": f.resource,
274
+ "check_type": next(
275
+ (c.check_type for c in report.checks if f in c.findings), "unknown"
276
+ ),
277
+ }
278
+ for f in critical_findings[:5]
279
+ ],
280
+ "high_findings": [
281
+ {
282
+ "title": f.title,
283
+ "resource": f.resource,
284
+ "check_type": next(
285
+ (c.check_type for c in report.checks if f in c.findings), "unknown"
286
+ ),
287
+ }
288
+ for f in high_findings[:5]
289
+ ],
290
+ "unhealthy_deployments": [
291
+ {
292
+ "platform": dep.platform,
293
+ "project": dep.project_name,
294
+ "status": dep.status.value,
295
+ }
296
+ for dep in unhealthy[:5]
297
+ ],
298
+ },
299
+ "actionable": self._has_actionable_issues(report),
300
+ }
301
+
302
+ # Include LLM decision if available
303
+ if llm_decision:
304
+ entry["llm_decision"] = llm_decision
305
+
306
+ history.append(entry)
307
+ self._save_email_history(history)
308
+
309
+ # Also write to smart_email SQLite if enabled (for unified history)
310
+ use_smart_email = getattr(self.settings, "use_smart_email", False)
311
+ if use_smart_email:
312
+ try:
313
+ import hashlib
314
+ import sqlite3
315
+
316
+ from devguard.utils import get_smart_email_db_path, import_smart_email
317
+
318
+ smart_email = import_smart_email()
319
+ if not smart_email:
320
+ logger.debug("smart_email module not available")
321
+ return
322
+
323
+ init_db = smart_email.init_db
324
+ normalize_topic = smart_email.normalize_topic
325
+ get_thread_id = smart_email.get_thread_id
326
+
327
+ # Get DB path
328
+ db_path = get_smart_email_db_path(self.settings)
329
+
330
+ init_db(db_path)
331
+ conn = sqlite3.connect(str(db_path))
332
+
333
+ # Determine severity from report
334
+ critical_vulns = len(report.get_critical_vulnerabilities())
335
+ critical_findings = len(report.get_critical_findings())
336
+ high_findings = len(report.get_high_findings())
337
+ unhealthy = len(report.get_unhealthy_deployments())
338
+
339
+ if critical_vulns > 0 or critical_findings > 0 or unhealthy > 0:
340
+ severity = "CRITICAL"
341
+ elif high_findings > 0:
342
+ severity = "HIGH"
343
+ elif report.summary.get("total_vulnerabilities", 0) > 0:
344
+ severity = "MEDIUM"
345
+ else:
346
+ severity = "LOW"
347
+
348
+ topic = "security_posture" # Canonical topic
349
+ normalized = normalize_topic(topic)
350
+ thread_id = get_thread_id(topic)
351
+
352
+ # Create alert record with comprehensive metadata
353
+ alert_id = hashlib.sha256(
354
+ f"{topic}:{entry['timestamp']}:guardian".encode()
355
+ ).hexdigest()[:12]
356
+
357
+ # Build comprehensive metadata for long-term analysis
358
+ comprehensive_metadata = {
359
+ "summary": entry["summary"],
360
+ "issues": entry["issues"],
361
+ "llm_decision": entry.get("llm_decision"),
362
+ "author": "guardian",
363
+ "actionable": entry.get("actionable", False),
364
+ "message_id": entry.get("message_id"),
365
+ "in_reply_to": entry.get("in_reply_to"),
366
+ "report_timestamp": report.generated_at.isoformat(),
367
+ "check_types": [c.check_type for c in report.checks],
368
+ "total_checks": len(report.checks),
369
+ # Store full report summary for analysis
370
+ "report_summary": {
371
+ "total_vulnerabilities": report.summary.get("total_vulnerabilities", 0),
372
+ "total_checks": report.summary.get("total_checks", 0),
373
+ "successful_checks": report.summary.get("successful_checks", 0),
374
+ "failed_checks": report.summary.get("failed_checks", 0),
375
+ },
376
+ }
377
+
378
+ # Add LLM reasoning if available
379
+ if llm_decision:
380
+ comprehensive_metadata["llm_reasoning"] = {
381
+ "should_send": llm_decision.get("should_send"),
382
+ "reasoning": llm_decision.get("reasoning"),
383
+ "priority": llm_decision.get("priority"),
384
+ "summary": llm_decision.get("summary"),
385
+ }
386
+
387
+ message_preview = self._format_email_text(report)[:500]
388
+
389
+ conn.execute(
390
+ """
391
+ INSERT OR REPLACE INTO alert_history
392
+ (id, topic, severity, subject, sent_at, thread_id, occurrence_count, author, message_preview, metadata_json)
393
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
394
+ """,
395
+ (
396
+ alert_id,
397
+ normalized,
398
+ severity,
399
+ subject,
400
+ entry["timestamp"],
401
+ thread_id,
402
+ "guardian",
403
+ message_preview,
404
+ json.dumps(comprehensive_metadata, default=str),
405
+ ),
406
+ )
407
+
408
+ conn.commit()
409
+ conn.close()
410
+
411
+ except Exception as e:
412
+ logger.debug(f"Could not write to smart_email DB: {e}")
413
+
414
+ def get_email_history(self, limit: int = 10) -> list[dict[str, Any]]:
415
+ """Get recent email history for agent introspection.
416
+
417
+ Returns the most recent emails with their summaries and issues.
418
+ Useful for agents to understand email patterns and decide when to send.
419
+
420
+ If use_smart_email is enabled, reads from smart_email SQLite database.
421
+ Otherwise, reads from JSON file.
422
+ """
423
+ use_smart_email = getattr(self.settings, "use_smart_email", False)
424
+
425
+ if use_smart_email:
426
+ # Try to read from smart_email SQLite
427
+ try:
428
+ import sqlite3
429
+
430
+ from devguard.utils import get_smart_email_db_path, import_smart_email
431
+
432
+ smart_email = import_smart_email()
433
+ if not smart_email:
434
+ logger.debug("smart_email module not available, falling back to JSON")
435
+ history = self._load_email_history()
436
+ return history[-limit:] if limit else history
437
+
438
+ init_db = smart_email.init_db
439
+ db_path = get_smart_email_db_path(self.settings)
440
+
441
+ init_db(db_path)
442
+ conn = sqlite3.connect(str(db_path))
443
+
444
+ # Query alert_history for Guardian emails (author='devguard')
445
+ rows = conn.execute(
446
+ """
447
+ SELECT topic, severity, subject, sent_at, author, message_preview, metadata_json
448
+ FROM alert_history
449
+ WHERE author = 'devguard' OR topic = 'security_posture'
450
+ ORDER BY sent_at DESC
451
+ LIMIT ?
452
+ """,
453
+ (limit,),
454
+ ).fetchall()
455
+
456
+ conn.close()
457
+
458
+ # Convert to Guardian history format with all preserved metadata
459
+ history = []
460
+ for row in rows:
461
+ (
462
+ topic_val,
463
+ severity,
464
+ subject,
465
+ sent_at,
466
+ author,
467
+ message_preview,
468
+ metadata_json,
469
+ ) = row
470
+ metadata = json.loads(metadata_json) if metadata_json else {}
471
+ history.append(
472
+ {
473
+ "timestamp": sent_at,
474
+ "subject": subject,
475
+ "author": author or "guardian",
476
+ "severity": severity,
477
+ "topic": topic_val,
478
+ "message_preview": message_preview,
479
+ "summary": metadata.get("summary", {}),
480
+ "issues": metadata.get("issues", {}),
481
+ "llm_decision": metadata.get("llm_decision"),
482
+ "llm_reasoning": metadata.get("llm_reasoning"),
483
+ "report_summary": metadata.get("report_summary", {}),
484
+ "actionable": metadata.get("actionable", False),
485
+ "full_metadata": metadata, # Preserve everything for deep analysis
486
+ }
487
+ )
488
+
489
+ return history
490
+
491
+ except Exception as e:
492
+ logger.debug(f"Could not read from smart_email DB: {e}, falling back to JSON")
493
+
494
+ # Fallback to JSON
495
+ history = self._load_email_history()
496
+ return history[-limit:] if limit else history
497
+
498
+ def _generate_message_id(self, report: GuardianReport) -> str:
499
+ """Generate a unique Message-ID for email threading."""
500
+ import hashlib
501
+ import time
502
+
503
+ # Use timestamp and random component for unique Message-ID per email
504
+ timestamp_ns = int(report.generated_at.timestamp() * 1e9)
505
+ unique_hash = hashlib.md5(f"{timestamp_ns}-{time.time_ns()}".encode()).hexdigest()[:12]
506
+
507
+ # Extract domain from From address for Message-ID
508
+ smtp_from = getattr(self.settings, "smtp_from", "devguard@localhost")
509
+ domain = smtp_from.split("@")[-1] if "@" in smtp_from else "localhost"
510
+
511
+ message_id = f"<devguard-{unique_hash}@{domain}>"
512
+ return message_id
513
+
514
+ async def _send_email(self, report: GuardianReport) -> None:
515
+ """
516
+ Send report via email using smart_email (SNS) or SMTP.
517
+
518
+ By default, uses smart_email system (SNS) with batching, deduplication, and threading.
519
+ Falls back to direct SMTP if smart_email is unavailable.
520
+
521
+ Configure via environment variables:
522
+ - USE_SMART_EMAIL: Use smart_email system (default: true, falls back to SMTP if unavailable)
523
+ - SMART_EMAIL_DB_PATH: Path to smart_email SQLite database
524
+ - SMTP_HOST: SMTP server hostname (fallback if smart_email fails)
525
+ - EMAIL_ONLY_ON_ISSUES: Only send emails when there are issues (default: True)
526
+ """
527
+ if not self.settings.alert_email:
528
+ return
529
+
530
+ # Check if we should send email (reduce noise)
531
+ email_only_on_issues = getattr(self.settings, "email_only_on_issues", True)
532
+ llm_enabled = getattr(self.settings, "email_llm_enabled", False)
533
+ use_smart_email = getattr(self.settings, "use_smart_email", False)
534
+ llm_decision: dict[str, Any] | None = None
535
+
536
+ # Use LLM for send decision if enabled
537
+ if llm_enabled:
538
+ llm_service = _get_llm_service(self.settings)
539
+ if llm_service:
540
+ report_dict = self._report_to_dict(report)
541
+ email_history = self.get_email_history(limit=10)
542
+ llm_decision = await llm_service.should_send_email(report_dict, email_history)
543
+
544
+ if not llm_decision.get("should_send", True):
545
+ logger.info(
546
+ f"Skipping email per LLM decision: {llm_decision.get('reasoning', 'No reason provided')}"
547
+ )
548
+ return
549
+ elif email_only_on_issues and not self._has_actionable_issues(report):
550
+ logger.debug("Skipping email: no actionable issues and email_only_on_issues=True")
551
+ return
552
+ elif email_only_on_issues and not self._has_actionable_issues(report):
553
+ logger.debug("Skipping email: no actionable issues and email_only_on_issues=True")
554
+ return
555
+
556
+ # Try smart_email first if enabled
557
+ if use_smart_email:
558
+ try:
559
+ success = await self._send_via_smart_email(report, llm_decision)
560
+ if success:
561
+ return # Successfully sent via smart_email
562
+ logger.warning("smart_email send failed, falling back to SMTP")
563
+ except Exception as e:
564
+ logger.warning(f"smart_email error: {e}, falling back to SMTP")
565
+
566
+ # Fallback to SMTP
567
+ # Check if SMTP is configured
568
+ smtp_host = getattr(self.settings, "smtp_host", None)
569
+ smtp_user = getattr(self.settings, "smtp_user", None)
570
+ smtp_password = getattr(self.settings, "smtp_password", None)
571
+ smtp_from = getattr(self.settings, "smtp_from", None)
572
+
573
+ if not all([smtp_host, smtp_user, smtp_password, smtp_from]):
574
+ logger.warning(
575
+ "Email sending requires SMTP configuration: "
576
+ "smtp_host, smtp_user, smtp_password, smtp_from"
577
+ )
578
+ return
579
+
580
+ try:
581
+ from email.mime.multipart import MIMEMultipart
582
+ from email.mime.text import MIMEText
583
+
584
+ import aiosmtplib
585
+ except ImportError:
586
+ logger.warning("aiosmtplib not installed. Install with: pip install aiosmtplib")
587
+ return
588
+
589
+ try:
590
+ smtp_port = getattr(self.settings, "smtp_port", 587)
591
+ use_tls = getattr(self.settings, "smtp_use_tls", True)
592
+
593
+ # Generate Message-ID for threading
594
+ message_id = self._generate_message_id(report)
595
+ last_message_id = self._get_last_message_id()
596
+
597
+ # Generate subject line (LLM-powered if enabled)
598
+ llm_enabled = getattr(self.settings, "email_llm_enabled", False)
599
+ if llm_enabled:
600
+ llm_service = _get_llm_service(self.settings)
601
+ if llm_service:
602
+ report_dict = self._report_to_dict(report)
603
+ priority = report_dict.get("llm_decision", {}).get("priority", "medium")
604
+ try:
605
+ subject = await llm_service.generate_subject_line(report_dict, priority)
606
+ except Exception as e:
607
+ logger.warning(f"LLM subject generation failed: {e}, using fallback")
608
+ subject_suffix = self._generate_subject(report)
609
+ subject = f"Guardian Security Report - {subject_suffix}"
610
+ else:
611
+ subject_suffix = self._generate_subject(report)
612
+ subject = f"Guardian Security Report - {subject_suffix}"
613
+ else:
614
+ subject_suffix = self._generate_subject(report)
615
+ subject = f"Guardian Security Report - {subject_suffix}"
616
+
617
+ # Create email message
618
+ msg = MIMEMultipart("alternative")
619
+ msg["From"] = smtp_from
620
+ msg["To"] = self.settings.alert_email
621
+ msg["Subject"] = subject
622
+ msg["Message-ID"] = message_id
623
+
624
+ # Set threading headers for proper email threading
625
+ if last_message_id:
626
+ msg["In-Reply-To"] = last_message_id
627
+ msg["References"] = last_message_id
628
+
629
+ # Create email body
630
+ text_body = self._format_email_text(report)
631
+ html_body = self._format_email_html(report)
632
+
633
+ msg.attach(MIMEText(text_body, "plain"))
634
+ msg.attach(MIMEText(html_body, "html"))
635
+
636
+ # Send email
637
+ async with aiosmtplib.SMTP(hostname=smtp_host, port=smtp_port, use_tls=use_tls) as smtp:
638
+ await smtp.login(smtp_user, smtp_password)
639
+ await smtp.send_message(msg)
640
+
641
+ # Save message ID for next email
642
+ self._save_message_id(message_id)
643
+
644
+ # Record email in history for agent introspection
645
+ # llm_decision was already computed above if LLM is enabled
646
+ self._record_email_history(report, message_id, subject, last_message_id, llm_decision)
647
+
648
+ logger.info(f"Email sent successfully to {self.settings.alert_email}")
649
+
650
+ except Exception as e:
651
+ logger.warning(f"Failed to send email: {str(e)}")
652
+
653
+ async def _send_via_smart_email(
654
+ self, report: GuardianReport, llm_decision: dict[str, Any] | None = None
655
+ ) -> bool:
656
+ """
657
+ Send report via smart_email system (SNS).
658
+
659
+ Returns True if sent successfully, False otherwise.
660
+ """
661
+ try:
662
+ from devguard.utils import get_smart_email_db_path, import_smart_email
663
+
664
+ smart_email = import_smart_email()
665
+ if not smart_email:
666
+ logger.debug("smart_email module not available, falling back to SMTP")
667
+ # Fallback handled in _send_email method
668
+ return False
669
+
670
+ smart_send_alert = smart_email.smart_send_alert
671
+ db_path = get_smart_email_db_path(self.settings)
672
+
673
+ # Convert report to format for smart_email
674
+ report_dict = self._report_to_dict(report)
675
+ summary = report_dict.get("summary", {})
676
+
677
+ # Determine severity
678
+ critical_vulns = summary.get("critical_vulnerabilities", 0)
679
+ critical_findings = summary.get("critical_findings", 0)
680
+ high_findings = summary.get("high_findings", 0)
681
+ unhealthy = summary.get("unhealthy_deployments", 0)
682
+ failed_checks = summary.get("failed_checks", 0)
683
+
684
+ if critical_vulns > 0 or critical_findings > 0 or unhealthy > 0:
685
+ severity = "CRITICAL"
686
+ elif high_findings > 0 or failed_checks > 0:
687
+ severity = "HIGH"
688
+ elif summary.get("total_vulnerabilities", 0) > 0:
689
+ severity = "MEDIUM"
690
+ else:
691
+ severity = "LOW"
692
+
693
+ # Generate topic (normalized for threading)
694
+ topic = "security_posture" # Use canonical topic from smart_email
695
+
696
+ # Generate headline/subject
697
+ if llm_decision and llm_decision.get("summary"):
698
+ headline = llm_decision["summary"]
699
+ else:
700
+ headline = self._generate_subject(report)
701
+
702
+ # Generate message body (plain text version)
703
+ message = self._format_email_text(report)
704
+
705
+ # Truncate message if too long (SNS has limits)
706
+ if len(message) > 4000:
707
+ message = message[:3900] + "\n\n[Message truncated - see full report in dashboard]"
708
+
709
+ # Determine if force immediate (CRITICAL/HIGH)
710
+ force_immediate = severity in ("CRITICAL", "HIGH")
711
+
712
+ # Build rich metadata for long-term analysis
713
+ report_dict = self._report_to_dict(report)
714
+ rich_metadata = {
715
+ "llm_decision": llm_decision,
716
+ "report_summary": report_dict.get("summary", {}),
717
+ "report_checks": [
718
+ {
719
+ "check_type": c.check_type,
720
+ "success": c.success,
721
+ "vulnerabilities_count": len(c.vulnerabilities),
722
+ "findings_count": len(c.findings),
723
+ }
724
+ for c in report.checks
725
+ ],
726
+ "actionable_issues": self._has_actionable_issues(report),
727
+ "report_generated_at": report.generated_at.isoformat(),
728
+ "issues": {
729
+ "critical_vulns": [
730
+ {
731
+ "package": f"{v.package_name}@{v.package_version}",
732
+ "severity": v.severity.value,
733
+ "cve": v.cve_id,
734
+ }
735
+ for v in report.get_critical_vulnerabilities()[:5]
736
+ ],
737
+ "critical_findings": [
738
+ {
739
+ "title": f.title,
740
+ "resource": f.resource,
741
+ "check_type": next(
742
+ (c.check_type for c in report.checks if f in c.findings), "unknown"
743
+ ),
744
+ }
745
+ for f in report.get_critical_findings()[:5]
746
+ ],
747
+ "high_findings": [
748
+ {
749
+ "title": f.title,
750
+ "resource": f.resource,
751
+ "check_type": next(
752
+ (c.check_type for c in report.checks if f in c.findings), "unknown"
753
+ ),
754
+ }
755
+ for f in report.get_high_findings()[:5]
756
+ ],
757
+ },
758
+ }
759
+
760
+ # Send via smart_email (with optional LLM support)
761
+ use_llm = getattr(self.settings, "email_llm_enabled", False)
762
+ success = smart_send_alert(
763
+ db_path=db_path,
764
+ topic=topic,
765
+ severity=severity,
766
+ headline=f"Guardian Security Report - {headline}",
767
+ message=message,
768
+ author="guardian",
769
+ force_immediate=force_immediate,
770
+ use_llm=use_llm,
771
+ llm_settings=self.settings if use_llm else None,
772
+ rich_metadata=rich_metadata,
773
+ )
774
+
775
+ if success:
776
+ logger.info(f"Email sent via smart_email (SNS) to {self.settings.alert_email}")
777
+ # Record in history for introspection
778
+ self._record_email_history(report, None, headline, None, llm_decision)
779
+
780
+ return success
781
+
782
+ except ImportError as e:
783
+ logger.warning(f"smart_email not available: {e}")
784
+ return False
785
+ except Exception as e:
786
+ logger.warning(f"smart_email send failed: {e}")
787
+ return False
788
+
789
+ def _generate_subject(self, report: GuardianReport) -> str:
790
+ """Generate a descriptive subject line based on report severity.
791
+
792
+ Uses consistent format for email threading while including status details.
793
+ """
794
+ critical_vulns = len(report.get_critical_vulnerabilities())
795
+ unhealthy = len(report.get_unhealthy_deployments())
796
+ total_vulns = report.summary.get("total_vulnerabilities", 0)
797
+ failed_checks = report.summary.get("failed_checks", 0)
798
+
799
+ # Determine urgency level
800
+ if critical_vulns > 0 or unhealthy > 0 or failed_checks > 0:
801
+ urgency = "URGENT"
802
+ elif total_vulns > 0:
803
+ urgency = "ALERT"
804
+ else:
805
+ urgency = "Status"
806
+
807
+ parts = []
808
+
809
+ if critical_vulns > 0:
810
+ parts.append(f"{critical_vulns} critical")
811
+ if unhealthy > 0:
812
+ parts.append(f"{unhealthy} unhealthy")
813
+ if total_vulns > 0 and critical_vulns == 0:
814
+ parts.append(f"{total_vulns} vulnerabilities")
815
+ if failed_checks > 0:
816
+ parts.append(f"{failed_checks} failed checks")
817
+
818
+ if not parts: # No issues
819
+ parts.append("All systems healthy")
820
+
821
+ status_text = " | ".join(parts) if parts else "All clear"
822
+ return f"{urgency}: {status_text}"
823
+
824
+ def _format_email_text(self, report: GuardianReport) -> str:
825
+ """Format report as plain text email."""
826
+ # Format timestamp
827
+ timestamp = report.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC")
828
+
829
+ lines = [
830
+ "GUARDIAN SECURITY REPORT",
831
+ "=" * 60,
832
+ f"Generated: {timestamp}",
833
+ "",
834
+ ]
835
+
836
+ # Executive summary
837
+ summary = report.summary
838
+ lines.append("EXECUTIVE SUMMARY")
839
+ lines.append("-" * 60)
840
+ lines.append(f"Total Checks: {summary.get('total_checks', 0)}")
841
+ lines.append(f" ✓ Successful: {summary.get('successful_checks', 0)}")
842
+ lines.append(f" ✗ Failed: {summary.get('failed_checks', 0)}")
843
+ lines.append("")
844
+
845
+ # Critical issues first
846
+ critical_vulns = report.get_critical_vulnerabilities()
847
+ critical_findings = report.get_critical_findings()
848
+ high_findings = report.get_high_findings()
849
+ unhealthy = report.get_unhealthy_deployments()
850
+
851
+ if critical_vulns or critical_findings or high_findings or unhealthy:
852
+ lines.append("⚠ CRITICAL ISSUES REQUIRING IMMEDIATE ATTENTION")
853
+ lines.append("-" * 60)
854
+
855
+ if critical_vulns:
856
+ lines.append(f"\nCritical Vulnerabilities ({len(critical_vulns)}):")
857
+ for vuln in critical_vulns[:15]:
858
+ lines.append(f" • {vuln.package_name}@{vuln.package_version}")
859
+ if vuln.cve_id:
860
+ lines.append(f" CVE: {vuln.cve_id}")
861
+ if vuln.summary:
862
+ lines.append(f" {vuln.summary}")
863
+ if vuln.first_patched_version:
864
+ lines.append(f" Fix: Upgrade to {vuln.first_patched_version}")
865
+ if vuln.references:
866
+ lines.append(f" Reference: {vuln.references[0]}")
867
+ lines.append("")
868
+
869
+ if critical_findings:
870
+ lines.append(f"\nCritical Security Findings ({len(critical_findings)}):")
871
+ for check in report.checks:
872
+ for finding in check.findings:
873
+ if finding.severity.value == "critical":
874
+ lines.append(f" • [{check.check_type.upper()}] {finding.title}")
875
+ lines.append(f" Resource: {finding.resource}")
876
+ lines.append(f" {finding.description}")
877
+ if finding.remediation:
878
+ lines.append(f" Remediation: {finding.remediation}")
879
+ lines.append("")
880
+
881
+ if high_findings:
882
+ lines.append(f"\nHigh Priority Findings ({len(high_findings)}):")
883
+ for check in report.checks:
884
+ for finding in check.findings:
885
+ if finding.severity.value == "high":
886
+ lines.append(f" • [{check.check_type.upper()}] {finding.title}")
887
+ lines.append(f" Resource: {finding.resource}")
888
+ lines.append(f" {finding.description}")
889
+ if finding.remediation:
890
+ lines.append(f" Remediation: {finding.remediation}")
891
+ lines.append("")
892
+
893
+ if unhealthy:
894
+ lines.append(f"\nUnhealthy Deployments ({len(unhealthy)}):")
895
+ for dep in unhealthy:
896
+ lines.append(f" • {dep.platform.upper()}/{dep.project_name}")
897
+ lines.append(f" Status: {dep.status.value}")
898
+ if dep.url:
899
+ lines.append(f" URL: {dep.url}")
900
+ if dep.error_message:
901
+ lines.append(f" Error: {dep.error_message}")
902
+ lines.append("")
903
+
904
+ # Other vulnerabilities
905
+ all_vulns = []
906
+ for check in report.checks:
907
+ for vuln in check.vulnerabilities:
908
+ if vuln.severity.value in ["high", "medium"]:
909
+ all_vulns.append((check.check_type, vuln))
910
+
911
+ if all_vulns:
912
+ lines.append(f"\nOther Vulnerabilities ({len(all_vulns)}):")
913
+ for check_type, vuln in all_vulns[:10]:
914
+ lines.append(
915
+ f" • [{check_type}] {vuln.package_name}@{vuln.package_version} ({vuln.severity.value})"
916
+ )
917
+ if vuln.summary:
918
+ lines.append(f" {vuln.summary}")
919
+
920
+ # Repository alerts
921
+ open_alerts = report.get_open_repository_alerts()
922
+ if open_alerts:
923
+ lines.append(f"\n\nOpen Repository Alerts ({len(open_alerts)}):")
924
+ for alert in open_alerts[:10]:
925
+ adv = alert.security_advisory
926
+ lines.append(f" • {alert.repository}: {alert.severity.value}")
927
+ if adv.get("summary"):
928
+ lines.append(f" {adv['summary']}")
929
+
930
+ # Cost metrics
931
+ cost_metrics = report.get_cost_metrics()
932
+ if cost_metrics:
933
+ total_cost = report.get_total_cost()
934
+ lines.append("\n\nCOST METRICS")
935
+ lines.append("-" * 60)
936
+ lines.append(f"Total: ${total_cost:.2f} USD")
937
+ for cost in cost_metrics:
938
+ if cost.amount is not None:
939
+ lines.append(f" • {cost.service}: ${cost.amount:.2f} ({cost.period})")
940
+ if cost.usage_percent is not None:
941
+ lines.append(f" Usage: {cost.usage_percent:.1f}%")
942
+
943
+ # API usage
944
+ api_usage = []
945
+ for check in report.checks:
946
+ api_usage.extend(check.api_usage)
947
+
948
+ if api_usage:
949
+ lines.append("\n\nAPI USAGE")
950
+ lines.append("-" * 60)
951
+ for usage in api_usage:
952
+ if usage.usage_percent is not None:
953
+ lines.append(f" • {usage.service}: {usage.usage_percent:.1f}% used")
954
+ if usage.credits_remaining is not None:
955
+ lines.append(f" Remaining: {usage.credits_remaining:.0f} credits")
956
+
957
+ # Check status
958
+ lines.append("\n\nCHECK STATUS")
959
+ lines.append("-" * 60)
960
+ for check in report.checks:
961
+ status = "✓" if check.success else "✗"
962
+ lines.append(f" {status} {check.check_type.upper()}")
963
+ if check.errors:
964
+ for error in check.errors[:3]:
965
+ lines.append(f" Error: {error}")
966
+
967
+ lines.append("\n" + "=" * 60)
968
+ lines.append("End of Report")
969
+
970
+ return "\n".join(lines)
971
+
972
+ def _format_email_html(self, report: GuardianReport) -> str:
973
+ """Format report as HTML email with modern styling."""
974
+ timestamp = report.generated_at.strftime("%Y-%m-%d %H:%M:%S UTC")
975
+ summary = report.summary
976
+
977
+ # Determine overall status
978
+ critical_vulns = report.get_critical_vulnerabilities()
979
+ unhealthy = report.get_unhealthy_deployments()
980
+ failed_checks = summary.get("failed_checks", 0)
981
+
982
+ if critical_vulns or unhealthy or failed_checks > 0:
983
+ status_color = "#d32f2f"
984
+ status_text = "⚠ Action Required"
985
+ elif summary.get("total_vulnerabilities", 0) > 0:
986
+ status_color = "#f57c00"
987
+ status_text = "⚠ Issues Detected"
988
+ else:
989
+ status_color = "#388e3c"
990
+ status_text = "✓ All Systems Healthy"
991
+
992
+ html = f"""<!DOCTYPE html>
993
+ <html>
994
+ <head>
995
+ <meta charset="utf-8">
996
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
997
+ <style>
998
+ * {{ box-sizing: border-box; }}
999
+ body {{
1000
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
1001
+ line-height: 1.6;
1002
+ color: #333;
1003
+ max-width: 800px;
1004
+ margin: 0 auto;
1005
+ padding: 20px;
1006
+ background-color: #f5f5f5;
1007
+ }}
1008
+ .container {{
1009
+ background: white;
1010
+ border-radius: 8px;
1011
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
1012
+ overflow: hidden;
1013
+ }}
1014
+ .header {{
1015
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1016
+ color: white;
1017
+ padding: 30px;
1018
+ text-align: center;
1019
+ }}
1020
+ .header h1 {{
1021
+ margin: 0;
1022
+ font-size: 28px;
1023
+ font-weight: 600;
1024
+ }}
1025
+ .status-badge {{
1026
+ display: inline-block;
1027
+ background: {status_color};
1028
+ color: white;
1029
+ padding: 8px 16px;
1030
+ border-radius: 20px;
1031
+ font-size: 14px;
1032
+ font-weight: 600;
1033
+ margin-top: 10px;
1034
+ }}
1035
+ .content {{
1036
+ padding: 40px 30px;
1037
+ }}
1038
+ .summary-grid {{
1039
+ display: grid;
1040
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1041
+ gap: 16px;
1042
+ margin: 30px 0;
1043
+ }}
1044
+ .summary-card {{
1045
+ background: #f8f9fa;
1046
+ padding: 15px;
1047
+ border-radius: 6px;
1048
+ border-left: 4px solid #667eea;
1049
+ }}
1050
+ .summary-card.critical {{
1051
+ border-left-color: #d32f2f;
1052
+ background: #ffebee;
1053
+ }}
1054
+ .summary-card.warning {{
1055
+ border-left-color: #f57c00;
1056
+ background: #fff3e0;
1057
+ }}
1058
+ .summary-card.success {{
1059
+ border-left-color: #388e3c;
1060
+ background: #e8f5e9;
1061
+ }}
1062
+ .summary-card h3 {{
1063
+ margin: 0 0 5px 0;
1064
+ font-size: 12px;
1065
+ text-transform: uppercase;
1066
+ color: #666;
1067
+ letter-spacing: 0.5px;
1068
+ }}
1069
+ .summary-card .value {{
1070
+ font-size: 24px;
1071
+ font-weight: 700;
1072
+ color: #333;
1073
+ }}
1074
+ .section {{
1075
+ margin: 40px 0;
1076
+ }}
1077
+ .section:first-of-type {{
1078
+ margin-top: 20px;
1079
+ }}
1080
+ .section h2 {{
1081
+ font-size: 22px;
1082
+ font-weight: 700;
1083
+ margin: 0 0 20px 0;
1084
+ padding-bottom: 12px;
1085
+ border-bottom: 3px solid #e0e0e0;
1086
+ letter-spacing: -0.3px;
1087
+ }}
1088
+ .section.critical h2 {{
1089
+ color: #d32f2f;
1090
+ border-bottom-color: #d32f2f;
1091
+ font-size: 24px;
1092
+ }}
1093
+ .section.warning h2 {{
1094
+ color: #f57c00;
1095
+ border-bottom-color: #f57c00;
1096
+ font-size: 22px;
1097
+ }}
1098
+ .vuln-item, .finding-item, .deployment-item {{
1099
+ background: #f8f9fa;
1100
+ padding: 18px;
1101
+ margin: 12px 0;
1102
+ border-radius: 6px;
1103
+ border-left: 4px solid #ddd;
1104
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
1105
+ }}
1106
+ .vuln-item.critical {{
1107
+ border-left-color: #d32f2f;
1108
+ background: #ffebee;
1109
+ }}
1110
+ .vuln-item.high {{
1111
+ border-left-color: #f57c00;
1112
+ background: #fff3e0;
1113
+ }}
1114
+ .vuln-item.medium {{
1115
+ border-left-color: #ffa726;
1116
+ background: #fff8e1;
1117
+ }}
1118
+ .deployment-item.unhealthy {{
1119
+ border-left-color: #d32f2f;
1120
+ background: #ffebee;
1121
+ }}
1122
+ .item-header {{
1123
+ font-weight: 700;
1124
+ font-size: 17px;
1125
+ margin-bottom: 10px;
1126
+ line-height: 1.4;
1127
+ color: #212121;
1128
+ }}
1129
+ .item-meta {{
1130
+ font-size: 13px;
1131
+ color: #666;
1132
+ margin: 6px 0;
1133
+ line-height: 1.5;
1134
+ }}
1135
+ .item-description {{
1136
+ margin: 12px 0;
1137
+ color: #555;
1138
+ line-height: 1.6;
1139
+ font-size: 14px;
1140
+ }}
1141
+ .item-remediation {{
1142
+ background: #e3f2fd;
1143
+ padding: 10px;
1144
+ border-radius: 4px;
1145
+ margin-top: 10px;
1146
+ font-size: 13px;
1147
+ }}
1148
+ .item-remediation strong {{
1149
+ color: #1976d2;
1150
+ }}
1151
+ .badge {{
1152
+ display: inline-block;
1153
+ padding: 3px 8px;
1154
+ border-radius: 12px;
1155
+ font-size: 11px;
1156
+ font-weight: 600;
1157
+ text-transform: uppercase;
1158
+ margin-left: 8px;
1159
+ }}
1160
+ .badge.critical {{
1161
+ background: #d32f2f;
1162
+ color: white;
1163
+ }}
1164
+ .badge.high {{
1165
+ background: #f57c00;
1166
+ color: white;
1167
+ }}
1168
+ .badge.medium {{
1169
+ background: #ffa726;
1170
+ color: white;
1171
+ }}
1172
+ .badge.low {{
1173
+ background: #9e9e9e;
1174
+ color: white;
1175
+ }}
1176
+ table {{
1177
+ width: 100%;
1178
+ border-collapse: collapse;
1179
+ margin: 15px 0;
1180
+ }}
1181
+ th, td {{
1182
+ padding: 12px;
1183
+ text-align: left;
1184
+ border-bottom: 1px solid #e0e0e0;
1185
+ }}
1186
+ th {{
1187
+ background: #f8f9fa;
1188
+ font-weight: 600;
1189
+ color: #555;
1190
+ font-size: 13px;
1191
+ text-transform: uppercase;
1192
+ }}
1193
+ .progress-bar {{
1194
+ background: #e0e0e0;
1195
+ border-radius: 10px;
1196
+ height: 20px;
1197
+ overflow: hidden;
1198
+ margin: 5px 0;
1199
+ }}
1200
+ .progress-fill {{
1201
+ height: 100%;
1202
+ background: linear-gradient(90deg, #4caf50, #8bc34a);
1203
+ transition: width 0.3s;
1204
+ }}
1205
+ .progress-fill.warning {{
1206
+ background: linear-gradient(90deg, #ff9800, #ffa726);
1207
+ }}
1208
+ .progress-fill.danger {{
1209
+ background: linear-gradient(90deg, #f44336, #e91e63);
1210
+ }}
1211
+ .footer {{
1212
+ background: #f8f9fa;
1213
+ padding: 20px;
1214
+ text-align: center;
1215
+ color: #666;
1216
+ font-size: 12px;
1217
+ border-top: 1px solid #e0e0e0;
1218
+ }}
1219
+ .timestamp {{
1220
+ color: #999;
1221
+ font-size: 13px;
1222
+ margin-top: 5px;
1223
+ }}
1224
+ a {{
1225
+ color: #667eea;
1226
+ text-decoration: none;
1227
+ }}
1228
+ a:hover {{
1229
+ text-decoration: underline;
1230
+ }}
1231
+ @media (max-width: 600px) {{
1232
+ body {{
1233
+ padding: 10px;
1234
+ }}
1235
+ .summary-grid {{
1236
+ grid-template-columns: 1fr;
1237
+ }}
1238
+ }}
1239
+ </style>
1240
+ </head>
1241
+ <body>
1242
+ <div class="container">
1243
+ <div class="header">
1244
+ <h1>Guardian Security Report</h1>
1245
+ <div class="status-badge">{status_text}</div>
1246
+ <div class="timestamp">{timestamp}</div>
1247
+ </div>
1248
+
1249
+ <div class="content">
1250
+ <div class="summary-grid">
1251
+ <div class="summary-card {"critical" if summary.get("critical_vulnerabilities", 0) > 0 else "success"}">
1252
+ <h3>Critical Vulnerabilities</h3>
1253
+ <div class="value">{summary.get("critical_vulnerabilities", 0)}</div>
1254
+ </div>
1255
+ <div class="summary-card {"warning" if summary.get("total_vulnerabilities", 0) > 0 else "success"}">
1256
+ <h3>Total Vulnerabilities</h3>
1257
+ <div class="value">{summary.get("total_vulnerabilities", 0)}</div>
1258
+ </div>
1259
+ <div class="summary-card {"critical" if summary.get("unhealthy_deployments", 0) > 0 else "success"}">
1260
+ <h3>Unhealthy Deployments</h3>
1261
+ <div class="value">{summary.get("unhealthy_deployments", 0)}</div>
1262
+ </div>
1263
+ <div class="summary-card {"critical" if summary.get("failed_checks", 0) > 0 else "success"}">
1264
+ <h3>Failed Checks</h3>
1265
+ <div class="value">{summary.get("failed_checks", 0)}</div>
1266
+ </div>
1267
+ <div class="summary-card">
1268
+ <h3>Total Checks</h3>
1269
+ <div class="value">{summary.get("total_checks", 0)}</div>
1270
+ </div>
1271
+ <div class="summary-card">
1272
+ <h3>Successful</h3>
1273
+ <div class="value">{summary.get("successful_checks", 0)}</div>
1274
+ </div>
1275
+ </div>"""
1276
+
1277
+ # Critical vulnerabilities
1278
+ if critical_vulns:
1279
+ html += f"""
1280
+ <div class="section critical">
1281
+ <h2>⚠ Critical Vulnerabilities ({len(critical_vulns)})</h2>"""
1282
+ for vuln in critical_vulns[:15]:
1283
+ severity_class = vuln.severity.value
1284
+ html += f"""
1285
+ <div class="vuln-item {severity_class}">
1286
+ <div class="item-header">
1287
+ {vuln.package_name}@{vuln.package_version}
1288
+ <span class="badge {severity_class}">{vuln.severity.value}</span>
1289
+ </div>"""
1290
+ if vuln.cve_id:
1291
+ html += f'<div class="item-meta">CVE: {vuln.cve_id}</div>'
1292
+ if vuln.summary:
1293
+ html += f'<div class="item-description">{vuln.summary}</div>'
1294
+ if vuln.first_patched_version:
1295
+ html += f"""
1296
+ <div class="item-remediation">
1297
+ <strong>Remediation:</strong> Upgrade to {vuln.first_patched_version}
1298
+ </div>"""
1299
+ if vuln.references:
1300
+ html += f'<div class="item-meta"><a href="{vuln.references[0]}" target="_blank">View Advisory →</a></div>'
1301
+ html += "</div>"
1302
+ html += "</div>"
1303
+
1304
+ # Critical findings
1305
+ critical_findings = report.get_critical_findings()
1306
+ if critical_findings:
1307
+ html += f"""
1308
+ <div class="section critical">
1309
+ <h2>⚠ Critical Security Findings ({len(critical_findings)})</h2>"""
1310
+ for check in report.checks:
1311
+ for finding in check.findings:
1312
+ if finding.severity.value == "critical":
1313
+ html += f"""
1314
+ <div class="finding-item">
1315
+ <div class="item-header">
1316
+ <span class="badge" style="background: #9e9e9e; margin-right: 8px;">{check.check_type.upper()}</span>
1317
+ {finding.title}
1318
+ </div>
1319
+ <div class="item-meta">Resource: {finding.resource}</div>
1320
+ <div class="item-description">{finding.description}</div>"""
1321
+ if finding.remediation:
1322
+ html += f"""
1323
+ <div class="item-remediation">
1324
+ <strong>Remediation:</strong> {finding.remediation}
1325
+ </div>"""
1326
+ html += "</div>"
1327
+ html += "</div>"
1328
+
1329
+ # High findings (Swarm, AWS, etc.)
1330
+ high_findings = report.get_high_findings()
1331
+ if high_findings:
1332
+ html += f"""
1333
+ <div class="section warning">
1334
+ <h2>⚠ High Priority Findings ({len(high_findings)})</h2>"""
1335
+ for check in report.checks:
1336
+ for finding in check.findings:
1337
+ if finding.severity.value == "high":
1338
+ html += f"""
1339
+ <div class="finding-item">
1340
+ <div class="item-header">
1341
+ <span class="badge" style="background: #9e9e9e; margin-right: 8px;">{check.check_type.upper()}</span>
1342
+ {finding.title}
1343
+ </div>
1344
+ <div class="item-meta">Resource: {finding.resource}</div>
1345
+ <div class="item-description">{finding.description}</div>"""
1346
+ if finding.remediation:
1347
+ html += f"""
1348
+ <div class="item-remediation">
1349
+ <strong>Remediation:</strong> {finding.remediation}
1350
+ </div>"""
1351
+ html += "</div>"
1352
+ html += "</div>"
1353
+
1354
+ # Unhealthy deployments
1355
+ if unhealthy:
1356
+ html += f"""
1357
+ <div class="section critical">
1358
+ <h2>⚠ Unhealthy Deployments ({len(unhealthy)})</h2>"""
1359
+ for dep in unhealthy:
1360
+ platform_display = (
1361
+ dep.platform.upper() if dep.platform in ["swarm", "aws"] else dep.platform
1362
+ )
1363
+ html += f"""
1364
+ <div class="deployment-item unhealthy">
1365
+ <div class="item-header">
1366
+ <span class="badge" style="background: #9e9e9e; margin-right: 8px;">{platform_display}</span>
1367
+ {dep.project_name}
1368
+ </div>
1369
+ <div class="item-meta">Status: {dep.status.value}</div>"""
1370
+ if dep.url:
1371
+ html += f'<div class="item-meta"><a href="{dep.url}" target="_blank">{dep.url}</a></div>'
1372
+ if dep.error_message:
1373
+ html += f'<div class="item-description" style="color: #d32f2f;">Error: {dep.error_message}</div>'
1374
+ html += "</div>"
1375
+ html += "</div>"
1376
+
1377
+ # Other vulnerabilities
1378
+ other_vulns = []
1379
+ for check in report.checks:
1380
+ for vuln in check.vulnerabilities:
1381
+ if vuln.severity.value in ["high", "medium"]:
1382
+ other_vulns.append((check.check_type, vuln))
1383
+
1384
+ if other_vulns:
1385
+ html += f"""
1386
+ <div class="section warning">
1387
+ <h2>Other Vulnerabilities ({len(other_vulns)})</h2>"""
1388
+ for check_type, vuln in other_vulns[:10]:
1389
+ severity_class = vuln.severity.value
1390
+ html += f"""
1391
+ <div class="vuln-item {severity_class}">
1392
+ <div class="item-header">
1393
+ [{check_type}] {vuln.package_name}@{vuln.package_version}
1394
+ <span class="badge {severity_class}">{vuln.severity.value}</span>
1395
+ </div>"""
1396
+ if vuln.summary:
1397
+ html += f'<div class="item-description">{vuln.summary}</div>'
1398
+ html += "</div>"
1399
+ html += "</div>"
1400
+
1401
+ # Repository alerts
1402
+ open_alerts = report.get_open_repository_alerts()
1403
+ if open_alerts:
1404
+ html += f"""
1405
+ <div class="section warning">
1406
+ <h2>Open Repository Alerts ({len(open_alerts)})</h2>"""
1407
+ for alert in open_alerts[:10]:
1408
+ adv = alert.security_advisory
1409
+ severity_class = alert.severity.value
1410
+ html += f"""
1411
+ <div class="vuln-item {severity_class}">
1412
+ <div class="item-header">
1413
+ {alert.repository}
1414
+ <span class="badge {severity_class}">{alert.severity.value}</span>
1415
+ </div>"""
1416
+ if adv.get("summary"):
1417
+ html += f'<div class="item-description">{adv["summary"]}</div>'
1418
+ html += "</div>"
1419
+ html += "</div>"
1420
+
1421
+ # Cost metrics
1422
+ cost_metrics = report.get_cost_metrics()
1423
+ if cost_metrics:
1424
+ total_cost = report.get_total_cost()
1425
+ html += """
1426
+ <div class="section">
1427
+ <h2>Cost Metrics</h2>
1428
+ <table>
1429
+ <tr><th>Service</th><th>Period</th><th>Amount</th><th>Usage</th></tr>"""
1430
+ for cost in cost_metrics:
1431
+ if cost.amount is not None:
1432
+ usage_display = (
1433
+ f"{cost.usage_percent:.1f}%" if cost.usage_percent is not None else "N/A"
1434
+ )
1435
+ html += f"""
1436
+ <tr>
1437
+ <td>{cost.service}</td>
1438
+ <td>{cost.period}</td>
1439
+ <td>${cost.amount:.2f}</td>
1440
+ <td>{usage_display}</td>
1441
+ </tr>"""
1442
+ html += f"""
1443
+ <tr style="font-weight: 600; background: #f8f9fa;">
1444
+ <td colspan="2">Total</td>
1445
+ <td>${total_cost:.2f}</td>
1446
+ <td></td>
1447
+ </tr>
1448
+ </table>
1449
+ </div>"""
1450
+
1451
+ # API usage
1452
+ api_usage = []
1453
+ for check in report.checks:
1454
+ api_usage.extend(check.api_usage)
1455
+
1456
+ if api_usage:
1457
+ html += """
1458
+ <div class="section">
1459
+ <h2>API Usage</h2>"""
1460
+ for usage in api_usage:
1461
+ if usage.usage_percent is not None:
1462
+ progress_class = (
1463
+ "danger"
1464
+ if usage.usage_percent > 90
1465
+ else "warning"
1466
+ if usage.usage_percent > 70
1467
+ else ""
1468
+ )
1469
+ html += f"""
1470
+ <div style="margin: 15px 0;">
1471
+ <div style="display: flex; justify-content: space-between; margin-bottom: 5px;">
1472
+ <span><strong>{usage.service}</strong></span>
1473
+ <span>{usage.usage_percent:.1f}%</span>
1474
+ </div>
1475
+ <div class="progress-bar">
1476
+ <div class="progress-fill {progress_class}" style="width: {usage.usage_percent}%"></div>
1477
+ </div>"""
1478
+ if usage.credits_remaining is not None:
1479
+ html += f'<div class="item-meta">Remaining: {usage.credits_remaining:.0f} credits</div>'
1480
+ html += "</div>"
1481
+ html += "</div>"
1482
+
1483
+ # Check status
1484
+ html += """
1485
+ <div class="section">
1486
+ <h2>Check Status</h2>
1487
+ <table>
1488
+ <tr><th>Check Type</th><th>Status</th><th>Issues</th></tr>"""
1489
+ for check in report.checks:
1490
+ status_icon = "✓" if check.success else "✗"
1491
+ status_color = "#388e3c" if check.success else "#d32f2f"
1492
+ issues = len(check.vulnerabilities) + len(check.findings) + len(check.errors)
1493
+ html += f"""
1494
+ <tr>
1495
+ <td><strong>{check.check_type.upper()}</strong></td>
1496
+ <td style="color: {status_color}; font-weight: 600;">{status_icon} {"Success" if check.success else "Failed"}</td>
1497
+ <td>{issues}</td>
1498
+ </tr>"""
1499
+ html += """
1500
+ </table>
1501
+ </div>
1502
+ </div>
1503
+
1504
+ <div class="footer">
1505
+ Generated by Guardian Security Monitoring System
1506
+ </div>
1507
+ </div>
1508
+ </body>
1509
+ </html>"""
1510
+
1511
+ return html
1512
+
1513
+ def _report_to_dict(self, report: GuardianReport) -> dict[str, Any]:
1514
+ """Convert report to dictionary for JSON serialization."""
1515
+ return {
1516
+ "generated_at": report.generated_at.isoformat(),
1517
+ "summary": report.summary,
1518
+ "checks": [
1519
+ {
1520
+ "check_type": check.check_type,
1521
+ "success": check.success,
1522
+ "timestamp": check.timestamp.isoformat(),
1523
+ "vulnerabilities_count": len(check.vulnerabilities),
1524
+ "findings_count": len(check.findings),
1525
+ "deployments_count": len(check.deployments),
1526
+ "repository_alerts_count": len(check.repository_alerts),
1527
+ "api_usage_count": len(check.api_usage),
1528
+ "errors": check.errors,
1529
+ "api_usage": [
1530
+ {
1531
+ "service": u.service,
1532
+ "credits_total": u.credits_total,
1533
+ "credits_used": u.credits_used,
1534
+ "credits_remaining": u.credits_remaining,
1535
+ "usage_percent": u.usage_percent,
1536
+ }
1537
+ for u in check.api_usage
1538
+ ],
1539
+ }
1540
+ for check in report.checks
1541
+ ],
1542
+ "critical_vulnerabilities": [
1543
+ {
1544
+ "package_name": v.package_name,
1545
+ "package_version": v.package_version,
1546
+ "severity": v.severity.value,
1547
+ "summary": v.summary,
1548
+ }
1549
+ for v in report.get_critical_vulnerabilities()
1550
+ ],
1551
+ "critical_findings": [
1552
+ {
1553
+ "severity": f.severity.value,
1554
+ "title": f.title,
1555
+ "description": f.description,
1556
+ "resource": f.resource,
1557
+ "remediation": f.remediation,
1558
+ }
1559
+ for f in report.get_critical_findings()
1560
+ ],
1561
+ "unhealthy_deployments": [
1562
+ {
1563
+ "platform": d.platform,
1564
+ "project_name": d.project_name,
1565
+ "status": d.status.value,
1566
+ "url": d.url,
1567
+ "error_message": d.error_message,
1568
+ }
1569
+ for d in report.get_unhealthy_deployments()
1570
+ ],
1571
+ }