Skip to content

HIDS/HIPS Architecture Implementation#2152

Open
sisyphuspulpfiction wants to merge 2 commits intosafing:developmentfrom
sisyphuspulpfiction:development
Open

HIDS/HIPS Architecture Implementation#2152
sisyphuspulpfiction wants to merge 2 commits intosafing:developmentfrom
sisyphuspulpfiction:development

Conversation

@sisyphuspulpfiction
Copy link
Copy Markdown

@sisyphuspulpfiction sisyphuspulpfiction commented Apr 6, 2026

This PR implements a full Host-based Intrusion Detection/Prevention System (HIDS/HIPS) for Portmaster across three domains:

Core (Go): Streaming local connection metrics over UDS sockets without blocking network filtration.
Sidecar (Python): A PyTorch anomaly detection daemon that learns tabular behavior metrics.
UI (Vue3): Frontend visualization of suspended states allowing one-click quarantine actions that propagate down through Portmaster's hierarchical configurations.

Summary by CodeRabbit

  • New Features
    • Added alert interface displaying suspicious activity detections with process information and risk scoring
    • Enabled quarantine functionality to isolate detected applications
    • Integrated machine learning-based anomaly detection to identify suspicious network patterns
    • Implemented telemetry collection pipeline for continuous activity monitoring and threat analysis

google-labs-jules bot and others added 2 commits April 6, 2026 20:45
- Phase 1: Injected telemetry hook in Go network connection teardown to stream data to Unix socket
- Phase 2: Added Python/PyTorch sidecar daemon to listen for telemetry, run anomaly detection, and fire alert endpoints
- Phase 3: Added Go API endpoints for alerts and app quarantining via Profile configuration defaults
- Scaffolded basic Vue3 WarningCard components to interact with the quarantine API

Co-authored-by: sisyphuspulpfiction <222319458+sisyphuspulpfiction@users.noreply.github.com>
…7782092985171259

HIDS/HIPS Architecture Implementation
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a complete HIDS (Host-Based Intrusion Detection System) UI stack: a Vue 3 frontend for displaying and quarantining suspicious processes, two new backend API endpoints for alerts and quarantine operations, telemetry collection infrastructure in the core service, and a Python-based ML anomaly detection sidecar using a tabular autoencoder model.

Changes

Cohort / File(s) Summary
Frontend Project Setup
.gitignore, portmaster-ui/.gitignore, portmaster-ui/package.json, portmaster-ui/vite.config.js, portmaster-ui/index.html
Initialized frontend project with Vite bundler, Vue 3 runtime, and build configuration. Added node_modules and dist to gitignore patterns. Entry point HTML scaffolds a root app container with module script loader.
Vue Application Components
portmaster-ui/src/main.js, portmaster-ui/src/App.vue, portmaster-ui/src/components/WarningCard.vue
Created Vue SFC hierarchy: root App component manages reactive alerts array and renders WarningCard subcomponents; WarningCard displays process metadata and includes async quarantineApp() method that POSTs to backend quarantine endpoint via FormData.
Backend HIDS API Endpoints
service/network/api.go
Registered two user-permitted POST endpoints: /hids/alert (accepts pid, binaryPath, score; logs warning) and /hids/quarantine (accepts profile name; retrieves profile, mutates filter/defaultAction config to "block", persists via prof.Save()).
Core Service Telemetry
service/network/connection.go, service/network/telemetry.go
Added telemetry collection hook in Connection.delete() that asynchronously sends connection metadata (PID, binary path, destination IP, byte counts, timestamps) as JSON over Unix domain socket at /tmp/portmaster_telemetry.sock.
ML Anomaly Detection Sidecar
sidecar/model.py, sidecar/inference.py, sidecar/main.py, sidecar/requirements.txt
Implemented InferenceEngine that listens on UDS socket, reads telemetry JSON, runs TabularAutoencoder model for anomaly detection, and posts alerts back to backend API when reconstruction loss exceeds threshold.

Sequence Diagram(s)

sequenceDiagram
    participant User as User (Frontend)
    participant Frontend as portmaster-ui
    participant Backend as Service API
    participant DB as Profile DB

    User->>Frontend: Click "Quarantine App" button
    Frontend->>Frontend: Build FormData (profile=processName)
    Frontend->>Backend: POST /api/v1/hids/quarantine
    Backend->>DB: GetLocalProfile(processName)
    DB-->>Backend: Profile object
    Backend->>Backend: Set filter/defaultAction to "block"
    Backend->>DB: prof.Save()
    DB-->>Backend: Success
    Backend-->>Frontend: {status:"quarantined", profile:id}
    Frontend->>Frontend: Set quarantined=true, disable button
    Frontend->>User: Display "App Quarantined"
Loading
sequenceDiagram
    participant Service as Portmaster Service
    participant Telemetry as UDS Socket
    participant Sidecar as InferenceEngine
    participant Model as TabularAutoencoder
    participant Alert as /hids/alert Endpoint

    Service->>Service: Connection finalized (delete)
    Service->>Service: Build ConnectionTelemetry struct
    Service->>Service: Marshal to JSON
    Service->>Telemetry: Async dial & write JSON+newline
    Sidecar->>Telemetry: Accept connection, read JSON
    Sidecar->>Sidecar: Parse telemetry fields
    Sidecar->>Sidecar: Build 3-feature tensor
    Sidecar->>Model: Forward pass (encoder→decoder)
    Model-->>Sidecar: Reconstructed output, MSE loss
    Sidecar->>Sidecar: Compare loss vs ANOMALY_THRESHOLD
    alt Loss > Threshold
        Sidecar->>Alert: POST {pid, binaryPath, score}
        Alert-->>Sidecar: {status:"alert_received"}
    end
    Telemetry-->>Service: Done
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'HIDS/HIPS Architecture Implementation' accurately captures the main objective of the pull request, which implements a Host-based Intrusion Detection/Prevention System across Go, Python, and Vue3 components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (9)
.gitignore (1)

70-71: Optional: Line 71 is partially redundant with the existing dist/ pattern at line 3.

The root .gitignore already contains dist/ at line 3, which will match portmaster-ui/dist/ when evaluated from the repository root. Line 71 (portmaster-ui/dist/) is explicit but not strictly necessary.

The current setup works correctly and being explicit can improve clarity, so this is purely optional feedback.

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

In @.gitignore around lines 70 - 71, The .gitignore contains a redundant entry
"portmaster-ui/dist/" because a root-level "dist/" already ignores that path;
remove the specific "portmaster-ui/dist/" line (or alternatively remove the
global "dist/" if you prefer explicit per-package ignores) so entries are not
duplicated—look for the "dist/" and "portmaster-ui/dist/" patterns in .gitignore
and keep only the form your project prefers.
portmaster-ui/package.json (1)

1-4: Mark this UI package as private.

Add "private": true to avoid accidental npm publication of an internal app package.

Suggested change
 {
   "name": "portmaster-ui-vue",
+  "private": true,
   "version": "1.0.0",
   "description": "Portmaster UI Transformation - HIDS/HIPS",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@portmaster-ui/package.json` around lines 1 - 4, Add the npm private flag to
the package manifest so the UI package cannot be accidentally published: update
package.json (the manifest containing "name": "portmaster-ui-vue") to include
the top-level property "private": true alongside "name", "version", and
"description".
sidecar/requirements.txt (1)

1-2: Pin sidecar dependency versions.

torch and requests are unversioned in sidecar/requirements.txt, making builds non-reproducible and vulnerable to breaking or security regressions at install time. Pin tested versions (e.g., torch==2.x.x, requests==2.x.x) or enforce via a constraints.txt file.

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

In `@sidecar/requirements.txt` around lines 1 - 2, The requirements file lists
unpinned packages "torch" and "requests" which makes builds non-reproducible;
update sidecar/requirements.txt to pin each package to tested versions (e.g.,
replace "torch" with a specific version like "torch==2.x.y" and "requests" with
"requests==2.x.y") or add a constraints file and reference it so installs are
deterministic; ensure the chosen versions match CI/tested matrix and update any
build/dev docs or CI that install these deps.
sidecar/inference.py (2)

52-55: Broad exception handling is acceptable for resilience but consider logging exception type.

The static analysis flags (BLE001) broad except Exception clauses. While this is appropriate for a long-running daemon to prevent crashes, consider logging the exception type for better debugging:

except Exception as e:
    logging.error(f"Error handling connection: {type(e).__name__}: {e}")

Also applies to: 77-78

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

In `@sidecar/inference.py` around lines 52 - 55, The except blocks in the
connection-handling code (the broad "except Exception as e" in the function
handling connections) should log the exception type as well as the message for
better debugging; update the logging.error calls in that handler (and the
similar broad except at the other site around lines 77-78) to include
type(e).__name__ alongside e, preserving the finally conn.close() behavior and
not changing the exception handling semantics.

13-13: Acknowledged: /tmp path flagged by static analysis (S108).

Using /tmp for the Unix socket is a potential security concern as the directory is world-writable. A malicious process could pre-create the socket to intercept telemetry.

For production, consider using a more restricted path like /run/portmaster/ or a path within the Portmaster data directory with appropriate permissions.

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

In `@sidecar/inference.py` at line 13, UDS_SOCKET_PATH set to
"/tmp/portmaster_telemetry.sock" is insecure; change it to a restricted,
configurable runtime path (e.g. use XDG_RUNTIME_DIR or
"/run/portmaster/portmaster_telemetry.sock") and ensure the directory is created
with secure permissions before binding. Update the UDS_SOCKET_PATH constant in
inference.py to read from configuration/env (fallback to a secure default), and
add code to create the parent directory with os.makedirs(..., exist_ok=True) and
set permissions (os.chmod) or use a umask so only the service user can write the
socket.
service/network/api.go (2)

83-94: Consider whether api.PermitUser is the appropriate permission level for quarantine.

The hids/quarantine endpoint can block all network traffic for an application by modifying its profile configuration. This is a high-impact action that could be abused or triggered accidentally.

Consider whether api.PermitAdmin would be more appropriate, or adding a confirmation mechanism.

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

In `@service/network/api.go` around lines 83 - 94, The hids/quarantine endpoint is
currently registered with api.PermitUser; update the RegisterEndpoint call for
the Endpoint with Path "hids/quarantine" so its Write permission uses
api.PermitAdmin instead of api.PermitUser, and optionally add a required
confirmation parameter (e.g., a boolean "confirm" Parameter or require an
additional confirmation middleware) to the Endpoint.Parameters to ensure
explicit confirmation before applying the profile change.

99-108: Consider validating and parsing input parameters.

handleHidsAlert accepts raw form values without validation:

  • pid should be validated as a positive integer
  • score should be parsed as a float for structured logging or future processing

Currently this only logs, but if extended to store alerts or broadcast to UI, malformed data could cause issues.

Optional validation
 func handleHidsAlert(ar *api.Request) (i interface{}, err error) {
-	pid := ar.Request.FormValue("pid")
+	pidStr := ar.Request.FormValue("pid")
+	pid, err := strconv.Atoi(pidStr)
+	if err != nil || pid <= 0 {
+		return nil, fmt.Errorf("invalid pid parameter")
+	}
 	binaryPath := ar.Request.FormValue("binaryPath")
-	score := ar.Request.FormValue("score")
+	scoreStr := ar.Request.FormValue("score")
+	score, err := strconv.ParseFloat(scoreStr, 64)
+	if err != nil {
+		return nil, fmt.Errorf("invalid score parameter")
+	}

-	log.Warningf("HIDS ALERT: Suspicious activity detected for PID %s (%s) with score %s", pid, binaryPath, score)
+	log.Warningf("HIDS ALERT: Suspicious activity detected for PID %d (%s) with score %.4f", pid, binaryPath, score)
 	return map[string]string{"status": "alert_received"}, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/network/api.go` around lines 99 - 108, handleHidsAlert currently
reads raw form values (pid, binaryPath, score) without validation; update the
function to validate pid is a positive integer (parse pid into an int and ensure
>0), parse score into a float64 (handle strconv.ParseFloat errors), and treat
binaryPath as a non-empty string; on parse/validation failure return a clear
error (or a structured bad-request response) and log the normalized parsed
values (use the parsed pid and score in log messages) so downstream
storage/broadcast code receives typed data; reference function handleHidsAlert
and variables pid, score, binaryPath and ensure any errors from parsing are
handled and returned instead of proceeding with raw strings.
portmaster-ui/src/App.vue (1)

4-10: Consider using a unique identifier for v-for key instead of pid.

PIDs can be recycled by the operating system after a process terminates. If two alerts from different processes happen to share the same PID (one historical, one current), Vue's keyed rendering may incorrectly reuse DOM elements or skip updates.

Consider adding a unique alert ID (e.g., timestamp + pid combination, or a UUID) when alerts are received.

Example approach
-      v-for="alert in alerts"
-      :key="alert.pid"
+      v-for="alert in alerts"
+      :key="alert.id"

Where alert.id could be generated as ${alert.pid}-${Date.now()} or a server-provided unique identifier.

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

In `@portmaster-ui/src/App.vue` around lines 4 - 10, The v-for key currently uses
alert.pid which can collide due to PID reuse; change the key to a stable unique
identifier (e.g., alert.id) and ensure that each alert item gets a generated
unique id when it is created/received (server-provided UUID or combine pid with
timestamp/UUID) so Vue can reliably track WarningCard instances; update the
template's v-for to use :key="alert.id" and populate alert.id in the code path
that constructs or normalizes the alerts array.
portmaster-ui/src/components/WarningCard.vue (1)

44-44: Hardcoded API URL may not match runtime configuration.

The URL http://127.0.0.1:817 is hardcoded, but per service/core/base/module.go, this is a configurable default. If the API listen address is changed at runtime, this will fail.

Consider using a configuration value or environment variable, or making the base URL a prop/inject from the parent application.

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

In `@portmaster-ui/src/components/WarningCard.vue` at line 44, The fetch call in
WarningCard.vue currently hardcodes the base URL ("http://127.0.0.1:817"), which
will break if the service listen address is changed; change the component to
accept the API base as a configurable value (e.g., a prop like apiBaseUrl or via
Vue provide/inject) and build the request URL by concatenating
`${apiBaseUrl}/api/v1/hids/quarantine` where the current fetch is used (the line
with fetch('http://127.0.0.1:817/api/v1/hids/quarantine')), defaulting to the
known default if the prop/env is not provided (or read from an environment
variable such as process.env.VUE_APP_API_BASE) so runtime configuration is
respected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@portmaster-ui/src/components/WarningCard.vue`:
- Around line 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.

In `@service/network/connection.go`:
- Around line 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.

In `@service/network/telemetry.go`:
- Around line 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.

In `@sidecar/inference.py`:
- Around line 87-92: The requests.post call currently sends JSON
(requests.post(url, json=payload)) but the Go handler handleHidsAlert reads form
values via Request.FormValue(), so change the send to use form-encoded data
(requests.post(url, data=payload, timeout=2)) and ensure payload values (e.g.,
payload['pid'], payload['score'], payload['binaryPath']) are strings (convert
numeric score/pid to str) before sending; keep the same try/except around the
call and continue to log success/failure using self.api_port, payload and score
identifiers.

In `@sidecar/main.py`:
- Around line 9-15: Change the CLI so --api-port is not always required: make
parser.add_argument("--api-port", ..., required=False) and after args =
parser.parse_args() add a validation that raises parser.error when
args.test_mode is False and args.api_port is None (e.g., if not args.test_mode
and args.api_port is None: parser.error("the --api-port argument is required
unless --test-mode is used")). Keep passing api_port=args.api_port into
InferenceEngine so test_mode can run without supplying a port.

---

Nitpick comments:
In @.gitignore:
- Around line 70-71: The .gitignore contains a redundant entry
"portmaster-ui/dist/" because a root-level "dist/" already ignores that path;
remove the specific "portmaster-ui/dist/" line (or alternatively remove the
global "dist/" if you prefer explicit per-package ignores) so entries are not
duplicated—look for the "dist/" and "portmaster-ui/dist/" patterns in .gitignore
and keep only the form your project prefers.

In `@portmaster-ui/package.json`:
- Around line 1-4: Add the npm private flag to the package manifest so the UI
package cannot be accidentally published: update package.json (the manifest
containing "name": "portmaster-ui-vue") to include the top-level property
"private": true alongside "name", "version", and "description".

In `@portmaster-ui/src/App.vue`:
- Around line 4-10: The v-for key currently uses alert.pid which can collide due
to PID reuse; change the key to a stable unique identifier (e.g., alert.id) and
ensure that each alert item gets a generated unique id when it is
created/received (server-provided UUID or combine pid with timestamp/UUID) so
Vue can reliably track WarningCard instances; update the template's v-for to use
:key="alert.id" and populate alert.id in the code path that constructs or
normalizes the alerts array.

In `@portmaster-ui/src/components/WarningCard.vue`:
- Line 44: The fetch call in WarningCard.vue currently hardcodes the base URL
("http://127.0.0.1:817"), which will break if the service listen address is
changed; change the component to accept the API base as a configurable value
(e.g., a prop like apiBaseUrl or via Vue provide/inject) and build the request
URL by concatenating `${apiBaseUrl}/api/v1/hids/quarantine` where the current
fetch is used (the line with
fetch('http://127.0.0.1:817/api/v1/hids/quarantine')), defaulting to the known
default if the prop/env is not provided (or read from an environment variable
such as process.env.VUE_APP_API_BASE) so runtime configuration is respected.

In `@service/network/api.go`:
- Around line 83-94: The hids/quarantine endpoint is currently registered with
api.PermitUser; update the RegisterEndpoint call for the Endpoint with Path
"hids/quarantine" so its Write permission uses api.PermitAdmin instead of
api.PermitUser, and optionally add a required confirmation parameter (e.g., a
boolean "confirm" Parameter or require an additional confirmation middleware) to
the Endpoint.Parameters to ensure explicit confirmation before applying the
profile change.
- Around line 99-108: handleHidsAlert currently reads raw form values (pid,
binaryPath, score) without validation; update the function to validate pid is a
positive integer (parse pid into an int and ensure >0), parse score into a
float64 (handle strconv.ParseFloat errors), and treat binaryPath as a non-empty
string; on parse/validation failure return a clear error (or a structured
bad-request response) and log the normalized parsed values (use the parsed pid
and score in log messages) so downstream storage/broadcast code receives typed
data; reference function handleHidsAlert and variables pid, score, binaryPath
and ensure any errors from parsing are handled and returned instead of
proceeding with raw strings.

In `@sidecar/inference.py`:
- Around line 52-55: The except blocks in the connection-handling code (the
broad "except Exception as e" in the function handling connections) should log
the exception type as well as the message for better debugging; update the
logging.error calls in that handler (and the similar broad except at the other
site around lines 77-78) to include type(e).__name__ alongside e, preserving the
finally conn.close() behavior and not changing the exception handling semantics.
- Line 13: UDS_SOCKET_PATH set to "/tmp/portmaster_telemetry.sock" is insecure;
change it to a restricted, configurable runtime path (e.g. use XDG_RUNTIME_DIR
or "/run/portmaster/portmaster_telemetry.sock") and ensure the directory is
created with secure permissions before binding. Update the UDS_SOCKET_PATH
constant in inference.py to read from configuration/env (fallback to a secure
default), and add code to create the parent directory with os.makedirs(...,
exist_ok=True) and set permissions (os.chmod) or use a umask so only the service
user can write the socket.

In `@sidecar/requirements.txt`:
- Around line 1-2: The requirements file lists unpinned packages "torch" and
"requests" which makes builds non-reproducible; update sidecar/requirements.txt
to pin each package to tested versions (e.g., replace "torch" with a specific
version like "torch==2.x.y" and "requests" with "requests==2.x.y") or add a
constraints file and reference it so installs are deterministic; ensure the
chosen versions match CI/tested matrix and update any build/dev docs or CI that
install these deps.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: cd776bd4-665b-4c53-9f59-f333f450f242

📥 Commits

Reviewing files that changed from the base of the PR and between b298265 and 1f8df15.

📒 Files selected for processing (15)
  • .gitignore
  • portmaster-ui/.gitignore
  • portmaster-ui/index.html
  • portmaster-ui/package.json
  • portmaster-ui/src/App.vue
  • portmaster-ui/src/components/WarningCard.vue
  • portmaster-ui/src/main.js
  • portmaster-ui/vite.config.js
  • service/network/api.go
  • service/network/connection.go
  • service/network/telemetry.go
  • sidecar/inference.py
  • sidecar/main.py
  • sidecar/model.py
  • sidecar/requirements.txt

Comment on lines +39 to +47
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
});
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.

Comment on lines +842 to +844
// Stream connection telemetry to HIDS sidecar before finalization
sendTelemetry(conn)

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.

Comment on lines +24 to +42
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,
}
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.

Comment on lines +87 to +92
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}")
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: Content-Type mismatch - sending JSON but backend expects form data.

requests.post(url, json=payload) sends the payload as application/json, but the Go endpoint handleHidsAlert reads parameters using ar.Request.FormValue(), which expects application/x-www-form-urlencoded or multipart/form-data.

This will cause all alert parameters (pid, binaryPath, score) to be empty strings on the backend.

Fix: Send as form data instead of JSON
     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)
+            requests.post(url, data=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}")

Note: data=payload sends as form-encoded. You may also need to convert numeric values to strings since form data is string-based.

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

In `@sidecar/inference.py` around lines 87 - 92, The requests.post call currently
sends JSON (requests.post(url, json=payload)) but the Go handler handleHidsAlert
reads form values via Request.FormValue(), so change the send to use
form-encoded data (requests.post(url, data=payload, timeout=2)) and ensure
payload values (e.g., payload['pid'], payload['score'], payload['binaryPath'])
are strings (convert numeric score/pid to str) before sending; keep the same
try/except around the call and continue to log success/failure using
self.api_port, payload and score identifiers.

Comment on lines +9 to +15
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()
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 | 🟡 Minor

Make --api-port conditional when --test-mode is used.

At Line 9, --api-port is always required, but Line 10 describes a standalone test mode. This currently blocks --test-mode runs without a port.

Suggested CLI fix
-    parser.add_argument("--api-port", type=int, required=True, help="Portmaster Core API Port")
+    parser.add_argument("--api-port", type=int, help="Portmaster Core API Port")
     parser.add_argument("--test-mode", action="store_true", help="Run model instantiation test and exit")
 
     args = parser.parse_args()
+    if not args.test_mode and args.api_port is None:
+        parser.error("--api-port is required unless --test-mode is set")
 
-    engine = InferenceEngine(api_port=args.api_port, test_mode=args.test_mode)
+    engine = InferenceEngine(api_port=args.api_port or 0, test_mode=args.test_mode)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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()
parser.add_argument("--api-port", type=int, help="Portmaster Core API Port")
parser.add_argument("--test-mode", action="store_true", help="Run model instantiation test and exit")
args = parser.parse_args()
if not args.test_mode and args.api_port is None:
parser.error("--api-port is required unless --test-mode is set")
engine = InferenceEngine(api_port=args.api_port or 0, test_mode=args.test_mode)
engine.run()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sidecar/main.py` around lines 9 - 15, Change the CLI so --api-port is not
always required: make parser.add_argument("--api-port", ..., required=False) and
after args = parser.parse_args() add a validation that raises parser.error when
args.test_mode is False and args.api_port is None (e.g., if not args.test_mode
and args.api_port is None: parser.error("the --api-port argument is required
unless --test-mode is used")). Keep passing api_port=args.api_port into
InferenceEngine so test_mode can run without supplying a port.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants