mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
Merge pull request #20 from DotNetRussell/feature/sessions-clear-shells
feat(sessions): bulk clear reverse-shell scanner noise
This commit is contained in:
@@ -349,6 +349,57 @@ def build_api_router() -> APIRouter:
|
||||
n = await state.sessions.close_orphaned_shells(probe=probe)
|
||||
return {"closed": n, "probed": probe}
|
||||
|
||||
@api.post("/sessions/clear")
|
||||
async def clear_sessions(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_scope("sessions:write", "admin")),
|
||||
) -> dict[str, Any]:
|
||||
"""Bulk remove reverse-shell/tcp noise (scanners on public ports)."""
|
||||
state = get_state(request)
|
||||
body: dict[str, Any] = {}
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
unverified_only = bool(body.get("unverified_only", True))
|
||||
closed_only = bool(body.get("closed_only", False))
|
||||
active_only = bool(body.get("active_only", False))
|
||||
delete = bool(body.get("delete", True))
|
||||
if body.get("all_shells"):
|
||||
unverified_only = False
|
||||
closed_only = False
|
||||
active_only = False
|
||||
decision = await state.policy.check_and_audit(
|
||||
auth,
|
||||
"sessions.clear",
|
||||
extra={
|
||||
"unverified_only": unverified_only,
|
||||
"closed_only": closed_only,
|
||||
"delete": delete,
|
||||
},
|
||||
)
|
||||
if not decision.allowed:
|
||||
raise HTTPException(403, decision.reason)
|
||||
result = await state.sessions.clear_shells(
|
||||
unverified_only=unverified_only,
|
||||
closed_only=closed_only,
|
||||
active_only=active_only,
|
||||
delete=delete,
|
||||
)
|
||||
return result
|
||||
|
||||
@api.delete("/sessions/{session_id}")
|
||||
async def delete_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(require_scope("sessions:write", "admin")),
|
||||
) -> dict[str, str]:
|
||||
state = get_state(request)
|
||||
ok = await state.sessions.delete(session_id)
|
||||
if not ok:
|
||||
raise HTTPException(404, "session not found")
|
||||
return {"status": "deleted", "id": session_id}
|
||||
|
||||
# ----- Tasks -----
|
||||
|
||||
@api.get("/tasks")
|
||||
|
||||
+52
-1
@@ -211,6 +211,24 @@ def cmd_sessions_close(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.post(f"/api/v1/sessions/{args.id}/close"))
|
||||
|
||||
|
||||
def cmd_sessions_delete(args: argparse.Namespace, client: Client) -> None:
|
||||
pp(client.delete(f"/api/v1/sessions/{args.id}"))
|
||||
|
||||
|
||||
def cmd_sessions_clear(args: argparse.Namespace, client: Client) -> None:
|
||||
"""Bulk remove reverse-shell noise (default: unverified only)."""
|
||||
body: dict[str, Any] = {
|
||||
"delete": not getattr(args, "close_only", False),
|
||||
"unverified_only": not getattr(args, "all_shells", False),
|
||||
"closed_only": bool(getattr(args, "closed", False)),
|
||||
"active_only": bool(getattr(args, "active", False)),
|
||||
"all_shells": bool(getattr(args, "all_shells", False)),
|
||||
}
|
||||
if body["closed_only"] or body["active_only"]:
|
||||
body["unverified_only"] = bool(getattr(args, "unverified", False))
|
||||
pp(client.post("/api/v1/sessions/clear", json=body))
|
||||
|
||||
|
||||
def cmd_sessions_reap(args: argparse.Namespace, client: Client) -> None:
|
||||
# Exec probe can take a bit with many sessions — raise timeout
|
||||
old = client._client.timeout
|
||||
@@ -797,9 +815,42 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
s_get = ses_sub.add_parser("get", help="Get session")
|
||||
s_get.add_argument("id")
|
||||
s_get.set_defaults(func=cmd_sessions_get, needs_client=True)
|
||||
s_close = ses_sub.add_parser("close", help="Close session")
|
||||
s_close = ses_sub.add_parser("close", help="Close session (keep row)")
|
||||
s_close.add_argument("id")
|
||||
s_close.set_defaults(func=cmd_sessions_close, needs_client=True)
|
||||
s_del = ses_sub.add_parser("delete", help="Hard-delete one session")
|
||||
s_del.add_argument("id")
|
||||
s_del.set_defaults(func=cmd_sessions_delete, needs_client=True)
|
||||
s_clear = ses_sub.add_parser(
|
||||
"clear",
|
||||
help="Bulk remove reverse-shell noise (default: unverified scanners)",
|
||||
)
|
||||
s_clear.add_argument(
|
||||
"--all-shells",
|
||||
action="store_true",
|
||||
help="Remove ALL reverse_shell/tcp sessions (verified too)",
|
||||
)
|
||||
s_clear.add_argument(
|
||||
"--closed",
|
||||
action="store_true",
|
||||
help="Only purge already-closed shell rows",
|
||||
)
|
||||
s_clear.add_argument(
|
||||
"--active",
|
||||
action="store_true",
|
||||
help="Only active shells",
|
||||
)
|
||||
s_clear.add_argument(
|
||||
"--unverified",
|
||||
action="store_true",
|
||||
help="With --closed/--active: only unverified",
|
||||
)
|
||||
s_clear.add_argument(
|
||||
"--close-only",
|
||||
action="store_true",
|
||||
help="Mark closed instead of hard-delete",
|
||||
)
|
||||
s_clear.set_defaults(func=cmd_sessions_clear, needs_client=True)
|
||||
s_reap = ses_sub.add_parser(
|
||||
"reap",
|
||||
help="Close dead reverse shells (no TCP, or fail exec probe)",
|
||||
|
||||
@@ -46,6 +46,7 @@ DEFAULT_POLICY: dict[str, Any] = {
|
||||
"action_risk": {
|
||||
"sessions.list": 0,
|
||||
"sessions.get": 0,
|
||||
"sessions.clear": 2,
|
||||
"tasks.list": 0,
|
||||
"tasks.create": 4,
|
||||
"listeners.create": 5,
|
||||
|
||||
@@ -71,16 +71,98 @@ class SessionManager:
|
||||
await self.db.update_session(session_id, **fields)
|
||||
await self.metrics.emit("session.heartbeat", {"id": session_id})
|
||||
|
||||
async def close(self, session_id: str) -> None:
|
||||
async def close(self, session_id: str, *, drop: bool = True) -> None:
|
||||
if drop and self.drop_channel is not None:
|
||||
try:
|
||||
await self.drop_channel(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
await self.db.update_session(session_id, status="closed")
|
||||
async with self._lock:
|
||||
self._live.pop(session_id, None)
|
||||
await self.metrics.emit("session.closed", {"id": session_id})
|
||||
|
||||
async def delete(self, session_id: str, *, drop: bool = True) -> bool:
|
||||
"""Hard-remove session row (and drop TCP if any)."""
|
||||
if drop and self.drop_channel is not None:
|
||||
try:
|
||||
await self.drop_channel(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
async with self._lock:
|
||||
self._live.pop(session_id, None)
|
||||
ok = await self.db.delete_session(session_id)
|
||||
if ok:
|
||||
await self.metrics.emit("session.deleted", {"id": session_id})
|
||||
return ok
|
||||
|
||||
async def clear_shells(
|
||||
self,
|
||||
*,
|
||||
unverified_only: bool = False,
|
||||
closed_only: bool = False,
|
||||
active_only: bool = False,
|
||||
delete: bool = True,
|
||||
kinds: tuple[str, ...] = ("reverse_shell", "tcp"),
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Bulk remove reverse-shell/tcp sessions (scanner noise cleanup).
|
||||
|
||||
unverified_only: only shells that are not exec-verified
|
||||
closed_only: only status=closed
|
||||
active_only: only status=active
|
||||
delete: hard-delete rows (default); if False, mark closed
|
||||
"""
|
||||
if closed_only:
|
||||
rows = await self.db.list_sessions(status="closed")
|
||||
elif active_only:
|
||||
rows = await self.db.list_sessions(status="active")
|
||||
else:
|
||||
rows = await self.db.list_sessions(status=None)
|
||||
removed = 0
|
||||
closed = 0
|
||||
for row in rows:
|
||||
if row.get("kind") not in kinds:
|
||||
continue
|
||||
sid = str(row["id"])
|
||||
status = row.get("status") or ""
|
||||
if closed_only and status != "closed":
|
||||
continue
|
||||
if active_only and status != "active":
|
||||
continue
|
||||
if unverified_only:
|
||||
verified = False
|
||||
if self.verified_check is not None:
|
||||
verified = bool(self.verified_check(sid))
|
||||
meta = row.get("metadata")
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
if isinstance(meta, dict) and (meta.get("verified") or meta.get("exec_ok")):
|
||||
verified = True
|
||||
if verified:
|
||||
continue
|
||||
if delete:
|
||||
if await self.delete(sid, drop=True):
|
||||
removed += 1
|
||||
else:
|
||||
await self.close(sid, drop=True)
|
||||
closed += 1
|
||||
if removed:
|
||||
await self.metrics.incr("sessions.cleared", float(removed))
|
||||
return {"removed": removed, "closed": closed}
|
||||
|
||||
async def reject(self, session_id: str, reason: str) -> None:
|
||||
"""Remove false-positive / scanner sessions entirely."""
|
||||
async with self._lock:
|
||||
self._live.pop(session_id, None)
|
||||
if self.drop_channel is not None:
|
||||
try:
|
||||
await self.drop_channel(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
await self.db.delete_session(session_id)
|
||||
await self.metrics.incr("sessions.rejected")
|
||||
await self.metrics.emit(
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_clear_unverified(client, admin_headers):
|
||||
# Create shell sessions via reverse_shell listener + raw connect that goes quiet
|
||||
lr = await client.post(
|
||||
"/api/v1/listeners",
|
||||
headers=admin_headers,
|
||||
json={"name": "shell-clear-test", "kind": "reverse_shell", "port": 19044},
|
||||
)
|
||||
assert lr.status_code == 200
|
||||
lid = lr.json()["id"]
|
||||
st = await client.post(f"/api/v1/listeners/{lid}/start", headers=admin_headers)
|
||||
assert st.status_code == 200
|
||||
|
||||
import asyncio
|
||||
|
||||
# silent connect — creates session until probe drops it
|
||||
reader, writer = await asyncio.open_connection("127.0.0.1", 19044)
|
||||
await asyncio.sleep(0.6)
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0.3)
|
||||
# clear unverified (and closed leftovers)
|
||||
clr = await client.post(
|
||||
"/api/v1/sessions/clear",
|
||||
headers=admin_headers,
|
||||
json={"unverified_only": False, "all_shells": True, "delete": True},
|
||||
)
|
||||
assert clr.status_code == 200
|
||||
assert "removed" in clr.json()
|
||||
|
||||
rows = await client.get(
|
||||
"/api/v1/sessions?status=all&kind=reverse_shell,tcp",
|
||||
headers=admin_headers,
|
||||
)
|
||||
shells = [r for r in rows.json() if r.get("kind") in ("reverse_shell", "tcp")]
|
||||
# all shells from this test should be gone
|
||||
assert not any(r.get("listener_id") == lid for r in shells)
|
||||
|
||||
await client.post(f"/api/v1/listeners/{lid}/stop", headers=admin_headers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_delete_endpoint(client, admin_headers):
|
||||
b = await client.post(
|
||||
"/api/v1/implant/beacon",
|
||||
json={"hostname": "clear-me", "username": "u"},
|
||||
)
|
||||
sid = b.json()["session_id"]
|
||||
d = await client.delete(f"/api/v1/sessions/{sid}", headers=admin_headers)
|
||||
assert d.status_code == 200
|
||||
g = await client.get(f"/api/v1/sessions/{sid}", headers=admin_headers)
|
||||
assert g.status_code == 404
|
||||
@@ -89,6 +89,8 @@
|
||||
${hint("Every implant or reverse-shell connection the server tracks (beacons, shells, closed). <strong>Reap dead</strong> probes and drops mute/zombie shells. <strong>Close selected</strong> uses the Shell dropdown target. List all dumps the full session table for forensics.", "sessions")}
|
||||
<div class="row">
|
||||
${can("sessions:write") ? '<button type="button" id="reapBtn">Reap dead</button>' : ""}
|
||||
${can("sessions:write") ? '<button type="button" class="danger" id="clearUnverifiedBtn" title="Delete unverified reverse shells (internet scanner noise)">Clear unverified</button>' : ""}
|
||||
${can("sessions:write") ? '<button type="button" class="danger" id="clearClosedBtn" title="Purge closed shell rows from DB">Purge closed shells</button>' : ""}
|
||||
${can("sessions:write") ? '<button type="button" class="danger" id="closeShellBtn">Close selected</button>' : ""}
|
||||
<button type="button" id="listAllSesBtn">List all</button>
|
||||
</div>
|
||||
@@ -580,6 +582,26 @@
|
||||
};
|
||||
}
|
||||
|
||||
if ($("clearUnverifiedBtn")) {
|
||||
$("clearUnverifiedBtn").onclick = async () => {
|
||||
if (!confirm("Delete all unverified reverse shells? (keeps verified)")) return;
|
||||
try {
|
||||
const res = await api("POST", "/api/v1/sessions/clear", { unverified_only: true, delete: true });
|
||||
showOk(`Cleared ${res.removed || 0} unverified shell(s)`);
|
||||
if (typeof refresh === "function") refresh();
|
||||
} catch (e) { showError(e.message || String(e)); }
|
||||
};
|
||||
}
|
||||
if ($("clearClosedBtn")) {
|
||||
$("clearClosedBtn").onclick = async () => {
|
||||
if (!confirm("Hard-delete all closed reverse-shell rows?")) return;
|
||||
try {
|
||||
const res = await api("POST", "/api/v1/sessions/clear", { closed_only: true, unverified_only: false, delete: true });
|
||||
showOk(`Purged ${res.removed || 0} closed shell(s)`);
|
||||
if (typeof refresh === "function") refresh();
|
||||
} catch (e) { showError(e.message || String(e)); }
|
||||
};
|
||||
}
|
||||
if ($("reapBtn")) {
|
||||
$("reapBtn").onclick = async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user