feat: listen_ports + patch_status commands, POSTURE/PATCH/PORTS chips, Scan All Selected button

This commit is contained in:
AetherForge
2026-05-30 23:31:52 -07:00
parent d005d5d07c
commit 159747877c
17 changed files with 421 additions and 41 deletions

View File

@@ -114,6 +114,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
(update.shares_accepted ?? a.shares_good)
),
status: 'online' as const,
...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),

View File

@@ -184,6 +184,18 @@
.cn-patch.patch-ok { color: var(--neon-cyan); background: rgba(0,245,255,0.08); }
.cn-patch.patch-stale { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
/* ── PORTS chip ────────────────────────────────────────────────────────────── */
.cn-ports {
font-size: 0.62rem;
font-family: var(--font-tech);
padding: 1px 4px;
border-radius: 3px;
letter-spacing: 0.04em;
cursor: default;
}
.cn-ports.ports-ok { color: #aaa; background: rgba(128,128,128,0.07); }
.cn-ports.ports-many { color: var(--neon-amber); background: rgba(255,176,32,0.1); }
.cn-elevated {
font-size: 0.62rem;
font-family: var(--font-tech);
@@ -416,6 +428,16 @@
border-color: var(--neon-green);
}
.crucible-op-scan {
color: var(--neon-cyan);
border-color: rgba(0,245,255,0.3);
font-weight: 700;
}
.crucible-op-scan:hover:not(:disabled) {
background: rgba(0,245,255,0.08);
border-color: var(--neon-cyan);
}
.crucible-shell-tabs {
display: flex;
gap: 0;

View File

@@ -47,14 +47,19 @@ function sshBadge(agent: Agent) {
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' };
if (score >= 80) return { label: `POSTURE ${score}`, cls: 'posture-good' };
if (score >= 40) return { label: `POSTURE ${score}`, cls: 'posture-warn' };
return { label: `POSTURE ${score}`, cls: 'posture-bad' };
}
function patchLabel(days?: number) {
if (days === undefined) return null;
return { label: `${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
return { label: `PATCH ${days}d`, cls: days <= 30 ? 'patch-ok' : 'patch-stale' };
}
function portsBadge(count?: number): { label: string; cls: string } | null {
if (count === undefined) return null;
return { label: `PORTS ${count}`, cls: count > 20 ? 'ports-many' : 'ports-ok' };
}
function postureTooltip(agent: Agent): string {
@@ -66,6 +71,7 @@ function postureTooltip(agent: Agent): string {
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)}`);
if (agent.listen_port_count !== undefined) lines.push(`TCP listeners: ${agent.listen_port_count} (run listen_ports for full list)`);
lines.push('──────────────────────');
// Patch exposure
@@ -448,6 +454,27 @@ export default function CruciblePage() {
);
};
// Fires posture + listen_ports + patch_status in parallel for all selected online nodes.
const scanSelected = (targets?: Agent[]) => {
const tgts = targets ?? selectedAgents.filter(online);
if (tgts.length === 0) return;
const cmds = ['posture', 'listen_ports', 'patch_status'] as const;
for (const a of tgts) {
for (const cmd of cmds) {
api.sendAgentCommand(a.id, cmd).catch((err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: a.id, agentName: a.name, isCmd: false,
text: `[ERROR] ${cmd}: ${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') {
@@ -476,7 +503,11 @@ export default function CruciblePage() {
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) };
return {
...postureBadge(score),
patch: patchLabel(patchDays),
ports: portsBadge(a.listen_port_count),
};
};
// ── Render ─────────────────────────────────────────────────────────────
@@ -554,11 +585,19 @@ export default function CruciblePage() {
{posture.patch && (
<div
className={`cn-patch ${posture.patch.cls}`}
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)`}
title={`Last patch: ${a.last_patch ?? '?'} (${a.last_patch_days ?? '?'}d ago)${a.pending_updates !== undefined ? ` · ${a.pending_updates} pending` : ''}`}
>
{posture.patch.label}
</div>
)}
{posture.ports && (
<div
className={`cn-ports ${posture.ports.cls}`}
title={`${a.listen_port_count ?? '?'} TCP listeners (run listen_ports for full list)`}
>
{posture.ports.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}
@@ -672,9 +711,9 @@ export default function CruciblePage() {
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
onClick={() => probePosture()}
title="Probe selected: AV, RTP, firewall (per-profile), SSH, patch age, elevation"
title="Probe posture (AV, firewall, SSH, patch) on selected nodes"
>
Probe Selected
Probe Posture
</button>
<button
className="button crucible-op-btn crucible-op-wake"
@@ -682,7 +721,15 @@ export default function CruciblePage() {
onClick={() => probePosture(agents.filter(online))}
title="Probe ALL online nodes at once"
>
Probe All
Fleet Posture Scan
</button>
<button
className="button crucible-op-btn crucible-op-scan"
disabled={selectedIds.size === 0}
onClick={() => scanSelected()}
title="Fire posture + listen_ports + patch_status in parallel on selected nodes"
>
Scan All Selected
</button>
</div>

View File

@@ -24,6 +24,9 @@ export interface Agent {
arch?: string;
os_version?: string;
capabilities?: AgentCapabilities;
// Listen ports count (full list via listen_ports command)
listen_port_count?: number;
// DNS config — T1016
dns_servers?: string[];
dns_search_domains?: string[];

View File

@@ -19,6 +19,7 @@ export interface WSStatsUpdate {
uptime_seconds?: number;
shares_submitted?: number;
shares_accepted?: number;
listen_port_count?: number;
// DNS config — T1016
dns_servers?: string[];
dns_search_domains?: string[];