diff --git a/.gitignore b/.gitignore index 7bc2f2a01..04615ba69 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ desktop/tauri/src-tauri/intel/ windows_kext/test/_testcert/ windows_kext/test/_out/ windows_kext/test/_delme/ +portmaster-ui/node_modules/ +portmaster-ui/dist/ diff --git a/portmaster-ui/.gitignore b/portmaster-ui/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/portmaster-ui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/portmaster-ui/index.html b/portmaster-ui/index.html new file mode 100644 index 000000000..ce4cd8d2d --- /dev/null +++ b/portmaster-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Portmaster HIDS UI + + +
+ + + diff --git a/portmaster-ui/package.json b/portmaster-ui/package.json new file mode 100644 index 000000000..591e84d7e --- /dev/null +++ b/portmaster-ui/package.json @@ -0,0 +1,17 @@ +{ + "name": "portmaster-ui-vue", + "version": "1.0.0", + "description": "Portmaster UI Transformation - HIDS/HIPS", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.2.47" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.0.0", + "vite": "^4.0.0" + } +} diff --git a/portmaster-ui/src/App.vue b/portmaster-ui/src/App.vue new file mode 100644 index 000000000..020dfdf86 --- /dev/null +++ b/portmaster-ui/src/App.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/portmaster-ui/src/components/WarningCard.vue b/portmaster-ui/src/components/WarningCard.vue new file mode 100644 index 000000000..2669a0673 --- /dev/null +++ b/portmaster-ui/src/components/WarningCard.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/portmaster-ui/src/main.js b/portmaster-ui/src/main.js new file mode 100644 index 000000000..01433bca2 --- /dev/null +++ b/portmaster-ui/src/main.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/portmaster-ui/vite.config.js b/portmaster-ui/vite.config.js new file mode 100644 index 000000000..05c17402a --- /dev/null +++ b/portmaster-ui/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], +}) diff --git a/service/network/api.go b/service/network/api.go index 5c18bcfde..6ccdb8e36 100644 --- a/service/network/api.go +++ b/service/network/api.go @@ -16,6 +16,8 @@ import ( "github.com/safing/portmaster/service/process" "github.com/safing/portmaster/service/resolver" "github.com/safing/portmaster/service/status" + "github.com/safing/portmaster/base/log" + "github.com/safing/portmaster/service/profile" ) func registerAPIEndpoints() error { @@ -61,9 +63,79 @@ func registerAPIEndpoints() error { return err } + // HIDS/HIPS Endpoints + if err := api.RegisterEndpoint(api.Endpoint{ + Path: "hids/alert", + Write: api.PermitUser, + StructFunc: handleHidsAlert, + Name: "HIDS Anomaly Alert", + Description: "Receives anomaly alerts from the ML sidecar.", + Parameters: []api.Parameter{ + {Method: http.MethodPost, Field: "pid", Description: "The Process ID."}, + {Method: http.MethodPost, Field: "binaryPath", Description: "Path to binary."}, + {Method: http.MethodPost, Field: "destIP", Description: "Destination IP."}, + {Method: http.MethodPost, Field: "score", Description: "Anomaly Score."}, + }, + }); err != nil { + return err + } + + if err := api.RegisterEndpoint(api.Endpoint{ + Path: "hids/quarantine", + Write: api.PermitUser, + StructFunc: handleHidsQuarantine, + Name: "Quarantine App", + Description: "Quarantines a specific profile by forcing the block default action.", + Parameters: []api.Parameter{ + {Method: http.MethodPost, Field: "profile", Description: "The Profile ID to quarantine."}, + }, + }); err != nil { + return err + } + return nil } +func handleHidsAlert(ar *api.Request) (i interface{}, err error) { + pid := ar.Request.FormValue("pid") + binaryPath := ar.Request.FormValue("binaryPath") + score := ar.Request.FormValue("score") + + // This would typically broadcast an event or update an alert state table for the UI to consume. + // For this transformation, we simply log it loudly. + log.Warningf("HIDS ALERT: Suspicious activity detected for PID %s (%s) with score %s", pid, binaryPath, score) + return map[string]string{"status": "alert_received"}, nil +} + +func handleHidsQuarantine(ar *api.Request) (i interface{}, err error) { + profileID := ar.Request.FormValue("profile") + if profileID == "" { + return nil, fmt.Errorf("missing profile parameter") + } + + // Fetch profile using profile.GetLocalProfile + // Since we only have the profile ID from the frontend, we use nil matching data. + // Profile source is expected to be local. + prof, err := profile.GetLocalProfile(profileID, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get profile: %v", err) + } + if prof == nil { + return nil, fmt.Errorf("profile not found") + } + + // Force default action to block in the configuration tree + config.PutValueIntoHierarchicalConfig(prof.Config, "filter/defaultAction", "block") + + err = prof.Save() + if err != nil { + return nil, fmt.Errorf("failed to save quarantined profile: %v", err) + } + + log.Warningf("HIDS: Quarantined profile %s", profileID) + return map[string]string{"status": "quarantined", "profile": profileID}, nil +} + // debugInfo returns the debugging information for support requests. func debugInfo(ar *api.Request) (data []byte, err error) { // Create debug information helper. diff --git a/service/network/connection.go b/service/network/connection.go index 2cdf12e72..6a6390ed1 100644 --- a/service/network/connection.go +++ b/service/network/connection.go @@ -839,6 +839,9 @@ func (conn *Connection) delete() { conn.Meta().Delete() + // Stream connection telemetry to HIDS sidecar before finalization + sendTelemetry(conn) + // Notify database controller if data is complete and thus connection was previously exposed. if conn.DataIsComplete() { dbController.PushUpdate(conn) diff --git a/service/network/telemetry.go b/service/network/telemetry.go new file mode 100644 index 000000000..03b7c85d6 --- /dev/null +++ b/service/network/telemetry.go @@ -0,0 +1,65 @@ +package network + +import ( + "encoding/json" + "net" + + "github.com/safing/portmaster/base/log" +) + +// ConnectionTelemetry holds the mapped features to be passed +// to the external HIDS/HIPS Python sidecar logic via Unix socket. +type ConnectionTelemetry struct { + PID int `json:"pid"` + BinaryPath string `json:"binaryPath"` + DestIP string `json:"destIP"` + BytesSent uint64 `json:"bytesSent"` + BytesReceived uint64 `json:"bytesReceived"` + Started int64 `json:"started"` + Ended int64 `json:"ended"` +} + +// sendTelemetry extracts required telemetry fields from a finalized connection +// and writes them over a non-blocking UDS connection to /tmp/portmaster_telemetry.sock +func sendTelemetry(conn *Connection) { + if conn == nil { + return + } + + ipStr := "" + if conn.Entity != nil { + ipStr = conn.Entity.IP.String() + } + + telemetry := ConnectionTelemetry{ + PID: conn.PID, + BinaryPath: conn.ProcessContext.BinaryPath, + DestIP: ipStr, + BytesSent: conn.BytesSent, + BytesReceived: conn.BytesReceived, + Started: conn.Started, + Ended: conn.Ended, + } + + data, err := json.Marshal(telemetry) + if err != nil { + log.Errorf("telemetry: failed to marshal connection telemetry: %s", err) + return + } + + // Dispatch non-blocking dial and send to prevent slowing down packet filter + go func(payload []byte) { + socketConn, err := net.Dial("unix", "/tmp/portmaster_telemetry.sock") + if err != nil { + // Fail silently or log minimally; do not block Core service. + // The python sidecar might not be running yet or at all. + return + } + defer socketConn.Close() + + _, err = socketConn.Write(append(payload, '\n')) + if err != nil { + log.Errorf("telemetry: failed to write to socket: %s", err) + } + }(data) +} diff --git a/sidecar/inference.py b/sidecar/inference.py new file mode 100644 index 000000000..e858798dd --- /dev/null +++ b/sidecar/inference.py @@ -0,0 +1,92 @@ +import os +import sys +import json +import socket +import logging +import requests +import torch +import torch.nn as nn +from model import TabularAutoencoder + +# Dummy threshold for anomaly score +ANOMALY_THRESHOLD = 0.5 +UDS_SOCKET_PATH = "/tmp/portmaster_telemetry.sock" + +class InferenceEngine: + def __init__(self, api_port: int, test_mode: bool = False): + self.api_port = api_port + self.test_mode = test_mode + self.model = TabularAutoencoder(input_dim=3) # bytesSent, bytesReceived, duration + self.model.eval() + self.criterion = nn.MSELoss() + + if os.path.exists(UDS_SOCKET_PATH): + os.remove(UDS_SOCKET_PATH) + + if not self.test_mode: + self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.server.bind(UDS_SOCKET_PATH) + self.server.listen(1) + logging.info(f"Listening on {UDS_SOCKET_PATH}") + + def run(self): + if self.test_mode: + logging.info("Running in test mode. Exiting.") + return + + while True: + conn, _ = self.server.accept() + try: + buffer = "" + while True: + data = conn.recv(1024) + if not data: + break + buffer += data.decode('utf-8') + if '\n' in buffer: + parts = buffer.split('\n') + for line in parts[:-1]: + if line.strip(): + self.process_telemetry(line) + buffer = parts[-1] + except Exception as e: + logging.error(f"Error handling connection: {e}") + finally: + conn.close() + + def process_telemetry(self, raw_json: str): + try: + data = json.loads(raw_json) + # Duration safely, max 1 so no div by 0 for now. This is a naive processing + duration = max(data.get("ended", 0) - data.get("started", 0), 1) + bytes_sent = float(data.get("bytesSent", 0)) + bytes_recv = float(data.get("bytesReceived", 0)) + + # Naive Normalization for dummy model + x = torch.tensor([bytes_sent/1000.0, bytes_recv/1000.0, duration/60.0], dtype=torch.float32) + + with torch.no_grad(): + reconstructed = self.model(x) + loss = self.criterion(reconstructed, x).item() + + if loss > ANOMALY_THRESHOLD: + self.trigger_alert(data, loss) + + except json.JSONDecodeError: + logging.error(f"Failed to decode JSON: {raw_json}") + except Exception as e: + logging.error(f"Error processing telemetry: {e}") + + def trigger_alert(self, data: dict, score: float): + payload = { + "pid": data.get("pid"), + "binaryPath": data.get("binaryPath"), + "destIP": data.get("destIP"), + "score": score + } + url = f"http://127.0.0.1:{self.api_port}/api/v1/hids/alert" + try: + requests.post(url, json=payload, timeout=2) + logging.warning(f"ALERT TRIGGERED for PID {payload['pid']} (Score {score})") + except requests.exceptions.RequestException as e: + logging.error(f"Failed to send alert to Core: {e}") diff --git a/sidecar/main.py b/sidecar/main.py new file mode 100644 index 000000000..5809e5a40 --- /dev/null +++ b/sidecar/main.py @@ -0,0 +1,18 @@ +import argparse +import logging +from inference import InferenceEngine + +def main(): + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + + parser = argparse.ArgumentParser(description="Portmaster HIDS Sidecar Daemon") + parser.add_argument("--api-port", type=int, required=True, help="Portmaster Core API Port") + parser.add_argument("--test-mode", action="store_true", help="Run model instantiation test and exit") + + args = parser.parse_args() + + engine = InferenceEngine(api_port=args.api_port, test_mode=args.test_mode) + engine.run() + +if __name__ == "__main__": + main() diff --git a/sidecar/model.py b/sidecar/model.py new file mode 100644 index 000000000..e7fabf60f --- /dev/null +++ b/sidecar/model.py @@ -0,0 +1,28 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +class TabularAutoencoder(nn.Module): + def __init__(self, input_dim=4): # e.g. bytesSent, bytesReceived, duration + super(TabularAutoencoder, self).__init__() + # Encoder + self.encoder = nn.Sequential( + nn.Linear(input_dim, 8), + nn.ReLU(True), + nn.Linear(8, 4), + nn.ReLU(True), + nn.Linear(4, 2) + ) + # Decoder + self.decoder = nn.Sequential( + nn.Linear(2, 4), + nn.ReLU(True), + nn.Linear(4, 8), + nn.ReLU(True), + nn.Linear(8, input_dim) + ) + + def forward(self, x): + encoded = self.encoder(x) + decoded = self.decoder(encoded) + return decoded diff --git a/sidecar/requirements.txt b/sidecar/requirements.txt new file mode 100644 index 000000000..b91e22465 --- /dev/null +++ b/sidecar/requirements.txt @@ -0,0 +1,2 @@ +torch +requests