Merge pull request #88 from SquidSec/feature/hosts-graph-locks-ui

feat: Assets host graph + session claim TTL
This commit is contained in:
☣️ Mr. The Plague ☣️
2026-08-03 11:38:30 -05:00
committed by GitHub
10 changed files with 697 additions and 44 deletions
+8 -6
View File
@@ -223,7 +223,7 @@ The `/ops` console is an **app shell** (multi-page nav + context rail + dock).
| Region | Purpose |
|--------|---------|
| **Top bar** | Host, online status, Connect, Refresh, **INKO** flyout |
| **Left nav** | Dashboard - Sessions - Listeners - Payloads - **Profiles** - **Artifacts** - Post-Ex - Collab - **INKO** - Observe - Admin |
| **Left nav** | Dashboard - Sessions - **Assets** - Listeners - Payloads - **Profiles** - **Artifacts** - Post-Ex - Collab - **INKO** - Observe - Admin |
| **Main** | Active workspace for the selected nav item |
| **Right rail** | Selected session context (claim, shell, task) |
| **Bottom dock** | Live event stream + command output (resizable) |
@@ -1151,19 +1151,21 @@ sc5 tokens revoke <id>
### What
Teams, **session claim/lock**, handoff packs, spectator snapshots, operator presence, team-scoped chat, and per-operator audit filters.
Teams, **session claim/lock** (TTL + renew on activity), handoff packs, spectator snapshots, operator presence, team-scoped chat, per-operator audit filters, and the **Assets** host graph.
**UI:** Ops -> **Collab**.
**UI:** Ops -> **Collab** (teams/chat) · Ops -> **Assets** (host graph).
### Why
Two operators must not stomp the same shell; shift changes need context; leads need read-only watch.
Two operators must not stomp the same shell; shift changes need context; leads need read-only watch; operators need a host-centric map of implants/access.
### How
| Action | API / UI |
|--------|----------|
| Claim session | `POST /api/v1/sessions/{id}/claim` - Context -> Claim |
| Host inventory / graph | `GET /api/v1/hosts` - Ops -> **Assets** |
| 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` |
| Handoff pack | `POST /api/v1/sessions/{id}/handoff` `{to, note}` |
| Spectate | `GET /api/v1/sessions/{id}/spectator` |
@@ -1171,7 +1173,7 @@ Two operators must not stomp the same shell; shift changes need context; leads n
| Team chat | `POST /api/v1/collab/chat` with optional `team_id` |
| My audit | `GET /api/v1/audit/me` or `?mine=true` |
Claim lock is enforced on shell, tasks, and file ops (admins bypass). Feature flag: `collab_teams`.
Claim lock is enforced on shell, tasks, and file ops (admins bypass). Default TTL: `SQUIDC5_SESSION_CLAIM_TTL_SEC` (3600; `0` = no expiry). Feature flag: `collab_teams`.
### Example
+148 -2
View File
@@ -19,6 +19,7 @@ from squidc5.auth.tokens import (
AuthContext,
scope_catalog,
)
from squidc5.collab.teams import claim_info
from squidc5.paths import web_file
from squidc5.policy.engine import PolicyDecision
@@ -275,6 +276,7 @@ class HandoffRequest(BaseModel):
class ClaimRequest(BaseModel):
force: bool = False
ttl_sec: int | None = None # override default claim TTL; 0 = no expiry
class PresenceHeartbeat(BaseModel):
@@ -705,7 +707,141 @@ def build_api_router() -> APIRouter:
else:
filtered.append(r)
rows = filtered
return rows
# Attach normalized claim info for UI locks
out: list[dict[str, Any]] = []
for r in rows:
row = dict(r)
meta = row.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except json.JSONDecodeError:
meta = {}
info = claim_info(meta if isinstance(meta, dict) else {})
row["claim"] = info
out.append(row)
return out
@api.get("/hosts")
async def list_hosts(
request: Request,
auth: AuthContext = Depends(require_scope("sessions:read", "admin")),
) -> dict[str, Any]:
"""Engagement host inventory: group sessions into host nodes for the Assets graph."""
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)
hosts: dict[str, dict[str, Any]] = {}
edges: list[dict[str, str]] = []
for r in rows:
if r.get("status") == "closed":
# still include closed for inventory but mark inactive
pass
meta = r.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except json.JSONDecodeError:
meta = {}
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()
node = hosts.get(host_key)
if not node:
node = {
"id": host_key,
"label": host_key,
"hostname": r.get("hostname"),
"addrs": [],
"sessions": [],
"kinds": set(),
"active": 0,
"claimed_by": None,
"os_info": r.get("os_info"),
"usernames": set(),
}
hosts[host_key] = node
addr = r.get("remote_addr")
if addr and addr not in node["addrs"]:
node["addrs"].append(addr)
if r.get("os_info") and not node.get("os_info"):
node["os_info"] = r.get("os_info")
if r.get("username"):
node["usernames"].add(r.get("username"))
kind = r.get("kind") or "?"
node["kinds"].add(kind)
if r.get("status") == "active":
node["active"] += 1
if info.get("claimed_by") and not node.get("claimed_by"):
node["claimed_by"] = info.get("claimed_by")
node["sessions"].append(
{
"id": r.get("id"),
"kind": kind,
"status": r.get("status"),
"verified": r.get("verified"),
"username": r.get("username"),
"remote_addr": addr,
"last_seen_at": r.get("last_seen_at"),
"claim": info,
}
)
# Simple edge: reverse_shell -> beacon on same host (access path)
# represented later when serializing
nodes = []
for h in hosts.values():
kinds = sorted(h["kinds"])
nodes.append(
{
"id": h["id"],
"label": h["label"],
"hostname": h.get("hostname"),
"addrs": h["addrs"],
"os_info": h.get("os_info"),
"usernames": sorted(h["usernames"]),
"kinds": kinds,
"active_sessions": h["active"],
"session_count": len(h["sessions"]),
"claimed_by": h.get("claimed_by"),
"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 :]:
edges.append(
{
"source": a["id"],
"target": b["id"],
"host": h["id"],
"rel": "co-host",
}
)
# Also emit session-level nodes for drill-down graph
session_nodes = []
for h in nodes:
for s in h["sessions"]:
session_nodes.append(
{
"id": s["id"],
"type": "session",
"host": h["id"],
"kind": s.get("kind"),
"status": s.get("status"),
"verified": s.get("verified"),
"claimed_by": (s.get("claim") or {}).get("claimed_by"),
"label": f"{s.get('kind')}:{(s.get('id') or '')[:8]}",
}
)
return {
"hosts": nodes,
"session_nodes": session_nodes,
"edges": edges,
"claim_ttl_sec": int(getattr(state.settings, "session_claim_ttl_sec", 0) or 0),
}
@api.get("/sessions/{session_id}")
async def get_session(
@@ -718,7 +854,15 @@ def build_api_router() -> APIRouter:
s = await state.sessions.get(session_id)
if not s:
raise HTTPException(404, "session not found")
return s
row = dict(s)
meta = row.get("metadata") or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except json.JSONDecodeError:
meta = {}
row["claim"] = claim_info(meta if isinstance(meta, dict) else {})
return row
@api.post("/sessions/{session_id}/close")
async def close_session(
@@ -2579,12 +2723,14 @@ def build_api_router() -> APIRouter:
"""M1: claim session lock (only claim holder or admin may task)."""
state = get_state(request)
force = bool(body and body.force)
ttl = body.ttl_sec if body else None
try:
result = await state.teams.claim(
session_id,
auth.name,
force=force,
is_admin=auth.has_scope("admin"),
ttl_sec=ttl,
)
except KeyError:
raise HTTPException(404, "session not found") from None
+2 -4
View File
@@ -1,6 +1,4 @@
from squidc5.collab.presence import PresenceService
from squidc5.collab.teams import TeamService
from squidc5.collab.teams import TeamService, claim_info
__all__ = ["PresenceService", "TeamService"]
__all__ = ["TeamService"]
__all__ = ["PresenceService", "TeamService", "claim_info"]
+115 -20
View File
@@ -21,9 +21,47 @@ def _meta(row: dict[str, Any] | None) -> dict[str, Any]:
return dict(meta) if isinstance(meta, dict) else {}
def claim_info(meta: dict[str, Any], *, now: float | None = None) -> dict[str, Any]:
"""Normalized claim fields for API/UI (handles expiry)."""
ts = now if now is not None else time.time()
claimed_by = meta.get("claimed_by")
claimed_at = meta.get("claimed_at")
expires_at = meta.get("claim_expires_at")
expired = False
if claimed_by and expires_at is not None:
try:
if float(expires_at) <= ts:
expired = True
claimed_by = None
except (TypeError, ValueError):
pass
remaining = None
if claimed_by and expires_at is not None:
try:
remaining = max(0.0, float(expires_at) - ts)
except (TypeError, ValueError):
remaining = None
return {
"claimed_by": claimed_by,
"claimed_at": claimed_at,
"claim_expires_at": expires_at if claimed_by else None,
"claim_remaining_sec": remaining,
"claim_expired": expired,
"locked": bool(claimed_by),
}
class TeamService:
def __init__(self, db: Database) -> None:
def __init__(
self,
db: Database,
*,
claim_ttl_sec: int = 3600,
renew_on_activity: bool = True,
) -> None:
self.db = db
self.claim_ttl_sec = max(0, int(claim_ttl_sec or 0))
self.renew_on_activity = bool(renew_on_activity)
async def list_teams(self) -> list[dict[str, Any]]:
return await self.db.list_teams()
@@ -32,6 +70,33 @@ class TeamService:
tid = await self.db.create_team(name, created_by)
return {"id": tid, "name": name, "created_by": created_by}
def _expiry(self, claimed_at: float, ttl_sec: int | None) -> float | None:
ttl = self.claim_ttl_sec if ttl_sec is None else max(0, int(ttl_sec))
if ttl <= 0:
return None
return claimed_at + ttl
async def _clear_expired_claim(self, session_id: str, meta: dict[str, Any]) -> dict[str, Any]:
"""If claim expired, clear lock fields and return updated meta."""
info = claim_info(meta)
if not info["claim_expired"]:
return meta
was = meta.get("claimed_by")
meta.pop("claimed_by", None)
meta.pop("claim_expires_at", None)
meta["claim_expired_at"] = time.time()
meta["claim_expired_was"] = was
await self.db.update_session(session_id, metadata=meta)
await self.db.audit(
actor="system",
actor_type="system",
action="session.claim_expire",
resource=session_id,
details={"was": was},
risk_score=1,
)
return meta
async def claim(
self,
session_id: str,
@@ -39,35 +104,48 @@ class TeamService:
*,
force: bool = False,
is_admin: bool = False,
ttl_sec: int | None = None,
) -> dict[str, Any]:
"""M1: claim session lock. Only admin may force-steal."""
"""Claim session lock. Only admin may force-steal. Optional per-claim TTL override."""
row = await self.db.get_session(session_id)
if not row:
raise KeyError(session_id)
meta = _meta(row)
meta = await self._clear_expired_claim(session_id, _meta(row))
current = meta.get("claimed_by") or meta.get("owner")
if current and current != actor and not (force and is_admin):
raise PermissionError(f"Session claimed by {current}")
# owner alone does not block after release cleared claimed_by
current_lock = meta.get("claimed_by")
if current_lock and current_lock != actor and not (force and is_admin):
raise PermissionError(f"Session claimed by {current_lock}")
now = time.time()
expires = self._expiry(now, ttl_sec)
meta["claimed_by"] = actor
meta["owner"] = actor
meta["claimed_at"] = now
if current and current != actor:
meta["previous_claim"] = current
if expires is not None:
meta["claim_expires_at"] = expires
else:
meta.pop("claim_expires_at", None)
if current_lock and current_lock != actor:
meta["previous_claim"] = current_lock
await self.db.update_session(session_id, metadata=meta)
await self.db.audit(
actor=actor,
actor_type="operator",
action="session.claim",
resource=session_id,
details={"force": bool(force and is_admin), "previous": current},
details={
"force": bool(force and is_admin),
"previous": current_lock,
"expires_at": expires,
},
risk_score=3 if force else 2,
)
return {
"session_id": session_id,
"claimed_by": actor,
"claimed_at": now,
"previous": current,
"claim_expires_at": expires,
"previous": current_lock or current,
}
async def release(
@@ -80,14 +158,14 @@ class TeamService:
row = await self.db.get_session(session_id)
if not row:
raise KeyError(session_id)
meta = _meta(row)
current = meta.get("claimed_by") or meta.get("owner")
meta = await self._clear_expired_claim(session_id, _meta(row))
current = meta.get("claimed_by")
if current and current != actor and not is_admin:
raise PermissionError(f"Session claimed by {current}")
meta.pop("claimed_by", None)
meta.pop("claim_expires_at", None)
meta["released_by"] = actor
meta["released_at"] = time.time()
# keep owner history lightly
await self.db.update_session(session_id, metadata=meta)
await self.db.audit(
actor=actor,
@@ -105,17 +183,25 @@ class TeamService:
actor: str,
*,
is_admin: bool = False,
renew: bool = True,
) -> None:
"""Raise PermissionError if claim lock blocks actor."""
"""Raise PermissionError if claim lock blocks actor. Renews TTL for holder."""
if is_admin:
return
row = await self.db.get_session(session_id)
if not row:
raise KeyError(session_id)
meta = _meta(row)
meta = await self._clear_expired_claim(session_id, _meta(row))
claimed = meta.get("claimed_by")
if claimed and claimed != actor:
raise PermissionError(f"Session claimed by {claimed}; claim or release first")
if claimed and claimed == actor and renew and self.renew_on_activity:
expires = meta.get("claim_expires_at")
if expires is not None and self.claim_ttl_sec > 0:
now = time.time()
meta["claim_expires_at"] = now + self.claim_ttl_sec
meta["claim_renewed_at"] = now
await self.db.update_session(session_id, metadata=meta)
team_id = meta.get("team_id")
if team_id:
members = await self.db.list_team_members(str(team_id))
@@ -134,7 +220,7 @@ class TeamService:
include_pack: bool = True,
state: Any = None,
) -> dict[str, Any]:
"""M2: handoff note + optional pack (tasks/output/ROE) + claim transfer."""
"""Handoff note + optional pack + claim transfer."""
row = await self.db.get_session(session_id)
if not row:
raise KeyError(session_id)
@@ -155,10 +241,16 @@ class TeamService:
if transfer_claim and to_actor:
meta = _meta(row)
now = time.time()
meta["claimed_by"] = to_actor
meta["owner"] = to_actor
meta["claimed_at"] = time.time()
meta["claimed_at"] = now
meta["handed_off_from"] = from_actor
expires = self._expiry(now, None)
if expires is not None:
meta["claim_expires_at"] = expires
else:
meta.pop("claim_expires_at", None)
await self.db.update_session(session_id, metadata=meta)
await self.db.audit(
@@ -225,11 +317,12 @@ class TeamService:
await self.db.set_session_owner(session_id, owner)
async def spectator_view(self, session_id: str, *, state: Any = None) -> dict[str, Any]:
"""M3: read-only snapshot (no shell interact)."""
"""Read-only snapshot (no shell interact)."""
row = await self.db.get_session(session_id)
if not row:
raise KeyError(session_id)
meta = _meta(row)
meta = await self._clear_expired_claim(session_id, _meta(row))
info = claim_info(meta)
tasks: list[dict[str, Any]] = []
try:
rows = await self.db.list_tasks(session_id=session_id)
@@ -261,8 +354,10 @@ class TeamService:
"username": row.get("username"),
"os_info": row.get("os_info"),
"owner": meta.get("owner"),
"claimed_by": meta.get("claimed_by"),
"claimed_at": meta.get("claimed_at"),
"claimed_by": info.get("claimed_by"),
"claimed_at": info.get("claimed_at"),
"claim_expires_at": info.get("claim_expires_at"),
"claim_remaining_sec": info.get("claim_remaining_sec"),
"team_id": meta.get("team_id"),
"handoffs": await self.session_notes(session_id),
"recent_tasks": tasks,
+9 -1
View File
@@ -55,6 +55,9 @@ class Settings(BaseSettings):
public_ip: str = "" # A-record for OAST DNS answers (SQUIDC5_PUBLIC_IP)
shell_stabilize_delay_sec: float = 0.8
shell_probe_wait_sec: float = 1.5
# Session claim/lock TTL (0 = no expiry). Renewed on claim-holder write activity when enabled.
session_claim_ttl_sec: int = 3600
session_claim_renew_on_activity: bool = True
# OAST Collaborator (SQUIDC5_OAST_*)
oast_enabled: bool = True
oast_zone: str = "oast.lab.invalid"
@@ -77,7 +80,12 @@ class Settings(BaseSettings):
raise ValueError("port must be 1-65535")
return int(v)
@field_validator("max_body_bytes", "rate_limit_per_minute", "auth_fail_limit_per_minute")
@field_validator(
"max_body_bytes",
"rate_limit_per_minute",
"auth_fail_limit_per_minute",
"session_claim_ttl_sec",
)
@classmethod
def _non_negative(cls, v: int) -> int:
if int(v) < 0:
+5 -1
View File
@@ -73,7 +73,11 @@ async def build_state(settings: Settings) -> AppState:
profiles = ProfileEngine(db)
await profiles.load()
implants = ImplantRegistry()
teams = TeamService(db)
teams = TeamService(
db,
claim_ttl_sec=int(getattr(settings, "session_claim_ttl_sec", 3600) or 0),
renew_on_activity=bool(getattr(settings, "session_claim_renew_on_activity", True)),
)
from squidc5.collab.presence import PresenceService
presence = PresenceService(ttl_sec=90.0)
+148
View File
@@ -0,0 +1,148 @@
"""Assets host graph API + claim TTL + ops UI markers."""
from __future__ import annotations
import time
import pytest
from httpx import ASGITransport, AsyncClient
from squidc5.collab.teams import TeamService, claim_info
from squidc5.config import Settings
from squidc5.main import create_app
ADMIN = "sc5_test_admin_token_bootstrap_hosts01"
@pytest.mark.asyncio
async def test_hosts_api_and_claim_ttl(tmp_path):
settings = Settings(
data_dir=tmp_path / "hg2",
debug=True,
mcp_enabled=False,
admin_token_bootstrap=ADMIN,
plugin_signing_secret="test-plugin-signing-secret-for-ci",
implant_require_auth=False,
rate_limit_per_minute=2000,
session_claim_ttl_sec=120,
)
app = create_app(settings)
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
h = {"Authorization": f"Bearer {ADMIN}"}
b1 = await client.post(
"/api/v1/implant/beacon",
json={"hostname": "workstation-a", "username": "alice"},
)
assert b1.status_code == 200
sid1 = b1.json()["session_id"]
b2 = await client.post(
"/api/v1/implant/beacon",
json={"hostname": "workstation-a", "username": "bob"},
)
assert b2.status_code == 200
sid2 = b2.json()["session_id"]
b3 = await client.post(
"/api/v1/implant/beacon",
json={"hostname": "dc01", "username": "svc"},
)
assert b3.status_code == 200
hosts = await client.get("/api/v1/hosts", headers=h)
assert hosts.status_code == 200
body = hosts.json()
assert body["claim_ttl_sec"] == 120
by_id = {x["id"]: x for x in body["hosts"]}
assert "workstation-a" in by_id
assert "dc01" 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"]
)
c = await client.post(f"/api/v1/sessions/{sid1}/claim", headers=h, json={})
assert c.status_code == 200
cj = c.json()
assert cj["claimed_by"]
assert cj.get("claim_expires_at") is not None
sess = await client.get(f"/api/v1/sessions/{sid1}", headers=h)
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",
headers=h,
json={"ttl_sec": 1},
)
assert c2.status_code == 200
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():
now = 1_000_000.0
open_lock = claim_info(
{"claimed_by": "alice", "claimed_at": now - 10, "claim_expires_at": now + 50},
now=now,
)
assert open_lock["locked"] is True
assert open_lock["claim_remaining_sec"] == 50
dead = claim_info(
{"claimed_by": "alice", "claim_expires_at": now - 1},
now=now,
)
assert dead["locked"] is False
assert dead["claim_expired"] is True
forever = claim_info({"claimed_by": "bob", "claimed_at": now}, now=now)
assert forever["locked"] is True
assert forever["claim_expires_at"] is None
@pytest.mark.asyncio
async def test_hosts_ui_markers(tmp_path):
settings = Settings(
data_dir=tmp_path / "hg3",
debug=True,
mcp_enabled=False,
admin_token_bootstrap=ADMIN,
plugin_signing_secret="test-plugin-signing-secret-for-ci",
rate_limit_per_minute=2000,
)
app = create_app(settings)
async with app.router.lifespan_context(app):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
h = {"Authorization": f"Bearer {ADMIN}"}
r = await client.get("/api/v1/ops/admin.js", headers=h)
assert r.status_code == 200
js = r.text
for m in (
"renderHostsView",
"drawHostGraph",
"/api/v1/hosts",
"hostGraph",
"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
+4
View File
@@ -207,12 +207,16 @@ async def test_ops_admin_collab_ui_markers(tmp_path):
"view-sessions",
"view-collab",
"ctxClaim",
"ctxForceClaim",
"/api/v1/sessions/",
"/api/v1/hosts",
"claim",
"handoff",
"presence",
"selectSession",
"renderSessionsView",
"renderHostsView",
"drawHostGraph",
):
assert m in js, m
+242 -7
View File
@@ -52,6 +52,10 @@
{ a: "tasks", t: "Tasks / beacons" },
{ a: "verified-reverse-shells", t: "Verified shells" },
],
hosts: [
{ a: "sessions", t: "Sessions / hosts" },
{ a: "multi-operator-collab", t: "Session locks" },
],
listeners: [
{ a: "listeners", t: "Listeners" },
{ a: "oast-collaborator", t: "OAST" },
@@ -448,16 +452,28 @@
if (el("ctxClaim")) el("ctxClaim").onclick = async () => {
try {
const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/claim`, {});
showOk("Claimed");
showOk("Lock claimed");
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
if (window.__SC5_refresh) await window.__SC5_refresh();
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxForceClaim")) el("ctxForceClaim").onclick = async () => {
if (!confirm("Force-steal lock from current holder?")) return;
try {
const r = await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/claim`, { force: true });
showOk("Force claimed");
if (el("ctxOut")) { el("ctxOut").textContent = JSON.stringify(r, null, 2); el("ctxOut").classList.remove("empty"); }
if (window.__SC5_refresh) await window.__SC5_refresh();
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxRelease")) el("ctxRelease").onclick = async () => {
try {
await api("POST", `/api/v1/sessions/${encodeURIComponent(selectedId)}/release`);
showOk("Released");
showOk("Lock released");
if (window.__SC5_refresh) await window.__SC5_refresh();
renderContext(true);
} catch (e) { showError(String(e.message || e)); }
};
if (el("ctxSpectate")) el("ctxSpectate").onclick = async () => {
@@ -504,6 +520,23 @@
return;
}
const m = metaOf(s);
const claim = s.claim || {
claimed_by: m.claimed_by,
claim_expires_at: m.claim_expires_at,
claim_remaining_sec: m.claim_remaining_sec,
locked: !!m.claimed_by,
};
function claimChipHtml(c) {
if (!c || !c.claimed_by) return '<span class="chip">unlocked</span>';
let extra = "";
if (c.claim_remaining_sec != null && c.claim_expires_at) {
const mleft = Math.max(0, Math.ceil(Number(c.claim_remaining_sec) / 60));
extra = " · " + mleft + "m left";
} else if (c.claim_expires_at == null && c.claimed_by) {
extra = " · no timeout";
}
return `<span class="chip warn">locked: ${esc(c.claimed_by)}${esc(extra)}</span>`;
}
// Soft update: keep form fields if same session already bound
if (!force && ctxBoundSid === selectedId && el("ctxMeta")) {
el("ctxMeta").innerHTML = `
@@ -512,7 +545,7 @@
<div class="chips" style="margin-bottom:10px">
<span class="chip">${esc(s.kind || "?")}</span>
<span class="chip ${s.verified ? "ok" : ""}">${s.verified ? "verified" : esc(s.status || "")}</span>
${m.claimed_by ? `<span class="chip warn">${esc(m.claimed_by)}</span>` : '<span class="chip">unlocked</span>'}
${claimChipHtml(claim)}
</div>
<div class="muted" style="font-size:0.78rem;margin-bottom:8px">
User: ${esc(s.username || "-")}<br/>OS: ${esc(s.os_info || "-")}<br/>Addr: ${esc(s.remote_addr || "-")}
@@ -520,12 +553,14 @@
return;
}
const shellOk = can("shell:interact") && (s.kind === "reverse_shell" || s.interactive || s.verified);
const canLock = can("shell:interact") || can("collab:use") || can("admin");
body.innerHTML = `
<div id="ctxMeta"></div>
<div class="row">
${can("shell:interact") || can("collab:use") ? '<button type="button" class="primary" id="ctxClaim">Claim</button>' : ""}
${can("shell:interact") || can("collab:use") ? '<button type="button" id="ctxRelease">Release</button>' : ""}
${can("sessions:read") ? '<button type="button" id="ctxSpectate">Spectate</button>' : ""}
${canLock ? '<button type="button" class="primary" id="ctxClaim">Claim lock</button>' : ""}
${canLock ? '<button type="button" class="ghost" id="ctxRelease">Release</button>' : ""}
${can("admin") ? '<button type="button" class="danger sm" id="ctxForceClaim">Force claim</button>' : ""}
${can("sessions:read") ? '<button type="button" class="ghost" id="ctxSpectate">Spectate</button>' : ""}
</div>
${shellOk ? `
<label for="ctxCmd">Shell command</label>
@@ -549,7 +584,7 @@
<div class="chips" style="margin-bottom:10px">
<span class="chip">${esc(s.kind || "?")}</span>
<span class="chip ${s.verified ? "ok" : ""}">${s.verified ? "verified" : esc(s.status || "")}</span>
${m.claimed_by ? `<span class="chip warn">${esc(m.claimed_by)}</span>` : '<span class="chip">unlocked</span>'}
${claimChipHtml(claim)}
</div>
<div class="muted" style="font-size:0.78rem;margin-bottom:8px">
User: ${esc(s.username || "-")}<br/>OS: ${esc(s.os_info || "-")}<br/>Addr: ${esc(s.remote_addr || "-")}
@@ -2602,11 +2637,210 @@
}
/* -- Assets / hosts graph -- */
let _hostsCache = { hosts: [], edges: [], claim_ttl_sec: 0, selected: null };
function renderHostsView(force) {
const root = el("view-hosts");
if (!root) return;
if (!force && viewBuilt.hosts && root.querySelector("#hostGraph")) {
loadHostsGraph();
return;
}
root.innerHTML = `
<div class="split" style="grid-template-columns: minmax(260px, 340px) 1fr">
<div class="list-panel">
<div class="lp-head">Hosts
<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>
<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>
<div class="chips" style="margin-bottom:8px">
<span class="chip ok">active</span>
<span class="chip warn">locked</span>
<span class="chip">idle / closed 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>
</div>
</div>
</div>`;
viewBuilt.hosts = true;
if (el("hostReload")) el("hostReload").onclick = () => loadHostsGraph();
loadHostsGraph();
}
async function loadHostsGraph() {
const tbody = el("hostTbody");
const graph = el("hostGraph");
const detail = el("hostDetail");
if (!tbody || !graph) return;
try {
const data = await api("GET", "/api/v1/hosts");
const hosts = data.hosts || [];
_hostsCache = {
hosts,
edges: data.edges || [],
claim_ttl_sec: data.claim_ttl_sec || 0,
selected: _hostsCache.selected,
};
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");
}
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>
<div class="muted mono" style="font-size:0.65rem">${esc((h.addrs || []).join(", ") || "")}</div>
</td>
<td>${esc(String(h.active_sessions || 0))}/${esc(String(h.session_count || 0))}</td>
<td>${lock}</td>
</tr>`;
}).join("") || '<tr><td colspan="3" class="muted">No hosts yet — catch a beacon or shell</td></tr>';
const pick = (id) => {
tbody.querySelectorAll("tr").forEach((x) => {
x.classList.toggle("selected", x.getAttribute("data-host") === id);
});
const h = hosts.find((x) => x.id === id);
_hostsCache.selected = id;
if (h) showHostDetail(h, detail);
drawHostGraph(graph, hosts, pick, id);
};
tbody.querySelectorAll("tr[data-host]").forEach((tr) => {
tr.onclick = () => pick(tr.getAttribute("data-host"));
});
const sel = _hostsCache.selected && hosts.some((h) => h.id === _hostsCache.selected)
? _hostsCache.selected
: null;
drawHostGraph(graph, hosts, pick, sel);
if (sel) {
const h = hosts.find((x) => x.id === sel);
if (h) showHostDetail(h, detail);
}
} catch (e) {
showError(String(e.message || e));
tbody.innerHTML = '<tr><td colspan="3" class="muted">Failed to load hosts</td></tr>';
}
}
function showHostDetail(h, detail) {
if (!detail || !h) return;
detail.classList.remove("empty");
const sess = (h.sessions || []).map((s) => {
const c = s.claim || {};
const lock = c.claimed_by ? c.claimed_by : "-";
const left = (c.claim_remaining_sec != null)
? ` · ${Math.ceil(c.claim_remaining_sec / 60)}m`
: "";
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.username || "-")}</td>
<td>${c.claimed_by ? `<span class="chip warn">${esc(lock)}${esc(left)}</span>` : '<span class="chip">unlocked</span>'}</td>
</tr>`;
}).join("");
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>
</div>
<table class="data"><thead><tr>
<th>Session</th><th>Kind</th><th>Status</th><th>User</th><th>Lock</th>
</tr></thead><tbody>${sess || '<tr><td colspan="5" class="muted">No sessions</td></tr>'}</tbody></table>`;
detail.querySelectorAll("tr[data-sid]").forEach((tr) => {
tr.onclick = () => {
const sid = tr.getAttribute("data-sid");
if (sid) selectSession(sid);
};
});
}
function drawHostGraph(container, hosts, onClick, selectedId) {
if (!container) return;
const w = Math.max(320, container.clientWidth || 480);
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>`;
return;
}
const n = hosts.length;
const cx = w / 2;
const cy = hgt / 2;
const R = Math.min(w, hgt) * (n === 1 ? 0 : 0.34);
const nodes = hosts.map((host, i) => {
const ang = (i / n) * Math.PI * 2 - Math.PI / 2;
return {
host,
x: n === 1 ? cx : cx + Math.cos(ang) * R,
y: n === 1 ? cy : cy + Math.sin(ang) * R,
};
});
const byId = Object.fromEntries(nodes.map((nd) => [nd.host.id, nd]));
let svg = `<svg width="100%" height="100%" viewBox="0 0 ${w} ${hgt}" xmlns="http://www.w3.org/2000/svg">`;
// Hub spokes + ring for multi-host engagement topology
if (n > 1) {
nodes.forEach((nd) => {
svg += `<line x1="${cx}" y1="${cy}" x2="${nd.x}" y2="${nd.y}" stroke="rgba(233,30,140,0.12)" stroke-width="1" stroke-dasharray="4 4"/>`;
});
for (let i = 0; i < n; i++) {
const a = nodes[i];
const b = nodes[(i + 1) % n];
svg += `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="rgba(233,30,140,0.18)" stroke-width="1.5"/>`;
}
}
// Co-host session edges collapsed to host pairs (thicker when many sessions share host - already same node)
// Cross-host: none from API; keep topology visual only
void byId;
nodes.forEach((node, idx) => {
const host = node.host;
const active = (host.active_sessions || 0) > 0;
const locked = !!host.claimed_by;
const sel = selectedId && host.id === selectedId;
const fill = locked ? "rgba(251,191,36,0.28)" : active ? "rgba(233,30,140,0.38)" : "rgba(255,255,255,0.07)";
const stroke = sel ? "#fff" : locked ? "rgba(251,191,36,0.9)" : "rgba(233,30,140,0.7)";
const sw = sel ? 3 : 2;
const r = 20 + Math.min(16, (host.session_count || 1) * 3);
const label = (host.label || host.id || "?").slice(0, 20);
const sub = (host.active_sessions || 0) + "/" + (host.session_count || 0);
svg += `<g class="host-node" data-idx="${idx}" style="cursor:pointer">
<circle cx="${node.x}" cy="${node.y}" r="${r + 4}" fill="none" stroke="${sel ? "rgba(233,30,140,0.35)" : "transparent"}" stroke-width="6"/>
<circle cx="${node.x}" cy="${node.y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"/>
<text x="${node.x}" y="${node.y + 4}" text-anchor="middle" fill="#fff" font-size="11" font-weight="700">${esc(sub)}</text>
<text x="${node.x}" y="${node.y + r + 16}" text-anchor="middle" fill="#c8c8d4" font-size="11" font-family="ui-monospace,monospace">${esc(label)}</text>
</g>`;
});
svg += "</svg>";
container.innerHTML = svg;
container.querySelectorAll(".host-node").forEach((g) => {
g.onclick = () => {
const i = Number(g.getAttribute("data-idx"));
if (nodes[i] && onClick) onClick(nodes[i].host.id);
};
});
}
/* -- View router -- */
function renderView(name) {
// Soft by default - preserve form focus/values; only build once per view
switch (name) {
case "sessions": renderSessionsView(false); break;
case "hosts": renderHostsView(false); break;
case "listeners": renderListenersView(false); break;
case "payloads": renderPayloadsView(false); break;
case "profiles": renderProfilesView(false); break;
@@ -2643,6 +2877,7 @@
renderSessionsView(false);
if (el("tskList") && !typing) loadTasksPanel();
}
if (currentIs("hosts") && !typing) renderHostsView(false);
if (currentIs("listeners")) renderListenersView(false);
if (el("pxSid") && !typing) el("pxSid").textContent = selectedId || "(none - pick in Sessions)";
document.querySelectorAll("tr[data-sid]").forEach((tr) => {
+16 -3
View File
@@ -40,16 +40,25 @@
button, input, select, textarea { font: inherit; color: inherit; }
button {
cursor: pointer; border: 1px solid var(--border2); background: var(--bg3);
border-radius: 6px; padding: 6px 12px; color: var(--text);
border-radius: 6px; padding: 6px 12px; min-height: 32px; color: var(--text);
font-size: 0.82rem; font-weight: 500; line-height: 1.2;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
button:hover { border-color: rgba(233,30,140,0.45); background: #22222c; }
button.primary { background: rgba(233,30,140,0.2); border-color: rgba(233,30,140,0.5); color: var(--pink2); font-weight: 600; }
button.primary:hover { background: rgba(233,30,140,0.35); }
button.danger { border-color: rgba(248,113,113,0.4); color: var(--bad); }
button.danger { border-color: rgba(248,113,113,0.4); color: var(--bad); background: rgba(248,113,113,0.08); }
button.danger:hover { background: rgba(248,113,113,0.16); }
button.ghost {
background: transparent; border-color: var(--border); color: var(--muted); font-weight: 500;
}
button.ghost:hover { color: var(--text); border-color: rgba(233,30,140,0.4); background: rgba(255,255,255,0.03); }
button.sm { padding: 4px 10px; min-height: 28px; font-size: 0.75rem; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
button.active, .nav-item.active {
background: rgba(233,30,140,0.18); border-color: rgba(233,30,140,0.4); color: var(--pink2);
}
.toolbar button, .row button { flex-shrink: 0; }
input, select, textarea {
width: 100%; background: #0e0e14; border: 1px solid var(--border2);
border-radius: 6px; padding: 7px 10px; outline: none;
@@ -986,6 +995,7 @@
<div class="side-nav" id="sideNav">
<button type="button" class="nav-item active" data-view="dashboard">Dashboard</button>
<button type="button" class="nav-item" data-view="sessions">Sessions <span class="badge-n" id="navNSes">0</span></button>
<button type="button" class="nav-item" data-view="hosts">Assets</button>
<button type="button" class="nav-item" data-view="listeners">Listeners <span class="badge-n" id="navNLis">0</span></button>
<button type="button" class="nav-item" data-view="payloads">Payloads</button>
<button type="button" class="nav-item" data-view="profiles">Profiles</button>
@@ -1039,6 +1049,7 @@
</div>
</div>
<div class="view" id="view-sessions"></div>
<div class="view" id="view-hosts"></div>
<div class="view" id="view-listeners"></div>
<div class="view" id="view-payloads"></div>
<div class="view" id="view-profiles"></div>
@@ -1212,6 +1223,7 @@
/* Nav data-view -> any-of scopes (dashboard always shown when connected) */
const NAV_GATES = {
sessions: ["sessions:read"],
hosts: ["sessions:read"],
listeners: ["listeners:read"],
payloads: ["payloads:generate"],
profiles: ["profiles:read"],
@@ -1239,7 +1251,7 @@
const need = NAV_GATES[preferred];
if (!need || canAny(need)) return preferred;
const order = [
"sessions", "listeners", "payloads", "profiles", "artifacts",
"sessions", "hosts", "listeners", "payloads", "profiles", "artifacts",
"postex", "collab", "ai", "observe", "admin",
];
for (const v of order) {
@@ -1275,6 +1287,7 @@
const VIEWS = {
dashboard: { title: "Dashboard", sub: "Teamserver health and shortcuts", doc: "status-overview" },
sessions: { title: "Sessions", sub: "Beacons and reverse shells", doc: "sessions" },
hosts: { title: "Assets", sub: "Host graph - implants, access, system info", doc: "sessions" },
listeners: { title: "Listeners", sub: "Bind ports and manage acceptors", doc: "listeners" },
payloads: { title: "Payloads", sub: "Generate implants and templates", doc: "payloads-and-implants" },
profiles: { title: "Profiles", sub: "Malleable C2 profiles - activate, edit, push", doc: "c2-profiles" },