-
-
Notifications
You must be signed in to change notification settings - Fork 473
HIDS/HIPS Architecture Implementation #2152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| dist/ |
| 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> |
| 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" | ||
| } | ||
| } |
| 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> |
| 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 | ||
| }); | ||
|
|
||
| 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> | ||
| 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') |
| 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()], | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| // Notify database controller if data is complete and thus connection was previously exposed. | ||
| if conn.DataIsComplete() { | ||
| dbController.PushUpdate(conn) | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Data race: Reading lock-protected Connection fields without holding the lock. Per the
Although Recommended fix: Accept pre-extracted values or lock internallyOption 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 |
||
|
|
||
| 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) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: Sending
processName(binary path) instead of profile ID will cause quarantine to always fail.The backend's
handleHidsQuarantine(inservice/network/api.go) callsprofile.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
profileIDalongsidebinaryPath:This requires:
profileIdprop to WarningCard🤖 Prompt for AI Agents