/* ============================================================ FLOWSHIELD — Blue Team (CIS Benchmark hardening) ============================================================ */ function BenchCard({ b, onClick, active }) { const ref = useRefL(null); const onMove = (e) => { const el=ref.current; if(!el)return; const r=el.getBoundingClientRect(); el.style.setProperty('--mx',((e.clientX-r.left)/r.width)*100+'%'); el.style.setProperty('--my',((e.clientY-r.top)/r.height)*100+'%'); }; return (
{b.name}
{b.target}
=80?'var(--ok)':b.score>=70?'var(--amber)':'var(--red)'} sub="" />
✓ {b.pass} ✕ {b.fail} N/A {b.na} {b.level}
); } function ControlRow({ c, onRemediate, remediated }) { const [open, setOpen] = useStateL(false); const status = remediated ? 'pass' : c.status; return (
c.remediation && setOpen(o=>!o)} style={{ display:'flex', alignItems:'center', gap:14, padding:'13px 16px', cursor: c.remediation?'pointer':'default' }}>
{c.id}
{c.title}
{c.cat} · {c.level}
{c.auto && AUTO} {c.remediation && }
{open && c.remediation && (
Remediación sugerida
$ {c.remediation}
{status==='fail' && (
onRemediate(c.id)}>Aplicar remediación
)}
)}
); } function btScoreColor(v) { return v>=80?'var(--ok)':v>=70?'var(--amber)':'var(--red)'; } function btProfLevel(title) { const t = (title||'').toLowerCase(); const m = t.match(/level\s*([12])/) || t.match(/\bl([12])\b/); if (m) return 'L'+m[1]; if (t.includes('ens')) return 'ENS'; if (t.includes('stig')) return 'STIG'; if (t.includes('pci')) return 'PCI'; return 'CIS'; } /* A real failed/error rule (from a stored scan) with its remediation. */ function RemediationRow({ f }) { const [open, setOpen] = useStateL(false); const sev = window.normSev(f.severity); return (
setOpen(o=>!o)} style={{ display:'flex', alignItems:'center', gap:14, padding:'13px 16px', cursor:'pointer' }}>
{f.title}
{f.rule_id}{f.result==='error'?' · error':''}
{open && (
Remediación
{f.remediation ? {f.remediation} : Sin script de remediación en el contenido SCAP. Abre el informe HTML para el detalle completo.}
)}
); } const SEV_BLOCKS = [['critical','Críticos'],['high','Altos'],['medium','Medios'],['low','Bajos'],['info','Informativos']]; /* Vista de histórico completo: todos los escaneos por sistema, con borrado. */ function DriftPanel({ data }) { if (data === 'loading') return
Comparando escaneos…
; if (data && data.__error) return
{data.__error}
; if (!data) return null; const d = data.score_delta; const dColor = d > 0 ? 'var(--ok)' : d < 0 ? 'var(--red)' : 'var(--txt-dim)'; const Sec = ({ title, rows, color }) => !rows.length ? null : (
{title} · {rows.length}
{rows.slice(0,12).map(r => (
{r.title}
))} {rows.length > 12 &&
+{rows.length-12} más…
}
); return (
Drift vs escaneo anterior {data.score_before}% → {data.score_after}% {d>0?'+':''}{d} pts resueltas {data.counts.fixed} nuevas {data.counts.regressed} persisten {data.counts.still_failing}
); } function HistoryView({ scans, onBack, onDelete }) { const [open, setOpen] = useStateL(null); const [cmp, setCmp] = useStateL(null); // { id, data } drift for a scan row const doCompare = async (current, base) => { if (cmp && cmp.id === current.scan_id) { setCmp(null); return; } setCmp({ id: current.scan_id, data: 'loading' }); try { const data = await window.fsApi('/api/blueteam/scans/compare?base=' + encodeURIComponent(base.scan_id) + '¤t=' + encodeURIComponent(current.scan_id), { method:'GET' }); setCmp({ id: current.scan_id, data }); } catch (e) { setCmp({ id: current.scan_id, data: { __error: e.message || 'No se pudo comparar.' } }); } }; const groups = {}; (scans||[]).forEach(s => { const key = (s.datastream||'?') + ' · ' + (s.target||'?'); if (!groups[key]) groups[key] = { key, os:s.os||s.datastream, datastream:s.datastream, target:s.target, scans:[] }; groups[key].scans.push(s); }); const systems = Object.values(groups); return (
← Volver al panel} /> {!systems.length ? ( ) : (
{systems.map(sys => { const latest = sys.scans[0]; const sc=(latest.summary&&latest.summary.score)||0; const isOpen = open===sys.key; return (
setOpen(isOpen?null:sys.key)} style={{ display:'flex', alignItems:'center', gap:14, padding:'14px 18px', cursor:'pointer' }}>
{sys.os}
{sys.target} · {sys.datastream}
{sys.scans.length} escaneo{sys.scans.length!==1?'s':''}
{isOpen && (
{sys.scans.map((s, i) => { const su=s.summary||{}; const ssc=su.score||0; const prev = sys.scans[i+1]; // older scan (list is newest-first) const canCompare = prev && s.findings_available && prev.findings_available; const showDrift = cmp && cmp.id === s.scan_id; return (
{fmtScanDate(s.ts)} {s.tailored && tailored} {su.remediated && remediado}
{s.profile_title||s.profile}
✓ {su.pass||0} ✕ {su.fail||0} N/A {su.notapplicable||0}
{ssc}%
{canCompare && doCompare(s, prev)} title="Comparar con el escaneo anterior (drift)">{showDrift?'Ocultar':'Comparar'}} s.report_available && window.open(s.report_url,'_blank')} style={{ opacity:s.report_available?1:0.4 }} title={s.report_available?'Abrir informe HTML':'Informe no disponible'}>Reporte onDelete(s.scan_id)} title="Eliminar escaneo">Borrar
{showDrift && }
);})}
)}
); })}
)}
); } function BlueTeam() { const [mode, setMode] = useStateL('panel'); const [scans, setScans] = useStateL(null); // null = cargando const [selKey, setSelKey] = useStateL(''); const [findings, setFindings] = useStateL(null); // findings reales del último scan del sistema seleccionado const [findingsBusy, setFindingsBusy] = useStateL(false); const [filter, setFilter] = useStateL('all'); // filtro (sistemas reales) const [fixed, setFixed] = useStateL([]); // controles remediados (sistemas de ejemplo) const [dfilter, setDfilter] = useStateL('all'); // filtro (sistemas de ejemplo) // Cargar el historial real (panel e histórico); se refresca al volver del scan. useEffectL(() => { if (mode === 'scan') return; let alive = true; window.fsApi('/api/blueteam/scans', { method:'GET' }) .then(d => { if(alive) setScans(d.scans || []); }) .catch(()=>{ if(alive) setScans([]); }); return () => { alive = false; }; }, [mode]); // Sistemas REALES: agrupar el historial por sistema (DataStream + objetivo). const groups = {}; (scans||[]).forEach(s => { const key = (s.datastream||'?') + ' · ' + (s.target||'?'); if (!groups[key]) groups[key] = { key, os:s.os||s.datastream, datastream:s.datastream, target:s.target, scans:[] }; groups[key].scans.push(s); }); const realSystems = Object.values(groups).slice(0, 8).map(sys => { const latest = sys.scans[0]; const su = latest.summary || {}; return { ...sys, _demo:false, latest, icon:'server', name:sys.os, target:(sys.target||'')+' · '+(sys.datastream||''), score:su.score||0, pass:su.pass||0, fail:su.fail||0, na:su.notapplicable||0, level:btProfLevel(latest.profile_title||latest.profile) }; }); // Sistemas de EJEMPLO (variedad: Ubuntu/Windows/Cisco…) desde el catálogo demo. const demoSystems = (window.CIS_BENCHMARKS||[]).map(b => ({ ...b, _demo:true, key:'demo:'+b.id })); const systems = [...realSystems, ...demoSystems].slice(0, 9); // máx 9 tarjetas → cuadrícula 3×3 const sel = systems.find(s=>s.key===selKey) || systems[0] || null; // Borrar un escaneo del histórico (usado desde el dashboard y desde el histórico). const delScan = async (sid) => { if (!sid || !window.confirm('¿Eliminar este escaneo del histórico? También se borrará su informe.')) return; try { await window.fsApi('/api/blueteam/scans/'+encodeURIComponent(sid), { method:'DELETE' }); setScans(xs => (xs||[]).filter(s => s.scan_id !== sid)); } catch(e) { /* el escaneo desaparece igualmente al refrescar */ } }; // Al cambiar de tarjeta, resetear el estado de remediación de ejemplo. useEffectL(() => { setFixed([]); setDfilter('all'); }, [sel && sel.key]); // Sistemas reales: cargar (bajo demanda) las reglas fallidas del último escaneo. const selScanId = (sel && !sel._demo && sel.latest) ? sel.latest.scan_id : ''; useEffectL(() => { setFindings(null); setFilter('all'); if (!sel || sel._demo || !sel.latest) return; if (!sel.latest.findings_available) { setFindings([]); return; } let alive = true; setFindingsBusy(true); window.fsApi('/api/blueteam/scans/'+encodeURIComponent(selScanId)+'/findings', { method:'GET' }) .then(d => { if(alive) setFindings(d.findings || []); }) .catch(()=>{ if(alive) setFindings([]); }) .finally(()=>{ if(alive) setFindingsBusy(false); }); return () => { alive = false; }; }, [sel && sel.key, selScanId]); if (mode === 'advisor') { return (
); } if (mode === 'scan') return setMode('panel')} />; if (mode === 'history') return setMode('panel')} onDelete={delScan} />; // Posture global (datos reales). const totalScans = (scans||[]).length; const avgScore = realSystems.length ? Math.round(realSystems.reduce((a,s)=>a+(s.score||0),0)/realSystems.length) : 0; const totalFail = realSystems.reduce((a,s)=>a+(s.fail||0),0); // ----- helpers de detalle por tipo de sistema ------------------------- const remediate = (id) => setFixed(f => [...f, id]); const demoControls = (window.CIS_CONTROLS||[]).filter(c => { const st = fixed.includes(c.id) ? 'pass' : c.status; if (dfilter==='fail') return st==='fail'; if (dfilter==='pass') return st==='pass'; return true; }); const demoAutoFail = (window.CIS_CONTROLS||[]).filter(c=>c.auto&&c.status==='fail'&&!fixed.includes(c.id)); const demoFailCount = (window.CIS_CONTROLS||[]).filter(c => c.status==='fail' && !fixed.includes(c.id)).length; const demoEff = sel && sel._demo ? Math.min(99, (sel.score||0) + fixed.length*2) : 0; // Agrupar controles demo por bloque/servicio (categoría). const demoBlocks = {}; demoControls.forEach(c => { (demoBlocks[c.cat] = demoBlocks[c.cat] || []).push(c); }); // Sistemas reales: reglas fallidas agrupadas por severidad (bloques). const fl = findings || []; return (
setMode('history')}>HistóricosetMode('scan')} glitch>Nuevo scan} /> {scans === null ? (
Cargando escaneos…
) : (<> {/* posture summary (reales) */}
{/* tarjetas: reales + ejemplos */}
Sistemas y benchmarks · {systems.filter(s=>!s._demo).length} reales · {systems.filter(s=>s._demo).length} de ejemplo
{systems.map(b => setSelKey(b.key)} />)}
{/* detalle del sistema seleccionado */} {sel && (
{sel.name} {sel._demo && EJEMPLO}
{sel._demo ? `${sel.target} · ${sel.total} controles · perfil ${sel.level}` : `${sel.target} · ${sel.scans.length} escaneo${sel.scans.length!==1?'s':''}`}
{sel._demo?demoEff:sel.score}%
{sel._demo?'tras remediación':'último · '+fmtScanDate(sel.latest.ts)}
{sel._demo ? (
{[['all','Todos'],['fail','Fallidos'],['pass','OK']].map(([k,l])=>( ))}
) : ( sel.latest.report_available && window.open(sel.latest.report_url,'_blank')} style={{ opacity: sel.latest.report_available?1:0.4 }} title={sel.latest.report_available?'Abrir informe HTML del último escaneo':'Informe no disponible (re-lanza el escaneo)'}>Último informe )}
{sel._demo ? ( /* ---- sistema de EJEMPLO: controles por bloque + remediación interactiva ---- */
{demoFailCount>0 && dfilter!=='pass' && (
{demoAutoFail.length} controles se pueden remediar automáticamente sin reinicio. setFixed(f=>[...new Set([...f, ...demoAutoFail.map(c=>c.id)])])}>Remediar todo
)} {Object.keys(demoBlocks).length ? Object.keys(demoBlocks).map(cat => (
{cat} · {demoBlocks[cat].length}
{demoBlocks[cat].map(c => )}
)) : }
) : (<> {/* ---- sistema REAL: historial + remediación por bloques de severidad ---- */}
Historial
{sel.scans.map(s => { const su=s.summary||{}; const ssc=su.score||0; return (
{fmtScanDate(s.ts)} {s.tailored && tailored} {su.remediated && remediado}
{s.profile_title||s.profile}
✓ {su.pass||0} ✕ {su.fail||0} N/A {su.notapplicable||0}
{ssc}%
s.report_available && window.open(s.report_url,'_blank')} style={{ opacity:s.report_available?1:0.4 }} title={s.report_available?'Abrir informe HTML':'Informe no disponible (re-lanza el escaneo)'}>Reporte delScan(s.scan_id)} title="Eliminar escaneo">Borrar
);})}
Remediación · último escaneo (por bloques de severidad)
{(findingsBusy || findings===null) ? (
Cargando remediación…
) : !sel.latest.findings_available ? (
Este escaneo se hizo antes de que se guardara el detalle por regla. Abre el informe HTML para ver la remediación, o vuelve a lanzarlo para tenerla aquí.
) : !fl.length ? ( ) : ( SEV_BLOCKS.map(([sv,label]) => { const items = fl.filter(f => window.normSev(f.severity) === window.normSev(sv)); if (!items.length) return null; return (
{label} · {items.length}
{items.map((f,i) => )}
); }) )}
)}
)} )}
); } function AuditStream() { const [lines, setLines] = useStateL([]); const ref = useRefL(null); useEffectL(() => { const seq = [ { t:'> cis-cat-engine · cargando benchmark', c:'acc' }, { t:'[*] comprobando sistema de ficheros … OK', c:'' }, { t:'[*] comprobando servicios … 2 desviaciones', c:'warn' }, { t:'[*] comprobando configuración SSH …', c:'' }, { t:'[!] 5.2.4 PermitRootLogin = yes (FAIL)', c:'err' }, { t:'[*] comprobando firewall … FAIL', c:'err' }, { t:'[*] comprobando auditoría (auditd) … OK', c:'ok' }, { t:'> evaluación completada · 170 desviaciones', c:'ok' }, ]; let i=0; const tick=()=>{ const cur=seq[i]; setLines(l=>[...l,cur]); i++; if(i{ if(ref.current) ref.current.scrollTop=ref.current.scrollHeight; }); return
{lines.map((l,i)=>l&&
{l.t}
)}
; } /* ============================================================ Historial de escaneos reales — agrupado por sistema (DataStream + objetivo). Cada entrada abre su informe HTML. Se monta al volver al panel, así que refleja el último escaneo lanzado. ============================================================ */ function fmtScanDate(iso) { try { return new Date(iso).toLocaleString('es-ES', { dateStyle:'medium', timeStyle:'short' }); } catch { return iso || '—'; } } function ScanHistory() { const [scans, setScans] = useStateL(null); // null = cargando const [open, setOpen] = useStateL(null); // clave del sistema desplegado useEffectL(() => { let alive = true; window.fsApi('/api/blueteam/scans', { method:'GET' }) .then(d => { if(alive) setScans(d.scans || []); }) .catch(()=>{ if(alive) setScans([]); }); return () => { alive = false; }; }, []); const scoreColor = (v) => v>=80?'var(--ok)':v>=70?'var(--amber)':'var(--red)'; if (scans === null) { return (<>
Historial de escaneos
Cargando historial…
); } // Agrupar por sistema: DataStream + objetivo escaneado. const groups = {}; scans.forEach(s => { const key = (s.datastream||'?') + ' · ' + (s.target||'?'); if (!groups[key]) groups[key] = { key, os:s.os||s.datastream, datastream:s.datastream, target:s.target, scans:[] }; groups[key].scans.push(s); }); const systems = Object.values(groups); return ( <>
Historial de escaneos
{!systems.length ? ( ) : (
{systems.map(sys => { const latest = sys.scans[0]; const sc = (latest.summary && latest.summary.score) || 0; const isOpen = open === sys.key; return (
setOpen(isOpen?null:sys.key)} style={{ display:'flex', alignItems:'center', gap:14, padding:'14px 18px', cursor:'pointer' }}>
{sys.os}
{sys.target} · {sys.datastream}
{sys.scans.length} escaneo{sys.scans.length!==1?'s':''}
{isOpen && (
{sys.scans.map(s => { const su = s.summary || {}; const ssc = su.score || 0; return (
{fmtScanDate(s.ts)} {s.tailored && tailored} {su.remediated && remediado}
{s.profile_title || s.profile}
✓ {su.pass||0} ✕ {su.fail||0} N/A {su.notapplicable||0}
{ssc}%
s.report_available && window.open(s.report_url,'_blank')} style={{ opacity:s.report_available?1:0.4 }} title={s.report_available?'Abrir informe HTML':'Informe no disponible (re-lanza el escaneo)'}>Reporte
); })}
)}
); })}
)} ); } /* ============================================================ CIS Benchmark — escaneo de hardening dinámico ============================================================ */ const SCAP_PROFILES = [ 'Perfil común para sistemas de propósito general (74 reglas)', 'CIS Benchmark — Nivel 1 · Servidor (186 reglas)', 'CIS Benchmark — Nivel 2 · Servidor (262 reglas)', 'CIS Benchmark — Nivel 1 · Estación de trabajo (142 reglas)', 'ENS · Categoría ALTA (211 reglas)', 'PCI-DSS v4.0 (96 reglas)', 'STIG · DISA (318 reglas)', ]; const SCAP_CONTENT = [ 'ssg-ubuntu2204-ds.xml — Guía de configuración segura de Ubuntu 22.04', 'ssg-rhel9-ds.xml — Guía de configuración segura de RHEL 9', 'ssg-windows2022-ds.xml — Guía de configuración segura de Windows Server 2022', 'ssg-debian12-ds.xml — Guía de configuración segura de Debian 12', ]; const SCAP_RULES = [ { id:'r1', cat:'Software', sev:'high', t:'gpgcheck habilitado en la configuración principal de paquetes', d:'Garantiza que el gestor de paquetes verifica las firmas GPG de todos los paquetes instalados.', rem:'echo "gpgcheck=1" >> /etc/dnf/dnf.conf' }, { id:'r2', cat:'Software', sev:'high', t:'gpgcheck habilitado en todos los repositorios', d:'Cada repositorio debe forzar la verificación de firmas.', rem:'sed -i "s/gpgcheck=0/gpgcheck=1/g" /etc/yum.repos.d/*.repo' }, { id:'r3', cat:'Software', sev:'low', t:'Deshabilitar prelinking', d:'El prelinking interfiere con AIDE y la verificación de integridad.', rem:'apt purge prelink -y' }, { id:'r4', cat:'Integridad', sev:'med', t:'Construir y probar la base de datos AIDE', d:'AIDE debe estar instalado e inicializado para detectar cambios no autorizados.', rem:'aideinit && cp /var/lib/aide/aide.db.new /var/lib/aide/aide.db' }, { id:'r5', cat:'Integridad', sev:'med', t:'Verificar y corregir permisos de ficheros con el gestor de paquetes', d:'Los permisos deben coincidir con los definidos por los paquetes.', rem:'rpm -Va --noconfig | awk \'$1 ~ /..5/\'' }, { id:'r6', cat:'Kernel', sev:'high', t:'Deshabilitar soporte de kernel para USB vía bootloader', d:'Impide el montaje de dispositivos de almacenamiento USB no autorizados.', rem:'GRUB_CMDLINE_LINUX="nousb"' }, { id:'r7', cat:'Permisos', sev:'med', t:'Permisos restrictivos en librerías compartidas', d:'/lib, /lib64 y /usr/lib deben tener permisos 0755 o más restrictivos.', rem:'find /lib /usr/lib -perm /022 -exec chmod go-w {} +' }, { id:'r8', cat:'Permisos', sev:'med', t:'Propiedad root en librerías compartidas', d:'Las librerías del sistema deben pertenecer a root.', rem:'chown root:root /lib /usr/lib -R' }, { id:'r9', cat:'Permisos', sev:'med', t:'Permisos restrictivos en ejecutables del sistema', d:'Los binarios en /bin, /sbin, /usr/bin deben ser no-escribibles por otros.', rem:'find /bin /sbin /usr/bin -perm /022 -exec chmod go-w {} +' }, { id:'r10', cat:'Cuentas', sev:'crit', t:'Login directo de root no permitido (SSH)', d:'PermitRootLogin debe estar a "no" para forzar el uso de cuentas nominales.', rem:'sed -i "s/^#*PermitRootLogin.*/PermitRootLogin no/" /etc/ssh/sshd_config' }, { id:'r11', cat:'Cuentas', sev:'high', t:'Root login restringido en consolas virtuales', d:'/etc/securetty no debe permitir login de root en TTYs.', rem:': > /etc/securetty' }, { id:'r12', cat:'Cuentas', sev:'crit', t:'Sólo root tiene UID 0', d:'Ninguna otra cuenta debe tener UID 0.', rem:'awk -F: \'($3==0){print}\' /etc/passwd' }, { id:'r13', cat:'Cuentas', sev:'crit', t:'Imposible iniciar sesión con contraseña vacía', d:'Ninguna cuenta debe tener una contraseña vacía.', rem:'passwd -l ' }, { id:'r14', cat:'Cuentas', sev:'high', t:'Hashes de contraseña en /etc/shadow', d:'Los hashes no deben almacenarse en /etc/passwd.', rem:'pwconv' }, { id:'r15', cat:'Contraseñas', sev:'med', t:'Longitud mínima de contraseña ≥ 14', d:'Política de complejidad mediante pam_pwquality.', rem:'minlen = 14 en /etc/security/pwquality.conf' }, { id:'r16', cat:'Contraseñas', sev:'low', t:'Edad mínima de contraseña ≥ 1 día', d:'Evita cambios de contraseña en cascada.', rem:'PASS_MIN_DAYS 1 en /etc/login.defs' }, { id:'r17', cat:'Contraseñas', sev:'low', t:'Edad máxima de contraseña ≤ 365 días', d:'Fuerza la rotación periódica de credenciales.', rem:'PASS_MAX_DAYS 365 en /etc/login.defs' }, { id:'r18', cat:'Auditoría', sev:'med', t:'Notificación de último acceso al iniciar sesión', d:'Muestra el último acceso para detectar usos no autorizados.', rem:'session required pam_lastlog.so showfailed' }, { id:'r19', cat:'Red', sev:'crit', t:'Verificar que el firewall está habilitado', d:'El cortafuegos del sistema debe estar activo con política deny por defecto.', rem:'ufw enable && ufw default deny incoming' }, { id:'r20', cat:'Red', sev:'high', t:'Deshabilitar reenvío de paquetes IP', d:'net.ipv4.ip_forward debe estar a 0 salvo en routers.', rem:'sysctl -w net.ipv4.ip_forward=0' }, ]; function ScapRule({ r, checked, onToggle }) { const [open, setOpen] = useStateL(false); return (
setOpen(o=>!o)}> e.stopPropagation()} className="scap-chk" /> {r.t}
{open && (
{r.d}
Categoría · {r.cat} · Remediación
$ {r.rem}
)}
); } function ScapWorkbench({ bench, onBack }) { // Live metadata from the backend (real DataStreams / profiles / rules). const [datastreams, setDatastreams] = useStateL([]); const [content, setContent] = useStateL(''); // datastream id const [profiles, setProfiles] = useStateL([]); const [profile, setProfile] = useStateL(''); // profile id const [ruleList, setRuleList] = useStateL([]); // rules of the profile const [loadingMeta, setLoadingMeta] = useStateL(false); const [target, setTarget] = useStateL('local'); const [host, setHost] = useStateL(''); const [port, setPort] = useStateL('22'); const [authMethod, setAuthMethod] = useStateL('password'); // password | key const [password, setPassword] = useStateL(''); const [sshKey, setSshKey] = useStateL(''); const [sel, setSel] = useStateL([]); const [opts, setOpts] = useStateL({ fetch:false, remediate:false }); const [phase, setPhase] = useStateL('idle'); // idle | running | done | error const [prog, setProg] = useStateL(0); const [results, setResults] = useStateL(null); const [reporting, setReporting] = useStateL(false); const [err, setErr] = useStateL(''); const [q, setQ] = useStateL(''); const [templates, setTemplates] = useStateL([]); // saved tailoring templates const [tplId, setTplId] = useStateL(''); // active template id ('' = none) const [metaBusy, setMetaBusy] = useStateL(''); // '' | 'upload' | 'save' const fileRef = useRefL(null); const tplFileRef = useRefL(null); // hidden input for ingesting template files const pendingSelRef = useRefL(null); // template rules awaiting a profile reload const pendingProfileRef = useRefL(null); // profile to force-pick after a DataStream switch const pendingTplIdRef = useRefL(null); // server template id to mark active after rules reload // 1) Load available DataStreams once. Retries a few times so a cold-start // race (first /api hit while the backend is still warming up) doesn't // leave the picker stuck on "Cargando contenido…" with no recovery. useEffectL(() => { let alive = true; const load = (tries) => { window.fsApi('/api/blueteam/datastreams', { method:'GET' }) .then(d => { if(!alive) return; const list = d.datastreams || []; setDatastreams(list); const def = list.find(x=>x.os==='debian12') || list[0]; if (def) setContent(def.id); }) .catch(()=>{ if(alive && tries>0) setTimeout(()=>load(tries-1), 1500); }); }; load(4); return () => { alive = false; }; }, []); // 2) When the DataStream changes, load its profiles. useEffectL(() => { if (!content) return; let alive = true; setLoadingMeta(true); setProfiles([]); setProfile(''); setRuleList([]); setSel([]); window.fsApi('/api/blueteam/datastreams/'+encodeURIComponent(content)+'/profiles', { method:'GET' }) .then(d => { if(!alive) return; const ps = d.profiles || []; setProfiles(ps); const want = pendingProfileRef.current; pendingProfileRef.current = null; const def = (want && ps.find(p=>p.id===want)) ? ps.find(p=>p.id===want) : (ps.find(p=>/_standard$/i.test(p.id)) || ps.find(p=>/high/i.test(p.id)) || ps[0]); if (def) setProfile(def.id); }) .catch(()=>{}).finally(()=>{ if(alive) setLoadingMeta(false); }); return () => { alive = false; }; }, [content]); // 3) When the profile changes, load the rules it selects. useEffectL(() => { if (!content || !profile) return; let alive = true; window.fsApi('/api/blueteam/datastreams/'+encodeURIComponent(content)+'/rules?profile='+encodeURIComponent(profile), { method:'GET' }) .then(d => { if(!alive) return; const rs = (d.rules||[]).map(r => ({ id:r.id, t:r.title, cat:r.category||'SCAP', sev:window.normSev(r.severity), d:r.description||'', rem:r.remediation||'' })); setRuleList(rs); if (pendingSelRef.current) { // applying a saved/ingested template setSel(pendingSelRef.current); pendingSelRef.current = null; if (pendingTplIdRef.current) { setTplId(pendingTplIdRef.current); pendingTplIdRef.current = null; } } else { // plain profile (re)load setTplId(''); setSel(rs.map(r=>r.id)); } }) .catch(()=>{}); return () => { alive = false; }; }, [content, profile]); // 3b) Saved tailoring templates available for this DataStream. useEffectL(() => { if (!content) { setTemplates([]); return; } let alive = true; window.fsApi('/api/blueteam/templates?datastream='+encodeURIComponent(content), { method:'GET' }) .then(d => { if(alive) setTemplates(d.templates||[]); }) .catch(()=>{ if(alive) setTemplates([]); }); return () => { alive = false; }; }, [content]); const toggle = (id) => { setTplId(''); setSel(s => s.includes(id) ? s.filter(x=>x!==id) : [...s, id]); }; const allSel = ruleList.length>0 && sel.length === ruleList.length; const rules = q ? ruleList.filter(r => (r.t+r.cat).toLowerCase().includes(q.toLowerCase())) : ruleList; // Apply a saved template: select its profile + exact rule subset. const applyTemplateObj = (t) => { if (!t) { setTplId(''); return; } setTplId(t.id); const want = t.selected_rules || []; if (t.base_profile && t.base_profile !== profile) { pendingSelRef.current = want; // effect 3 will apply it after the rules reload setProfile(t.base_profile); } else { setSel(want); } }; const applyTemplate = (id) => applyTemplateObj(templates.find(x=>x.id===id)); const onCustomChange = (v) => { if (v === '__new__') { saveTemplate(); return; } // keep current selection in the dropdown if (!v) { setTplId(''); setSel(ruleList.map(r=>r.id)); return; } applyTemplate(v); }; // Upload a custom SCAP DataStream (new OS) and select it. const uploadXml = async (file) => { if (!file) return; setMetaBusy('upload'); setErr(''); try { const fd = new FormData(); fd.append('file', file); const entry = await window.fsApiForm('/api/blueteam/datastreams/upload', fd); const list = await window.fsApi('/api/blueteam/datastreams', { method:'GET' }); setDatastreams(list.datastreams || []); if (entry && entry.id) setContent(entry.id); } catch(e) { setErr(e && e.message ? e.message : 'No se pudo subir el contenido SCAP.'); setPhase('error'); } finally { setMetaBusy(''); if (fileRef.current) fileRef.current.value=''; } }; // Export the current rule selection to a portable template FILE. Uses the // browser's native "Save As" window (File System Access API) to choose the // filename — no in-app modal/prompt. Falls back to a download on browsers // without the API. The saved file can later be re-imported with "Ingestar". const saveTemplate = async () => { if (!content || !profile) return; if (!sel.length) { setErr('Selecciona al menos una regla para guardar la plantilla.'); setPhase('error'); return; } const ds = datastreams.find(d=>d.id===content); const suggested = ('flowshield-' + (ds ? (ds.os||ds.id) : content) + '-' + sel.length + 'reglas.json') .toLowerCase().replace(/[^a-z0-9.-]+/g,'-'); const makeDoc = (name) => JSON.stringify({ kind:'flowshield-scap-template', version:1, name, datastream:content, base_profile:profile, selected_rules:[...sel], count:sel.length, saved_at:new Date().toISOString(), }, null, 2); setMetaBusy('save'); setErr(''); try { if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: suggested, types: [{ description:'Plantilla FlowShield', accept:{ 'application/json':['.json'] } }], }); const name = handle.name.replace(/\.json$/i,''); const w = await handle.createWritable(); await w.write(makeDoc(name)); await w.close(); } else { const name = suggested.replace(/\.json$/i,''); const blob = new Blob([makeDoc(name)], { type:'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = suggested; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } } catch(e) { if (!(e && e.name === 'AbortError')) { // ignore: user closed the save dialog setErr('No se pudo guardar la plantilla: ' + (e && e.message ? e.message : e)); setPhase('error'); } } finally { setMetaBusy(''); } }; // Ingest a previously-saved template file: register it server-side (so it // gets a real tailoring profile) and select it, switching DataStream/profile // if the template targets a different one than the current view. const ingestTemplate = async (file) => { if (!file) return; setMetaBusy('ingest'); setErr(''); try { const doc = JSON.parse(await file.text()); const dsId = doc.datastream, bp = doc.base_profile; const rules = Array.isArray(doc.selected_rules) ? doc.selected_rules : []; if (!dsId || !bp || !rules.length) throw new Error('el fichero no es una plantilla válida de FlowShield.'); if (!datastreams.find(d=>d.id===dsId)) throw new Error('el DataStream de la plantilla («'+dsId+'») no está disponible aquí. Súbelo antes con «Subir XML».'); const name = (doc.name || file.name.replace(/\.json$/i,'') || 'Plantilla importada').toString().slice(0,80); const tpl = await window.fsApi('/api/blueteam/templates', { method:'POST', body:{ name, datastream:dsId, base_profile:bp, selected_rules:rules } }); if (dsId === content) { const d = await window.fsApi('/api/blueteam/templates?datastream='+encodeURIComponent(dsId), { method:'GET' }); setTemplates(d.templates || []); applyTemplateObj({ id:tpl.id, base_profile:bp, selected_rules:rules }); } else { pendingProfileRef.current = bp; // effect 2 will pick this profile after the DS switch pendingSelRef.current = rules; // effect 3 will apply this rule subset pendingTplIdRef.current = tpl.id; // …and mark the template active setContent(dsId); } } catch(e) { setErr('No se pudo ingestar la plantilla: ' + (e && e.message ? e.message : e)); setPhase('error'); } finally { setMetaBusy(''); if (tplFileRef.current) tplFileRef.current.value=''; } }; const scan = async () => { if (!content || !profile || phase==='running') return; if (target==='remote' && !host.trim()) { setErr('Indica el host remoto (usuario@host).'); setPhase('error'); return; } setPhase('running'); setProg(6); setResults(null); setErr(''); const timer = setInterval(()=> setProg(p => Math.min(p + Math.random()*5, 92)), 450); try { // Tailoring: a saved template wins; otherwise send an ad-hoc subset only // when the user actually narrowed the profile's rule set. const subset = sel.length > 0 && sel.length < ruleList.length; const body = { datastream: content, profile, target, host: target==='remote' ? host.trim() : null, port: parseInt(port,10) || 22, username: null, password: (target==='remote' && authMethod==='password') ? password : null, ssh_key: (target==='remote' && authMethod==='key') ? sshKey : null, fetch_remote_resources: !!opts.fetch, remediate: !!opts.remediate, template_id: tplId || null, selected_rules: (!tplId && subset) ? sel : null, }; const r = await window.fsApi('/api/blueteam/scan', { method:'POST', body }); const s = r.summary || {}; const total = s.selected_total || ((s.pass||0)+(s.fail||0)+(s.notapplicable||0)+(s.error||0)); setResults({ pass:s.pass||0, fail:s.fail||0, notapp:s.notapplicable||0, error:s.error||0, score:s.score||0, total, scan_id:r.scan_id, report_url:r.report_url, arf_url:r.arf_url, findings:r.findings||[], profile_title:r.profile_title, target:r.target }); setProg(100); setPhase('done'); } catch(e) { setErr(e && e.message ? e.message : 'Error ejecutando el escaneo.'); setPhase('error'); setProg(0); } finally { clearInterval(timer); } }; const downloadReport = async () => { if (!results || reporting) return; setReporting(true); try { await window.fsDownloadPdf('/api/report/blueteam', results); } catch (e) { setErr('No se pudo generar el informe: ' + (e && e.message ? e.message : e)); } finally { setReporting(false); } }; return (
{/* config grid */}
Contenido
uploadXml(e.target.files && e.target.files[0])} /> fileRef.current && fileRef.current.click()} style={{ flexShrink:0 }}>{metaBusy==='upload'?'Subiendo…':'Subir XML'} {(datastreams.find(d=>d.id===content)||{}).label || (bench && bench.name) || 'Selecciona un DataStream'}
Personalización
ingestTemplate(e.target.files && e.target.files[0])} /> tplFileRef.current && tplFileRef.current.click()} style={{ flexShrink:0 }}>{metaBusy==='ingest'?'Ingestando…':'Ingestar plantilla'}
Perfil
Objetivo
setTarget('local')} label="Máquina local" /> setTarget('remote')} label="Máquina remota (vía SSH)" />
Usuario y host
setHost(e.target.value)} placeholder="usuario@host · p.ej. root@10.0.4.21" />
setPort(e.target.value)} placeholder="Puerto" style={{ textAlign:'center' }} />
{target==='remote' && <> Autenticación
setAuthMethod('password')} label="Contraseña" /> setAuthMethod('key')} label="Clave SSH" />
{authMethod==='password'?'Contraseña':'Clave privada'} {authMethod==='password' ? setPassword(e.target.value)} placeholder="Contraseña SSH del objetivo" autoComplete="new-password" /> :