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">● {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> ' 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)")
|