#!/usr/bin/env python3
"""
SecObs Secure Agent (metrics + security detection)

Sends THIS machine's metrics AND basic security detections to a SecObs server,
authenticated with an agent key. It is passive: it reads local state (processes,
network connections, listening ports, failed-login logs) and reports findings.
It does NOT give the server any access to or control over this machine.

This is lightweight host monitoring with security detection - NOT a full EDR/AV.

Run only on machines you own or are explicitly authorized to monitor.

Setup:
    pip install psutil

Configure via environment variables:
    SECOBS_SERVER     e.g. http://52.23.23.133:8888   (or https://secobs.shop)
    SECOBS_AGENT_KEY  the key from your SecObs server  (required)
    SECOBS_HOSTNAME   optional label (defaults to this machine's hostname)
    SECOBS_INTERVAL   seconds between cycles (default 5)

Run (Linux/Mac):
    SECOBS_SERVER=http://IP:8888 SECOBS_AGENT_KEY=xxxx python3 secure_agent.py
Run (Windows PowerShell):
    $env:SECOBS_SERVER="http://IP:8888"; $env:SECOBS_AGENT_KEY="xxxx"; python secure_agent.py
"""
import os
import sys
import json
import time
import socket
import urllib.request
import urllib.error

try:
    import psutil
except ImportError:
    print("ERROR: psutil missing. Run:  pip install psutil")
    sys.exit(1)

SERVER   = os.environ.get("SECOBS_SERVER", "http://localhost:8888").rstrip("/")
KEY      = os.environ.get("SECOBS_AGENT_KEY", "")
HOST     = os.environ.get("SECOBS_HOSTNAME", socket.gethostname())
INTERVAL = int(os.environ.get("SECOBS_INTERVAL", "5"))
IS_WIN   = os.name == "nt"

# ── Detection rules ──────────────────────────────────────────────
# Ports commonly used by backdoors / C2 / offensive tools.
SUSPICIOUS_PORTS = {
    4444, 4445, 1337, 31337, 12345, 12346,
    6666, 6667, 5555, 2323, 1234, 9999, 54321,
}
# Known offensive-security / credential-theft tool names.
SUSPICIOUS_PROCS = {
    "mimikatz", "mimidrv", "nc", "ncat", "netcat", "psexec",
    "procdump", "lazagne", "rubeus", "cobaltstrike", "meterpreter",
    "powersploit", "bloodhound", "sharphound", "winpeas",
}

LOGIN_WINDOW    = 300   # seconds to look back for failed logins
LOGIN_THRESHOLD = 5     # this many failures in the window => alert
DEDUP_WINDOW    = 300   # don't re-send the same alert within this many seconds

_last_sent = {}         # signature -> last sent epoch
_state     = {}         # baseline / counters carried between cycles


def _now():
    return time.time()


def should_send(sig):
    """De-dup: True if this signature hasn't been sent in DEDUP_WINDOW."""
    t = _last_sent.get(sig, 0)
    if _now() - t >= DEDUP_WINDOW:
        _last_sent[sig] = _now()
        return True
    return False


def _proc_name(pid):
    try:
        if pid:
            return psutil.Process(pid).name()
    except Exception:
        pass
    return "?"


def make_alert(alert_type, severity, rule, message="",
               process_name="", remote_ip="", remote_port=0, local_port=0):
    return {
        "host": HOST,
        "alert_type": alert_type,
        "severity": severity,
        "rule": rule,
        "message": message or rule,
        "process_name": process_name,
        "remote_ip": remote_ip,
        "remote_port": int(remote_port or 0),
        "local_port": int(local_port or 0),
    }


# ── Detections ───────────────────────────────────────────────────
def detect_listening():
    """Processes listening on suspicious ports, and brand-new listeners."""
    out = []
    try:
        conns = psutil.net_connections(kind="inet")
    except Exception:
        return out

    current = set()
    for c in conns:
        if c.status == psutil.CONN_LISTEN and c.laddr:
            port = c.laddr.port
            current.add(port)
            if port in SUSPICIOUS_PORTS:
                pname = _proc_name(c.pid)
                sig = f"listen:{port}:{pname}"
                if should_send(sig):
                    out.append(make_alert(
                        "backdoor", "HIGH",
                        f"Suspicious listening port {port}",
                        process_name=pname, local_port=port,
                        message=f"Process '{pname}' is listening on suspicious port {port}"))

    base = _state.get("listen_baseline")
    if base is None:
        _state["listen_baseline"] = set(current)        # first run = baseline only
    else:
        for port in (current - base):
            if port in SUSPICIOUS_PORTS:
                continue                                  # already alerted above
            if port < 1024:
                continue                                  # ignore common system ports
            pname = "?"
            for c in conns:
                if c.status == psutil.CONN_LISTEN and c.laddr and c.laddr.port == port:
                    pname = _proc_name(c.pid); break
            sig = f"newlisten:{port}"
            if should_send(sig):
                out.append(make_alert(
                    "anomaly", "MEDIUM",
                    f"New listening port {port}",
                    process_name=pname, local_port=port,
                    message=f"A new service started listening on port {port} ('{pname}')"))
        _state["listen_baseline"] = base | current
    return out


def detect_connections():
    """Established outbound connections to suspicious remote ports."""
    out = []
    try:
        conns = psutil.net_connections(kind="inet")
    except Exception:
        return out
    for c in conns:
        if c.status == psutil.CONN_ESTABLISHED and c.raddr:
            rport = c.raddr.port
            if rport in SUSPICIOUS_PORTS:
                rip = c.raddr.ip
                pname = _proc_name(c.pid)
                lport = c.laddr.port if c.laddr else 0
                sig = f"conn:{rip}:{rport}"
                if should_send(sig):
                    out.append(make_alert(
                        "anomaly", "HIGH",
                        f"Suspicious outbound connection (port {rport})",
                        process_name=pname, remote_ip=rip,
                        remote_port=rport, local_port=lport,
                        message=f"'{pname}' connected to {rip}:{rport} (known suspicious port)"))
    return out


def detect_processes():
    """Known offensive-security / malware tool names running."""
    out = []
    try:
        iterator = psutil.process_iter(["name", "pid"])
    except Exception:
        return out
    for p in iterator:
        try:
            name = (p.info.get("name") or "")
        except Exception:
            continue
        low = name.lower()
        base = low.rsplit(".", 1)[0]  # strip .exe
        if base in SUSPICIOUS_PROCS or low in SUSPICIOUS_PROCS:
            sig = f"proc:{low}:{p.info.get('pid')}"
            if should_send(sig):
                out.append(make_alert(
                    "malware", "CRITICAL",
                    f"Suspicious process: {name}",
                    process_name=name,
                    message=f"Known offensive-security tool '{name}' is running (pid {p.info.get('pid')})"))
    return out


def detect_failed_logins():
    """Failed-login burst. Best-effort, OS-specific, may need elevated rights."""
    out = []
    count = 0
    try:
        if IS_WIN:
            import subprocess
            r = subprocess.run(
                ["wevtutil", "qe", "Security",
                 "/q:*[System[(EventID=4625)]]", "/c:100", "/rd:true", "/f:text"],
                capture_output=True, text=True, timeout=15)
            text = r.stdout or ""
            count = text.count("Event ID: 4625")
        else:
            path = "/var/log/auth.log"
            if not os.path.exists(path):
                path = "/var/log/secure"
            if os.path.exists(path):
                with open(path, "r", errors="ignore") as f:
                    lines = f.readlines()[-500:]
                count = sum(1 for ln in lines if "Failed password" in ln)
    except Exception:
        return out  # no permission / not available => skip silently

    prev = _state.get("login_count")
    _state["login_count"] = count
    if prev is None:
        return out                       # first cycle: establish baseline only
    delta = count - prev
    if delta >= LOGIN_THRESHOLD:
        sig = "loginburst"
        if should_send(sig):
            out.append(make_alert(
                "intrusion", "HIGH",
                "Failed login burst",
                message=f"{delta} failed login attempts detected in the last cycle"))
    return out


# ── Metrics ──────────────────────────────────────────────────────
def collect_metrics():
    net = psutil.net_io_counters()
    return {
        "host":     HOST,
        "cpu":      psutil.cpu_percent(interval=1),
        "memory":   psutil.virtual_memory().percent,
        "disk":     psutil.disk_usage(os.environ.get("SECOBS_DISK", "C:\\" if IS_WIN else "/")).percent,
        "net_sent": net.bytes_sent,
        "net_recv": net.bytes_recv,
        "service":  "agent",
        "env":      "prod",
    }


# ── Transport ────────────────────────────────────────────────────
def _post(path, payload):
    req = urllib.request.Request(
        SERVER + path,
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json", "X-Agent-Key": KEY},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=10) as r:
        return r.status


def send_metrics(m):
    return _post("/api/ingest", m)


def send_alerts(alerts):
    if not alerts:
        return 0
    _post("/api/alert", {"alerts": alerts})
    return len(alerts)


# ── Main loop ────────────────────────────────────────────────────
def run_detections():
    found = []
    for fn in (detect_listening, detect_connections, detect_processes, detect_failed_logins):
        try:
            found.extend(fn())
        except Exception:
            pass
    return found


def main():
    if not KEY:
        print("ERROR: set SECOBS_AGENT_KEY (get it from your SecObs server).")
        sys.exit(1)
    print(f"SecObs agent -> {SERVER}  as host '{HOST}'  every {INTERVAL}s")
    print("Monitoring: metrics + security (ports, connections, processes, logins)")
    print("Press Ctrl+C to stop.\n")
    while True:
        try:
            send_metrics(collect_metrics())
            print(".", end="", flush=True)
        except urllib.error.HTTPError as e:
            print(f"\nmetrics rejected ({e.code}): {e.reason}  (check agent key)")
        except Exception as e:
            print(f"\nmetrics failed: {e}  (check server address / network)")

        try:
            alerts = run_detections()
            n = send_alerts(alerts)
            if n:
                print(f"\n[!] {n} security alert(s) sent: " +
                      ", ".join(a["rule"] for a in alerts))
        except urllib.error.HTTPError as e:
            print(f"\nalert rejected ({e.code}): {e.reason}")
        except Exception as e:
            print(f"\nalert send failed: {e}")

        time.sleep(INTERVAL)


if __name__ == "__main__":
    main()
