π fix(security): stop hive-snapshot.service executing code planted under /tmp (#5483) - #5503
Conversation
|
Retargeting this at #5504 rather than closing it β #5499 merged first for #5483, but shipped two defects this PR gets right. 1. The contract test is registered nowhere. 2. To be precise about what I established: I verified the script calls those commands; I did not reproduce a live failure with Please rebase onto current Your reportβs judgement was the right one throughout β verifying the guard generalized unmodified before writing the fix, and refusing to copy the sibling unitβs hardening wholesale. The |
β¦r /tmp (#5483) hive-snapshot.service ran ExecStart=/tmp/hive/dashboard/publish-snapshot.sh, resolving the executed script out of a world-writable parent that is cleared on every reboot. /tmp's sticky bit prevents replacing entries owned by others but not creating new ones, so a local user who created the path before hive-deploy repopulated the checkout got code execution as the service user. Lower severity than #5435 (Type=oneshot on a timer, not Restart=always; no EnvironmentFile) but not zero: publish-snapshot.sh reads a GitHub App token from /var/run/hive-metrics/gh-app-token.cache and pushes to kubestellar/docs. bin/hive-checkout-guard.sh from #5481 generalizes to this with no changes: it resolves the service user from the running EUID and takes the directory and entrypoints as arguments, so it is wired in as-is via ExecStartPre. ProtectHome is deliberately NOT read-only here, unlike hive-discord.service: publish-snapshot.sh drives git and gh, which write under $HOME. PrivateTmp and ProtectSystem=strict remain absent for the same reason as #5481, plus the DOCS_REPO_DIR clone also lives under /tmp. The new contract test executes the guard against real directory trees and asserts on exit status rather than grepping for ExecStartPre, and is registered individually in v2-ci.yml because an unregistered deploy test never runs. Fixes #5483 Signed-off-by: Andy Anderson <andy@clubanderson.com>
4ffe48f to
d4821d5
Compare
|
Rebased onto current #5499 landed the guard wiring and the test file first; both conflicted here. I resolved by taking this PR's side of 1. 2. Verified the test actually bitesRan it three ways rather than trusting a green result β the vacuous-guard failure has appeared four times in this repo today:
The assertion fails for the right reason on the mutation and passes on the fix. This is a local demonstration of the guard's behaviour, not a merge signal β CI remains the gate, and the point of this PR is that CI will now actually run it. Delta is 5 files: the Retargeted |
|
[APPROVALNOTIFIER] This PR is APPROVED Approval requirements bypassed by manually added approval. This pull-request has been approved by: The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Thank you for your contribution! Your PR has been merged. Check out what's new:
Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey |
Security Fix
Files claimed:
systemd/hive-snapshot.service,src/deploy/test_hive_snapshot_unit_contract.sh(new),.github/workflows/v2-ci.yml,bin/hive-deploy.sh(comment only),CHANGELOG.md.hive-snapshot.serviceranExecStart=/tmp/hive/dashboard/publish-snapshot.sh, resolving the executed script out of a world-writable parent that is cleared on every reboot./tmp's sticky bit stops a user deleting or renaming entries owned by someone else β it does not stop them creating/tmp/hive/dashboard/publish-snapshot.shin the window after a reboot wipes/tmpand beforehive-deployrepopulates the checkout. This was the last remaining unit insystemd/with that shape.Severity, stated precisely. Lower than #5435 and I am not claiming otherwise: the unit is
Type=oneshotdriven byhive-snapshot.timer, notRestart=always, so planted code runs on the timer's 15-minute schedule rather than immediately at boot, and the unit carries noEnvironmentFile, so systemd hands it no credential. It is not zero, though, and the issue understates it slightly:publish-snapshot.shitself reads a GitHub App token from/var/run/hive-metrics/gh-app-token.cacheand uses it to push branches and merge PRs intokubestellar/docs. So the payoff isdevexecution that reaches a write credential, just not one systemd placed in the environment.The guard generalized unmodified
bin/hive-checkout-guard.shis byte-identical to what #5481 landed βgit diffagainstorigin/v4shows no change to it. It already resolves the service user from the running EUID rather than hardcodingdev, and already takes the directory and entrypoint list as arguments, so it needed nothing:As the issue notes,
publish-snapshot.shships mode 755 rather than 644, so the relevant assertion is the guard's group/other-writable check on the file; the exec bit is irrelevant to it. The test pins both directions of that β 755 must be accepted (or every real run breaks) while 775 and 757 must be refused, so the guard keys on the write bits rather than on mode equality.bin/hive-deploy.shneeded only a comment update: #5481 already added the explicit bootstrap block, and it installs the one file both units now depend on. The comment named only the discord unit and said "refuse to start the bot"; it now names both units. No behaviour change there.Demonstrated failing, then passing
Per the #5388 standard, the test was run against the unfixed unit before the fix was written.
Against the unfixed unit (
git show origin/v4:systemd/hive-snapshot.service) β 18 passed, 3 failed, exit 1:The behaviour assertions pass in both states because they exercise the guard script directly against real trees β that is the point of them, and it is why the unit-file assertions above are what actually distinguish fixed from unfixed.
Against the fixed unit β 24 passed, 0 failed, exit 0:
src/deploy/test_hive_discord_unit_contract.shstill reports 21 passed, 0 failed β this PR does not disturb it.Hardening differs from hive-discord.service on purpose
NoNewPrivileges,ProtectSystem=full,PrivateDevicesandRestrictSUIDSGIDare set, matching #5481. Two deliberate divergences, both pinned by the test so a later "make the units consistent" pass cannot undo them silently:PrivateTmpabsent andProtectSystemnotstrictβ same trap as fix(security): stop hive-discord.service executing code planted under /tmp (#5435)Β #5481, with an extra reason here: besides the checkout,DOCS_REPO_DIR=/tmp/kubestellar-docs-snapshotis also under/tmp, and the script clones into it, commits from it and pushes. A private or read-only/tmpbreaks the job twice over.ProtectHomeis NOTread-only, unlike the discord unit. I checked whatpublish-snapshot.shactually does rather than mirroring the sibling: it drivesgitandgh, both of which write under$HOME(ghrefreshes~/.config/ghstate,gitwrites lock files alongside~/.gitconfig). CopyingProtectHome=read-onlyacross would have broken the push/PR path rather than hardened it. The test asserts its absence with that reasoning attached.Registered in
v2-ci.ymlnext to the other deploy contract tests βsrc/deploy/test_*.share wired individually there, so an unregistered test would never run.Not verified
ExecStartPrewas not observed refusing a real timer firing. The guard's logic is verified directly; its integration with systemd is verified only by reading the unit.ProtectHome's absence is reasoned, not measured. I established thatgitandghwrite under$HOMEfrom the script's contents and general tool behaviour, not by running the unit withProtectHome=read-onlyand observing a failure. The conservative choice (leave$HOMEwritable) is the one that cannot break the job, so an over-cautious call here costs nothing.PrivateDevices/RestrictSUIDSGIDuntested at runtime β carried over from fix(security): stop hive-discord.service executing code planted under /tmp (#5435)Β #5481 on the assumption the snapshot job needs neither device nodes nor setuid execution. Nothing inpublish-snapshot.shorbuild-snapshot.mjssuggests it does, but this was not exercised.systemd/still carry no hardening directives. The issue calls a consistent pass across them the natural follow-up; I deliberately kept this PR to the one unit named in hive-snapshot.service executes publish-snapshot.sh from /tmp/hive with no ownership guard (same class as #5435)Β #5483, and theProtectHomedivergence found here is evidence that such a pass needs to be per-unit rather than uniform.statwas inherited from fix(security): stop hive-discord.service executing code planted under /tmp (#5435)Β #5481 unchanged; the GNU branch is exercised by CI on Linux, which is the platform that matters.Fixes #5504