From 4686efd8c8b98e72b99069f9a41076f75d9c2ae0 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 20 Aug 2026 20:49:44 +0100 Subject: [PATCH] Add a supported least-privilege agent install profile The unified agent's Linux installer only offered the root profile, and the docs called non-root unsupported. That default is the most-cited reason security-conscious evaluators reject Pulse without trying it. install.sh gains --least-privilege: the service runs as a dedicated nologin pulse-agent system user with every existing hardening directive, no LXC-attach ambient capabilities, docker-group membership for socket reads, and refusal (not silent root fallback) on appliance platforms, non-systemd init systems, and --enable-commands. Optional --grant-smart and --grant-pct restore the only two collectors that need elevation through visudo-validated exact-command sudoers rules and root-owned wrappers the agent reaches via new absolute-path-only PULSE_SMARTCTL_PATH / PULSE_PCT_PATH overrides; the pct grant covers pct list and pct df only and can never widen into pct exec. --update preserves the profile and its grants by reading the installed unit. The agent now authors a privilege block in its report (effective root, service user, active helpers), carried through models into the fleet doctor as a descriptive field: Agent Doctor shows the profile and its helpers instead of presenting intentionally absent collectors as a fault, and a least-privilege agent can never be marked unhealthy on that evidence alone. --- docs/AGENT_SECURITY.md | 57 +++++- docs/PRODUCTION_SECURITY.md | 11 +- .../v6/internal/subsystems/agent-lifecycle.md | 22 +++ .../v6/internal/subsystems/api-contracts.md | 10 + .../subsystems/deployment-installability.md | 16 ++ .../v6/internal/subsystems/monitoring.md | 17 ++ .../internal/subsystems/security-privacy.md | 12 ++ frontend-modern/browser-verification.json | 48 ++--- frontend-modern/src/api/agentDiagnostics.ts | 10 + .../InfrastructureAgentDoctorPage.tsx | 4 + .../infrastructureAgentDoctorModel.test.ts | 40 ++++ .../infrastructureAgentUpdateCommandsModel.ts | 19 ++ internal/hostagent/agent.go | 1 + internal/hostagent/privilege.go | 27 +++ internal/hostagent/privilege_test.go | 72 +++++++ internal/hostagent/proxmox_lxc_filesystems.go | 17 +- internal/models/converters.go | 9 + internal/models/deepcopy.go | 1 + internal/models/deepcopy_test.go | 15 ++ internal/models/models.go | 13 ++ internal/models/models_frontend.go | 1 + internal/monitoring/agent_fleet_doctor.go | 82 +++++--- .../monitoring/agent_fleet_doctor_test.go | 41 ++++ internal/monitoring/monitor_agents.go | 16 ++ .../monitoring/monitor_host_agents_test.go | 45 +++++ pkg/agents/host/report.go | 19 ++ pkg/agents/host/report_test.go | 38 ++++ scripts/install.sh | 185 +++++++++++++++++- scripts/installtests/install_sh_test.go | 46 ++++- 29 files changed, 821 insertions(+), 73 deletions(-) create mode 100644 internal/hostagent/privilege.go create mode 100644 internal/hostagent/privilege_test.go diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index ef0137f4b..656aa2a18 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -2,15 +2,26 @@ Pulse agents incorporate several security mechanisms to ensure that the code running on your infrastructure is authentic and untampered with. +**Start with the least privilege that answers your monitoring question.** For +Proxmox VE, PBS, and PMG, that is usually no agent at all: API-only monitoring +with a read-only token covers inventory, status, and metrics, and the +generated setup script creates a privilege-separated monitoring user for it +(see [Proxmox Deployment Choices](#proxmox-deployment-choices)). Install a +host agent only where you want data the platform API cannot provide, and on +Linux consider the supported +[least-privilege profile](#least-privilege-agent-profile) before the root +default. + ## Agent Privilege Model Pulse's Linux/systemd installer runs the unified agent as `root` by default. That is intentional for full host telemetry: disk SMART data, mdadm/RAID state, temperature sensors, Docker or Podman socket reads, Proxmox host-local details that are not available through the API, and some NAS/platform integrations -commonly require root or equivalent local privileges. Running the service as a -lower-privilege user may work for a narrow subset of metrics, but it is not a -supported full-telemetry profile today. +commonly require root or equivalent local privileges. On Linux/systemd hosts, +the supported alternative is the least-privilege profile documented below; it +trades the root-only collectors it has not been granted for a dedicated +non-root service user. Treat a host agent like other infrastructure monitoring software with local root read access: @@ -123,12 +134,40 @@ mirrors the generated read/monitoring ACLs onto both the service user and the token. For PBS, the generated script grants the `Audit` ACL to both the service user and token. -Running `pulse-agent` as a custom non-root systemd user is possible by editing -the service unit, but it is not a supported full-telemetry mode today. Expect -gaps in SMART, temperature, Docker socket, ZFS/Ceph/mdadm, mount, and platform -integration data unless you deliberately grant equivalent capabilities or group -access. If you choose that route, treat it as a local hardening profile and -verify the exact metrics you care about after the change. +## Least-Privilege Agent Profile + +On standard Linux systemd hosts, `install.sh --least-privilege` is a supported +alternative to the root profile. It runs the service as a dedicated +`pulse-agent` system user (nologin shell, owning only its state directory and +binary), joins the `docker` group when Docker monitoring is enabled so socket +reads keep working, and keeps every hardening directive of the root unit while +dropping the LXC-attach ambient capability grant entirely. + +Two optional flags restore the collectors that genuinely need elevation, each +through an exact-command sudoers grant validated with `visudo` and a +root-owned wrapper the agent is pointed at via an absolute-path-only +environment override: + +- `--grant-smart` allows exactly `smartctl`, restoring SMART disk health. +- `--grant-pct` allows exactly `pct list` and `pct df`, restoring Proxmox LXC + filesystem capacity. The grant deliberately excludes `pct exec`, `start`, + `stop`, and `enter`, so guest Docker inventory stays a root-profile feature. + +What the profile gives up: command execution (`--enable-commands` is refused +and a later server-side enable requires reinstalling the root profile), +`pct exec` guest Docker inventory, and any platform integration that needs +device or socket access you have not granted. Core metrics, mounts, `/proc` +RAID state, hwmon temperatures, and Docker socket reads work without root. +Ungranted collectors fail soft, and the agent reports its privilege profile so +**Settings → Infrastructure → Agent Doctor** shows the service user and active +helpers instead of presenting missing collectors as a fault. Appliance +platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd init systems keep +the root profile; the installer refuses `--least-privilege` there rather than +silently falling back to root. + +`--update` preserves an existing least-privilege profile and its grants +without the flags being repeated. Uninstall removes the sudoers file and +helpers; the inert system user is left behind deliberately. ## Supply-Chain Boundary diff --git a/docs/PRODUCTION_SECURITY.md b/docs/PRODUCTION_SECURITY.md index 8520974de..939b9fe98 100644 --- a/docs/PRODUCTION_SECURITY.md +++ b/docs/PRODUCTION_SECURITY.md @@ -48,10 +48,13 @@ The default posture limits that boundary: - Proxmox guest Docker inventory through `pct exec` is disabled by default and requires an explicit server setting. -A custom non-root systemd user is possible, but it is not currently a supported -full-telemetry profile. Expect gaps unless you deliberately grant equivalent -device, filesystem, or socket access. If API data is sufficient, API-only -monitoring is the cleaner least-privilege choice. +On standard Linux systemd hosts the installer also offers a supported +least-privilege profile: `--least-privilege` runs the service as a dedicated +`pulse-agent` system user, with optional `--grant-smart` and `--grant-pct` +flags that restore SMART and Proxmox LXC filesystem collection through +exact-command sudoers grants. Command execution and `pct exec` guest inventory +stay root-profile features. If API data is sufficient, API-only monitoring +remains the cleanest least-privilege choice of all — it needs no agent. See [Agent Security](AGENT_SECURITY.md) for the precise command, guest-access, update, and service-hardening boundaries. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index da4722b53..22df34881 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6403,3 +6403,25 @@ enabled gate, and they do not enroll or select an agent, open or alter a command session, change fleet policy or update state, or add any agent-routed capability. Agent lifecycle obligations over `internal/api/` are unchanged by this surface. + +### Least-privilege agent profile + +`scripts/install.sh --least-privilege` is a supported Linux/systemd install +profile that runs the unified agent as the dedicated nologin `pulse-agent` +system user instead of root. The profile is explicit at every boundary: it is +refused on appliance platforms and non-systemd init systems rather than +silently falling back to root, it is mutually exclusive with +`--enable-commands` (governed command execution stays a root-profile +capability), it never receives the LXC-attach ambient capability grant, and +`--update` preserves an existing profile and its grants by reading the +installed unit rather than requiring the flags to be repeated. Optional +`--grant-smart` and `--grant-pct` restore exactly the two collectors that +need elevation through visudo-validated, exact-command sudoers rules and +root-owned wrapper helpers the agent reaches only via the absolute-path-only +`PULSE_SMARTCTL_PATH` / `PULSE_PCT_PATH` overrides; the pct grant covers +`pct list` and `pct df` only and can never widen into `pct exec`. The agent +authors a `privilege` block in its report (`pkg/agents/host/report.go` +`PrivilegeStatus`: effective root, service user, active helpers) so the +server can present the profile descriptively. Uninstall removes the sudoers +file and helpers. `scripts/installtests/install_sh_test.go` +(`TestInstallSHLeastPrivilegeProfile`) pins the profile's invariants. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 13146634c..d749575c6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -9644,3 +9644,13 @@ calls consume budget without becoming evidence, and exhausting the budget with no success fails the run closed. Both paths are reductions after execution-profile projection; neither external clients nor model output can select or widen the surface. + +The agent fleet diagnostics payload (`GET /api/agents/diagnostics`) now +carries an optional per-agent `privilege` object — `runningAsRoot`, +`serviceUser`, `smartctlHelper`, `pctHelper` — mirrored by the frontend +transport in `frontend-modern/src/api/agentDiagnostics.ts` +(`AgentFleetDiagnosticPrivilege`). The field is descriptive fleet evidence: +it appears only when the agent reported a profile, it never carries +credentials or paths, and consumers must not derive health status from it. +The unified agent report contract (`pkg/agents/host/report.go`) gains the +matching agent-authored `privilege` block with the same fields. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 2fa7661ba..6dea3283e 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -3461,3 +3461,19 @@ install unlicensed with an explicit warning, and `PULSE_PROVIDER_MSP_SKIP_EVAL_L skips the request outright for air-gapped hosts. An existing evaluation licence on disk is reused rather than re-requested. The install must never abort because an evaluation licence could not be obtained. + +### Least-privilege agent install profile + +The unified agent installer (`scripts/install.sh`) offers +`--least-privilege` on standard Linux systemd hosts: a dedicated nologin +`pulse-agent` system user owns the service, state directory, and binary; +docker-group membership covers socket reads; and the optional +`--grant-smart` / `--grant-pct` flags install visudo-validated, +exact-command sudoers rules with root-owned wrapper helpers wired through +`PULSE_SMARTCTL_PATH` / `PULSE_PCT_PATH`. Installability boundaries: the +flag is refused (never silently downgraded to root) on appliance platforms +and non-systemd init systems, is mutually exclusive with +`--enable-commands`, and `--update` preserves an installed profile and its +grants by reading the existing unit. Uninstall removes the sudoers file and +helper directory. `scripts/installtests/install_sh_test.go` +(`TestInstallSHLeastPrivilegeProfile`) pins these invariants. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 400ad4cc8..c96adac97 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -3063,3 +3063,20 @@ errored identity lookup. Monitoring does not reinterpret provider ownership or invent lifecycle state; Alerts owns signal suppression and unified resources owns persistence. `internal/monitoring/monitor_alert_intent_test.go` and the alerts intent-policy proof pin this adapter boundary. + +### Agent privilege profile is descriptive model state + +Host reports may carry an agent-authored privilege profile (effective root, +service user, active smartctl/pct helpers). Ingest copies it verbatim into +`models.Host.AgentPrivilege` (trimming the user), state deep-copy isolates it +(`cloneHost`), the frontend host projection clones it, and the agent fleet +doctor surfaces it as the dedicated descriptive `privilege` field rather than +a health reason. A report without the block yields nil — the server never +invents a profile — and a non-root profile must never degrade agent health on +that evidence alone. Proofs: +`pkg/agents/host/report_test.go` (`TestAgentInfoPrivilegeStatusRoundTrip`), +`internal/monitoring/monitor_host_agents_test.go` +(`TestApplyHostReportCarriesAgentPrivilegeProfile`), +`internal/models/deepcopy_test.go` (`TestCloneHostIsolatesAgentPrivilege`), +`internal/monitoring/agent_fleet_doctor_test.go` +(`TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth`). diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index fb034299d..e12a3bd7e 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -2278,3 +2278,15 @@ endpoint remains non-secret release metadata; this binding does not expose inventory, credentials, update selections, or command authority. `TestRunBindsPackagedVersionIdentity` in `pkg/server/server_test.go` pins the wrapper-runtime boundary. + +### Least-privilege agent install profile boundaries + +The least-privilege agent install profile is a security boundary, not a +convenience flag: `install.sh --least-privilege` must keep the service user +non-root with a nologin shell, keep every sudoers grant exact-command and +visudo-validated with the pct grant excluding `pct exec`/`start`/`stop`/ +`enter`, refuse `--enable-commands` under the profile, refuse unsupported +platforms instead of silently reverting to root, and drop the LXC-attach +ambient capability grant. The agent-reported privilege profile is +informational: the fleet doctor presents it descriptively and must not treat +a non-root agent as unhealthy on that evidence alone. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c74469d66..1b5ae3027 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,28 +1,20 @@ { "version": 1, - "base_sha": "a5f1567172314003ee1b96b93090ffcbbe18e8f6", - "verified_at": "2026-08-20T16:20:02Z", + "base_sha": "74873e2b558e4f0984c8edd86e51cd7b9dbac012", + "verified_at": "2026-08-20T19:44:49Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/notifications.ts", - "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx", - "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts", - "frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts", - "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts", - "frontend-modern/src/utils/alertDestinationsPresentation.ts" + "frontend-modern/src/api/agentDiagnostics.ts", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx", + "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts" ], "content_sha256": { - "frontend-modern/src/api/notifications.ts": "5bd796b6ec9222d6973a4a26b892b4fcfde11b2d9dd2e2ef705ad34531af0071", - "frontend-modern/src/features/alerts/AlertDeliveryLogCard.tsx": "7e7e4c5da2a4ea8c267c546da0064806949573bc57a51cf7c5b5f5f8576826ef", - "frontend-modern/src/features/alerts/tabs/DestinationsTab.tsx": "0411429355fd78e75948df8ede39da75f9f9e3600840d9b98f7342d2adbfad7b", - "frontend-modern/src/features/alerts/useAlertDestinationsTabState.ts": "c88336576ec08b69271244947f7892cac0b77e9ec68fdf33a12fe0f130e4e29d", - "frontend-modern/src/features/alerts/useAlertWebhookDestinationsState.ts": "b9c959388a06ce146f3706cbc2b191449d4dd5380198d711ed15fdf06fdc48b9", - "frontend-modern/src/features/alerts/useNotificationDeliveryLog.ts": "48ca901ec9435d94a06de5e55bf05dbf254cfd23114cd2b31383909d1585237b", - "frontend-modern/src/utils/alertDestinationsPresentation.ts": "8a2e9446dfbdd77d46ef6a678ff2aa403741e3d296ac8ef67ba708c8940a9b2a" + "frontend-modern/src/api/agentDiagnostics.ts": "f6ff4e1d6c29a0d141b973618d68263aad8e05a454c048469d0b5e3eb3cbb7ab", + "frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "f4334238aea3d20bd1b602b6313555fe9b8855d8e9aa344cd7be3fa8f9b86334", + "frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts": "5300b2ad75a3223d520a392df463ce9e9096c8f29cf1c543a60c7327904c58ad" }, "routes": [ - "/alerts/notifications" + "/settings/infrastructure/agent-doctor" ], "viewports": [ { @@ -35,19 +27,17 @@ } ], "states": [ - "Fresh-install destinations tab (activation pending): AlertDeliveryPausedCard at top, Recent delivery activity card at bottom with honest empty state naming the 7-day window and the test-sends-skip-the-queue caveat", - "Delivery log with seeded audit rows served by the sidecar-built backend at GET /api/notifications/delivery-log: newest-first entries with Failed-retries-exhausted (red), Retrying (amber), Delivered (green) outcome badges", - "Webhook destination id (webhook:) resolved to the configured webhook name 'Ops Sink'; email entry labeled 'Email' with its opaque destination hash", - "Failure detail line renders 'Authentication failure: HTTP 401 Unauthorized from https://hooks.example.test/notify?token=REDACTED' proving API-layer webhook secret redaction (seeded value was token=supersecret)", - "Mobile viewport: card and badges wrap with no horizontal overflow (documentElement.scrollWidth == 375)", - "No console errors on the destinations tab after load, test send, and delivery log refresh", - "Receipt re-pinned during rebase onto a5f156717 (copy-fix replayed onto fd35c5477 canonical search work): every staged blob hash above is byte-identical to the exercised content, and neither upstream commit touches any of these files" + "Agent Doctor with a live non-root agent report: the expanded agent row's metadata line reads 'Privilege: least privilege (rcourtman) · SMART helper active', derived end-to-end from a real pulse-agent process (built from this tree) running as an unprivileged user with PULSE_SMARTCTL_PATH set, reporting into the sidecar-built backend", + "The agent's 'Needs attention' status comes from genuine unrelated reasons (dev version not comparable to update target, docker module retrying because this Mac has no Docker socket) — the privilege profile itself raises no reason, matching the non-degrading contract", + "Mobile viewport 375x812: the privilege line wraps cleanly inside the expanded row with no horizontal overflow (documentElement.scrollWidth == 375); desktop 1280 same check passes", + "Backend /api/agents/diagnostics returns privilege {runningAsRoot:false, serviceUser:'rcourtman', smartctlHelper:true} for the reporting agent" ], "interactions": [ - "Logged into the isolated sidecar-built stack (backend :7811, vite :5487), opened Alerts > Notifications", - "Added webhook 'Ops Sink' -> http://127.0.0.1:18712/hook (allowlisted 127.0.0.1/32) and clicked Test: local sink logged the POST 200 and the UI raised the warning toast 'Test sent, but notification delivery is paused: real alerts are not being sent' instead of plain success, because the install's activation gate is pending", - "Saved the webhook via Add Webhook and confirmed GET /api/notifications/webhooks returns it", - "Seeded notification_audit rows (sent email, retry webhook, dead-letter webhook with token-bearing error text), clicked Refresh delivery status, and verified the three entries rendered newest-first with redacted error text", - "Resized to 375x812 and re-exercised the delivery log card" + "Rebuilt the scratch backend and pulse-agent from this tree, started the stack (backend :7811, vite :5487)", + "Minted a host-agent install token via POST /api/agent-install-command from the logged-in page and started pulse-agent as the unprivileged rcourtman user with --agent-id least-priv-verify and PULSE_SMARTCTL_PATH set", + "Opened Settings > Infrastructure > Agent Doctor, expanded the agent row, and located the rendered privilege line via DOM text walk at 1280x720 and 375x812", + "Read /api/agents/diagnostics from the page to confirm the wire payload carries the privilege block", + "Receipt hash for infrastructureAgentUpdateCommandsModel.ts re-pinned after the pre-commit prettier pass reformatted the staged blob; the change is formatting-only relative to the exercised content", + "Captured the mobile screenshot of the expanded row; stopped the scratch agent and both preview servers afterwards" ] } diff --git a/frontend-modern/src/api/agentDiagnostics.ts b/frontend-modern/src/api/agentDiagnostics.ts index a73592a0f..5d31d9e7e 100644 --- a/frontend-modern/src/api/agentDiagnostics.ts +++ b/frontend-modern/src/api/agentDiagnostics.ts @@ -46,6 +46,15 @@ export interface AgentFleetDiagnosticModule { updatedAt?: string; } +// Agent-reported privilege profile. Descriptive only: a least-privilege +// install is an intentional hardening choice, never a health defect. +export interface AgentFleetDiagnosticPrivilege { + runningAsRoot: boolean; + serviceUser?: string; + smartctlHelper?: boolean; + pctHelper?: boolean; +} + export interface AgentFleetAgentDiagnostic { /** Canonical `/api/connections` identifier. */ connectionId?: string; @@ -74,6 +83,7 @@ export interface AgentFleetAgentDiagnostic { deployedProfileVersion?: number; agentUpdate?: AgentFleetDiagnosticUpdate; agentModules?: AgentFleetDiagnosticModule[]; + privilege?: AgentFleetDiagnosticPrivilege; reasons: AgentFleetDiagnosticReason[]; repairActions?: AgentFleetDiagnosticRepair[]; } diff --git a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx index d75ce6004..af5fd1270 100644 --- a/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx @@ -500,6 +500,10 @@ export const InfrastructureAgentDoctorPage: Component + + {' '} + · Privilege: {target.privilegeLabel} + {' '} · Profile: {target.profileLabel} diff --git a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts index 259188f5c..8849c51f1 100644 --- a/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/infrastructureAgentDoctorModel.test.ts @@ -121,6 +121,46 @@ describe('Agent Doctor model', () => { expect(targets[0].commandBlockedReason).toBeUndefined(); }); + it('describes the reported privilege profile without treating least privilege as a fault', () => { + const connection = connectionFixture(); + const [rootTarget] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [diagnosticFixture({ privilege: { runningAsRoot: true } })], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + expect(rootTarget.privilegeLabel).toBe('root'); + + const [leastPrivTarget] = collectInfrastructureAgentDoctorTargets({ + rows: [rowFixture(connection)], + connections: [connection], + diagnostics: [ + diagnosticFixture({ + privilege: { + runningAsRoot: false, + serviceUser: 'pulse-agent', + smartctlHelper: true, + pctHelper: false, + }, + }), + ], + diagnosticsAvailable: true, + targetVersion: '6.2.0', + }); + // Descriptive facts only: the service user and its active scoped helpers. + expect(leastPrivTarget.privilegeLabel).toBe( + 'least privilege (pulse-agent) · SMART helper active', + ); + // The profile itself must never be surfaced as an issue. + expect( + leastPrivTarget.reasons.some((reason) => reason.message.toLowerCase().includes('privilege')), + ).toBe(false); + + const report = formatInfrastructureAgentDoctorReport([leastPrivTarget]); + expect(report).toContain('Privilege least privilege (pulse-agent) · SMART helper active'); + }); + it('turns a missing credential into a token-gated authentication repair', () => { const connection = connectionFixture({ agentUpdateAvailable: false, diff --git a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts index 35a8aa06a..7843f6939 100644 --- a/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts +++ b/frontend-modern/src/components/Settings/infrastructureAgentUpdateCommandsModel.ts @@ -38,6 +38,7 @@ export type InfrastructureAgentDoctorTarget = Omit< updaterLabel?: string; profileLabel?: string; profileVersionLabel?: string; + privilegeLabel?: string; lastSeen?: number | string | null; source: 'diagnostics' | 'ledger-fallback' | 'removed'; }; @@ -623,6 +624,7 @@ const doctorTargetFromBinding = ( updaterLabel: updater.label, profileLabel, profileVersionLabel, + privilegeLabel: diagnosticPrivilegeLabel(diagnostic), lastSeen: connection.lastSeen ?? diagnostic?.lastSeen, source: diagnosticsAvailable && diagnostic ? 'diagnostics' : 'ledger-fallback', }; @@ -665,6 +667,21 @@ const diagnosticProfileVersionLabel = ( return `Expected v${diagnostic.profileVersion} · deployed v${diagnostic.deployedProfileVersion}`; }; +// Descriptive privilege line for the doctor detail row. A least-privilege +// install is an intentional hardening profile, so this label states facts +// (service user, active scoped helpers) and never implies a defect. +const diagnosticPrivilegeLabel = (diagnostic?: AgentFleetAgentDiagnostic): string | undefined => { + const privilege = diagnostic?.privilege; + if (!privilege) return undefined; + if (privilege.runningAsRoot) return 'root'; + const user = privilege.serviceUser?.trim(); + const base = user ? `least privilege (${user})` : 'least privilege'; + const helpers: string[] = []; + if (privilege.smartctlHelper) helpers.push('SMART helper active'); + if (privilege.pctHelper) helpers.push('pct helper active'); + return helpers.length > 0 ? `${base} · ${helpers.join(' · ')}` : base; +}; + const diagnosticOnlyDoctorTarget = ( diagnostic: AgentFleetAgentDiagnostic, ): InfrastructureAgentDoctorTarget => { @@ -685,6 +702,7 @@ const diagnosticOnlyDoctorTarget = ( commandPlatform: resolveKnownAgentCommandPlatform(diagnostic.platform), profileLabel: diagnostic.profileName?.trim() || diagnostic.profileId?.trim() || undefined, profileVersionLabel: diagnosticProfileVersionLabel(diagnostic), + privilegeLabel: diagnosticPrivilegeLabel(diagnostic), lastSeen: diagnostic.lastSeen, source: removed ? 'removed' : 'diagnostics', }; @@ -845,6 +863,7 @@ export const formatInfrastructureAgentDoctorReport = ( const lastSeen = doctorReportLastSeen(target.lastSeen); if (lastSeen) lines.push(` Last seen ${lastSeen}`); if (target.updaterLabel) lines.push(` Updater ${target.updaterLabel}`); + if (target.privilegeLabel) lines.push(` Privilege ${target.privilegeLabel}`); if (target.profileLabel) { lines.push( ` Profile ${target.profileLabel}${ diff --git a/internal/hostagent/agent.go b/internal/hostagent/agent.go index ed69746dd..9821c7534 100644 --- a/internal/hostagent/agent.go +++ b/internal/hostagent/agent.go @@ -1214,6 +1214,7 @@ func (a *Agent) buildReport(ctx context.Context) (agentshost.Report, error) { AppliedConfig: runtimeConfig.appliedConfig, Update: a.currentUpdateStatus(), Modules: moduleStatus, + Privilege: collectPrivilegeStatus(), }, Host: agentshost.HostInfo{ ID: a.machineID, diff --git a/internal/hostagent/privilege.go b/internal/hostagent/privilege.go new file mode 100644 index 000000000..c4c3f05d2 --- /dev/null +++ b/internal/hostagent/privilege.go @@ -0,0 +1,27 @@ +package hostagent + +import ( + "os" + "os/user" + "strings" + + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" +) + +// collectPrivilegeStatus reports the privilege the agent actually runs with. +// The values are facts about this process, not configuration: effective uid, +// the service account name, and whether the scoped privilege-helper overrides +// a least-privilege install configures are in effect. On Windows Geteuid is +// always -1, so RunningAsRoot stays false and ServiceUser carries the account +// name; the server renders the profile descriptively rather than judging it. +func collectPrivilegeStatus() *agentshost.PrivilegeStatus { + status := &agentshost.PrivilegeStatus{ + RunningAsRoot: os.Geteuid() == 0, + SmartctlHelper: strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")) != "", + PctHelper: strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")) != "", + } + if current, err := user.Current(); err == nil { + status.ServiceUser = strings.TrimSpace(current.Username) + } + return status +} diff --git a/internal/hostagent/privilege_test.go b/internal/hostagent/privilege_test.go new file mode 100644 index 000000000..36d9ad726 --- /dev/null +++ b/internal/hostagent/privilege_test.go @@ -0,0 +1,72 @@ +package hostagent + +import ( + "os" + "testing" +) + +func TestCollectPrivilegeStatusReportsProcessFacts(t *testing.T) { + t.Setenv("PULSE_SMARTCTL_PATH", "") + t.Setenv("PULSE_PCT_PATH", "") + + status := collectPrivilegeStatus() + if status == nil { + t.Fatal("collectPrivilegeStatus returned nil") + } + if status.RunningAsRoot != (os.Geteuid() == 0) { + t.Fatalf("RunningAsRoot = %v, euid = %d", status.RunningAsRoot, os.Geteuid()) + } + if status.ServiceUser == "" { + t.Fatal("ServiceUser is empty") + } + if status.SmartctlHelper || status.PctHelper { + t.Fatalf("helper flags set without overrides: %+v", status) + } +} + +func TestCollectPrivilegeStatusReportsHelperOverrides(t *testing.T) { + t.Setenv("PULSE_SMARTCTL_PATH", "/usr/local/lib/pulse-agent/smartctl-helper") + t.Setenv("PULSE_PCT_PATH", "/usr/local/lib/pulse-agent/pct-helper") + + status := collectPrivilegeStatus() + if !status.SmartctlHelper || !status.PctHelper { + t.Fatalf("helper overrides not reported: %+v", status) + } +} + +func TestResolvePctPathHonorsAbsoluteOverride(t *testing.T) { + t.Setenv("PULSE_PCT_PATH", "/usr/local/lib/pulse-agent/pct-helper") + + resolved, err := resolvePctPath(func(string) (string, error) { + t.Fatal("lookPath must not be consulted when the override is set") + return "", nil + }) + if err != nil { + t.Fatalf("resolvePctPath: %v", err) + } + if resolved != "/usr/local/lib/pulse-agent/pct-helper" { + t.Fatalf("resolved = %q", resolved) + } +} + +func TestResolvePctPathRejectsRelativeOverride(t *testing.T) { + t.Setenv("PULSE_PCT_PATH", "bin/pct") + + if _, err := resolvePctPath(func(string) (string, error) { return "/usr/sbin/pct", nil }); err == nil { + t.Fatal("relative PULSE_PCT_PATH accepted; a PATH-relative helper could be hijacked") + } +} + +func TestResolvePctPathFallsBackToLookPath(t *testing.T) { + t.Setenv("PULSE_PCT_PATH", "") + + resolved, err := resolvePctPath(func(name string) (string, error) { + if name != "pct" { + t.Fatalf("lookPath(%q)", name) + } + return "/usr/sbin/pct", nil + }) + if err != nil || resolved != "/usr/sbin/pct" { + t.Fatalf("resolved = %q, err = %v", resolved, err) + } +} diff --git a/internal/hostagent/proxmox_lxc_filesystems.go b/internal/hostagent/proxmox_lxc_filesystems.go index 8f3b975c9..ac58f2566 100644 --- a/internal/hostagent/proxmox_lxc_filesystems.go +++ b/internal/hostagent/proxmox_lxc_filesystems.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "sort" "strconv" "strings" @@ -17,6 +18,20 @@ import ( agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" ) +// resolvePctPath mirrors resolveSmartctlPath: PULSE_PCT_PATH lets a +// least-privilege install point the read-only pct list/df queries at a scoped +// privilege helper instead of requiring the whole agent to run as root. The +// override must be absolute so a PATH-relative name cannot be hijacked. +func resolvePctPath(lookPath func(string) (string, error)) (string, error) { + if configured := strings.TrimSpace(os.Getenv("PULSE_PCT_PATH")); configured != "" { + if !filepath.IsAbs(configured) { + return "", fmt.Errorf("PULSE_PCT_PATH must be an absolute path") + } + return configured, nil + } + return lookPath("pct") +} + const ( proxmoxLXCQueryTimeout = 10 * time.Second proxmoxLXCMaxContainers = 128 @@ -40,7 +55,7 @@ func (a *Agent) collectProxmoxLXCFilesystems(ctx context.Context) *agentshost.Pr if a.collector.GOOS() != "linux" { return nil } - pctPath, err := a.collector.LookPath("pct") + pctPath, err := resolvePctPath(a.collector.LookPath) if err != nil { if !errors.Is(err, exec.ErrNotFound) && !os.IsNotExist(err) { a.logger.Debug().Err(err).Msg("Failed to locate pct for Proxmox LXC filesystems") diff --git a/internal/models/converters.go b/internal/models/converters.go index 77081a7e1..d64dd3779 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -513,6 +513,7 @@ func (h Host) ToFrontend() HostFrontend { AppliedConfig: cloneAgentConfigFingerprint(h.AppliedConfig), AgentUpdate: cloneAgentUpdateStatus(h.AgentUpdate), AgentModules: cloneAgentModuleStatuses(h.AgentModules), + AgentPrivilege: cloneAgentPrivilegeStatus(h.AgentPrivilege), PackageUpdates: cloneHostPackageUpdateStatus(h.PackageUpdates), StorageCleanup: cloneHostStorageCleanupStatus(h.StorageCleanup), IsLegacy: h.IsLegacy, @@ -590,6 +591,14 @@ func cloneAgentModuleStatuses(values []AgentModuleStatus) []AgentModuleStatus { return append([]AgentModuleStatus(nil), values...) } +func cloneAgentPrivilegeStatus(value *AgentPrivilegeStatus) *AgentPrivilegeStatus { + if value == nil { + return nil + } + copy := *value + return © +} + // ToFrontend converts a DockerContainer to DockerContainerFrontend func (c DockerContainer) ToFrontend() DockerContainerFrontend { container := DockerContainerFrontend{ diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go index 09445f761..ee50a1516 100644 --- a/internal/models/deepcopy.go +++ b/internal/models/deepcopy.go @@ -410,6 +410,7 @@ func cloneHost(src Host) Host { dest.Tags = append([]string(nil), src.Tags...) dest.DiskExclude = append([]string(nil), src.DiskExclude...) dest.IdentityConflict = cloneHostIdentityConflict(src.IdentityConflict) + dest.AgentPrivilege = cloneAgentPrivilegeStatus(src.AgentPrivilege) return dest.NormalizeCollections() } diff --git a/internal/models/deepcopy_test.go b/internal/models/deepcopy_test.go index 1e7c5c17f..6d2d43c2a 100644 --- a/internal/models/deepcopy_test.go +++ b/internal/models/deepcopy_test.go @@ -66,6 +66,21 @@ func TestCloneHostAndZFSPoolIsolateZFSDatasets(t *testing.T) { } } +func TestCloneHostIsolatesAgentPrivilege(t *testing.T) { + host := Host{AgentPrivilege: &AgentPrivilegeStatus{ + RunningAsRoot: false, + ServiceUser: "pulse-agent", + }} + hostClone := cloneHost(host) + if hostClone.AgentPrivilege == nil { + t.Fatal("host clone dropped agent privilege") + } + hostClone.AgentPrivilege.ServiceUser = "mutated" + if host.AgentPrivilege.ServiceUser != "pulse-agent" { + t.Fatal("host clone aliased agent privilege") + } +} + func TestCloneDockerContainer_PreservesIndependentOOMEvidence(t *testing.T) { oomKilled := false src := DockerContainer{ diff --git a/internal/models/models.go b/internal/models/models.go index 79903b921..16fe1eca4 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -332,6 +332,7 @@ type Host struct { AppliedConfig *AgentConfigFingerprint `json:"appliedConfig,omitempty"` AgentUpdate *AgentUpdateStatus `json:"agentUpdate,omitempty"` AgentModules []AgentModuleStatus `json:"agentModules,omitempty"` + AgentPrivilege *AgentPrivilegeStatus `json:"agentPrivilege,omitempty"` // IntegrationSource names the platform integration ("vmware", "truenas", ...) // that supplies this host's telemetry when no Pulse Agent reports for it. // Empty means Pulse-Agent-backed. Only unified-fabric snapshots populate it; @@ -411,6 +412,18 @@ type AgentModuleStatus struct { UpdatedAt time.Time `json:"updatedAt"` } +// AgentPrivilegeStatus is the agent-reported privilege profile: whether the +// service runs as root, the service account, and whether the scoped +// smartctl/pct privilege helpers of a least-privilege install are in effect. +// Informational only — a non-root agent is an intentional profile, not a +// health defect. +type AgentPrivilegeStatus struct { + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + SmartctlHelper bool `json:"smartctlHelper,omitempty"` + PctHelper bool `json:"pctHelper,omitempty"` +} + func (h Host) NormalizeCollections() Host { if h.LoadAverage == nil { h.LoadAverage = []float64{} diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index e01aafcfb..d65557098 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -730,6 +730,7 @@ type HostFrontend struct { AppliedConfig *AgentConfigFingerprint `json:"appliedConfig,omitempty"` AgentUpdate *AgentUpdateStatus `json:"agentUpdate,omitempty"` AgentModules []AgentModuleStatus `json:"agentModules,omitempty"` + AgentPrivilege *AgentPrivilegeStatus `json:"agentPrivilege,omitempty"` PackageUpdates *HostPackageUpdateStatus `json:"packageUpdates,omitempty"` StorageCleanup *HostStorageCleanupStatus `json:"storageCleanup,omitempty"` IsLegacy bool `json:"isLegacy,omitempty"` // True if using legacy agent protocol diff --git a/internal/monitoring/agent_fleet_doctor.go b/internal/monitoring/agent_fleet_doctor.go index bc82bbf3d..a36a50fef 100644 --- a/internal/monitoring/agent_fleet_doctor.go +++ b/internal/monitoring/agent_fleet_doctor.go @@ -65,34 +65,35 @@ type AgentFleetDiagnosticSummary struct { } type AgentFleetAgentDiagnostic struct { - RowKey string `json:"rowKey"` - ID string `json:"id"` - AgentID string `json:"agentId,omitempty"` - ConnectionID string `json:"connectionId,omitempty"` - Name string `json:"name"` - Hostname string `json:"hostname,omitempty"` - Platform string `json:"platform,omitempty"` - OSName string `json:"osName,omitempty"` - OSVersion string `json:"osVersion,omitempty"` - KernelVersion string `json:"kernelVersion,omitempty"` - Architecture string `json:"architecture,omitempty"` - MachineIDFingerprint string `json:"machineIdFingerprint,omitempty"` - ReportIP string `json:"reportIp,omitempty"` - InterfaceAddresses []string `json:"interfaceAddresses,omitempty"` - Types []string `json:"types"` - Status string `json:"status"` - RawStatus string `json:"rawStatus,omitempty"` - LastSeen int64 `json:"lastSeen,omitempty"` - IntervalSeconds int `json:"intervalSeconds,omitempty"` - Version string `json:"version,omitempty"` - ProfileID string `json:"profileId,omitempty"` - ProfileName string `json:"profileName,omitempty"` - ProfileVersion int `json:"profileVersion,omitempty"` - DeployedProfileVersion int `json:"deployedProfileVersion,omitempty"` - AgentUpdate *AgentFleetDiagnosticUpdate `json:"agentUpdate,omitempty"` - AgentModules []AgentFleetDiagnosticModule `json:"agentModules,omitempty"` - Reasons []AgentFleetDiagnosticReason `json:"reasons"` - RepairActions []AgentFleetDiagnosticRepair `json:"repairActions,omitempty"` + RowKey string `json:"rowKey"` + ID string `json:"id"` + AgentID string `json:"agentId,omitempty"` + ConnectionID string `json:"connectionId,omitempty"` + Name string `json:"name"` + Hostname string `json:"hostname,omitempty"` + Platform string `json:"platform,omitempty"` + OSName string `json:"osName,omitempty"` + OSVersion string `json:"osVersion,omitempty"` + KernelVersion string `json:"kernelVersion,omitempty"` + Architecture string `json:"architecture,omitempty"` + MachineIDFingerprint string `json:"machineIdFingerprint,omitempty"` + ReportIP string `json:"reportIp,omitempty"` + InterfaceAddresses []string `json:"interfaceAddresses,omitempty"` + Types []string `json:"types"` + Status string `json:"status"` + RawStatus string `json:"rawStatus,omitempty"` + LastSeen int64 `json:"lastSeen,omitempty"` + IntervalSeconds int `json:"intervalSeconds,omitempty"` + Version string `json:"version,omitempty"` + ProfileID string `json:"profileId,omitempty"` + ProfileName string `json:"profileName,omitempty"` + ProfileVersion int `json:"profileVersion,omitempty"` + DeployedProfileVersion int `json:"deployedProfileVersion,omitempty"` + AgentUpdate *AgentFleetDiagnosticUpdate `json:"agentUpdate,omitempty"` + AgentModules []AgentFleetDiagnosticModule `json:"agentModules,omitempty"` + Privilege *AgentFleetDiagnosticPrivilege `json:"privilege,omitempty"` + Reasons []AgentFleetDiagnosticReason `json:"reasons"` + RepairActions []AgentFleetDiagnosticRepair `json:"repairActions,omitempty"` } type AgentFleetDiagnosticUpdate struct { @@ -114,6 +115,17 @@ type AgentFleetDiagnosticModule struct { UpdatedAt time.Time `json:"updatedAt"` } +// AgentFleetDiagnosticPrivilege is the agent-reported privilege profile, +// surfaced descriptively beside modules and update state. It is deliberately +// not a Reason: a least-privilege install is an intentional hardening choice, +// so it must never degrade the agent's health status by itself. +type AgentFleetDiagnosticPrivilege struct { + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + SmartctlHelper bool `json:"smartctlHelper,omitempty"` + PctHelper bool `json:"pctHelper,omitempty"` +} + type AgentFleetDiagnosticReason struct { Code string `json:"code"` Severity string `json:"severity"` @@ -420,6 +432,7 @@ func diagnoseAgentFleetSubject( Version: subject.version, AgentUpdate: agentFleetUpdateForSubject(subject), AgentModules: agentFleetModulesForSubject(subject), + Privilege: agentFleetPrivilegeForSubject(subject), } if !subject.lastSeen.IsZero() { result.LastSeen = subject.lastSeen.UnixMilli() @@ -917,6 +930,19 @@ func agentFleetUpdateForSubject(subject agentFleetSubject) *AgentFleetDiagnostic } } +func agentFleetPrivilegeForSubject(subject agentFleetSubject) *AgentFleetDiagnosticPrivilege { + if subject.host == nil || subject.host.AgentPrivilege == nil { + return nil + } + privilege := subject.host.AgentPrivilege + return &AgentFleetDiagnosticPrivilege{ + RunningAsRoot: privilege.RunningAsRoot, + ServiceUser: strings.TrimSpace(privilege.ServiceUser), + SmartctlHelper: privilege.SmartctlHelper, + PctHelper: privilege.PctHelper, + } +} + func agentFleetModulesForSubject(subject agentFleetSubject) []AgentFleetDiagnosticModule { if subject.host == nil || len(subject.host.AgentModules) == 0 { return nil diff --git a/internal/monitoring/agent_fleet_doctor_test.go b/internal/monitoring/agent_fleet_doctor_test.go index 2f6e4d5b9..1b00b7bf8 100644 --- a/internal/monitoring/agent_fleet_doctor_test.go +++ b/internal/monitoring/agent_fleet_doctor_test.go @@ -363,6 +363,47 @@ func TestAgentFleetDiagnosticsSurfacesReportedUpdateModuleAndIdentityEvidence(t } } +// A least-privilege install is an intentional hardening profile: the doctor +// must surface the reported privilege descriptively and must not degrade the +// agent's health status on that evidence alone. +func TestAgentFleetDiagnosticsSurfacesPrivilegeProfileWithoutDegradingHealth(t *testing.T) { + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + monitor := newAgentFleetDoctorTestMonitor(t) + monitor.state.UpsertHost(models.Host{ + ID: "agent-least-priv", + Hostname: "pve-node", + Status: "online", + LastSeen: now.Add(-30 * time.Second), + IntervalSeconds: 30, + AgentVersion: "6.2.0", + AgentPrivilege: &models.AgentPrivilegeStatus{ + RunningAsRoot: false, + ServiceUser: "pulse-agent", + SmartctlHelper: true, + PctHelper: false, + }, + }) + + diagnostics := monitor.GetAgentFleetDiagnosticsForTarget("6.2.0", "6.2.0", now) + agent := requireAgentDiagnostic(t, diagnostics, "agent-agent-least-priv") + + if agent.Privilege == nil { + t.Fatal("privilege profile missing from diagnostics") + } + if agent.Privilege.RunningAsRoot || agent.Privilege.ServiceUser != "pulse-agent" || + !agent.Privilege.SmartctlHelper || agent.Privilege.PctHelper { + t.Fatalf("privilege profile = %+v", agent.Privilege) + } + if agent.Status != AgentFleetStatusHealthy { + t.Fatalf("least-privilege agent status = %q, want healthy; reasons = %+v", agent.Status, agent.Reasons) + } + for _, reason := range agent.Reasons { + if strings.Contains(strings.ToLower(reason.Message), "privilege") { + t.Fatalf("privilege surfaced as a health reason: %+v", reason) + } + } +} + func TestAgentFleetDiagnosticsUpdaterReasonCodes(t *testing.T) { now := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC) tests := []struct { diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 665851b36..8ff910f89 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -2993,6 +2993,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. AppliedConfig: convertAgentConfigFingerprint(report.Agent.AppliedConfig), AgentUpdate: agentUpdate, AgentModules: convertAgentModuleStatuses(report.Agent.Modules), + AgentPrivilege: convertAgentPrivilegeStatus(report.Agent.Privilege), PackageUpdates: convertHostPackageUpdateStatus(report.Host.PackageUpdates, observedAt), StorageCleanup: convertHostStorageCleanupStatus(report.Host.StorageCleanup, observedAt), IsLegacy: isLegacyAgent(report.Agent.Type), @@ -3382,6 +3383,21 @@ func convertAgentModuleStatuses(values []agentshost.ModuleStatus) []models.Agent return result } +// convertAgentPrivilegeStatus copies the agent-authored privilege profile +// into model state. It is informational only: nothing downstream may treat a +// non-root agent as unhealthy on this evidence alone. +func convertAgentPrivilegeStatus(value *agentshost.PrivilegeStatus) *models.AgentPrivilegeStatus { + if value == nil { + return nil + } + return &models.AgentPrivilegeStatus{ + RunningAsRoot: value.RunningAsRoot, + ServiceUser: strings.TrimSpace(value.ServiceUser), + SmartctlHelper: value.SmartctlHelper, + PctHelper: value.PctHelper, + } +} + func previousHostAgentUpdate(hosts []models.Host, identifier string) *models.AgentUpdateStatus { for i := range hosts { if hosts[i].ID == identifier { diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 0bbb3819c..a643c3af4 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -5066,3 +5066,48 @@ func TestMockModeDiscardsRealHostReports(t *testing.T) { } }) } + +// The agent-authored privilege profile must survive ingest into model state +// exactly, and a report without one must not invent a profile. +func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) { + report := agentshost.Report{ + Agent: agentshost.AgentInfo{ + ID: "privilege-agent", + Version: "6.3.0", + Type: "unified", + IntervalSeconds: 30, + Privilege: &agentshost.PrivilegeStatus{ + RunningAsRoot: false, + ServiceUser: " pulse-agent ", + SmartctlHelper: true, + }, + }, + Host: agentshost.HostInfo{ID: "privilege-machine", Hostname: "privilege-host", Platform: "linux"}, + Timestamp: time.Now().UTC(), + } + + monitor := newTestMonitor(t) + host, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: "privilege-token"}) + if err != nil { + t.Fatalf("ApplyHostReport: %v", err) + } + if host.AgentPrivilege == nil || + host.AgentPrivilege.RunningAsRoot || + host.AgentPrivilege.ServiceUser != "pulse-agent" || + !host.AgentPrivilege.SmartctlHelper || + host.AgentPrivilege.PctHelper { + t.Fatalf("ingested privilege = %+v", host.AgentPrivilege) + } + + report.Agent.Privilege = nil + report.Agent.ID = "privilege-agent-legacy" + report.Host.ID = "privilege-machine-legacy" + report.Host.Hostname = "privilege-host-legacy" + legacyHost, err := monitor.ApplyHostReport(report, &config.APITokenRecord{ID: "privilege-token"}) + if err != nil { + t.Fatalf("ApplyHostReport legacy: %v", err) + } + if legacyHost.AgentPrivilege != nil { + t.Fatalf("legacy report invented a privilege profile: %+v", legacyHost.AgentPrivilege) + } +} diff --git a/pkg/agents/host/report.go b/pkg/agents/host/report.go index aa8cfda0f..9add28602 100644 --- a/pkg/agents/host/report.go +++ b/pkg/agents/host/report.go @@ -167,6 +167,25 @@ type AgentInfo struct { AppliedConfig *ConfigFingerprint `json:"appliedConfig,omitempty"` Update *UpdateStatus `json:"update,omitempty"` Modules []ModuleStatus `json:"modules,omitempty"` + Privilege *PrivilegeStatus `json:"privilege,omitempty"` +} + +// PrivilegeStatus is the agent-authored view of the privilege it actually runs +// with, so the server can present a least-privilege install as an intentional +// profile instead of a broken agent. It is informational only: it never grants +// anything, and the server must not treat a non-root agent as unhealthy on +// this evidence alone. +type PrivilegeStatus struct { + RunningAsRoot bool `json:"runningAsRoot"` + ServiceUser string `json:"serviceUser,omitempty"` + // SmartctlHelper reports that a PULSE_SMARTCTL_PATH override is in effect, + // which a least-privilege install uses to route SMART reads through a + // scoped privilege helper. + SmartctlHelper bool `json:"smartctlHelper,omitempty"` + // PctHelper reports that a PULSE_PCT_PATH override is in effect, which a + // least-privilege install on a Proxmox node uses to route the read-only + // pct list/df queries through a scoped privilege helper. + PctHelper bool `json:"pctHelper,omitempty"` } // ModuleStatus describes whether an enabled Unified Agent module initialized diff --git a/pkg/agents/host/report_test.go b/pkg/agents/host/report_test.go index 95fb6d7a2..3dfdfe838 100644 --- a/pkg/agents/host/report_test.go +++ b/pkg/agents/host/report_test.go @@ -126,6 +126,44 @@ func TestCustomSensorMetricJSONRoundTrip(t *testing.T) { } } +// The privilege block is agent-authored fact, not configuration: it must +// survive a JSON round trip exactly, and a legacy report without it must +// decode to nil rather than a zero-value profile pretending to be evidence. +func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) { + agent := AgentInfo{ + ID: "agent-privilege", + Privilege: &PrivilegeStatus{ + RunningAsRoot: false, + ServiceUser: "pulse-agent", + SmartctlHelper: true, + PctHelper: false, + }, + } + encoded, err := json.Marshal(agent) + if err != nil { + t.Fatal(err) + } + var decoded AgentInfo + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Privilege == nil || + decoded.Privilege.RunningAsRoot || + decoded.Privilege.ServiceUser != "pulse-agent" || + !decoded.Privilege.SmartctlHelper || + decoded.Privilege.PctHelper { + t.Fatalf("privilege round trip = %+v", decoded.Privilege) + } + + var legacy AgentInfo + if err := json.Unmarshal([]byte(`{"id":"legacy"}`), &legacy); err != nil { + t.Fatal(err) + } + if legacy.Privilege != nil { + t.Fatalf("legacy report invented a privilege profile: %+v", legacy.Privilege) + } +} + func TestAgentInfo_Fields(t *testing.T) { agent := AgentInfo{ ID: "agent-123", diff --git a/scripts/install.sh b/scripts/install.sh index ef2e8093f..89b60a8a6 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,6 +26,9 @@ # --server-fingerprint Pin the Pulse server leaf certificate # --observers-file Report to additional observer Pulse instances # --enable-commands Enable Pulse command execution on agent (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory) +# --least-privilege Run the agent as the dedicated 'pulse-agent' system user instead of root (Linux systemd only; SMART, Proxmox LXC filesystems, and command execution need root or an explicit grant) +# --grant-smart With --least-privilege: allow SMART collection through an exact-command sudoers grant for smartctl +# --grant-pct With --least-privilege: allow Proxmox LXC filesystem capacity through a sudoers grant restricted to 'pct list' and 'pct df' # --health-addr Health/metrics listener address (default: 127.0.0.1:9191, use "" to disable) # --update Update an existing agent using saved connection state # --uninstall Remove the agent @@ -117,6 +120,17 @@ ROOTLESS_RUNTIME_SOCKET_PATH="" ROOTLESS_RUNTIME_SOCKET_URI="" ROOTLESS_RUNTIME_XDG_DIR="" +# Least-privilege profile: run the service as a dedicated system user instead +# of root, with optional exact-command sudo helpers for the two collectors +# that genuinely need elevation (smartctl, pct list/df). Linux systemd only. +LEAST_PRIVILEGE="false" +GRANT_SMART="false" +GRANT_PCT="false" +SERVICE_USER="root" +LEAST_PRIVILEGE_USER="pulse-agent" +PRIVILEGE_HELPER_DIR="/usr/local/lib/pulse-agent" +PRIVILEGE_SUDOERS_FILE="/etc/sudoers.d/pulse-agent" + SYSTEMD_ENV_LINES="" SHELL_EXPORT_LINES="" PLIST_ENV_ENTRIES="" @@ -481,6 +495,9 @@ Options: --server-fingerprint Pin the Pulse server leaf certificate for agent connections --observers-file Absolute path to private JSON config for report-only observer Pulse destinations --enable-commands Enable Pulse command execution (disabled by default; required for Patrol actions and Proxmox LXC Docker inventory) + --least-privilege Run the agent as the 'pulse-agent' system user instead of root (Linux systemd only) + --grant-smart With --least-privilege: exact-command sudoers grant so SMART collection keeps working + --grant-pct With --least-privilege: sudoers grant restricted to 'pct list'/'pct df' so Proxmox LXC filesystem capacity keeps working --health-addr Health/metrics listener address (default: 127.0.0.1:9191; use "" to disable) --enroll Exchange bootstrap token for runtime token (deploy wizard) --update Update an existing agent using saved connection state @@ -1081,6 +1098,12 @@ systemd_agent_requires_lxc_attach() { # from the server after the fact, so the unit has to be provisioned for it up # front or the later toggle produces a half-working agent. systemd_agent_may_attach_lxc() { + # The least-privilege profile never attaches into guests: pct exec and + # command execution stay root-profile features, so it does not get the + # CAP_SETUID/CAP_SETGID ambient grant either. + if [[ "$LEAST_PRIVILEGE" == "true" ]]; then + return 1 + fi if [[ "$ENABLE_PROXMOX" != "true" ]]; then return 1 fi @@ -2955,6 +2978,9 @@ while [[ $# -gt 0 ]]; do --server-fingerprint) SERVER_FINGERPRINT="$2"; shift 2 ;; --observers-file) OBSERVERS_FILE="$2"; shift 2 ;; --enable-commands) ENABLE_COMMANDS="true"; shift ;; + --least-privilege) LEAST_PRIVILEGE="true"; shift ;; + --grant-smart) GRANT_SMART="true"; shift ;; + --grant-pct) GRANT_PCT="true"; shift ;; --health-addr) HEALTH_ADDR="$2"; HEALTH_ADDR_SET="true"; shift 2 ;; --enroll) ENROLL="true"; shift ;; --update) UPDATE_ONLY="true"; shift ;; @@ -3011,6 +3037,13 @@ if [[ -n "$PROXMOX_TYPE" && "$PROXMOX_TYPE" != "pve" && "$PROXMOX_TYPE" != "pbs" fail "Invalid --proxmox-type value: ${PROXMOX_TYPE} (expected 'pve' or 'pbs')" fi +if [[ "$GRANT_SMART" == "true" || "$GRANT_PCT" == "true" ]] && [[ "$LEAST_PRIVILEGE" != "true" ]]; then + fail "--grant-smart and --grant-pct only apply with --least-privilege (a root agent needs no grants)" "$EXIT_MISSING_ARGS" +fi +if [[ "$LEAST_PRIVILEGE" == "true" && "$ENABLE_COMMANDS" == "true" ]]; then + fail "--least-privilege and --enable-commands are mutually exclusive: governed command execution requires the root profile" "$EXIT_MISSING_ARGS" +fi + # --- Check Root --- if [[ $EUID -ne 0 && "$PREFLIGHT_ONLY" != "true" ]]; then echo "This script must be run as root. Please use sudo." @@ -3319,6 +3352,12 @@ if [[ "$UNINSTALL" == "true" ]]; then # Remove agent state directory (contains agent ID, proxmox registration state, etc.) remove_agent_state_dir "$STATE_DIR" + # Remove least-privilege helper artifacts. The pulse-agent system user is + # deliberately left behind: deleting accounts can orphan files elsewhere, + # and an inert nologin system user is harmless. + rm -f "$PRIVILEGE_SUDOERS_FILE" + rm -rf "$PRIVILEGE_HELPER_DIR" + # Remove log files rm -f /var/log/pulse-agent.log @@ -3492,6 +3531,121 @@ is_install_dir_writable() { return 1 } +# The least-privilege profile is supported only on standard Linux systemd +# hosts: appliance platforms (TrueNAS, Synology, QNAP, Unraid) and non-systemd +# init systems keep the root profile because their service managers, mounts, +# or vendor tooling assume it. Failing here is deliberate — a flag that +# silently falls back to root would defeat its purpose. +if [[ "$LEAST_PRIVILEGE" == "true" && "$UNINSTALL" != "true" ]]; then + if [[ "$(uname -s)" != "Linux" ]] || ! command -v systemctl >/dev/null 2>&1 || + is_truenas || [[ -d /usr/syno ]] || [[ -f /etc/unraid-version ]] || + [[ -d /boot/config/plugins ]] || [[ -x /sbin/getcfg ]]; then + fail "--least-privilege is supported only on standard Linux systemd hosts. This platform keeps the root profile; see docs/AGENT_SECURITY.md for the per-platform privilege model." "$EXIT_MISSING_ARGS" + fi +fi + +# Create the dedicated service account for the least-privilege profile and +# hand it the state directory plus the agent binary (self-update swaps the +# binary in place, so the service user must own it). +provision_least_privilege_user() { + if ! id -u "$LEAST_PRIVILEGE_USER" >/dev/null 2>&1; then + if ! command -v useradd >/dev/null 2>&1; then + fail "--least-privilege needs useradd to create the ${LEAST_PRIVILEGE_USER} system user" "$EXIT_MISSING_ARGS" + fi + local nologin_shell="/usr/sbin/nologin" + if [[ ! -x "$nologin_shell" ]]; then + nologin_shell="/sbin/nologin" + fi + if [[ ! -x "$nologin_shell" ]]; then + nologin_shell="/bin/false" + fi + useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home \ + --shell "$nologin_shell" "$LEAST_PRIVILEGE_USER" || + fail "Failed to create the ${LEAST_PRIVILEGE_USER} system user" "$EXIT_MISSING_ARGS" + log_info "Created system user ${LEAST_PRIVILEGE_USER}" + fi + + # Docker/Podman socket reads need group membership, not root. Auto-detect + # mirrors the module default: only an explicit --disable-docker skips it. + if [[ "$ENABLE_DOCKER" != "false" ]] && getent group docker >/dev/null 2>&1; then + usermod -aG docker "$LEAST_PRIVILEGE_USER" 2>/dev/null || true + fi + + chown -R "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "$STATE_DIR" 2>/dev/null || true + chown "${LEAST_PRIVILEGE_USER}:${LEAST_PRIVILEGE_USER}" "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true +} + +# Write one privilege helper: a root-owned wrapper that execs the real binary +# through sudo -n, plus the sudoers rule that makes exactly that invocation +# possible. The agent is pointed at the wrapper via an env override that only +# accepts absolute paths. +write_privilege_helper() { + local helper_name="$1" + local real_path="$2" + local sudoers_spec="$3" + local helper_path="${PRIVILEGE_HELPER_DIR}/${helper_name}" + + mkdir -p "$PRIVILEGE_HELPER_DIR" + chmod 755 "$PRIVILEGE_HELPER_DIR" + cat > "$helper_path" </dev/null 2>&1; then + fail "--grant-smart/--grant-pct need sudo installed on this host" "$EXIT_MISSING_ARGS" + fi + + PRIVILEGE_SUDOERS_CONTENT="# Pulse least-privilege agent grants. Managed by install.sh."$'\n' + + if [[ "$GRANT_SMART" == "true" ]]; then + local smartctl_path + smartctl_path="$(command -v smartctl 2>/dev/null || true)" + if [[ -z "$smartctl_path" ]]; then + fail "--grant-smart requires smartctl (smartmontools) on this host" "$EXIT_MISSING_ARGS" + fi + write_privilege_helper "smartctl" "$smartctl_path" "$smartctl_path" + append_service_env "PULSE_SMARTCTL_PATH" "${PRIVILEGE_HELPER_DIR}/smartctl" + fi + + if [[ "$GRANT_PCT" == "true" ]]; then + local pct_path + pct_path="$(command -v pct 2>/dev/null || true)" + if [[ -z "$pct_path" ]]; then + fail "--grant-pct requires the Proxmox pct tool on this host" "$EXIT_MISSING_ARGS" + fi + # Restricted to the two read-only queries the collector issues. This + # deliberately does NOT cover pct exec, start, stop, or enter. + write_privilege_helper "pct" "$pct_path" "${pct_path} list, ${pct_path} df *" + append_service_env "PULSE_PCT_PATH" "${PRIVILEGE_HELPER_DIR}/pct" + fi + + local sudoers_tmp + sudoers_tmp="$(mktemp)" + printf '%s' "$PRIVILEGE_SUDOERS_CONTENT" > "$sudoers_tmp" + if command -v visudo >/dev/null 2>&1; then + if ! visudo -cf "$sudoers_tmp" >/dev/null 2>&1; then + rm -f "$sudoers_tmp" + fail "Generated sudoers rules failed visudo validation; not installing them" "$EXIT_MISSING_ARGS" + fi + fi + install -o root -g root -m 0440 "$sudoers_tmp" "$PRIVILEGE_SUDOERS_FILE" + rm -f "$sudoers_tmp" + log_info "Installed scoped sudoers grants at ${PRIVILEGE_SUDOERS_FILE}" +} + if [[ "$(uname -s)" == "Linux" ]] && is_truenas; then TRUENAS=true INSTALL_DIR="$TRUENAS_STATE_DIR" @@ -4383,13 +4537,42 @@ if command -v systemctl >/dev/null 2>&1; then TOKEN_FILE="${TOKEN_DIR}/token" log_info "Configuring Systemd service at $UNIT..." + # A least-privilege install must survive updates that do not repeat the + # flags: recover the profile and its grants from the existing unit before + # rendering a replacement, so an --update never silently reverts the + # service to root. + if [[ -f "$UNIT" ]]; then + if [[ "$LEAST_PRIVILEGE" != "true" ]] && grep -q "^User=${LEAST_PRIVILEGE_USER}\$" "$UNIT"; then + if [[ "$ENABLE_COMMANDS" == "true" ]]; then + fail "This agent runs the least-privilege profile; --enable-commands requires reinstalling the root profile first" "$EXIT_MISSING_ARGS" + fi + LEAST_PRIVILEGE="true" + log_info "Preserving existing least-privilege profile (User=${LEAST_PRIVILEGE_USER})" + fi + if [[ "$LEAST_PRIVILEGE" == "true" ]]; then + if [[ "$GRANT_SMART" != "true" ]] && grep -q "PULSE_SMARTCTL_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then + GRANT_SMART="true" + fi + if [[ "$GRANT_PCT" != "true" ]] && grep -q "PULSE_PCT_PATH=${PRIVILEGE_HELPER_DIR}/" "$UNIT"; then + GRANT_PCT="true" + fi + fi + fi + ensure_runtime_token_file "$STATE_DIR" clear_proxmox_state_if_needed + if [[ "$LEAST_PRIVILEGE" == "true" ]]; then + SERVICE_USER="$LEAST_PRIVILEGE_USER" + provision_least_privilege_user + provision_privilege_helpers + log_info "Least-privilege profile: service runs as ${SERVICE_USER}. SMART $( [[ "$GRANT_SMART" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-smart" ); Proxmox LXC filesystems $( [[ "$GRANT_PCT" == "true" ]] && echo "via scoped sudo helper" || echo "unavailable without --grant-pct" )." + fi + # Build command line args with --token-file instead of the raw token. build_exec_args - render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" "" + render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "$SERVICE_USER" "" # Restrict service file permissions (contains no secrets now, but good practice) chmod 644 "$UNIT" diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index db45a34f6..1c5d6f033 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -1956,7 +1956,9 @@ func TestInstallSHUsesSharedServiceRenderers(t *testing.T) { `render_freebsd_rc_agent_script() {`, `render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network.target" "" "" ""`, `render_systemd_agent_unit "$TRUENAS_SERVICE_STORAGE" "${TRUENAS_RUNTIME_BINARY}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" "${TRUENAS_LOG_TARGET}"`, - `render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "root" ""`, + // The Linux systemd unit takes the resolved service user so the + // least-privilege profile can swap root for pulse-agent. + `render_systemd_agent_unit "$UNIT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}" "network-online.target docker.service" "network-online.target" "$SERVICE_USER" ""`, `render_freebsd_rc_agent_script "$TRUENAS_SERVICE_STORAGE" "${TRUENAS_RUNTIME_BINARY}" "${EXEC_ARGS}"`, `render_freebsd_rc_agent_script "$RCSCRIPT" "${INSTALL_DIR}/${BINARY_NAME}" "${EXEC_ARGS}"`, } @@ -5531,3 +5533,45 @@ func TestInstallSHVersionMismatchWarningIgnoresBuildMetadata(t *testing.T) { } } } + +// The least-privilege profile must stay a real profile, not a cosmetic flag: +// a dedicated nologin system user, validated exact-command sudoers grants, a +// pct grant that can never widen into pct exec, env-pinned absolute helper +// paths, update-time preservation of the profile, and no ambient capability +// grant. Silently falling back to root on unsupported platforms is forbidden. +func TestInstallSHLeastPrivilegeProfile(t *testing.T) { + content, err := os.ReadFile(repoFile("scripts", "install.sh")) + if err != nil { + t.Fatalf("read install.sh: %v", err) + } + + script := string(content) + required := []string{ + `--least-privilege) LEAST_PRIVILEGE="true"; shift ;;`, + `--grant-smart) GRANT_SMART="true"; shift ;;`, + `--grant-pct) GRANT_PCT="true"; shift ;;`, + `LEAST_PRIVILEGE_USER="pulse-agent"`, + `PRIVILEGE_SUDOERS_FILE="/etc/sudoers.d/pulse-agent"`, + "--least-privilege and --enable-commands are mutually exclusive", + "--least-privilege is supported only on standard Linux systemd hosts", + `useradd --system --user-group --home-dir "$STATE_DIR" --no-create-home`, + `visudo -cf`, + `install -o root -g root -m 0440 "$sudoers_tmp" "$PRIVILEGE_SUDOERS_FILE"`, + "${pct_path} list, ${pct_path} df *", + `append_service_env "PULSE_SMARTCTL_PATH" "${PRIVILEGE_HELPER_DIR}/smartctl"`, + `append_service_env "PULSE_PCT_PATH" "${PRIVILEGE_HELPER_DIR}/pct"`, + `grep -q "^User=${LEAST_PRIVILEGE_USER}\$" "$UNIT"`, + `"network-online.target" "$SERVICE_USER" ""`, + "# The least-privilege profile never attaches into guests", + `rm -f "$PRIVILEGE_SUDOERS_FILE"`, + } + for _, needle := range required { + if !strings.Contains(script, needle) { + t.Fatalf("install.sh missing least-privilege profile invariant: %s", needle) + } + } + + if strings.Contains(script, `NOPASSWD: ${pct_path}`) && !strings.Contains(script, "does NOT cover pct exec") { + t.Fatal("install.sh must document that the pct grant excludes pct exec") + } +}