vamp-cloud-enum 1.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.
vampsec_report.py ADDED
@@ -0,0 +1,959 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ vampsec_report.py — Módulo de Informes Unificado VampSecure Labs
4
+ =================================================================
5
+ VampSecure Labs · VampSecure Studios
6
+ Para Uso Exclusivo en Pruebas de Penetración Autorizadas
7
+
8
+ DESCRIPCIÓN
9
+ -----------
10
+ Módulo compartido de generación de informes para todas las herramientas
11
+ del toolkit VampSecure Labs. Proporciona una estructura de datos y formatos
12
+ de salida unificados para la entrega profesional de resultados a clientes.
13
+
14
+ · Finding — estructura normalizada de un hallazgo de seguridad
15
+ · ReportMeta — metadatos del engagement (cliente, auditor, scope)
16
+ · VampSecReport — clase principal con métodos de exportación:
17
+ · to_json() — JSON estructurado (esquema estándar VSL)
18
+ · to_markdown() — Markdown para wikis y sistemas de tickets
19
+ · to_html_client() — HTML profesional light para entrega al cliente
20
+ · to_pdf() — PDF nativo vía fpdf2 para entrega al cliente
21
+
22
+ DEPENDENCIAS
23
+ ------------
24
+ Requeridas : stdlib únicamente
25
+ Opcionales : fpdf2 >= 2.7.0 (solo para to_pdf())
26
+ pip install fpdf2
27
+
28
+ INTEGRACIÓN
29
+ -----------
30
+ Copiar este fichero al directorio raíz de cada herramienta. No se
31
+ distribuye como paquete independiente para mantener la autonomía de cada
32
+ repositorio.
33
+
34
+ AUTORÍA
35
+ -------
36
+ © VampSecure Studios — VampSecure Labs Security Research Division
37
+ Todos los derechos reservados. Uso exclusivo en entornos autorizados.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ from dataclasses import asdict, dataclass, field
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+ from typing import Dict, List, Optional
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Mapas de severidad y paletas de color
50
+ # ---------------------------------------------------------------------------
51
+
52
+ # Orden de severidad de mayor a menor (usado en la ordenación de findings)
53
+ SEVERITY_ORDER: Dict[str, int] = {
54
+ "CRITICAL": 0,
55
+ "HIGH": 1,
56
+ "MEDIUM": 2,
57
+ "LOW": 3,
58
+ "INFO": 4,
59
+ }
60
+
61
+ # Colores tema claro/profesional para informe de cliente (hex CSS)
62
+ _LIGHT_BG: Dict[str, str] = {
63
+ "CRITICAL": "#c0392b",
64
+ "HIGH": "#d35400",
65
+ "MEDIUM": "#d4ac0d",
66
+ "LOW": "#2980b9",
67
+ "INFO": "#7f8c8d",
68
+ "UNKNOWN": "#95a5a6",
69
+ }
70
+
71
+ # Color del texto sobre el badge de cada severidad
72
+ _LIGHT_TEXT: Dict[str, str] = {
73
+ "CRITICAL": "#ffffff",
74
+ "HIGH": "#ffffff",
75
+ "MEDIUM": "#2c3e50",
76
+ "LOW": "#ffffff",
77
+ "INFO": "#ffffff",
78
+ "UNKNOWN": "#ffffff",
79
+ }
80
+
81
+ # Tuplas RGB para fpdf2 (fondo badge)
82
+ _PDF_BG: Dict[str, tuple] = {
83
+ "CRITICAL": (192, 57, 43),
84
+ "HIGH": (211, 84, 0),
85
+ "MEDIUM": (212, 172, 13),
86
+ "LOW": (41, 128, 185),
87
+ "INFO": (127, 140, 141),
88
+ "UNKNOWN": (149, 165, 166),
89
+ }
90
+
91
+ # Tuplas RGB para el texto sobre el badge PDF
92
+ _PDF_FG: Dict[str, tuple] = {
93
+ "CRITICAL": (255, 255, 255),
94
+ "HIGH": (255, 255, 255),
95
+ "MEDIUM": (44, 62, 80),
96
+ "LOW": (255, 255, 255),
97
+ "INFO": (255, 255, 255),
98
+ "UNKNOWN": (255, 255, 255),
99
+ }
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Estructuras de datos
104
+ # ---------------------------------------------------------------------------
105
+
106
+ @dataclass
107
+ class Finding:
108
+ """
109
+ Representa un único hallazgo de seguridad en formato normalizado VSL.
110
+
111
+ Campos obligatorios
112
+ -------------------
113
+ id : Identificador único en formato PREFIX-NNN (p.ej. FTC-001)
114
+ title : Título corto del hallazgo (recomendado: < 80 caracteres)
115
+ severity : CRITICAL | HIGH | MEDIUM | LOW | INFO
116
+ description : Descripción técnica detallada del problema
117
+ evidence : Prueba técnica concreta (respuesta HTTP, versión, hash…)
118
+ affected : Activo, host, endpoint o fichero afectado
119
+ remediation : Acciones concretas de mitigación o corrección
120
+
121
+ Campos opcionales
122
+ -----------------
123
+ cvss : Puntuación CVSS base 0.0–10.0
124
+ cve : ID CVE si aplica (p.ej. CVE-2024-3400)
125
+ references : URLs de advisories, RFCs o documentación técnica
126
+ tags : Etiquetas para agrupación temática
127
+ """
128
+
129
+ id : str
130
+ title : str
131
+ severity : str # CRITICAL / HIGH / MEDIUM / LOW / INFO
132
+ description : str
133
+ evidence : str
134
+ affected : str
135
+ remediation : str
136
+ cvss : Optional[float] = None
137
+ cve : Optional[str] = None
138
+ references : List[str] = field(default_factory=list)
139
+ tags : List[str] = field(default_factory=list)
140
+
141
+ def __post_init__(self) -> None:
142
+ self.severity = self.severity.upper()
143
+ if self.severity not in SEVERITY_ORDER:
144
+ self.severity = "INFO"
145
+
146
+
147
+ @dataclass
148
+ class ReportMeta:
149
+ """
150
+ Metadatos del engagement de auditoría incluidos en todos los informes.
151
+
152
+ Attributes
153
+ ----------
154
+ tool : Nombre de la herramienta (p.ej. "vamp-forticheck")
155
+ tool_version : Versión de la herramienta (p.ej. "2.0")
156
+ client : Nombre del cliente (aparece en portada y cabecera)
157
+ engagement : Nombre o referencia del engagement
158
+ auditor : Nombre del auditor o equipo
159
+ scope : Descripción del alcance del análisis
160
+ generated : Fecha/hora de generación ISO-8601 UTC; auto si vacío
161
+ """
162
+
163
+ tool : str
164
+ tool_version : str
165
+ client : str = "Confidencial"
166
+ engagement : str = ""
167
+ auditor : str = "VampSecure Labs — Security Research Division"
168
+ scope : str = ""
169
+ generated : str = ""
170
+
171
+ def __post_init__(self) -> None:
172
+ if not self.generated:
173
+ self.generated = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Clase principal de generación de informes
178
+ # ---------------------------------------------------------------------------
179
+
180
+ class VampSecReport:
181
+ """
182
+ Generador de informes unificado para el toolkit VampSecure Labs.
183
+
184
+ Recibe los metadatos del engagement y la lista de hallazgos en formato
185
+ normalizado Finding y puede exportar en cuatro formatos:
186
+
187
+ · JSON — esquema VSL estándar (machine-readable)
188
+ · Markdown — para wikis, sistemas de tickets, README de engagement
189
+ · HTML cliente — tema claro/profesional para entrega al cliente
190
+ · PDF — documento PDF nativo para entrega al cliente (fpdf2)
191
+
192
+ Los findings se ordenan automáticamente por severidad decreciente.
193
+
194
+ Ejemplo de uso
195
+ --------------
196
+ >>> from vampsec_report import Finding, ReportMeta, VampSecReport
197
+ >>> meta = ReportMeta(tool="vamp-forticheck", tool_version="2.0",
198
+ ... client="AcmeCorp S.A.", engagement="Pentest-2026-07")
199
+ >>> findings = [
200
+ ... Finding(id="FTC-001", title="Path traversal SSL-VPN", severity="CRITICAL",
201
+ ... description="...", evidence="...", affected="vpn.acme.com",
202
+ ... remediation="...", cve="CVE-2018-13379", cvss=9.8),
203
+ ... ]
204
+ >>> report = VampSecReport(meta, findings)
205
+ >>> report.to_html_client("informe_cliente.html")
206
+ >>> report.to_pdf("informe_cliente.pdf") # requiere fpdf2
207
+ """
208
+
209
+ # Versión del esquema JSON VSL — incrementar si cambia la estructura
210
+ SCHEMA_VERSION = "1.0"
211
+ BRAND = "VampSecure Labs"
212
+ BRAND_FULL = "VampSecure Labs — Security Research Division"
213
+ COPYRIGHT = "© VampSecure Studios. Todos los derechos reservados."
214
+ CONFIDENTIAL = "CONFIDENCIAL — Uso exclusivo del cliente destinatario."
215
+
216
+ def __init__(self, meta: ReportMeta, findings: List[Finding]) -> None:
217
+ self.meta = meta
218
+ self.findings = sorted(findings, key=lambda f: SEVERITY_ORDER.get(f.severity, 99))
219
+
220
+ # ── Estadísticas de resumen ────────────────────────────────────────────
221
+
222
+ def _stats(self) -> Dict[str, int]:
223
+ """Devuelve el conteo de findings por severidad ordenado de mayor a menor."""
224
+ counts: Dict[str, int] = {s: 0 for s in SEVERITY_ORDER}
225
+ for f in self.findings:
226
+ counts[f.severity] = counts.get(f.severity, 0) + 1
227
+ return counts
228
+
229
+ # ── JSON ───────────────────────────────────────────────────────────────
230
+
231
+ def to_json(self, path: str) -> None:
232
+ """
233
+ Exporta el informe como JSON con esquema estándar VSL.
234
+
235
+ Estructura:
236
+ schema_version · generated · meta · summary.by_severity ·
237
+ summary.total · findings[]
238
+ """
239
+ stats = self._stats()
240
+ datos = {
241
+ "schema_version": self.SCHEMA_VERSION,
242
+ "generated": self.meta.generated,
243
+ "meta": {
244
+ "tool": self.meta.tool,
245
+ "tool_version": self.meta.tool_version,
246
+ "client": self.meta.client,
247
+ "engagement": self.meta.engagement,
248
+ "auditor": self.meta.auditor,
249
+ "scope": self.meta.scope,
250
+ },
251
+ "summary": {
252
+ "total": len(self.findings),
253
+ "by_severity": stats,
254
+ },
255
+ "findings": [asdict(f) for f in self.findings],
256
+ }
257
+ Path(path).write_text(json.dumps(datos, indent=2, ensure_ascii=False), encoding="utf-8")
258
+
259
+ # ── Markdown ───────────────────────────────────────────────────────────
260
+
261
+ def to_markdown(self, path: str) -> None:
262
+ """
263
+ Exporta el informe en formato Markdown.
264
+
265
+ Incluye resumen ejecutivo con tabla de severidades y hallazgos
266
+ detallados con descripción, evidencia y remediación.
267
+ """
268
+ stats = self._stats()
269
+ lineas = [
270
+ f"# Informe de Seguridad — {self.meta.client}",
271
+ "",
272
+ f"**Herramienta:** {self.meta.tool} v{self.meta.tool_version} ",
273
+ f"**Auditor:** {self.meta.auditor} ",
274
+ f"**Engagement:** {self.meta.engagement or '—'} ",
275
+ f"**Generado:** {self.meta.generated} ",
276
+ f"**Scope:** {self.meta.scope or '—'}",
277
+ "",
278
+ f"> {self.CONFIDENTIAL}",
279
+ "",
280
+ "---",
281
+ "",
282
+ "## Resumen Ejecutivo",
283
+ "",
284
+ "| Severidad | Hallazgos |",
285
+ "|-----------|-----------|",
286
+ ]
287
+ for sev in SEVERITY_ORDER:
288
+ if stats[sev]:
289
+ lineas.append(f"| {sev} | {stats[sev]} |")
290
+ lineas += [
291
+ f"| **TOTAL** | **{len(self.findings)}** |",
292
+ "",
293
+ "---",
294
+ "",
295
+ "## Hallazgos",
296
+ "",
297
+ ]
298
+ for f in self.findings:
299
+ cvss_str = f" · CVSS {f.cvss:.1f}" if f.cvss is not None else ""
300
+ cve_str = f" · {f.cve}" if f.cve else ""
301
+ lineas += [
302
+ f"### {f.id} — {f.title}",
303
+ "",
304
+ f"**Severidad:** {f.severity}{cvss_str}{cve_str} ",
305
+ f"**Afectado:** `{f.affected}`",
306
+ "",
307
+ "**Descripción:** ",
308
+ f.description,
309
+ "",
310
+ "**Evidencia:** ",
311
+ "```",
312
+ f.evidence,
313
+ "```",
314
+ "",
315
+ "**Remediación:** ",
316
+ f.remediation,
317
+ "",
318
+ ]
319
+ if f.references:
320
+ lineas.append("**Referencias:** ")
321
+ for ref in f.references:
322
+ lineas.append(f"- {ref}")
323
+ lineas.append("")
324
+ lineas.append("---")
325
+ lineas.append("")
326
+ lineas += [
327
+ f"*{self.BRAND_FULL}* ",
328
+ f"*{self.COPYRIGHT}* ",
329
+ f"*Generado: {self.meta.generated}*",
330
+ ]
331
+ Path(path).write_text("\n".join(lineas), encoding="utf-8")
332
+
333
+ # ── HTML cliente (tema claro/profesional) ──────────────────────────────
334
+
335
+ def to_html_client(self, path: str) -> None:
336
+ """
337
+ Genera informe HTML con tema claro/profesional para entrega al cliente.
338
+
339
+ Características:
340
+ · Fondo blanco, tipografía oscura, paleta VampSecure (rojo #c0392b)
341
+ · Portada virtual con datos del engagement
342
+ · Resumen ejecutivo con distribución de riesgos en barras
343
+ · Tabla de hallazgos con código de color por severidad
344
+ · Tarjetas de hallazgo detallado con evidencia y remediación
345
+ · Marca de agua CSS «CONFIDENCIAL» en todas las páginas
346
+ · @media print para impresión directa a PDF desde el navegador
347
+ · Completamente standalone: sin CDN ni dependencias externas
348
+ """
349
+ stats = self._stats()
350
+ filas = self._filas_tabla()
351
+ detalles = self._tarjetas_hallazgo()
352
+ barras = self._barras_riesgo(stats)
353
+ kpis = " ".join(
354
+ f'<div class="kpi"><div class="kv" style="color:{_LIGHT_BG[s]}">{v}</div>'
355
+ f'<div class="kl">{s}</div></div>'
356
+ for s, v in stats.items() if v > 0
357
+ )
358
+ badge_css = " ".join(
359
+ f'.sev-{s.lower()}{{background:{_LIGHT_BG[s]};color:{_LIGHT_TEXT[s]}}}'
360
+ for s in _LIGHT_BG
361
+ )
362
+
363
+ html = f"""<!DOCTYPE html>
364
+ <html lang="es">
365
+ <head>
366
+ <meta charset="UTF-8">
367
+ <meta name="viewport" content="width=device-width,initial-scale=1">
368
+ <title>Informe de Seguridad — {self.meta.client} — {self.meta.generated[:10]}</title>
369
+ <style>
370
+ *{{box-sizing:border-box;margin:0;padding:0}}
371
+ body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
372
+ background:#f4f4f8;color:#1a1a2e;line-height:1.65;font-size:14px}}
373
+ a{{color:#2980b9;text-decoration:none}}
374
+ code{{font-family:'Courier New',monospace;background:#f0f0f5;
375
+ padding:1px 5px;border-radius:3px;font-size:.88em}}
376
+ pre{{background:#f4f4f8;border:1px solid #dde;border-radius:4px;
377
+ padding:10px 12px;font-size:.8em;white-space:pre-wrap;
378
+ word-break:break-all;line-height:1.5;font-family:'Courier New',monospace}}
379
+ .wrap{{max-width:980px;margin:0 auto;background:#fff;
380
+ box-shadow:0 2px 16px rgba(0,0,0,.09)}}
381
+
382
+ /* Portada */
383
+ .portada{{background:linear-gradient(140deg,#1a1a2e 0%,#8b0000 60%,#c0392b 100%);
384
+ color:#fff;padding:56px 48px 48px;position:relative;overflow:hidden;
385
+ page-break-after:always}}
386
+ .portada::before{{content:"";position:absolute;top:-60px;right:-60px;
387
+ width:260px;height:260px;border-radius:50%;
388
+ background:rgba(255,255,255,.04)}}
389
+ .portada-logo{{font-size:.78em;letter-spacing:3px;text-transform:uppercase;
390
+ opacity:.65;margin-bottom:36px}}
391
+ .portada-titulo{{font-size:1.9em;font-weight:700;letter-spacing:.5px;margin-bottom:4px}}
392
+ .portada-sub{{font-size:.95em;opacity:.7;margin-bottom:32px}}
393
+ .portada-grid{{display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:.83em}}
394
+ .portada-item span{{display:block;opacity:.55;font-size:.78em;
395
+ text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}}
396
+ .portada-item strong{{font-weight:600}}
397
+ .portada-item.full{{grid-column:1/-1}}
398
+ .conf-badge{{position:absolute;top:20px;right:28px;font-size:.7em;
399
+ letter-spacing:2px;border:1px solid rgba(255,255,255,.3);
400
+ padding:3px 8px;border-radius:3px;opacity:.6;text-transform:uppercase}}
401
+
402
+ /* Secciones */
403
+ .sec{{padding:30px 48px;border-bottom:1px solid #eef}}
404
+ .sec:last-child{{border-bottom:none}}
405
+ .sec-title{{font-size:1em;font-weight:700;color:#c0392b;text-transform:uppercase;
406
+ letter-spacing:.6px;margin-bottom:18px;display:flex;
407
+ align-items:center;gap:10px}}
408
+ .sec-title::after{{content:'';flex:1;height:1px;background:#eef}}
409
+
410
+ /* KPIs */
411
+ .kpis{{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:22px}}
412
+ .kpi{{background:#f8f8fa;border:1px solid #e5e5ef;border-radius:6px;
413
+ padding:14px 20px;min-width:110px;text-align:center;
414
+ border-top:3px solid #c0392b}}
415
+ .kv{{font-size:2em;font-weight:700;color:#1a1a2e}}
416
+ .kl{{font-size:.7em;color:#999;text-transform:uppercase;letter-spacing:.4px;margin-top:2px}}
417
+
418
+ /* Barras de riesgo */
419
+ .bars{{display:flex;flex-direction:column;gap:9px;margin-bottom:22px}}
420
+ .bar-row{{display:flex;align-items:center;gap:10px}}
421
+ .bar-track{{flex:1;background:#eef0f5;border-radius:3px;height:16px;overflow:hidden}}
422
+ .bar-fill{{height:100%;border-radius:3px}}
423
+ .bar-count{{font-size:.8em;color:#aaa;width:22px;text-align:right}}
424
+
425
+ /* Badges de severidad */
426
+ .badge{{display:inline-block;padding:2px 8px;border-radius:3px;
427
+ font-size:.74em;font-weight:700;letter-spacing:.3px}}
428
+ {badge_css}
429
+
430
+ /* Tabla */
431
+ table{{width:100%;border-collapse:collapse;font-size:.84em}}
432
+ th{{background:#f7f7fa;color:#666;padding:8px 10px;text-align:left;
433
+ border-bottom:2px solid #e8e8ef;font-size:.76em;text-transform:uppercase;
434
+ letter-spacing:.4px;font-weight:700}}
435
+ td{{padding:8px 10px;border-bottom:1px solid #f0f0f5;vertical-align:top}}
436
+ tr:last-child td{{border-bottom:none}}
437
+ tr:hover td{{background:#fafafa}}
438
+
439
+ /* Tarjetas de hallazgo */
440
+ .card{{background:#fff;border:1px solid #e5e5ef;border-radius:6px;
441
+ margin:14px 0;overflow:hidden;page-break-inside:avoid}}
442
+ .card-head{{padding:13px 18px;border-bottom:1px solid #f0f0f5;
443
+ display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
444
+ .card-id{{font-size:.78em;color:#999;font-family:'Courier New',monospace;font-weight:600}}
445
+ .card-name{{font-weight:700;flex:1;color:#1a1a2e}}
446
+ .card-body{{padding:16px 18px;display:grid;
447
+ grid-template-columns:1fr 1fr;gap:16px}}
448
+ .card-body.solo{{grid-template-columns:1fr}}
449
+ .fld{{}}
450
+ .fld-lbl{{font-size:.7em;color:#bbb;text-transform:uppercase;
451
+ letter-spacing:.5px;margin-bottom:4px;font-weight:700}}
452
+ .fld-val{{font-size:.85em;color:#2c3e50;line-height:1.6}}
453
+ .card-evi{{padding:0 18px 14px}}
454
+ .card-rem{{padding:14px 18px;background:#f9fff8;border-top:1px solid #eef5ee}}
455
+ .card-refs{{padding:8px 18px;background:#f8f8fa;border-top:1px solid #eee;
456
+ font-size:.76em}}
457
+
458
+ /* Marca de agua */
459
+ body::before{{content:"CONFIDENCIAL";position:fixed;top:50%;left:50%;
460
+ transform:translate(-50%,-50%) rotate(-35deg);
461
+ font-size:7em;color:rgba(192,57,43,.035);font-weight:900;
462
+ letter-spacing:8px;pointer-events:none;z-index:0;white-space:nowrap}}
463
+
464
+ /* Pie */
465
+ .pie{{background:#f7f7fa;border-top:1px solid #e8e8ef;padding:14px 48px;
466
+ font-size:.72em;color:#bbb;display:flex;
467
+ justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px}}
468
+ .pie strong{{color:#aaa}}
469
+
470
+ /* Print */
471
+ @media print{{
472
+ body{{background:#fff;font-size:11pt}}
473
+ body::before{{display:none}}
474
+ .wrap{{box-shadow:none;max-width:100%}}
475
+ .portada{{page-break-after:always;-webkit-print-color-adjust:exact;
476
+ print-color-adjust:exact}}
477
+ .card{{page-break-inside:avoid}}
478
+ .sec{{padding:18px 32px}}
479
+ .pie{{position:fixed;bottom:0;left:0;right:0;
480
+ -webkit-print-color-adjust:exact;print-color-adjust:exact}}
481
+ .badge,.bar-fill,.kpi{{-webkit-print-color-adjust:exact;print-color-adjust:exact}}
482
+ }}
483
+ </style>
484
+ </head>
485
+ <body>
486
+ <div class="wrap">
487
+
488
+ <!-- PORTADA -->
489
+ <div class="portada">
490
+ <div class="conf-badge">Confidencial</div>
491
+ <div class="portada-logo">&#9679; {self.BRAND}</div>
492
+ <div class="portada-titulo">Informe de Auditoría de Seguridad</div>
493
+ <div class="portada-sub">{self.meta.tool} v{self.meta.tool_version}</div>
494
+ <div class="portada-grid">
495
+ <div class="portada-item"><span>Cliente</span><strong>{self.meta.client}</strong></div>
496
+ <div class="portada-item"><span>Engagement</span><strong>{self.meta.engagement or '—'}</strong></div>
497
+ <div class="portada-item"><span>Auditor</span><strong>{self.meta.auditor}</strong></div>
498
+ <div class="portada-item"><span>Fecha</span><strong>{self.meta.generated}</strong></div>
499
+ <div class="portada-item full"><span>Scope / Alcance</span><strong>{self.meta.scope or '—'}</strong></div>
500
+ </div>
501
+ </div>
502
+
503
+ <!-- RESUMEN EJECUTIVO -->
504
+ <div class="sec">
505
+ <div class="sec-title">Resumen Ejecutivo</div>
506
+ <div class="kpis">
507
+ <div class="kpi"><div class="kv">{len(self.findings)}</div><div class="kl">Total</div></div>
508
+ {kpis}
509
+ </div>
510
+ {barras}
511
+ </div>
512
+
513
+ <!-- TABLA DE HALLAZGOS -->
514
+ <div class="sec">
515
+ <div class="sec-title">Tabla de Hallazgos</div>
516
+ <table>
517
+ <tr><th>#</th><th>ID</th><th>Hallazgo</th><th>Severidad</th><th>CVSS</th><th>Activo Afectado</th></tr>
518
+ {filas}
519
+ </table>
520
+ </div>
521
+
522
+ <!-- HALLAZGOS DETALLADOS -->
523
+ <div class="sec">
524
+ <div class="sec-title">Hallazgos Detallados</div>
525
+ {detalles}
526
+ </div>
527
+
528
+ <!-- PIE -->
529
+ <div class="pie">
530
+ <span><strong>{self.BRAND_FULL}</strong> · {self.COPYRIGHT}</span>
531
+ <span>{self.CONFIDENTIAL}</span>
532
+ </div>
533
+ </div>
534
+ </body>
535
+ </html>"""
536
+ Path(path).write_text(html, encoding="utf-8")
537
+
538
+ def _barras_riesgo(self, stats: Dict[str, int]) -> str:
539
+ total = sum(stats.values()) or 1
540
+ partes = ['<div class="bars">']
541
+ for sev, count in stats.items():
542
+ if not count:
543
+ continue
544
+ pct = round(count / total * 100)
545
+ color = _LIGHT_BG.get(sev, "#888")
546
+ partes.append(
547
+ f'<div class="bar-row">'
548
+ f'<span class="badge sev-{sev.lower()}" style="width:90px;text-align:center">{sev}</span>'
549
+ f'<div class="bar-track"><div class="bar-fill" style="width:{pct}%;background:{color}"></div></div>'
550
+ f'<span class="bar-count">{count}</span>'
551
+ f'</div>'
552
+ )
553
+ partes.append('</div>')
554
+ return "\n".join(partes)
555
+
556
+ def _filas_tabla(self) -> str:
557
+ if not self.findings:
558
+ return '<tr><td colspan="6" style="text-align:center;color:#bbb;padding:18px">Sin hallazgos</td></tr>'
559
+ lineas = ""
560
+ for i, f in enumerate(self.findings, 1):
561
+ cvss_s = f"{f.cvss:.1f}" if f.cvss is not None else "—"
562
+ cve_p = f' <span style="font-size:.78em;color:#bbb">{f.cve}</span>' if f.cve else ""
563
+ lineas += (
564
+ f'<tr>'
565
+ f'<td style="color:#ccc">{i}</td>'
566
+ f'<td><code>{f.id}</code></td>'
567
+ f'<td><strong>{f.title}</strong>{cve_p}</td>'
568
+ f'<td><span class="badge sev-{f.severity.lower()}">{f.severity}</span></td>'
569
+ f'<td style="text-align:center">{cvss_s}</td>'
570
+ f'<td><code>{f.affected[:55]}</code></td>'
571
+ f'</tr>\n'
572
+ )
573
+ return lineas
574
+
575
+ def _tarjetas_hallazgo(self) -> str:
576
+ if not self.findings:
577
+ return '<p style="color:#bbb;text-align:center;padding:20px">Sin hallazgos detectados.</p>'
578
+ partes = []
579
+ for f in self.findings:
580
+ border = _LIGHT_BG.get(f.severity, "#888")
581
+ cvss_b = f'<span style="font-size:.77em;color:#aaa;margin-left:6px">CVSS {f.cvss:.1f}</span>' if f.cvss is not None else ""
582
+ cve_b = f'<span style="font-size:.77em;color:#aaa;margin-left:6px">{f.cve}</span>' if f.cve else ""
583
+ refs_h = "".join(f'<a href="{r}">{r}</a>&nbsp; ' for r in f.references)
584
+
585
+ grid_class = "card-body" if f.cve else "card-body solo"
586
+ cve_cell = (
587
+ f'<div class="fld"><div class="fld-lbl">CVE</div>'
588
+ f'<div class="fld-val">{f.cve}</div></div>'
589
+ ) if f.cve else ""
590
+
591
+ partes.append(
592
+ f'<div class="card" style="border-left:4px solid {border}">'
593
+ f'<div class="card-head">'
594
+ f' <span class="card-id">{f.id}</span>'
595
+ f' <span class="card-name">{f.title}</span>'
596
+ f' <span class="badge sev-{f.severity.lower()}">{f.severity}</span>'
597
+ f' {cvss_b}{cve_b}'
598
+ f'</div>'
599
+ f'<div class="{grid_class}">'
600
+ f' <div class="fld"><div class="fld-lbl">Activo afectado</div>'
601
+ f' <div class="fld-val"><code>{f.affected}</code></div></div>'
602
+ f' {cve_cell}'
603
+ f' <div class="fld" style="grid-column:1/-1">'
604
+ f' <div class="fld-lbl">Descripción</div>'
605
+ f' <div class="fld-val">{f.description}</div></div>'
606
+ f'</div>'
607
+ f'<div class="card-evi">'
608
+ f' <div class="fld-lbl">Evidencia técnica</div>'
609
+ f' <pre>{f.evidence}</pre>'
610
+ f'</div>'
611
+ f'<div class="card-rem">'
612
+ f' <div class="fld-lbl">Remediación</div>'
613
+ f' <div class="fld-val">{f.remediation}</div>'
614
+ f'</div>'
615
+ + (f'<div class="card-refs"><strong>Referencias:</strong> {refs_h}</div>' if refs_h else "")
616
+ + f'</div>'
617
+ )
618
+ return "\n".join(partes)
619
+
620
+ # ── PDF (requiere fpdf2) ───────────────────────────────────────────────
621
+
622
+ def to_pdf(self, path: str) -> None:
623
+ """
624
+ Genera informe PDF profesional para entrega al cliente.
625
+
626
+ Requiere: pip install fpdf2 >= 2.7.0
627
+
628
+ Estructura del PDF:
629
+ · Página 1 — portada con datos del engagement
630
+ · Página 2 — resumen ejecutivo y tabla de hallazgos
631
+ · Páginas N — un hallazgo por página con descripción,
632
+ evidencia y remediación
633
+ · Cabecera y pie corporativo en todas las páginas excepto portada
634
+
635
+ Lanza RuntimeError si fpdf2 no está instalado.
636
+ """
637
+ try:
638
+ from fpdf import FPDF # noqa: F401
639
+ except ImportError:
640
+ raise RuntimeError("fpdf2 no está instalado. Ejecuta: pip install fpdf2")
641
+
642
+ pdf = self._crear_pdf()
643
+ pdf.set_auto_page_break(auto=True, margin=22)
644
+
645
+ self._pdf_portada(pdf)
646
+ self._pdf_resumen(pdf)
647
+ for f in self.findings:
648
+ self._pdf_hallazgo(pdf, f)
649
+
650
+ pdf.output(path)
651
+
652
+ def _crear_pdf(self):
653
+ """Instancia FPDF con cabecera y pie corporativos VampSecure Labs."""
654
+ from fpdf import FPDF
655
+
656
+ meta = self.meta
657
+ brand = self.BRAND
658
+
659
+ class _PDF(FPDF):
660
+ def header(self):
661
+ if self.page_no() == 1:
662
+ return
663
+ self.set_font("Helvetica", "B", 7)
664
+ self.set_text_color(160, 160, 180)
665
+ self.set_y(7)
666
+ self.cell(0, 5, brand, align="L")
667
+ self.set_font("Helvetica", "", 7)
668
+ self.cell(0, 5, f"{meta.client} · {meta.engagement or 'Informe'}", align="R")
669
+ self.set_draw_color(220, 220, 235)
670
+ self.line(10, 13, 200, 13)
671
+ self.set_y(17)
672
+
673
+ def footer(self):
674
+ if self.page_no() == 1:
675
+ return
676
+ self.set_y(-14)
677
+ self.set_draw_color(220, 220, 235)
678
+ self.line(10, self.get_y(), 200, self.get_y())
679
+ self.ln(1)
680
+ self.set_font("Helvetica", "I", 7)
681
+ self.set_text_color(180, 180, 195)
682
+ self.cell(
683
+ 0, 5,
684
+ f"CONFIDENCIAL · Página {self.page_no()} · {meta.generated}",
685
+ align="C",
686
+ )
687
+
688
+ p = _PDF()
689
+ p.set_margins(15, 22, 15)
690
+ return p
691
+
692
+ def _pdf_portada(self, pdf) -> None:
693
+ """Renderiza la portada del PDF (página 1)."""
694
+ pdf.add_page()
695
+ # Fondo oscuro completo
696
+ pdf.set_fill_color(26, 26, 46)
697
+ pdf.rect(0, 0, 210, 297, "F")
698
+ # Banda inferior roja
699
+ pdf.set_fill_color(192, 57, 43)
700
+ pdf.rect(0, 230, 210, 67, "F")
701
+
702
+ # Logo/brand
703
+ pdf.set_y(52)
704
+ pdf.set_font("Helvetica", "B", 8)
705
+ pdf.set_text_color(160, 160, 200)
706
+ pdf.cell(0, 6, self.BRAND.upper(), align="C")
707
+ pdf.ln(3)
708
+ pdf.set_fill_color(192, 57, 43)
709
+ pdf.rect(55, pdf.get_y(), 100, 1, "F")
710
+ pdf.ln(16)
711
+
712
+ # Título
713
+ pdf.set_font("Helvetica", "B", 22)
714
+ pdf.set_text_color(255, 255, 255)
715
+ pdf.cell(0, 10, "INFORME DE AUDITORÍA", align="C")
716
+ pdf.ln(9)
717
+ pdf.set_font("Helvetica", "", 13)
718
+ pdf.set_text_color(220, 220, 220)
719
+ pdf.cell(0, 7, "DE SEGURIDAD", align="C")
720
+ pdf.ln(5)
721
+ pdf.set_font("Helvetica", "I", 10)
722
+ pdf.set_text_color(170, 170, 190)
723
+ pdf.cell(0, 7, f"{self.meta.tool} v{self.meta.tool_version}", align="C")
724
+
725
+ # Cuadro de datos del engagement
726
+ pdf.set_y(142)
727
+ pdf.set_fill_color(38, 38, 65)
728
+ pdf.rect(24, pdf.get_y(), 162, 76, "F")
729
+ pdf.set_y(pdf.get_y() + 9)
730
+ campos = [
731
+ ("Cliente", self.meta.client),
732
+ ("Engagement", self.meta.engagement or "—"),
733
+ ("Auditor", self.meta.auditor),
734
+ ("Fecha", self.meta.generated),
735
+ ("Scope", self.meta.scope or "—"),
736
+ ]
737
+ for lbl, val in campos:
738
+ pdf.set_x(34)
739
+ pdf.set_font("Helvetica", "", 7)
740
+ pdf.set_text_color(140, 140, 170)
741
+ pdf.cell(32, 5, lbl.upper(), align="L")
742
+ pdf.set_font("Helvetica", "B", 8)
743
+ pdf.set_text_color(240, 240, 255)
744
+ pdf.multi_cell(118, 5, str(val)[:65], align="L")
745
+
746
+ # CONFIDENCIAL
747
+ pdf.set_y(248)
748
+ pdf.set_font("Helvetica", "I", 7.5)
749
+ pdf.set_text_color(255, 200, 200)
750
+ pdf.cell(0, 6, self.CONFIDENTIAL, align="C")
751
+
752
+ def _pdf_resumen(self, pdf) -> None:
753
+ """Renderiza el resumen ejecutivo y la tabla de hallazgos (página 2)."""
754
+ pdf.add_page()
755
+ stats = self._stats()
756
+
757
+ # Título sección
758
+ pdf.set_y(17)
759
+ pdf.set_font("Helvetica", "B", 13)
760
+ pdf.set_text_color(26, 26, 46)
761
+ pdf.cell(0, 8, "RESUMEN EJECUTIVO", align="L")
762
+ pdf.ln(3)
763
+ pdf.set_draw_color(192, 57, 43)
764
+ pdf.line(15, pdf.get_y(), 195, pdf.get_y())
765
+ pdf.ln(8)
766
+
767
+ # Total de hallazgos
768
+ pdf.set_font("Helvetica", "", 9)
769
+ pdf.set_text_color(80, 80, 100)
770
+ pdf.cell(0, 6, f"Total de hallazgos identificados: {len(self.findings)}", align="L")
771
+ pdf.ln(9)
772
+
773
+ # Barras de distribución por severidad
774
+ max_count = max(stats.values()) or 1
775
+ for sev, count in stats.items():
776
+ if not count:
777
+ continue
778
+ r, g, b = _PDF_BG.get(sev, (150, 150, 150))
779
+ rt, gt, bt = _PDF_FG.get(sev, (255, 255, 255))
780
+ pdf.set_x(15)
781
+ pdf.set_fill_color(r, g, b)
782
+ pdf.set_text_color(rt, gt, bt)
783
+ pdf.set_font("Helvetica", "B", 7.5)
784
+ pdf.cell(26, 7, sev, fill=True, align="C")
785
+ bar_w = max(2, int(count / max_count * 130))
786
+ pdf.set_fill_color(r, g, b)
787
+ pdf.cell(bar_w, 7, "", fill=True)
788
+ pdf.set_fill_color(230, 230, 238)
789
+ pdf.cell(130 - bar_w, 7, "", fill=True)
790
+ pdf.set_text_color(80, 80, 100)
791
+ pdf.set_font("Helvetica", "B", 9)
792
+ pdf.cell(14, 7, str(count), align="R")
793
+ pdf.ln(9)
794
+
795
+ # Tabla de hallazgos
796
+ pdf.ln(6)
797
+ pdf.set_font("Helvetica", "B", 11)
798
+ pdf.set_text_color(26, 26, 46)
799
+ pdf.cell(0, 7, "Tabla de hallazgos", align="L")
800
+ pdf.ln(5)
801
+ pdf.set_draw_color(220, 220, 235)
802
+ pdf.line(15, pdf.get_y(), 195, pdf.get_y())
803
+ pdf.ln(4)
804
+
805
+ # Cabecera de tabla
806
+ pdf.set_font("Helvetica", "B", 7)
807
+ pdf.set_fill_color(240, 240, 248)
808
+ pdf.set_text_color(100, 100, 130)
809
+ for header, w in [("ID", 18), ("Hallazgo", 82), ("Severidad", 26), ("CVSS", 14), ("Activo", 55)]:
810
+ pdf.cell(w, 6, header, fill=True, border=1, align="C" if header in ("Severidad", "CVSS") else "L")
811
+ pdf.ln()
812
+
813
+ # Filas de tabla
814
+ for f in self.findings:
815
+ r, g, b = _PDF_BG.get(f.severity, (150, 150, 150))
816
+ rt, gt, bt = _PDF_FG.get(f.severity, (255, 255, 255))
817
+ cvss_s = f"{f.cvss:.1f}" if f.cvss is not None else "—"
818
+ pdf.set_font("Helvetica", "", 7)
819
+ pdf.set_fill_color(252, 252, 255)
820
+ pdf.set_text_color(60, 60, 80)
821
+ pdf.cell(18, 6, f.id[:10], border=1)
822
+ pdf.cell(82, 6, f.title[:52], border=1)
823
+ pdf.set_fill_color(r, g, b)
824
+ pdf.set_text_color(rt, gt, bt)
825
+ pdf.set_font("Helvetica", "B", 7)
826
+ pdf.cell(26, 6, f.severity, fill=True, border=1, align="C")
827
+ pdf.set_fill_color(252, 252, 255)
828
+ pdf.set_text_color(60, 60, 80)
829
+ pdf.set_font("Helvetica", "", 7)
830
+ pdf.cell(14, 6, cvss_s, border=1, align="C")
831
+ pdf.cell(55, 6, f.affected[:32], border=1)
832
+ pdf.ln()
833
+
834
+ def _pdf_hallazgo(self, pdf, f: Finding) -> None:
835
+ """Renderiza un hallazgo individual en una nueva página del PDF."""
836
+ pdf.add_page()
837
+ r, g, b = _PDF_BG.get(f.severity, (150, 150, 150))
838
+ rt, gt, bt = _PDF_FG.get(f.severity, (255, 255, 255))
839
+
840
+ # Banda de color con ID + título + severidad
841
+ pdf.set_fill_color(r, g, b)
842
+ pdf.rect(0, 10, 210, 16, "F")
843
+ pdf.set_y(14)
844
+ pdf.set_font("Helvetica", "B", 7.5)
845
+ pdf.set_text_color(rt, gt, bt)
846
+ pdf.set_x(15)
847
+ pdf.cell(22, 6, f.id, align="L")
848
+ pdf.set_font("Helvetica", "B", 10)
849
+ pdf.cell(130, 6, f.title[:60], align="L")
850
+ pdf.set_font("Helvetica", "", 7.5)
851
+ cvss_p = f"CVSS {f.cvss:.1f} " if f.cvss is not None else ""
852
+ cve_p = f.cve or ""
853
+ pdf.cell(0, 6, f"{cvss_p}{cve_p}", align="R")
854
+
855
+ pdf.set_y(30)
856
+
857
+ def seccion(titulo: str, texto: str, mono: bool = False) -> None:
858
+ """Renderiza una sección de hallazgo con título y cuerpo de texto."""
859
+ pdf.ln(3)
860
+ pdf.set_x(15)
861
+ pdf.set_font("Helvetica", "B", 7)
862
+ pdf.set_text_color(160, 160, 185)
863
+ pdf.cell(0, 5, titulo.upper(), align="L")
864
+ pdf.ln(4)
865
+ pdf.set_x(15)
866
+ if mono:
867
+ pdf.set_fill_color(240, 240, 248)
868
+ pdf.set_font("Courier", "", 7.5)
869
+ pdf.set_text_color(44, 62, 80)
870
+ pdf.multi_cell(180, 4.5, str(texto)[:900], fill=True, align="L")
871
+ else:
872
+ pdf.set_font("Helvetica", "", 9)
873
+ pdf.set_text_color(44, 62, 80)
874
+ pdf.multi_cell(180, 5.2, str(texto)[:700], align="L")
875
+
876
+ # Activo afectado (inline)
877
+ pdf.set_x(15)
878
+ pdf.set_font("Helvetica", "B", 7)
879
+ pdf.set_text_color(160, 160, 185)
880
+ pdf.cell(38, 5, "ACTIVO AFECTADO", align="L")
881
+ pdf.set_font("Courier", "", 8)
882
+ pdf.set_text_color(44, 62, 80)
883
+ pdf.cell(0, 5, str(f.affected)[:80], align="L")
884
+ pdf.ln(7)
885
+
886
+ seccion("Descripción", f.description)
887
+ seccion("Evidencia técnica", f.evidence, mono=True)
888
+ seccion("Remediación", f.remediation)
889
+
890
+ if f.references:
891
+ pdf.ln(3)
892
+ pdf.set_x(15)
893
+ pdf.set_font("Helvetica", "B", 7)
894
+ pdf.set_text_color(160, 160, 185)
895
+ pdf.cell(0, 5, "REFERENCIAS", align="L")
896
+ pdf.ln(4)
897
+ pdf.set_font("Helvetica", "", 7.5)
898
+ pdf.set_text_color(41, 128, 185)
899
+ for ref in f.references[:5]:
900
+ pdf.set_x(15)
901
+ pdf.cell(0, 4.5, str(ref)[:100], align="L")
902
+ pdf.ln()
903
+
904
+
905
+ # ---------------------------------------------------------------------------
906
+ # Función de utilidad: construir meta desde argumentos CLI
907
+ # ---------------------------------------------------------------------------
908
+
909
+ def meta_from_args(args, tool: str, version: str) -> ReportMeta:
910
+ """
911
+ Construye un ReportMeta a partir de los argumentos CLI estándar VSL.
912
+
913
+ Los argumentos esperados son: args.client, args.engagement,
914
+ args.auditor, args.scope (todos opcionales con valores por defecto).
915
+ Compatible con los grupos de argumentos añadidos por add_report_args().
916
+ """
917
+ return ReportMeta(
918
+ tool = tool,
919
+ tool_version = version,
920
+ client = getattr(args, "client", "Confidencial"),
921
+ engagement = getattr(args, "engagement", ""),
922
+ auditor = getattr(args, "auditor", "VampSecure Labs — Security Research Division"),
923
+ scope = getattr(args, "report_scope", getattr(args, "scope", "")),
924
+ )
925
+
926
+
927
+ def add_report_args(parser) -> None:
928
+ """
929
+ Añade el grupo de argumentos de informe de cliente al parser de argparse.
930
+
931
+ Añade:
932
+ --client NAME nombre del cliente (portada del informe)
933
+ --engagement REF referencia del engagement
934
+ --auditor NOMBRE nombre del auditor
935
+ --scope TEXTO descripción del alcance
936
+ --report-html FILE HTML profesional para entrega al cliente
937
+ --report-pdf FILE PDF para entrega al cliente (requiere fpdf2)
938
+
939
+ Uso:
940
+ add_report_args(parser)
941
+ args = parser.parse_args()
942
+ meta = meta_from_args(args, tool="vamp-xxx", version=VERSION)
943
+ """
944
+ grp = parser.add_argument_group(
945
+ "Informe de cliente (VampSecure Labs formato unificado)"
946
+ )
947
+ grp.add_argument("--client", metavar="NOMBRE", default="Confidencial",
948
+ help="Nombre del cliente para la portada del informe")
949
+ grp.add_argument("--engagement", metavar="REF", default="",
950
+ help="Referencia o nombre del engagement")
951
+ grp.add_argument("--auditor", metavar="NOMBRE",
952
+ default="VampSecure Labs — Security Research Division",
953
+ help="Nombre del auditor o equipo")
954
+ grp.add_argument("--report-scope", metavar="TEXTO", default="", dest="report_scope",
955
+ help="Descripción del alcance del análisis para el informe de cliente")
956
+ grp.add_argument("--report-html", metavar="FICHERO", dest="report_html",
957
+ help="Informe HTML profesional para entrega al cliente")
958
+ grp.add_argument("--report-pdf", metavar="FICHERO", dest="report_pdf",
959
+ help="Informe PDF para entrega al cliente (requiere: pip install fpdf2)")