feat: listen_ports + patch_status commands, POSTURE/PATCH/PORTS chips, Scan All Selected button
This commit is contained in:
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user