Skip to content

fix(process): reject a pid that would signal a process group - #593

Merged
retr0h merged 2 commits into
mainfrom
fix/process-pid-validation
Oct 7, 2026
Merged

retr0h merged 2 commits into
mainfrom
fix/process-pid-validation

Conversation

@retr0h

@retr0h retr0h commented Oct 4, 2026

Copy link
Copy Markdown
Collaborator

The process endpoints declare required,min=1 on their pid path parameter. Nothing enforced it.

What was wrong

Both handlers validated the hostname and then took the pid as given:

// process_signal_post.go
if errMsg, ok := validateHostname(request.Hostname); !ok { ... }
if errMsg, ok := validation.Struct(request.Body); !ok { ... }   // the signal name
pid := request.Pid                                              // nothing

The provider validated the signal name and called syscall.Kill(pid, sig) with the pid unchecked. allowedSignals includes KILL.

min=1 is the load-bearing rule here, not required. kill(2) overloads the sign of its first argument:

pid meaning
> 0 that one process
0 every process in the caller's process group
-1 every process the caller may signal
< -1 every process in process group -pid

required on an int only rejects 0. Everything below it passes required and is caught by min=1 alone. The agent generally runs as root on a managed host, so the sign is the difference between stopping one process and stopping the host. A caller holding process:write to stop a runaway process had the second available to them, and 0 is an easy accident rather than only an attack.

The fix

Validated in both layers, because https://osapi-io.github.io/specs/components/osapi/providers says the request path does not discharge the provider's own checks, and the provider is the frame that actually calls kill(2):

  • validatePid in the API package, mirroring the declared tag, called in process_get.go and process_signal_post.go.
  • A guard in the Debian provider's Signal and Get. It runs before the signal name is looked up, so a bad pid is refused whatever signal arrives with it. Darwin and Linux are ErrUnsupported stubs and need nothing.

The 400 also now says what to do. validation.Var has no field name to report and min carries no hint, so the bare message was:

Key: '' Error:Field validation for '' failed on the 'min' tag

It now leads with pid -1 must be greater than zero and keeps the tag's message after it, so a test can still assert which rule rejected the request.

Tests

Four provider cases covering 0, -1, a process group, and a bad pid with a bad signal name. Each sets no mock expectation, so gomock fails the test if Kill is reached at all. That is the assertion that matters; checking the error string alone would pass even if the signal had been sent first.

Five HTTP cases across both endpoints asserting the body names required (for 0) or min (for negatives).

Verified against a running controller and agent

Before:

get pid=-5  exit=0  {"job_id":"c58b738f-...","results":[{...,"status":"skipped"}]}

It became a job and crossed NATS. After, 0, -1, -5 and -42 are all refused at the API with no job created.

How it was found

By going back through the min declarations one call site at a time to check whether the rule was load-bearing, after an audit had reported it as untestable and I had wrongly concluded the declarations were redundant. On 146 plain string fields required genuinely does mask min. Seven are not strings, and on this one min was the only guard and it was not wired up.

just test passes, coverage 100%.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c

@codecov

codecov Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #593   +/-   ##
=======================================
  Coverage   99.95%   99.95%           
=======================================
  Files         501      501           
  Lines       24099    24120   +21     
=======================================
+ Hits        24089    24110   +21     
  Misses         10       10           
Files with missing lines Coverage Δ
...nternal/controller/api/node/process/process_get.go 100.00% <100.00%> (ø)
...controller/api/node/process/process_signal_post.go 100.00% <100.00%> (ø)
internal/controller/api/node/process/validate.go 100.00% <100.00%> (ø)
internal/provider/node/process/debian.go 99.04% <100.00%> (+0.10%) ⬆️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2f036a6...30aa04f. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

retr0h and others added 2 commits October 7, 2026 10:38
The process endpoints declare required,min=1 on their pid path parameter
and nothing enforced it. Both handlers validated the hostname and passed
request.Pid straight through, so a value below 1 reached the provider,
which validated the signal name and then called syscall.Kill with the pid
as given.

min=1 is the rule that matters here, not required. kill(2) overloads the
sign: 0 is every process in the caller's process group, -1 is every
process the caller may signal, and below -1 is a whole process group.
required only rejects 0, so without min=1 the sign turns one signal into
a host-wide one, and the agent usually runs as root.

Validated in both places, because the provider contract says the request
path does not discharge the provider's own checks and the provider is the
frame that calls kill(2). The provider's check runs before the signal
name is looked up, so a bad pid is refused whatever signal came with it.

The 400 now names the value and the bound. validation.Var has no field
name to report and min carries no hint, so the bare message read "Field
validation for '' failed on the 'min' tag", which tells a caller nothing
about what to send instead.

Tests at both levels. The provider cases set no mock expectation, so the
controller fails the test if Kill is reached at all, which is the
assertion that matters rather than the error string. Verified against a
running controller and agent: before, pid -5 returned a job id and
crossed NATS; now all of 0, -1, -5 and -42 are refused at the API with
no job created.

Found by checking, one call site at a time, whether min was load-bearing
where an audit had reported it as untestable. On 146 plain string fields
required does mask it. On this one it was the only guard, and it was not
wired up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c
The guard was added to both Signal and Get, and only Signal's was
tested, so `return nil, err` in Get was the one statement this change
left uncovered. Codecov caught it: misses 10 -> 11.

Two cases, shaped like Signal's. setupMock sets no expectation, so the
controller fails the test if NewProcess is reached at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c
@retr0h
retr0h force-pushed the fix/process-pid-validation branch from 6cd1e67 to 30aa04f Compare October 7, 2026 17:44
@retr0h
retr0h merged commit ad400d7 into main Oct 7, 2026
12 checks passed
@retr0h
retr0h deleted the fix/process-pid-validation branch October 7, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant