/* ============================================================
FLOWSHIELD — Blue Team (CIS Benchmark hardening)
============================================================ */
function BenchCard({ b, onClick, onOpen, 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._demo && EJEMPLO }
{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 &&
}
);})}
)}
);
})}
)}
);
}
/* Quesito (donut) pass/fail/N-A de una sección/capa, con el score al centro. */
function ScoreDonut({ counts, size = 104 }) {
const na = (counts.notapplicable||0)+(counts.notchecked||0)+(counts.unknown||0);
const fail = (counts.fail||0)+(counts.error||0);
const segs = [['pass', counts.pass||0, 'var(--ok)'], ['fail', fail, 'var(--red)'],
['na', na, 'var(--txt-mute)']];
const total = segs.reduce((s,x)=>s+x[1],0) || 1;
const checked = (counts.pass||0)+fail;
const score = checked ? Math.round(100*(counts.pass||0)/checked) : 0;
const thick = 12, r = (size-thick)/2, c = 2*Math.PI*r;
let off = 0;
return (
{segs.map(([k,val,col]) => {
if (!val) return null;
const len = (val/total)*c;
const seg = ;
off += len; return seg;
})}
);
}
/* Fila de control result-aware (pass/fail/N-A) expandible con descripción y
remediación. Sirve para el detalle real y para los ejemplos (misma forma). */
function AnalysisControlRow({ c }) {
const [open, setOpen] = useStateL(false);
const res = c.result;
const isPass = res === 'pass';
const isFail = res === 'fail' || res === 'error';
const col = isPass ? 'var(--ok)' : isFail ? 'var(--red)' : 'var(--txt-mute)';
const sym = isPass ? '✓' : isFail ? '✕' : '–';
const expandable = !!(c.description || c.remediation);
return (
expandable && setOpen(o=>!o)} style={{ display:'flex', alignItems:'center', gap:14, padding:'12px 16px', cursor: expandable?'pointer':'default' }}>
{sym}
{c.title}
{c.id}{!isPass && !isFail ? ' · '+res : ''}
{expandable &&
}
{open && expandable && (
{c.description && <>
Descripción
{c.description}
>}
{c.remediation && <>
Remediación
{c.remediation}
>}
)}
);
}
/* Construye secciones a partir de los controles de muestra de un sistema demo. */
function _demoSections(controls) {
const secs = {}; const order = [];
(controls||[]).forEach(c => {
const key = c.cat || 'General';
if (!secs[key]) { secs[key] = { key, title:key, counts:{pass:0,fail:0,error:0,notapplicable:0,notchecked:0,unknown:0}, controls:[] }; order.push(key); }
const res = c.status === 'pass' ? 'pass' : 'fail';
secs[key].counts[res]++;
secs[key].controls.push({ id:c.id, title:c.title, severity:c.sev, result:res,
description:c.description||'', remediation:c.remediation||'' });
});
return order.map(k => { const s=secs[k]; const chk=s.counts.pass+s.counts.fail;
s.score = chk ? Math.round(100*s.counts.pass/chk) : 0; s.total = s.controls.length; return s; });
}
/* Control segmentado (píldora) coherente con la estética FlowShield: fondo
hundido, segmento activo en azul-soft con su contador. Sustituye a los .seg
sueltos que no encajaban con el resto de la app. */
function Segmented({ value, onChange, options }) {
return (
{options.map(o => {
const active = value === o.k;
return (
onChange(o.k)} style={{
display:'inline-flex', alignItems:'center', gap:8, padding:'7px 14px',
border:'1px solid '+(active?'color-mix(in srgb,var(--blue) 45%,transparent)':'transparent'),
borderRadius:7, cursor:'pointer', transition:'all .16s',
background: active?'var(--blue-soft)':'transparent',
color: active?'var(--blue)':'var(--txt-dim)',
fontFamily:'var(--font-display)', fontWeight:600, fontSize:12.5, letterSpacing:'.01em' }}>
{o.dot && }
{o.label}
{o.count!=null && {o.count} }
);
})}
);
}
/* Radar (polígono) del perfil de seguridad de la máquina: un eje por sección/
capa (kernel, servicios, ssh, auditoría…), radio = score de esa capa. Da la
lectura tipo "pentágono" de puntos fuertes y débiles de un vistazo. */
function SectionRadar({ sections, size = 300 }) {
const axes = (sections||[]).filter(s => (s.total||0) > 0);
const n = axes.length;
if (n < 3) return null;
const cx = size/2, cy = size/2, R = size/2 - 54;
const rings = [0.25, 0.5, 0.75, 1];
const at = (i, frac) => {
const a = -Math.PI/2 + i*2*Math.PI/n;
return [cx + R*frac*Math.cos(a), cy + R*frac*Math.sin(a)];
};
const ringPoly = (frac) => axes.map((_,i)=>at(i,frac).join(',')).join(' ');
const dataPts = axes.map((s,i)=>at(i, Math.max(0.02,(s.score||0)/100)));
const dataPoly = dataPts.map(p=>p.join(',')).join(' ');
const short = (t) => { t=t||''; return t.length>15 ? t.slice(0,14)+'…' : t; };
return (
{rings.map((f,ri)=>(
))}
{axes.map((_,i)=>{ const [x,y]=at(i,1); return (
);})}
{axes.map((s,i)=>{ const [x,y]=dataPts[i]; return (
);})}
{axes.map((s,i)=>{ const [x,y]=at(i,1.16);
const a=-Math.PI/2 + i*2*Math.PI/n; const cos=Math.cos(a);
const anchor = cos>0.3?'start':cos<-0.3?'end':'middle';
return (
{short(s.title)}
{Math.round(s.score||0)}%
);
})}
);
}
/* Fila de ranking de capa (punto fuerte / prioridad de mejora). */
function RankRow({ s, kind }) {
const fails = (s.counts.fail||0)+(s.counts.error||0);
const col = btScoreColor(s.score||0);
return (
{s.title}
{kind==='bad' && fails>0 && ✕{fails} }
{Math.round(s.score||0)}%
);
}
/* Sub-vista de análisis profundo de un benchmark: dashboard por capa (radar +
score + prioridades) y todos los controles evaluados, agrupados y expandibles. */
function CisAnalysis({ sel, onBack }) {
const isDemo = !!(sel && sel._demo);
const [data, setData] = useStateL(isDemo ? { available:true } : null);
const [flt, setFlt] = useStateL('all');
const [openSec, setOpenSec] = useStateL(null);
useEffectL(() => {
if (isDemo) return;
const sid = sel && sel.latest ? sel.latest.scan_id : '';
if (!sid) { setData({ available:false, sections:[] }); return; }
let alive = true; setData(null);
window.fsApi('/api/blueteam/scans/'+encodeURIComponent(sid)+'/detail', { method:'GET' })
.then(d => { if(alive) setData(d); })
.catch(()=>{ if(alive) setData({ available:false, sections:[] }); });
return () => { alive = false; };
}, [sel && sel.key]);
const sections = isDemo ? _demoSections(sel.controls) : ((data && data.sections) || []);
const totals = sections.reduce((a,s)=>{ a.pass+=s.counts.pass||0; a.fail+=(s.counts.fail||0)+(s.counts.error||0);
a.na+=(s.counts.notapplicable||0)+(s.counts.notchecked||0)+(s.counts.unknown||0); a.total+=s.total||0; return a; },
{pass:0,fail:0,na:0,total:0});
const overallScore = isDemo ? (sel.score||0) : ((data && data.score) || 0);
const filt = (ctrls) => ctrls.filter(c => flt==='all' ? true
: flt==='fail' ? (c.result==='fail'||c.result==='error') : c.result==='pass');
// Ranking de capas para el dashboard: fuertes (mejor score) y prioridades
// (con fallos, peor score primero). Y las secciones visibles según el filtro.
const ranked = [...sections].sort((a,b)=>(b.score||0)-(a.score||0));
const strengths = ranked.slice(0, 3);
const improve = [...sections].filter(s => (s.counts.fail||0)+(s.counts.error||0) > 0)
.sort((a,b)=>(a.score||0)-(b.score||0)).slice(0, 3);
const visibleSections = flt==='all' ? sections : sections.filter(s => filt(s.controls).length);
return (
← volver al panel
{(!isDemo && data === null) ? (
Cargando detalle del escaneo…
) : (!isDemo && data && data.available === false) ? (
) : (<>
{isDemo &&
Sistema de ejemplo: se muestra una muestra de controles. El análisis completo (todos los controles) aparece en escaneos de máquinas reales.
}
{/* ===== Dashboard de la máquina: radar por capa + score + prioridades ===== */}
{sections.length >= 3 && (
Perfil de seguridad por capa
)}
Puntos fuertes
{strengths.length ? strengths.map(s =>
)
:
—
}
Prioridades de mejora
{improve.length ? improve.map(s =>
)
:
Sin fallos · máquina conforme
}
{/* Filtro + detalle por capa */}
Análisis detallado por capa
{/* Quesitos por sección/capa */}
{visibleSections.length ? (
{visibleSections.map(s => {
const shown = filt(s.controls);
const isOpen = openSec === s.key;
return (
setOpenSec(o => o===s.key ? null : s.key)} style={{ display:'flex', alignItems:'center', gap:16, padding:'16px 18px', cursor:'pointer' }}>
{s.title}
✓ {s.counts.pass||0}
✕ {(s.counts.fail||0)+(s.counts.error||0)}
N/A {(s.counts.notapplicable||0)+(s.counts.notchecked||0)+(s.counts.unknown||0)}
{s.total} controles · pulsa para {isOpen?'cerrar':'ver detalle'}
{isOpen && (
{shown.length ? shown.map(c =>
)
:
Sin controles en este filtro.
}
)}
);
})}
) : sections.length ? (
) :
}
>)}
);
}
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 (
setMode('panel')} style={{ display:'inline-flex', alignItems:'center', gap:7, background:'none', border:'none', color:'var(--txt-dim)', cursor:'pointer', fontSize:12.5, marginBottom:18, fontFamily:'var(--font-mono)' }}>
← volver al panel
);
}
if (mode === 'scan') return setMode('panel')} />;
if (mode === 'history') return setMode('panel')} onDelete={delScan} />;
if (mode === 'benchDetail' && sel) return setMode('panel')} />;
// 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]);
// Controles del sistema de ejemplo SELECCIONADO (cada tarjeta trae los suyos,
// reales del DataStream de ese SO). Antes se usaba una lista global única, por
// eso Windows/Cisco enseñaban controles de Linux.
const demoAll = (sel && sel.controls) || [];
const demoControls = demoAll.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 = demoAll.filter(c=>c.auto&&c.status==='fail'&&!fixed.includes(c.id));
const demoFailCount = demoAll.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('advisor')} style={{ display:'inline-flex', alignItems:'center', gap:6 }}>
Asesor IA
setMode('history')}>Histórico setMode('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)} onOpen={()=>{ setSelKey(b.key); setMode('benchDetail'); }} />)}
{/* 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':''}`}
setMode('benchDetail')}
title="Ver todos los controles agrupados por sección, con quesitos por capa">Ver análisis completo
{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])=>(
setDfilter(k)} className="seg" style={{ padding:'7px 13px', background: dfilter===k?'var(--blue-soft)':'var(--bg-void)', borderColor: dfilter===k?'var(--blue)':'var(--line-bright)', color: dfilter===k?'var(--blue)':'var(--txt-dim)' }}>{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);
// Preseleccionar el DataStream/perfil de la tarjeta desde la que se
// entró (p. ej. la tarjeta Ubuntu abre el scan ya en ssg-ubuntu2204).
const fromCard = bench && bench.datastream && list.find(x=>x.id===bench.datastream);
const def = fromCard || list.find(x=>x.os==='debian12') || list[0];
if (def) setContent(def.id);
if (fromCard && bench.profile) pendingProfileRef.current = bench.profile; })
.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 (
← volver al panel
{/* config grid */}
{/* rules header */}
Reglas
{sel.length} de {ruleList.length} en el perfil
{/* rules list */}
{rules.map(r =>
toggle(r.id)} />)}
{!rules.length && {loadingMeta?'Cargando reglas del perfil…':(ruleList.length?'Sin reglas que coincidan.':'Selecciona contenido y perfil para cargar las reglas.')}
}
{/* footer / progress + scan */}
{phase==='idle' && `${prog}% (${ruleList.length} reglas en el perfil)`}
{phase==='running' && `${prog}% · evaluando ${target==='remote'?(host||'host remoto'):'contenedor local'} …`}
{phase==='error' && ✕ {err} }
{phase==='done' && results && 100% · {results.pass} pass · {results.fail} fail · {results.notapp} N/A · cumplimiento {results.score}% }
setOpts(o=>({...o,fetch:!o.fetch}))} label="Obtener recursos remotos (OVAL/CVE)" />
setOpts(o=>({...o,remediate:!o.remediate}))} label="Remediar automáticamente" danger />
{phase==='running' ? 'ESCANEANDO…' : phase==='done' ? 'RE-ESCANEAR' : 'SCAN'}
{phase==='done' && results && (
Resultado del escaneo · {results.target || (target==='remote'?host:'local')}
{reporting ? 'Generando…' : 'Informe PDF'}
window.open(results.report_url,'_blank')}>Ver informe HTML
window.open(results.arf_url,'_blank')}>Descargar ARF
=80?'var(--ok)':results.score>=70?'var(--amber)':'var(--red)'} sub="CUMPLIMIENTO" />
{opts.remediate && (
Remediación aplicada
{results.fail} reglas corregidas automáticamente en el objetivo.
)}
)}
);
}
function Lbl({ children, dim }) {
return {children}
;
}
function Radio({ on, onClick, label }) {
return (
{on && }
{label}
);
}
function Check({ on, onClick, label, danger }) {
const c = danger ? 'var(--red)' : 'var(--blue)';
return (
{on && }
{label}
);
}
function ResBar({ label, v, total, color }) {
return (
);
}
Object.assign(window, { BlueTeam, ScapWorkbench });