Merge pull request #89 from SquidSec/fix/hosts-graph-shells-only-dismiss

fix(assets): real shells only + drop from graph
This commit is contained in:
☣️ Mr. The Plague ☣️
2026-08-03 13:26:51 -05:00
committed by GitHub
8 changed files with 362 additions and 47 deletions
+3 -1
View File
@@ -1163,7 +1163,9 @@ Two operators must not stomp the same shell; shift changes need context; leads n
| Action | API / UI |
|--------|----------|
| Host inventory / graph | `GET /api/v1/hosts` - Ops -> **Assets** |
| Host inventory / graph | `GET /api/v1/hosts` - Ops -> **Assets** (verified/interactive shells + implants only) |
| Drop host from graph | `POST /api/v1/hosts/{id}/hide` - Assets → **Drop** (sessions kept) |
| Restore host | `DELETE /api/v1/hosts/{id}/hide` - **Show dismissed** → Restore |
| Claim session | `POST /api/v1/sessions/{id}/claim` `{force?, ttl_sec?}` - Context -> Claim lock |
| Force claim | same endpoint with `force: true` (admin) |
| Release | `POST /api/v1/sessions/{id}/release` |
+62 -9
View File
@@ -725,19 +725,24 @@ def build_api_router() -> APIRouter:
@api.get("/hosts")
async def list_hosts(
request: Request,
include_hidden: bool = False,
auth: AuthContext = Depends(require_scope("sessions:read", "admin")),
) -> dict[str, Any]:
"""Engagement host inventory: group sessions into host nodes for the Assets graph."""
"""Engagement host inventory: real shells/implants only (Assets graph)."""
from squidc5.hosts.graph import host_key_for_session, is_asset_session
state = get_state(request)
await state.policy.check_and_audit(auth, "sessions.list")
await state.sessions.close_orphaned_shells()
rows = await state.sessions.list(status=None)
hidden_ids = await state.db.list_hidden_host_ids()
hosts: dict[str, dict[str, Any]] = {}
edges: list[dict[str, str]] = []
skipped_noise = 0
for r in rows:
if r.get("status") == "closed":
# still include closed for inventory but mark inactive
pass
if not is_asset_session(r):
skipped_noise += 1
continue
meta = r.get("metadata") or {}
if isinstance(meta, str):
try:
@@ -747,7 +752,7 @@ def build_api_router() -> APIRouter:
if not isinstance(meta, dict):
meta = {}
info = claim_info(meta)
host_key = (r.get("hostname") or r.get("remote_addr") or r.get("id") or "unknown").strip()
host_key = host_key_for_session(r)
node = hosts.get(host_key)
if not node:
node = {
@@ -761,11 +766,15 @@ def build_api_router() -> APIRouter:
"claimed_by": None,
"os_info": r.get("os_info"),
"usernames": set(),
"hidden": host_key in hidden_ids,
}
hosts[host_key] = node
addr = r.get("remote_addr")
if addr and addr not in node["addrs"]:
node["addrs"].append(addr)
if r.get("hostname") and not node.get("hostname"):
node["hostname"] = r.get("hostname")
node["label"] = r.get("hostname") or node["label"]
if r.get("os_info") and not node.get("os_info"):
node["os_info"] = r.get("os_info")
if r.get("username"):
@@ -782,16 +791,18 @@ def build_api_router() -> APIRouter:
"kind": kind,
"status": r.get("status"),
"verified": r.get("verified"),
"interactive": r.get("interactive"),
"username": r.get("username"),
"remote_addr": addr,
"last_seen_at": r.get("last_seen_at"),
"os_info": r.get("os_info"),
"claim": info,
}
)
# Simple edge: reverse_shell -> beacon on same host (access path)
# represented later when serializing
nodes = []
for h in hosts.values():
if h.get("hidden") and not include_hidden:
continue
kinds = sorted(h["kinds"])
nodes.append(
{
@@ -805,10 +816,10 @@ def build_api_router() -> APIRouter:
"active_sessions": h["active"],
"session_count": len(h["sessions"]),
"claimed_by": h.get("claimed_by"),
"hidden": bool(h.get("hidden")),
"sessions": h["sessions"],
}
)
# Edges between sessions on same host for graph layout
sess = h["sessions"]
for i, a in enumerate(sess):
for b in sess[i + 1 :]:
@@ -820,7 +831,6 @@ def build_api_router() -> APIRouter:
"rel": "co-host",
}
)
# Also emit session-level nodes for drill-down graph
session_nodes = []
for h in nodes:
for s in h["sessions"]:
@@ -836,13 +846,56 @@ def build_api_router() -> APIRouter:
"label": f"{s.get('kind')}:{(s.get('id') or '')[:8]}",
}
)
hidden_rows = await state.db.list_hidden_hosts() if include_hidden or hidden_ids else []
return {
"hosts": nodes,
"session_nodes": session_nodes,
"edges": edges,
"claim_ttl_sec": int(getattr(state.settings, "session_claim_ttl_sec", 0) or 0),
"hidden_count": len(hidden_ids),
"skipped_noise": skipped_noise,
"hidden": [
{"id": r["host_id"], "hidden_by": r.get("hidden_by"), "hidden_at": r.get("hidden_at")}
for r in hidden_rows
]
if include_hidden
else [],
}
@api.post("/hosts/{host_id:path}/hide")
async def hide_host(
host_id: str,
request: Request,
auth: AuthContext = Depends(require_scope("sessions:write", "admin")),
) -> dict[str, Any]:
"""Drop a host node from the Assets graph (sessions kept; can unhide)."""
state = get_state(request)
hid = (host_id or "").strip()
if not hid or hid in (".", "..") or len(hid) > 512:
raise HTTPException(400, "invalid host id")
await state.policy.check_and_audit(auth, "hosts.hide", resource=hid)
await state.db.hide_host_graph(hid, hidden_by=auth.name)
await state.metrics.emit("hosts.hide", {"host_id": hid, "actor": auth.name})
return {"status": "hidden", "id": hid}
@api.delete("/hosts/{host_id:path}/hide")
async def unhide_host(
host_id: str,
request: Request,
auth: AuthContext = Depends(require_scope("sessions:write", "admin")),
) -> dict[str, Any]:
"""Restore a dismissed host on the Assets graph."""
state = get_state(request)
hid = (host_id or "").strip()
if not hid or len(hid) > 512:
raise HTTPException(400, "invalid host id")
await state.policy.check_and_audit(auth, "hosts.unhide", resource=hid)
ok = await state.db.unhide_host_graph(hid)
if not ok:
raise HTTPException(404, "host not hidden")
await state.metrics.emit("hosts.unhide", {"host_id": hid, "actor": auth.name})
return {"status": "visible", "id": hid}
@api.get("/sessions/{session_id}")
async def get_session(
session_id: str,
+10
View File
@@ -247,12 +247,22 @@ CREATE INDEX IF NOT EXISTS idx_conn_tickets_token ON connection_tickets(token_id
CREATE INDEX IF NOT EXISTS idx_conn_tickets_exp ON connection_tickets(expires_at);
"""
HOST_GRAPH_HIDDEN_SQL = """
CREATE TABLE IF NOT EXISTS host_graph_hidden (
host_id TEXT PRIMARY KEY,
hidden_by TEXT,
hidden_at REAL NOT NULL,
note TEXT NOT NULL DEFAULT ''
);
"""
MIGRATIONS: Sequence[tuple[int, str, str]] = (
(1, "baseline schema", BASELINE_SQL),
(2, "HITL approval queue", HITL_REQUESTS_SQL),
(3, "audit integrity chain columns", AUDIT_INTEGRITY_SQL),
(4, "operator assets library", OPERATOR_ASSETS_SQL),
(5, "connection tickets for token handoff", CONNECTION_TICKETS_SQL),
(6, "host graph dismiss list", HOST_GRAPH_HIDDEN_SQL),
)
SCHEMA_VERSION_TABLE = """
+26
View File
@@ -245,6 +245,32 @@ class Database:
cur = await self.execute("DELETE FROM operator_assets WHERE id = ?", (asset_id,))
return cur.rowcount > 0
# --- Host graph dismiss ---
async def hide_host_graph(self, host_id: str, *, hidden_by: str | None = None, note: str = "") -> None:
await self.execute(
"""INSERT INTO host_graph_hidden (host_id, hidden_by, hidden_at, note)
VALUES (?, ?, ?, ?)
ON CONFLICT(host_id) DO UPDATE SET
hidden_by = excluded.hidden_by,
hidden_at = excluded.hidden_at,
note = excluded.note""",
(host_id, hidden_by, _now(), note or ""),
)
async def unhide_host_graph(self, host_id: str) -> bool:
cur = await self.execute("DELETE FROM host_graph_hidden WHERE host_id = ?", (host_id,))
return cur.rowcount > 0
async def list_hidden_host_ids(self) -> set[str]:
rows = await self.fetchall("SELECT host_id FROM host_graph_hidden")
return {str(r["host_id"]) for r in rows if r.get("host_id")}
async def list_hidden_hosts(self) -> list[dict[str, Any]]:
return await self.fetchall(
"SELECT host_id, hidden_by, hidden_at, note FROM host_graph_hidden ORDER BY hidden_at DESC"
)
# --- Sessions ---
async def create_session(
+3
View File
@@ -0,0 +1,3 @@
from squidc5.hosts.graph import host_key_for_session, is_asset_session
__all__ = ["host_key_for_session", "is_asset_session"]
+72
View File
@@ -0,0 +1,72 @@
"""Assets host graph: which sessions count as real assets."""
from __future__ import annotations
import json
from typing import Any
SHELL_KINDS = frozenset({"reverse_shell", "tcp"})
IMPLANT_KINDS = frozenset({"beacon"})
def _meta(row: dict[str, Any]) -> dict[str, Any]:
meta = row.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except json.JSONDecodeError:
meta = {}
return meta if isinstance(meta, dict) else {}
def is_asset_session(row: dict[str, Any]) -> bool:
"""True if session is a real shell/implant worth putting on the Assets graph.
Excludes scanner noise, false shells, and bare TCP connects that never
verified or went interactive. Closed shells that once verified (or were
interactive / stage-2) still count as historical assets.
"""
kind = (row.get("kind") or "").strip()
status = (row.get("status") or "").strip()
verified = bool(row.get("verified"))
interactive = bool(row.get("interactive"))
meta = _meta(row)
if meta.get("rejected") or meta.get("false_positive") or meta.get("session_rejected"):
return False
if meta.get("host_graph_exclude"):
return False
if kind in SHELL_KINDS:
if verified or interactive:
return True
# Historical: closed shell that had real access markers
if status == "closed":
if meta.get("was_verified") or meta.get("stage2") or meta.get("stabilized"):
return True
if meta.get("exec_probe_ok") or meta.get("shell_verified"):
return True
# Closed + known user/os from a real session is enough
if row.get("username") or row.get("os_info") or row.get("hostname"):
return True
return False
if kind in IMPLANT_KINDS:
# Real implant callback needs some identity (not empty probe)
if row.get("hostname") or row.get("username") or row.get("os_info"):
return True
# still allow if verified flag or implant id in meta
if verified or meta.get("implant_id") or meta.get("agent_id"):
return True
return False
return False
def host_key_for_session(row: dict[str, Any]) -> str:
"""Stable host node id: prefer hostname, else remote addr, else session id."""
for k in ("hostname", "remote_addr", "id"):
v = row.get(k)
if v is not None and str(v).strip():
return str(v).strip()
return "unknown"
+68 -9
View File
@@ -9,11 +9,40 @@ from httpx import ASGITransport, AsyncClient
from squidc5.collab.teams import TeamService, claim_info
from squidc5.config import Settings
from squidc5.hosts.graph import is_asset_session
from squidc5.main import create_app
ADMIN = "sc5_test_admin_token_bootstrap_hosts01"
def test_is_asset_session_filters_noise():
assert is_asset_session(
{"kind": "reverse_shell", "status": "active", "verified": True, "hostname": "h1"}
)
assert is_asset_session(
{"kind": "reverse_shell", "status": "active", "interactive": True}
)
assert is_asset_session(
{
"kind": "reverse_shell",
"status": "closed",
"hostname": "old-box",
"username": "root",
}
)
assert is_asset_session(
{"kind": "beacon", "status": "active", "hostname": "workstation-a", "username": "alice"}
)
# scanner / unverified active shell
assert not is_asset_session(
{"kind": "reverse_shell", "status": "active", "verified": False, "interactive": False}
)
assert not is_asset_session({"kind": "beacon", "status": "active"})
assert not is_asset_session(
{"kind": "reverse_shell", "status": "closed", "metadata": {"rejected": True}}
)
@pytest.mark.asyncio
async def test_hosts_api_and_claim_ttl(tmp_path):
settings = Settings(
@@ -49,25 +78,61 @@ async def test_hosts_api_and_claim_ttl(tmp_path):
)
assert b3.status_code == 200
# noise: unverified reverse_shell should not appear
state = app.state.app_state
noise = await state.sessions.register(
kind="reverse_shell",
remote_addr="1.2.3.4",
hostname=None,
)
await state.db.update_session(noise, status="active")
hosts = await client.get("/api/v1/hosts", headers=h)
assert hosts.status_code == 200
body = hosts.json()
assert body["claim_ttl_sec"] == 120
assert body.get("skipped_noise", 0) >= 1
by_id = {x["id"]: x for x in body["hosts"]}
assert "workstation-a" in by_id
assert "dc01" in by_id
assert noise not in by_id
assert "1.2.3.4" not in by_id
wa = by_id["workstation-a"]
assert wa["session_count"] == 2
assert set(wa["usernames"]) == {"alice", "bob"}
assert "beacon" in wa["kinds"]
assert isinstance(body.get("edges"), list)
assert isinstance(body.get("session_nodes"), list)
pair = {sid1, sid2}
assert any(
{e["source"], e["target"]} == pair and e.get("rel") == "co-host"
for e in body["edges"]
)
# dismiss host from graph
hide = await client.post(
"/api/v1/hosts/workstation-a/hide",
headers=h,
)
assert hide.status_code == 200
hosts2 = await client.get("/api/v1/hosts", headers=h)
ids2 = {x["id"] for x in hosts2.json()["hosts"]}
assert "workstation-a" not in ids2
assert "dc01" in ids2
assert hosts2.json()["hidden_count"] >= 1
# still visible with include_hidden
hosts3 = await client.get("/api/v1/hosts?include_hidden=true", headers=h)
by3 = {x["id"]: x for x in hosts3.json()["hosts"]}
assert by3["workstation-a"]["hidden"] is True
unhide = await client.delete(
"/api/v1/hosts/workstation-a/hide",
headers=h,
)
assert unhide.status_code == 200
hosts4 = await client.get("/api/v1/hosts", headers=h)
assert "workstation-a" in {x["id"] for x in hosts4.json()["hosts"]}
c = await client.post(f"/api/v1/sessions/{sid1}/claim", headers=h, json={})
assert c.status_code == 200
cj = c.json()
@@ -78,11 +143,6 @@ async def test_hosts_api_and_claim_ttl(tmp_path):
assert sess.status_code == 200
claim = sess.json().get("claim") or {}
assert claim.get("locked") is True
assert claim.get("claim_remaining_sec") is not None
hosts2 = await client.get("/api/v1/hosts", headers=h)
wa2 = {x["id"]: x for x in hosts2.json()["hosts"]}["workstation-a"]
assert wa2.get("claimed_by")
c2 = await client.post(
f"/api/v1/sessions/{sid2}/claim",
@@ -93,7 +153,6 @@ async def test_hosts_api_and_claim_ttl(tmp_path):
time.sleep(1.2)
ts: TeamService = app.state.app_state.teams
await ts.assert_write_access(sid2, "other-op", is_admin=False)
await ts.assert_write_access(sid2, "other-op")
def test_claim_info_unit():
@@ -138,11 +197,11 @@ async def test_hosts_ui_markers(tmp_path):
"drawHostGraph",
"/api/v1/hosts",
"hostGraph",
"host-hide",
"hostShowHidden",
"ctxForceClaim",
"claimChipHtml",
):
assert m in js, m
html = await client.get("/ops")
assert html.status_code == 200
assert 'data-view="hosts"' in html.text
assert 'id="view-hosts"' in html.text
+118 -28
View File
@@ -2638,7 +2638,7 @@
/* -- Assets / hosts graph -- */
let _hostsCache = { hosts: [], edges: [], claim_ttl_sec: 0, selected: null };
let _hostsCache = { hosts: [], edges: [], claim_ttl_sec: 0, selected: null, showHidden: false, hidden: [] };
function renderHostsView(force) {
const root = el("view-hosts");
@@ -2650,21 +2650,26 @@
root.innerHTML = `
<div class="split" style="grid-template-columns: minmax(260px, 340px) 1fr">
<div class="list-panel">
<div class="lp-head">Hosts
<div class="lp-head">Assets
<button type="button" class="ghost sm" id="hostReload" style="margin-left:auto">Reload</button>
</div>
<div class="lp-body"><table class="data"><thead><tr>
<th>Host</th><th>Sessions</th><th>Lock</th>
</tr></thead><tbody id="hostTbody"></tbody></table></div>
<div class="lp-body">
<label class="muted" style="display:flex;align-items:center;gap:6px;padding:6px 8px;font-size:0.75rem">
<input type="checkbox" id="hostShowHidden" /> Show dismissed
</label>
<table class="data"><thead><tr>
<th>Host</th><th>Access</th><th></th>
</tr></thead><tbody id="hostTbody"></tbody></table>
</div>
</div>
<div class="work-panel">
<div class="wp-head">Asset graph <span class="muted" id="hostGraphMeta" style="font-weight:400;margin-left:8px;font-size:0.72rem"></span></div>
<div class="wp-body" style="display:flex;flex-direction:column;min-height:0;height:100%">
<p class="muted" style="font-size:0.75rem;margin:0 0 8px">Compromised hosts as nodes. Pink = active access; amber = session lock held. Click host for implants; click a session row to open the lock rail.</p>
<p class="muted" style="font-size:0.75rem;margin:0 0 8px">Verified shells, interactive shells, and implants only — scanners/noise excluded. Drop removes the node from the graph (sessions kept).</p>
<div class="chips" style="margin-bottom:8px">
<span class="chip ok">active</span>
<span class="chip ok">active shell/implant</span>
<span class="chip warn">locked</span>
<span class="chip">idle / closed only</span>
<span class="chip">historical only</span>
</div>
<div id="hostGraph" style="flex:1;min-height:300px;border:1px solid var(--border);border-radius:10px;background:#0a0a10;position:relative;overflow:hidden"></div>
<div id="hostDetail" class="outbox empty" style="margin-top:10px;max-height:240px;overflow:auto">Select a host</div>
@@ -2673,6 +2678,13 @@
</div>`;
viewBuilt.hosts = true;
if (el("hostReload")) el("hostReload").onclick = () => loadHostsGraph();
if (el("hostShowHidden")) {
el("hostShowHidden").checked = !!_hostsCache.showHidden;
el("hostShowHidden").onchange = () => {
_hostsCache.showHidden = !!el("hostShowHidden").checked;
loadHostsGraph();
};
}
loadHostsGraph();
}
@@ -2682,31 +2694,48 @@
const detail = el("hostDetail");
if (!tbody || !graph) return;
try {
const data = await api("GET", "/api/v1/hosts");
const q = _hostsCache.showHidden ? "?include_hidden=true" : "";
const data = await api("GET", "/api/v1/hosts" + q);
const hosts = data.hosts || [];
_hostsCache = {
hosts,
edges: data.edges || [],
claim_ttl_sec: data.claim_ttl_sec || 0,
selected: _hostsCache.selected,
showHidden: _hostsCache.showHidden,
hidden: data.hidden || [],
hidden_count: data.hidden_count || 0,
skipped_noise: data.skipped_noise || 0,
};
if (el("hostGraphMeta")) {
const ttl = _hostsCache.claim_ttl_sec;
el("hostGraphMeta").textContent =
hosts.length + " host(s) · claim TTL " + (ttl > 0 ? Math.round(ttl / 60) + "m" : "off");
const bits = [
hosts.filter((h) => !h.hidden).length + " asset(s)",
];
if (_hostsCache.hidden_count) bits.push(_hostsCache.hidden_count + " dismissed");
if (_hostsCache.skipped_noise) bits.push(_hostsCache.skipped_noise + " noise skipped");
bits.push("claim TTL " + (ttl > 0 ? Math.round(ttl / 60) + "m" : "off"));
el("hostGraphMeta").textContent = bits.join(" · ");
}
const canWrite = can("sessions:write") || can("admin");
tbody.innerHTML = hosts.map((h) => {
const lock = h.claimed_by
? `<span class="chip warn">${esc(h.claimed_by)}</span>`
: `<span class="chip">-</span>`;
return `<tr data-host="${esc(h.id)}">
<td><strong>${esc(h.label)}</strong>
: "";
const hideBtn = canWrite
? (h.hidden
? `<button type="button" class="ghost sm host-unhide" data-host="${esc(h.id)}">Restore</button>`
: `<button type="button" class="ghost sm host-hide" data-host="${esc(h.id)}" title="Drop from graph">Drop</button>`)
: "";
return `<tr data-host="${esc(h.id)}" class="${h.hidden ? "muted" : ""}">
<td><strong>${esc(h.label)}</strong>${h.hidden ? ' <span class="chip">dismissed</span>' : ""}
<div class="muted mono" style="font-size:0.65rem">${esc((h.addrs || []).join(", ") || "")}</div>
<div class="muted" style="font-size:0.65rem">${esc((h.kinds || []).join(", "))} · ${esc((h.usernames || []).slice(0, 3).join(", ") || "-")}</div>
</td>
<td>${esc(String(h.active_sessions || 0))}/${esc(String(h.session_count || 0))}</td>
<td>${lock}</td>
<td>${esc(String(h.active_sessions || 0))}/${esc(String(h.session_count || 0))} ${lock}</td>
<td style="white-space:nowrap">${hideBtn}</td>
</tr>`;
}).join("") || '<tr><td colspan="3" class="muted">No hosts yet — catch a beacon or shell</td></tr>';
}).join("") || '<tr><td colspan="3" class="muted">No shell/implant assets yet</td></tr>';
const pick = (id) => {
tbody.querySelectorAll("tr").forEach((x) => {
x.classList.toggle("selected", x.getAttribute("data-host") === id);
@@ -2714,15 +2743,47 @@
const h = hosts.find((x) => x.id === id);
_hostsCache.selected = id;
if (h) showHostDetail(h, detail);
drawHostGraph(graph, hosts, pick, id);
drawHostGraph(graph, hosts.filter((x) => !x.hidden || _hostsCache.showHidden), pick, id);
};
tbody.querySelectorAll("tr[data-host]").forEach((tr) => {
tr.onclick = () => pick(tr.getAttribute("data-host"));
tr.onclick = (ev) => {
if (ev.target.closest("button")) return;
pick(tr.getAttribute("data-host"));
};
});
const sel = _hostsCache.selected && hosts.some((h) => h.id === _hostsCache.selected)
tbody.querySelectorAll("button.host-hide").forEach((btn) => {
btn.onclick = async (ev) => {
ev.stopPropagation();
const id = btn.getAttribute("data-host");
if (!id || !confirm("Drop " + id + " from the Assets graph? Sessions stay in Sessions list.")) return;
try {
await api("POST", "/api/v1/hosts/" + encodeURIComponent(id) + "/hide", {});
showOk("Dropped from graph");
if (_hostsCache.selected === id) {
_hostsCache.selected = null;
if (detail) { detail.classList.add("empty"); detail.textContent = "Select a host"; }
}
await loadHostsGraph();
} catch (e) { showError(String(e.message || e)); }
};
});
tbody.querySelectorAll("button.host-unhide").forEach((btn) => {
btn.onclick = async (ev) => {
ev.stopPropagation();
const id = btn.getAttribute("data-host");
if (!id) return;
try {
await api("DELETE", "/api/v1/hosts/" + encodeURIComponent(id) + "/hide");
showOk("Restored to graph");
await loadHostsGraph();
} catch (e) { showError(String(e.message || e)); }
};
});
const visible = hosts.filter((x) => !x.hidden || _hostsCache.showHidden);
const sel = _hostsCache.selected && visible.some((h) => h.id === _hostsCache.selected)
? _hostsCache.selected
: null;
drawHostGraph(graph, hosts, pick, sel);
drawHostGraph(graph, visible.filter((h) => !h.hidden), pick, sel);
if (sel) {
const h = hosts.find((x) => x.id === sel);
if (h) showHostDetail(h, detail);
@@ -2736,6 +2797,7 @@
function showHostDetail(h, detail) {
if (!detail || !h) return;
detail.classList.remove("empty");
const canWrite = can("sessions:write") || can("admin");
const sess = (h.sessions || []).map((s) => {
const c = s.claim || {};
const lock = c.claimed_by ? c.claimed_by : "-";
@@ -2745,18 +2807,26 @@
return `<tr data-sid="${esc(s.id)}" style="cursor:pointer">
<td class="mono">${esc(String(s.id || "").slice(0, 12))}</td>
<td>${esc(s.kind || "")}</td>
<td>${esc(s.status || "")}${s.verified ? " ✓" : ""}</td>
<td>${esc(s.status || "")}${s.verified ? " ✓" : ""}${s.interactive ? " ⎆" : ""}</td>
<td>${esc(s.username || "-")}</td>
<td>${c.claimed_by ? `<span class="chip warn">${esc(lock)}${esc(left)}</span>` : '<span class="chip">unlocked</span>'}</td>
</tr>`;
}).join("");
const dropBtn = canWrite
? (h.hidden
? `<button type="button" class="ghost sm" id="hostDetailUnhide">Restore to graph</button>`
: `<button type="button" class="danger sm" id="hostDetailHide">Drop from graph</button>`)
: "";
detail.innerHTML = `
<div style="margin-bottom:8px">
<strong style="font-size:1rem">${esc(h.label)}</strong>
<div class="muted" style="font-size:0.78rem;margin-top:4px">
OS: ${esc(h.os_info || "-")} · Addrs: ${esc((h.addrs || []).join(", ") || "-")}<br/>
Users: ${esc((h.usernames || []).join(", ") || "-")} · Kinds: ${esc((h.kinds || []).join(", "))}
<div style="margin-bottom:8px;display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap">
<div style="flex:1;min-width:160px">
<strong style="font-size:1rem">${esc(h.label)}</strong>
<div class="muted" style="font-size:0.78rem;margin-top:4px">
OS: ${esc(h.os_info || "-")} · Addrs: ${esc((h.addrs || []).join(", ") || "-")}<br/>
Users: ${esc((h.usernames || []).join(", ") || "-")} · Kinds: ${esc((h.kinds || []).join(", "))}
</div>
</div>
${dropBtn}
</div>
<table class="data"><thead><tr>
<th>Session</th><th>Kind</th><th>Status</th><th>User</th><th>Lock</th>
@@ -2767,6 +2837,26 @@
if (sid) selectSession(sid);
};
});
if (el("hostDetailHide")) {
el("hostDetailHide").onclick = async () => {
if (!confirm("Drop " + h.label + " from the Assets graph?")) return;
try {
await api("POST", "/api/v1/hosts/" + encodeURIComponent(h.id) + "/hide", {});
showOk("Dropped from graph");
_hostsCache.selected = null;
await loadHostsGraph();
} catch (e) { showError(String(e.message || e)); }
};
}
if (el("hostDetailUnhide")) {
el("hostDetailUnhide").onclick = async () => {
try {
await api("DELETE", "/api/v1/hosts/" + encodeURIComponent(h.id) + "/hide");
showOk("Restored to graph");
await loadHostsGraph();
} catch (e) { showError(String(e.message || e)); }
};
}
}
function drawHostGraph(container, hosts, onClick, selectedId) {
@@ -2775,7 +2865,7 @@
const hgt = Math.max(280, container.clientHeight || 320);
if (!hosts.length) {
container.innerHTML = `<div class="empty-state" style="height:100%;display:flex;align-items:center;justify-content:center;margin:0">
<div><strong>No host nodes</strong><div class="muted" style="margin-top:6px">Beacons and shells appear here grouped by hostname / remote address.</div></div>
<div><strong>No asset nodes</strong><div class="muted" style="margin-top:6px">Verified/interactive shells and implants appear here. Noise and dismissed hosts are hidden.</div></div>
</div>`;
return;
}