Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 2 additions & 0 deletions portmaster-ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
12 changes: 12 additions & 0 deletions portmaster-ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portmaster HIDS UI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
17 changes: 17 additions & 0 deletions portmaster-ui/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
41 changes: 41 additions & 0 deletions portmaster-ui/src/App.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<div class="app-container">
<h1>Portmaster HIDS Interface</h1>
<WarningCard
v-for="alert in alerts"
:key="alert.pid"
:processName="alert.binaryPath"
:pid="alert.pid"
:score="alert.score"
/>
</div>
</template>

<script>
import WarningCard from './components/WarningCard.vue';

export default {
name: 'App',
components: {
WarningCard
},
data() {
return {
alerts: []
}
},
mounted() {
// Dummy initial state to show UI, typically this would connect via WebSocket
this.alerts = [
{ pid: 1042, binaryPath: "/usr/bin/curl", score: 0.85 }
];
}
}
</script>

<style>
.app-container {
font-family: Arial, sans-serif;
padding: 20px;
}
</style>
102 changes: 102 additions & 0 deletions portmaster-ui/src/components/WarningCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<template>
<div class="warning-card">
<div class="header">
<span class="icon">⚠️</span>
<h2>Suspicious Activity Detected</h2>
</div>
<div class="content">
<p><strong>{{ processName }}</strong> is exhibiting suspicious behavior.</p>
<p>PID: {{ pid }} | Anomaly Score: {{ score }}</p>
</div>
<div class="actions">
<button @click="quarantineApp" :disabled="quarantined">
{{ quarantined ? 'App Quarantined' : 'Quarantine App' }}
</button>
</div>
<div v-if="error" class="error">
{{ error }}
</div>
</div>
</template>

<script>
export default {
name: 'WarningCard',
props: {
processName: String,
pid: Number,
score: Number
},
data() {
return {
quarantined: false,
error: null
}
},
methods: {
async quarantineApp() {
try {
const formData = new FormData();
// Since the UI doesn't inherently have the profile ID linked, we simulate passing it.
// In full impl, this would resolve the PID -> Profile ID. For now we pass the binary path as fallback.
formData.append('profile', this.processName);

const res = await fetch('http://127.0.0.1:817/api/v1/hids/quarantine', {
method: 'POST',
body: formData
});
Comment on lines +39 to +47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Sending processName (binary path) instead of profile ID will cause quarantine to always fail.

The backend's handleHidsQuarantine (in service/network/api.go) calls profile.GetLocalProfile(profileID, nil, nil), which expects an actual profile identifier (e.g., "local/some-profile-id"), not a human-readable binary path like /usr/bin/curl.

As noted in the comment on lines 40-41, this needs to resolve PID → Profile ID. Without this, every quarantine attempt will fail with "profile not found".

Suggested approach

The alert data structure should include the actual profile ID from the backend. Modify the telemetry/alert flow to include profileID alongside binaryPath:

  const formData = new FormData();
- // Since the UI doesn't inherently have the profile ID linked, we simulate passing it.
- // In full impl, this would resolve the PID -> Profile ID. For now we pass the binary path as fallback.
- formData.append('profile', this.processName);
+ formData.append('profile', this.profileId);

This requires:

  1. Adding profileId prop to WarningCard
  2. Including profile ID in the alert payload from the Python sidecar
  3. Including profile ID in the telemetry sent from Go
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@portmaster-ui/src/components/WarningCard.vue` around lines 39 - 47, The UI is
sending the binary path (this.processName) as the 'profile' field which causes
handleHidsQuarantine/profile.GetLocalProfile to fail; update WarningCard (and
the telemetry flow) to send the actual profile ID: add a profileId prop to
WarningCard, change the form payload to append('profile', this.profileId)
instead of this.processName in the POST to /api/v1/hids/quarantine, and ensure
the backend-facing telemetry/alert pipeline (Python sidecar and Go telemetry
sender) is updated to include profileId alongside binaryPath so consumers
calling profile.GetLocalProfile receive a valid profile identifier.


if (res.ok) {
this.quarantined = true;
this.error = null;
} else {
throw new Error("Failed to quarantine");
}
} catch (err) {
this.error = "Error attempting to quarantine application: " + err.message;
}
}
}
}
</script>

<style scoped>
.warning-card {
border: 2px solid #e74c3c;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
background-color: #fdf2f0;
}
.header {
display: flex;
align-items: center;
color: #c0392b;
}
.header h2 {
margin: 0 0 0 10px;
}
.icon {
font-size: 24px;
}
.content p {
margin: 8px 0;
}
.actions button {
background-color: #e74c3c;
color: white;
border: none;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.actions button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.error {
color: red;
margin-top: 10px;
}
</style>
4 changes: 4 additions & 0 deletions portmaster-ui/src/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')
7 changes: 7 additions & 0 deletions portmaster-ui/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
})
72 changes: 72 additions & 0 deletions service/network/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions service/network/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,9 @@ func (conn *Connection) delete() {

conn.Meta().Delete()

// Stream connection telemetry to HIDS sidecar before finalization
sendTelemetry(conn)

Comment on lines +842 to +844
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restrict telemetry emission to complete IP connections.

At Line 842, telemetry is emitted for all connection types during delete. That includes DNS request objects and incomplete records, which can contaminate anomaly inputs and increase sidecar noise.

Suggested guard
-	// Stream connection telemetry to HIDS sidecar before finalization
-	sendTelemetry(conn)
+	// Stream telemetry only for complete IP connections.
+	if conn.Type == IPConnection && conn.DataIsComplete() {
+		sendTelemetry(conn)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/network/connection.go` around lines 842 - 844, Telemetry is currently
emitted unconditionally via sendTelemetry(conn) during deletion, causing DNS and
incomplete records to be sent; wrap the sendTelemetry(conn) call with a guard
that only allows emission for finished IP connections (e.g., check the
connection object for an IP-type and a terminal/complete state such as conn.Type
== IP or conn.IsIP() and conn.State/Status == Complete/Closed or
conn.IsComplete()), or use any existing helper like conn.IsIP() or
conn.IsComplete() if present; place this conditional immediately before the
sendTelemetry(conn) invocation so only complete IP connections are streamed to
the HIDS sidecar.

// Notify database controller if data is complete and thus connection was previously exposed.
if conn.DataIsComplete() {
dbController.PushUpdate(conn)
Expand Down
65 changes: 65 additions & 0 deletions service/network/telemetry.go
Original file line number Diff line number Diff line change
@@ -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,
}
Comment on lines +24 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Data race: Reading lock-protected Connection fields without holding the lock.

Per the Connection struct documentation in service/network/connection.go:

  • Entity: "Access to entity must be guarded by the connection lock" (lines 116-120)
  • Ended: "access must be guarded by the connection lock" (lines 139-143)
  • BytesSent/BytesReceived are also mutable counters

Although delete() comments state "Callers must still make sure to lock the connection itself," sendTelemetry reads these fields without verifying the lock is held. If the caller doesn't hold the lock, this creates a data race.

Recommended fix: Accept pre-extracted values or lock internally

Option 1: Lock within sendTelemetry (safest)

 func sendTelemetry(conn *Connection) {
 	if conn == nil {
 		return
 	}

+	conn.Lock()
 	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,
 	}
+	conn.Unlock()

Option 2: Pass pre-extracted telemetry struct from caller while lock is held

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/network/telemetry.go` around lines 24 - 42, sendTelemetry currently
reads mutable Connection fields (Entity, Ended, BytesSent, BytesReceived, etc.)
without holding the connection lock, causing data races; fix by either acquiring
the connection's lock inside sendTelemetry (lock/unlock around reads of
conn.Entity, conn.Ended, conn.BytesSent, conn.BytesReceived and any other
mutable fields before building ConnectionTelemetry) or change the API to accept
a pre-populated ConnectionTelemetry (or explicit values) that callers populate
while holding the connection lock; update signatures and call sites accordingly
(referencing sendTelemetry, Connection, ConnectionTelemetry and the fields
Entity/Ended/BytesSent/BytesReceived) so all mutable reads are protected.


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)
}
Loading