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/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
|
+
}
|