feat: Tenable-style patch_status - pending_updates, last_patch, reboot_pending across full stack

This commit is contained in:
AetherForge
2026-05-30 23:11:32 -07:00
parent 9232f4c448
commit 4207f6c21b
43 changed files with 4359 additions and 180 deletions

View File

@@ -45,6 +45,57 @@ function sshBadge(agent: Agent) {
return { label: 'SSH ?', cls: 'ssh-unk' };
}
function postureBadge(score?: number) {
if (score === undefined) return { label: 'POSTURE ?', cls: 'posture-unk' };
if (score >= 80) return { label: `P:${score}`, cls: 'posture-good' };
if (score >= 40) return { label: `P:${score}`, cls: 'posture-warn' };
return { label: `P:${score}`, cls: 'posture-bad' };
}
function patchLabel(days?: number) {
if (days === undefined) return null;
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
}
function postureTooltip(agent: Agent): string {
const lines: string[] = [];
const yn = (v?: boolean) => v === true ? '✓' : v === false ? '✗' : '?';
const na = (v: unknown) => v !== undefined && v !== null ? String(v) : '?';
lines.push(`Defender: ${yn(agent.defender_enabled)} RTP: ${yn(agent.defender_rtp)}`);
if (agent.av_products?.length) lines.push(`AV: ${agent.av_products.join(', ')}`);
lines.push(`FW Domain:${yn(agent.firewall_domain)} Private:${yn(agent.firewall_private)} Public:${yn(agent.firewall_public)}`);
lines.push(`SSH: ${yn(agent.ssh_available)} Elevated: ${yn(agent.agent_elevated)}`);
lines.push('──────────────────────');
// Patch exposure
if (agent.last_patch) lines.push(`Last patch: ${agent.last_patch} (${na(agent.last_patch_days)}d ago)`);
else if (agent.last_patch_days !== undefined) lines.push(`Last patch: ${agent.last_patch_days}d ago`);
if (agent.pending_updates !== undefined) {
const u = agent.pending_updates;
lines.push(`Pending updates: ${u < 0 ? 'unknown' : u === 0 ? 'none ✓' : `${u}`}`);
}
if (agent.reboot_pending !== undefined) {
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
}
return lines.join('\n');
}
function pendingBadge(agent: Agent): { label: string; cls: string } | null {
const u = agent.pending_updates;
if (u === undefined) return null;
if (u < 0) return { label: 'UPD ?', cls: 'upd-unk' };
if (u === 0) return { label: 'UP TO DATE', cls: 'upd-ok' };
if (u <= 5) return { label: `${u} UPD`, cls: 'upd-warn' };
return { label: `${u} UPD`, cls: 'upd-bad' };
}
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
if (agent.reboot_pending === undefined) return null;
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
return null; // no badge when not pending — cleaner UI
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
@@ -99,8 +150,9 @@ export default function CruciblePage() {
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
// SSH status overrides (from probe results)
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
const selectedAgents = useMemo(
@@ -128,20 +180,35 @@ export default function CruciblePage() {
for (const r of newEntries) {
const aid = r.agent_id;
if (!aid) continue;
// Only show results from agents that are selected (or all if nothing selected)
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Parse SSH probe results to update ssh status
const msg = r.message ?? '';
// Always update badges from probe / heartbeat command responses
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
} else if (msg.includes('SSH_PROBE:OFFLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
if (r.action === 'posture' || msg.includes('{')) {
try {
const start = msg.indexOf('{');
if (start >= 0) {
const p = JSON.parse(msg.slice(start)) as { posture_score?: number; last_patch_days?: number; ssh_listening?: boolean };
if (typeof p.posture_score === 'number') {
setPostureOverride((prev) => ({
...prev,
[aid]: { score: p.posture_score!, patchDays: p.last_patch_days },
}));
}
if (p.ssh_listening === true) setSshOverride((prev) => ({ ...prev, [aid]: true }));
if (p.ssh_listening === false) setSshOverride((prev) => ({ ...prev, [aid]: false }));
}
} catch { /* ignore malformed JSON */ }
}
if (selectedIds.size > 0 && !selectedIds.has(aid)) continue;
const agent = agents.find((a) => a.id === aid);
const name = agent?.name ?? aid.slice(0, 8);
// Split multi-line output
const msgLines = msg.split('\n').filter(Boolean);
for (const line of msgLines) {
lines.push({
@@ -258,6 +325,28 @@ export default function CruciblePage() {
}
};
const probePosture = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
Promise.all(
tgts.map((a) =>
api.sendAgentCommand(a.id, 'posture').catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: a.id,
agentName: a.name,
isCmd: false,
text: `[ERROR] posture probe: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
},
]);
})
)
);
};
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { sendCmd(); return; }
if (e.key === 'ArrowUp') {
@@ -282,6 +371,13 @@ export default function CruciblePage() {
return sshBadge({ ...a });
};
const postureStatus = (a: Agent) => {
const o = postureOverride[a.id];
const score = o?.score ?? a.posture_score;
const patchDays = o?.patchDays ?? a.last_patch_days;
return { ...postureBadge(score), patch: patchLabel(patchDays) };
};
// ── Render ─────────────────────────────────────────────────────────────
return (
@@ -319,6 +415,7 @@ export default function CruciblePage() {
const sel = selectedIds.has(a.id);
const isOn = online(a);
const ssh = sshStatus(a);
const posture = postureStatus(a);
const color = agentColor(a.id, allIds);
return (
<div
@@ -345,7 +442,36 @@ export default function CruciblePage() {
<span>{a.cpu_cores}c</span>
<span>{formatHashrate(a.hashrate_15m)}</span>
</div>
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
<div className="cn-badges">
<div className={`cn-ssh ${ssh.cls}`}>{ssh.label}</div>
<div
className={`cn-posture ${posture.cls}`}
title={postureTooltip(a)}
>
{posture.label}
</div>
{posture.patch && (
<div
className={`cn-patch ${posture.patch.cls}`}
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
>
{posture.patch.label}
</div>
)}
{(() => { const pb = pendingBadge(a); return pb && (
<div className={`cn-upd ${pb.cls}`} title={`${pb.label === 'UP TO DATE' ? 'No pending updates' : `${a.pending_updates} pending update(s)`}`}>
{pb.label}
</div>
); })()}
{(() => { const rb = rebootBadge(a); return rb && (
<div className={`cn-reboot ${rb.cls}`} title="System reboot required to apply updates">
{rb.label}
</div>
); })()}
{a.agent_elevated && (
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
)}
</div>
</div>
</div>
);
@@ -393,6 +519,26 @@ export default function CruciblePage() {
<span className="section-ornament"></span> OPERATIONS
</div>
<div className="crucible-ops">
<div className="crucible-op-group">
<span className="cop-label">Posture</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
>
Probe Selected
</button>
<button
className="button crucible-op-btn crucible-op-wake"
disabled={agents.filter(online).length === 0}
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Probe All
</button>
</div>
<div className="crucible-op-group">
<span className="cop-label">SSH</span>
<button