mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
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:
+3
-1
@@ -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` |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from squidc5.hosts.graph import host_key_for_session, is_asset_session
|
||||
|
||||
__all__ = ["host_key_for_session", "is_asset_session"]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user