Skip to content

Fix cmd.exec allowlist bypass; harden exec and logs; remove exporter mode - #5

Merged
skeeeon merged 10 commits into
mainfrom
exec-hardening
Sep 24, 2026
Merged

skeeeon merged 10 commits into
mainfrom
exec-hardening

Conversation

@skeeeon

@skeeeon skeeeon commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Security fix: merge and release promptly. Anyone able to publish to cmd.exec could run arbitrary commands on the device, whatever the allowlist said, as long as one script existed in commands.scripts_directory. This PR discloses the bug, so every deployed agent with a scripts directory stays exposed until it runs a release containing the fix. The CHANGELOG's Unreleased section is written for v0.3.1.

Ten commits, each passing on its own. They're easiest to review one at a time.

1. fix(exec): the caller's string never reaches a shell. Security.

The script check reduced the request to its last path element, confirmed a script with that name existed, and then passed the whole request to bash -c (or powershell -Command). So $(anything)/deploy.sh passed the check and ran anything. Both platforms had the same bug, because each carried its own copy of the check.

  • A script request must now be a bare filename. The agent builds the path itself and starts that file directly, by its shebang on unix or with powershell -File on Windows, so no shell ever parses the request.
  • An allowlisted command now runs the operator's allowlist entry, not the request. The two only had to compare equal after collapsing whitespace, so a newline could split one allowed line into two commands.
  • The check now lives in one place, internal/tasks/exec.go. The platform files only say how to start a process.

2. fix(exec): a failed command returns its output, and the timeout is a real limit

  • Failed commands keep their output. A non-zero exit used to reply with only "command exited with code 1", so the stderr explaining the failure never left the box. The reply keeps status: "error" and now includes output and exit_code.
  • exit_code is present exactly when the command ran, 0 included.
  • Timeouts return what was printed before the command was killed.
  • The timeout now limits how long a reply takes. When the timeout killed a shell, a child still holding the output pipe (a sleep, say) kept the reply waiting until the child finished: a 1s timeout over sleep 5 took 5s. A script that exited normally while leaving a background process behind held the reply for as long as that process lived. cmd.WaitDelay now caps the wait at one second after the command exits or is killed.

3. fix(logs): the allowlist alone decides what cmd.logs can read

  • Removed a substring denylist (sam, system32, .exe, .., …). It refused nothing the allowlist admitted, but it did block /var/log/samba/*.
  • Removed a second check behind the allowlist. It only understood *, so every pattern using ? or [...] matched nothing.
  • The Windows path tests could never have passed. They named files that don't exist, and the check looks at real files on disk. They now share the cross-platform test, which uses real temp files.

4. refactor(metrics): remove exporter mode

source: exporter (scraping node_exporter or windows_exporter instead of using gopsutil) was unused. Removed with it:

  • the collector interface and factory, which had one implementation left
  • the HTTP client only the exporter used
  • the ignored ScrapeMetrics(exporterURL) parameter

Old configs still load, because the keys are ignored, and they get the same payload. A test covers this.

5. chore: strip the // ADDED: / // MODIFIED: comments

Comments only. No behaviour change.

6. fix(service): check the action before touching the service manager

Found by the Windows test run. Windows connected to the Service Control Manager before checking the action, and the SCM refuses non-admin connections, so from an ordinary shell a bogus action returned "Access is denied" instead of "invalid action". Linux and FreeBSD already checked first. The allowlist and action checks now live once, in checkServiceRequest, and run first on every platform. The three copies of isServiceAllowed are merged into that one place, which also fixes the darwin test build of internal/tasks.

7. fix(build): the stub platforms compile again, and CI keeps them honest

GetOSInfo had no stub, so the agent didn't build on a Mac at all. The stub now reports "not supported", and the health command's existing fallback answers with runtime.GOOS. CI now runs GOOS=darwin go vet ./..., because the stub files compile on no platform it was already vetting, which is how they drifted. darwin is still not a release target.

8. fix(exec): allowlisted commands run through /bin/sh on FreeBSD

FreeBSD's base system has no bash, so /bin/bash -c failed with "no such file" and every allowlisted command failed on a stock install. None of the documented FreeBSD examples need bash. FreeBSD now uses its own /bin/sh, and Linux is unchanged. TestUnixShellExists checks that the shell exists on whatever platform runs the tests.

9. fix(exec): a timeout kills everything the command started

The timeout killed only the shell or powershell.exe, so its children kept running after the reply went out.

  • Linux/FreeBSD: the command gets its own process group, and cancelling kills the group.
  • Windows: cancelling runs taskkill /T /F.
  • Faster timeouts: killing the group also closes the output pipe immediately, so the unix timeout test now returns in 0.5s instead of 1.5s.
  • Only timeouts and shutdown kill. A command that exits normally and leaves a daemon running is left alone, and the test checks both cases.

10. docs: CLAUDE.md spells makefile the way the file is named

Upgrade notes

  • Full-path script requests are refused. Send deploy.sh, not /opt/agent/scripts/deploy.sh.
  • cmd.exec's exit_code now appears on success and on failure. Failures also carry output.
  • Windows scripts report their real exit code, not just 0 or 1.
  • tasks.system_metrics.source / exporter_url are ignored.
  • Log allowlists are no longer second-guessed. If you relied on the old denylist to carve pieces out of a broad allowed_log_paths pattern, narrow the pattern.

Testing

  • New regression tests fail on the parent commit for the reason they exist, and pass on this branch:
    • the injection runs and leaves a marker file
    • the newline splits the allowlisted command
    • the 0.5s timeout takes 5s
    • ?, [...] and samba paths are refused
  • go test -race ./... passes.
  • go vet is clean on linux, windows and freebsd.
  • linux/amd64, linux/arm64, windows/amd64 and freebsd/amd64 all build.
  • golangci-lint findings are identical to v0.3.0.
  • Windows go test ./internal/tasks/ passes from a non-admin shell as of commit 6: the exec gate, -File exit codes, the injection refusal, the log path forms and service validation. The first run caught the ordering bug that commit 6 fixes.
  • Windows re-run after commit 9 passes, including TestTimeoutKillsTheProcessTree: PowerShell starts a ping child, the command times out, and the child is gone.
  • GOOS=darwin go vet ./... is clean.

Found along the way

Everything this work turned up is fixed on this branch: the darwin build (commit 7), FreeBSD's missing bash (commit 8) and children outliving a timeout (commit 9).

🤖 Generated with Claude Code

skeeeon and others added 10 commits September 24, 2026 02:28
The script gate reduced a cmd.exec request to filepath.Base, confirmed a
script of that name existed in scripts_directory, and then ran the
unreduced request through `bash -c` (or `powershell -Command`). So
`$(anything)/deploy.sh` passed the check and ran `anything`: arbitrary
execution for any caller able to publish to cmd.exec, whatever the
allowlist said. Both platforms carried the same copy of the gate, and
the same hole.

Script requests must now be a bare filename. The agent builds the path
itself and starts that file directly -- by shebang on unix, with
`powershell -File` on Windows -- so no shell parses the request.
Allowlisted commands run the operator's allowlist entry rather than the
request, which only had to match after whitespace normalization; a
newline could split one allowed line into two commands.

The gate now lives once, in exec.go. The platform files say only how to
start a process.

Behaviour changes: a full-path script request is refused where it used
to work, and a Windows script's own `exit N` is now the reported exit
code instead of collapsing to 0 or 1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A command that ran and exited non-zero replied with only "command
exited with code 1": the executor returned the output, and the handler
dropped it on the error path. The stderr that said why never left the
box. One pure function, execResponse, now builds the reply for every
outcome. A non-zero exit keeps status "error", so callers checking
status are unaffected, and adds output and exit_code. exit_code is a
pointer, present exactly when the command ran; omitempty used to drop
it on success.

A timeout now returns what the command printed before it was killed.

The timeout also did not bound anything. Killing a shell left its
child holding the output pipe, and Run waited for the pipe: a 1s
timeout over `sleep 5` took 5s. A script that exited while leaving a
daemon behind held the reply for as long as the daemon lived.
cmd.WaitDelay caps output collection at one second after the process
exits or is killed. ErrWaitDelay after a clean exit is reported as
success, since the command itself succeeded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
isPathAllowed put a substring denylist in front of the allowlist
("sam", "system32", ".exe", ".dll", ".sys", "..") and a prefix check
behind it. Neither refused anything the allowlist admitted: the cleaned
absolute request must exactly equal a filepath.Glob match of an allowed
pattern, which already rules out traversal. What they did refuse was
/var/log/samba/*, any path under a home directory containing "sam", and
-- because the prefix check split the pattern only on "*" -- every
pattern using "?" or "[...]". Both are gone.

The Windows copy of the path tests named files that did not exist, and
glob reads the disk, so its positive cases could not have passed and
its "blocked" cases passed for the wrong reason. The unix test already
used real files in a temp dir with filepath.Join, so it now runs on
every platform, and the Windows file keeps only the Windows path forms.

Docs: windows.md promised a recursive "**" that Go's glob does not have,
and suggested allowlisting a binary .evtx file, which the denylist
refused anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`tasks.system_metrics.source: exporter` read CPU, memory and disk by
scraping node_exporter or windows_exporter instead of using gopsutil.
Nothing used it, and it was a second implementation of the same
figures: a Prometheus text parser, per-platform metric-name tables and
its own rate cache, about 500 lines plus tests. Anyone who wants
node_exporter's series can run it and have Prometheus scrape it
directly.

With one collector left, the MetricsCollector interface and the factory
that chose between the two go as well, along with the HTTP client only
the exporter used, the ignored ScrapeMetrics(exporterURL) parameter,
the unused collector argument to validateMetrics, and the Name and
ResetCache methods that existed only for the interface. NewExecutor can
no longer fail, so it no longer returns an error.

The `source` and `exporter_url` keys are deleted rather than rejected.
Viper ignores keys it does not know, so an old config loads and gets
the builtin collector, in the same payload shape. A test pins that down.

`go mod tidy` demotes prometheus/client_model to indirect, and promotes
nats-io/jwt and nkeys to direct, which the code already imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`// ADDED:` and `// MODIFIED: Now accepts context for cancellation`
record what a past diff did, which is git's job, and read as noise once
the change is the only code there is. Markers that only narrated are
deleted; the three that also explained the code keep the explanation.
Comments only; gofmt realigned the neighbours.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Windows, ControlService connected to the Service Control Manager
before looking at the action; "invalid action" was the switch's default
branch, reached only after the connection. The SCM refuses a non-admin
connection, so from an ordinary shell a bogus action came back as
"Access is denied" -- the answer depended on who ran the agent rather
than on what was asked. TestControlService caught it on a Windows box.
Linux and FreeBSD already validated first.

The allowlist and action checks now live once, in checkServiceRequest,
called first on every platform. The three identical copies of
isServiceAllowed go with them. The stub had none, which is why the
package's tests did not compile on darwin; the stub now runs the same
check before answering "not supported".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The `!windows && !linux && !freebsd` build had no GetOSInfo, so the
agent did not build on a Mac at all. The stub reports "not supported"
and the health command's existing fallback answers with runtime.GOOS.

The stubs compile on no platform CI vetted, which is how they drifted.
CI now runs `GOOS=darwin go vet ./...` -- vet only; darwin is still not
a release target.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
runShell used /bin/bash on both unix platforms. FreeBSD's base system
has no bash -- the port installs it under /usr/local -- so on a stock
install every allowlisted command failed with "no such file". None of
the documented FreeBSD examples need bash, and nothing told anyone to
install it. FreeBSD now uses its own /bin/sh; Linux is unchanged.

TestUnixShellExists checks that the shell exists on whatever platform
runs the tests. It is the check that would have caught this.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The timeout killed only the process runProcess started -- the shell or
powershell.exe -- so a child it had started kept running after the
reply had gone out. WaitDelay stopped the reply waiting for it, but did
not stop it.

On Linux and FreeBSD the command now gets its own process group and
cmd.Cancel kills the group. On Windows cmd.Cancel runs `taskkill /T /F`,
which walks the tree by parent process. Killing the group also closes
the output pipe at once, so a timeout no longer waits out WaitDelay:
the unix timeout test now returns in 0.5s instead of 1.5s.

Only cancellation (timeout or agent shutdown) does this. A command that
exits normally and leaves a daemon behind is left alone, and the unix
test now checks that as well as the kill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@skeeeon
skeeeon merged commit 56f5a9c into main Sep 24, 2026
1 check passed
@skeeeon
skeeeon deleted the exec-hardening branch September 24, 2026 04:12
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.

1 participant