Skip to content
4 changes: 4 additions & 0 deletions agent/internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ func (a *Agent) pollAndApply() {
return
}

slog.Debug("poll complete", "actions", len(actions))

for _, action := range actions {
switch action.Action {
case ActionStart:
Expand Down Expand Up @@ -170,6 +172,8 @@ func (a *Agent) sendHeartbeat() {
a.sampleResults = append(results, a.sampleResults...)
a.mu.Unlock()
}
} else {
slog.Debug("heartbeat sent", "pipelines", len(hb.Pipelines), "sampleResults", len(results))
}
}

Expand Down
12 changes: 11 additions & 1 deletion agent/internal/agent/enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package agent

import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
Expand All @@ -23,10 +24,13 @@ func loadOrEnroll(cfg *config.Config, c *client.Client) (string, error) {
if err == nil {
token := strings.TrimSpace(string(data))
if token != "" {
slog.Debug("loaded existing node token", "path", tokenPath)
return token, nil
}
}

slog.Debug("no existing node token", "path", tokenPath)

// Need to enroll
if cfg.Token == "" {
return "", fmt.Errorf("no node token found at %s and VF_TOKEN is not set — cannot enroll", tokenPath)
Expand All @@ -35,6 +39,12 @@ func loadOrEnroll(cfg *config.Config, c *client.Client) (string, error) {
hostname, _ := os.Hostname()
vectorVersion := detectVectorVersion(cfg.VectorBin)

prefix := cfg.Token
if len(cfg.Token) > 24 {
prefix = cfg.Token[:24] + "..."
}
slog.Debug("enrolling", "hostname", hostname, "os", runtime.GOOS+"/"+runtime.GOARCH, "tokenPrefix", prefix)

resp, err := c.Enroll(client.EnrollRequest{
Token: cfg.Token,
Hostname: hostname,
Expand All @@ -54,7 +64,7 @@ func loadOrEnroll(cfg *config.Config, c *client.Client) (string, error) {
return "", fmt.Errorf("persist node token: %w", err)
}

fmt.Printf("Enrolled as node %s in environment %q\n", resp.NodeID, resp.EnvironmentName)
slog.Info("enrolled successfully", "nodeId", resp.NodeID, "environment", resp.EnvironmentName)
return resp.NodeToken, nil
}

Expand Down
12 changes: 12 additions & 0 deletions agent/internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
)
Expand Down Expand Up @@ -57,21 +58,25 @@ func (c *Client) Enroll(req EnrollRequest) (*EnrollResponse, error) {
}
httpReq.Header.Set("Content-Type", "application/json")

slog.Debug("http request", "method", "POST", "url", c.baseURL+"/api/agent/enroll")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
slog.Debug("http error", "method", "POST", "url", "/api/agent/enroll", "error", err)
return nil, fmt.Errorf("enroll request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("http response", "method", "POST", "url", "/api/agent/enroll", "status", resp.StatusCode, "body", string(respBody))
return nil, fmt.Errorf("enroll failed (status %d): %s", resp.StatusCode, string(respBody))
}

var result EnrollResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode enroll response: %w", err)
}
slog.Debug("http response", "method", "POST", "url", "/api/agent/enroll", "status", 200)
Comment on lines +61 to +79
Copy link

Choose a reason for hiding this comment

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

Inconsistent URL representation in request vs. error/response debug logs

The request log uses the fully-qualified URL (c.baseURL + "/api/agent/enroll") while the subsequent error and response logs drop back to just the relative path ("/api/agent/enroll"). This pattern is repeated for all three methods (Enroll, GetConfig, SendHeartbeat).

In practice this means that when an http error or http response log line appears you lose the hostname/port context needed to identify which server instance the agent was talking to — which is exactly the information most useful during a 401 enrollment failure investigation, particularly when debugging multi-environment deployments.

Consider using the same full URL in all three log sites:

Suggested change
slog.Debug("http request", "method", "POST", "url", c.baseURL+"/api/agent/enroll")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
slog.Debug("http error", "method", "POST", "url", "/api/agent/enroll", "error", err)
return nil, fmt.Errorf("enroll request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("http response", "method", "POST", "url", "/api/agent/enroll", "status", resp.StatusCode, "body", string(respBody))
return nil, fmt.Errorf("enroll failed (status %d): %s", resp.StatusCode, string(respBody))
}
var result EnrollResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode enroll response: %w", err)
}
slog.Debug("http response", "method", "POST", "url", "/api/agent/enroll", "status", 200)
endpoint := c.baseURL + "/api/agent/enroll"
slog.Debug("http request", "method", "POST", "url", endpoint)
// ...
slog.Debug("http error", "method", "POST", "url", endpoint, "error", err)
// ...
slog.Debug("http response", "method", "POST", "url", endpoint, "status", resp.StatusCode, "body", string(respBody))

Apply the same fix to GetConfig() and SendHeartbeat() methods for consistency.

Comment on lines +61 to +79
Copy link

Choose a reason for hiding this comment

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

Inconsistent URL representation in request vs. error/response debug logs

The request log uses the fully-qualified URL (c.baseURL + "/api/agent/enroll") while the subsequent error and response logs drop back to just the relative path ("/api/agent/enroll"). This pattern is repeated for all three methods (Enroll, GetConfig, SendHeartbeat).

In practice this means that when an http error or http response log line appears you lose the hostname/port context needed to identify which server instance the agent was talking to — which is exactly the information most useful during a 401 enrollment failure investigation, particularly when debugging multi-environment deployments.

Consider using the same full URL in all three log sites for consistency and debuggability.

return &result, nil
}

Expand Down Expand Up @@ -118,21 +123,25 @@ func (c *Client) GetConfig() (*ConfigResponse, error) {
}
httpReq.Header.Set("Authorization", "Bearer "+c.nodeToken)

slog.Debug("http request", "method", "GET", "url", c.baseURL+"/api/agent/config")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
slog.Debug("http error", "method", "GET", "url", "/api/agent/config", "error", err)
return nil, fmt.Errorf("config request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("http response", "method", "GET", "url", "/api/agent/config", "status", resp.StatusCode, "body", string(respBody))
return nil, fmt.Errorf("config request failed (status %d): %s", resp.StatusCode, string(respBody))
}

var result ConfigResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode config response: %w", err)
}
slog.Debug("http response", "method", "GET", "url", "/api/agent/config", "status", 200, "pipelines", len(result.Pipelines))
return &result, nil
}

Expand Down Expand Up @@ -231,14 +240,17 @@ func (c *Client) SendHeartbeat(req HeartbeatRequest) error {
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.nodeToken)

slog.Debug("http request", "method", "POST", "url", c.baseURL+"/api/agent/heartbeat")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
slog.Debug("http error", "method", "POST", "url", "/api/agent/heartbeat", "error", err)
return fmt.Errorf("heartbeat request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
slog.Debug("http response", "method", "POST", "url", "/api/agent/heartbeat", "status", resp.StatusCode, "body", string(respBody))
return fmt.Errorf("heartbeat failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
Expand Down
9 changes: 8 additions & 1 deletion src/app/api/agent/enroll/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ export async function POST(request: Request) {
const body = await request.json();
const parsed = enrollSchema.safeParse(body);
if (!parsed.success) {
console.error("[enroll] invalid input:", parsed.error.flatten().fieldErrors);
Comment on lines 18 to +19
Copy link

Choose a reason for hiding this comment

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

Always-on validation-error log on unauthenticated endpoint

console.error("[enroll] invalid input:", ...) fires unconditionally for every malformed request to an unauthenticated endpoint. The enrollment endpoint requires no prior authentication, so a flood of crafted malformed requests will emit one console.error per request to production logs indefinitely — there is no rate limit or gating here.

The payload is parsed.error.flatten().fieldErrors, which contains only field names (e.g. { hostname: ["Required"] }), not actual input values, so this doesn't leak user data. The concern is log volume under a malformed-request DoS. Consider dropping this line (Zod validation errors are already returned to the caller in the 400 response body) or downgrading to a debug-only log.

return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}

const { token, hostname, os, agentVersion, vectorVersion } = parsed.data;
const safeHostname = hostname.replace(/[\r\n\t"]/g, " ");
const safeVersion = (agentVersion ?? "unknown").replace(/[\r\n\t"]/g, " ");
console.log(`[enroll] attempt from hostname="${safeHostname}" agentVersion="${safeVersion}"`);

// Find all environments that have an enrollment token
const environments = await prisma.environment.findMany({
Expand All @@ -37,6 +41,7 @@ export async function POST(request: Request) {
team: { select: { id: true } },
},
});
console.log(`[enroll] found ${environments.length} candidate environment(s)`);
Copy link

Choose a reason for hiding this comment

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

Always-on pre-auth environment count log

This console.log fires on every enrollment attempt — including unauthenticated and brute-force ones — before any token verification has occurred. It unconditionally writes the number of environments with active enrollment tokens to production logs.

Two concerns:

  1. Log volume: A brute-force attacker can cause one [enroll] found N candidate environment(s) line per attempt to be emitted, flooding production logs. The existing [enroll] REJECTED line already captures this count on failed attempts, making this line redundant.
  2. Pre-auth information disclosure: Anyone with read access to server logs can determine how many environments have enrollment tokens configured from any failed attempt, before the request is authenticated.

Consider removing this line, or gating it behind a debug logger, since the rejected/success lines already include the checked count.


// Try each environment's enrollment token
let matchedEnv: (typeof environments)[0] | null = null;
Expand All @@ -48,6 +53,7 @@ export async function POST(request: Request) {
}

if (!matchedEnv) {
console.error(`[enroll] REJECTED -- no matching environment (checked ${environments.length})`);
return NextResponse.json(
{ error: "Invalid enrollment token" },
{ status: 401 },
Expand All @@ -73,6 +79,7 @@ export async function POST(request: Request) {
metadata: { enrolledVia: "agent" },
},
});
console.log(`[enroll] SUCCESS -- node ${node.id} enrolled in "${matchedEnv.name}"`);
Copy link

Choose a reason for hiding this comment

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

matchedEnv.name embedded in log without sanitization

safeHostname and safeVersion are sanitized with /[\r\n\t"]/g before being interpolated into log lines, but matchedEnv.name on this success log is used raw. Environment names are admin-controlled (not from the enrollment request), so the risk is low — but if an admin sets a name containing a newline or a double-quote, it bypasses the same injection guard that was carefully applied to the request fields on lines 27–28.

For consistency, apply the same sanitization here:

Suggested change
console.log(`[enroll] SUCCESS -- node ${node.id} enrolled in "${matchedEnv.name}"`);
console.log(`[enroll] SUCCESS -- node ${node.id} enrolled in "${matchedEnv.name.replace(/[\r\n\t"]/g, " ")}"`);

Context Used: Rule from dashboard - ## Security & Cryptography Review Rules

When reviewing changes to authentication, authorization, en... (source)


return NextResponse.json({
nodeId: node.id,
Expand All @@ -81,7 +88,7 @@ export async function POST(request: Request) {
environmentName: matchedEnv.name,
});
} catch (error) {
console.error("Agent enrollment error:", error);
console.error("[enroll] unexpected error:", error);
return NextResponse.json(
{ error: "Enrollment failed" },
{ status: 500 },
Expand Down
Loading