Skip to content

feat(sudo): fall back to script(1) when sudoers sets requiretty - #514

Open
kke wants to merge 15 commits into
mainfrom
feat/sudo-requiretty-script
Open

kke wants to merge 15 commits into
mainfrom
feat/sudo-requiretty-script

Conversation

@kke

@kke kke commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Falls back to running sudo inside script(1) when sudoers sets requiretty, keeping the caller's stdin/stdout/stderr outside the terminal. Background and design in #506.

  • sudo.TTY decorator; RegisterSudo uses it only after sudo's tty rejection and a probe that checks root and intact streams. A warning is logged when it's chosen.
  • rig_test_regular_user_requiretty runs the integration suite as a non-root user under requiretty. It switches to the original sudo where sudo-rs is the default, because sudo-rs ignores requiretty.

Fixes #506

kke added 2 commits November 2, 2026 14:40
With Defaults requiretty, sudo refuses to run without a terminal, and
rig runs commands without one, so sudo was unusable on such hosts. The
ANSSI-BP-028 hardening profiles set it on current RHEL, Debian and SLES.

requiretty asks for a controlling terminal, not a terminal on stdin.
The new TTY decorator runs sudo inside script(1), which provides one,
and hands sudo the caller's stdin, stdout and stderr, set aside as
descriptors 3 to 5 around script. No data passes through the terminal,
so binary input, end of input and separate stderr work as with plain
sudo. The exit status comes back on descriptor 6, because script exits
0 when it is interrupted.

RegisterSudo picks TTY only when the plain probe fails with sudo's tty
rejection and a TTY probe shows the command runs as root with its
streams intact. Choosing it logs a warning about the extra process and
pseudo-terminal per command. When script is not usable, the requiretty
warning now also suggests installing util-linux.

Refs #506

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
rig_test_regular_user_requiretty provisions rigtest-user with sudo
alone, under Defaults requiretty, with util-linux script installed. It
switches to the original sudo where sudo-rs is the default, as sudo-rs
does not know requiretty. It checks that plain sudo is refused, so the
run cannot quietly test nothing new, and always passes -expect-sudo,
which the plain probe would otherwise leave off.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 2, 2026 12:44
@github-actions github-actions Bot added the additive-change Applied by the API diff workflow label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Exported API changed by this pull request

Compared against 7dfac40.

This only adds exported API.

Compared for linux, windows, darwin. Entries read ./package.Symbol; the module root package is unprefixed.

Compatible changes (1):

- ./sudo.TTY: added

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The exit-status relay lacks execution-backed tests for nonzero exits, interruption, and SSH cancellation, leaving exit-code propagation and elevated-process termination unverified.

1 open issue
What changed in this PR

Adds a sudo-specific fallback for hosts enforcing requiretty, addressing #506 without enabling terminal processing for ordinary commands.

Changes:

  • Adds sudo.TTY, using util-linux script(1) while preserving separate command streams and relaying exit status.
  • Selects the fallback after a tty rejection and a successful root/stream probe, with diagnostic warnings.
  • Adds registry and command-generation tests plus a requiretty integration scenario.
File Description
test/​test.sh Provisions a requiretty user and runs the integration suite with sudo required.
sudo/​sudo.go Implements the TTY decorator, validation probe, and fallback selection.
sudo/​sudo_test.go Adds a golden test for the generated TTY wrapper.
sudo/​registry_test.go Tests fallback selection and rejection when probing fails.

Review effort: Balanced

Comment thread sudo/sudo.go Resolved
kke added 2 commits November 2, 2026 14:53
The golden and registry tests only checked the command string and a
mocked wrapper, so the exit status relay was not exercised. Run the
wrapper with util-linux script and a stand-in sudo that runs its command
unprivileged: exit status 17 and separate stderr, 1 MiB of binary stdin,
and a script interrupted before the command reports a status, which must
exit 130 rather than script's 0. Skipped where util-linux script is not
available.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
Run an elevated command that exits 17, and one the context cuts short,
through whatever sudo runner rig detected, which under requiretty is
sudo.TTY. The cancelled command must report the context error, not
success. Whether the elevated process is gone afterwards is not checked:
over native SSH a command without a terminal outlives the cancelled
session with plain sudo too.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 2, 2026 12:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The separate terminal session and cross-shell status relay need real requiretty SSH/OpenSSH cancellation and disconnect validation to assess descendant-process cleanup beyond exit-status assertions.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

The extra process and pseudo-terminal per command matter less than a
cancelled or disconnected sudo command staying alive on the host, so
lead with that.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 2, 2026 13:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The runner-selection test can pass even when subsequent commands use plain Sudo instead of TTY, so it needs an assertion that the actual whoami command equals sudo.TTY("whoami").

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Test does not verify whoami uses the TTY-wrapped sudo runner

sudo/​registry_test.go:94

These assertions can pass even if the selected runner uses plain Sudo instead of TTY: the discovery probe already satisfies the script -qec check, and the unstubbed whoami invocation succeeds because MockRunner.ErrDefault is nil. Assert that the actual whoami command equals sudo.TTY("whoami") so the test catches a fallback that probes successfully but does not wrap subsequent commands.

Review effort: Balanced

The probe already sends a script command, so checking for one passed
even if later commands went through plain Sudo. Match the exact
sudo.TTY form of the command instead.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 2, 2026 13:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Human assessment is needed to evaluate an unpublished Alpine/BusyBox compatibility finding that causes the interruption test to return the wrong exit status.

No open issues 🎉

Review effort: Balanced

kke added 2 commits November 2, 2026 15:35
The warnings reach the users of whatever tool uses the library, which
has no reason to name it.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
BusyBox ps has no -o ppid= or -p, so on Alpine with util-linux script
the interruption never happened and the test saw exit 0 instead of 130.
The test only runs on Linux, so read the parent from /proc instead.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 2, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The privileged PTY wrapper changes signal and process-lifetime semantics, requiring runtime validation of cancellation and disconnect behavior beyond the discovery probe’s successful-execution and stream-forwarding checks.

No open issues 🎉

Review effort: Balanced

A cancelled command over native SSH gets SIGINT, which reached the
wrapper shell but not sudo: script(1) runs sudo in a session of its own,
and sudo does not pass on the hangup it gets when script exits. The
command kept running after its context ended, where plain Sudo stops.

The wrapper and the shell script starts now run their child in the
background and pass SIGINT, SIGTERM and SIGHUP on as SIGTERM, so the
signal reaches sudo, which passes it to the command. The exit status
still comes back on descriptor 6, now through a temporary file, so the
command's own status is reported.

Running script in the background makes the elevated command start with
SIGINT and SIGQUIT ignored, as POSIX shells start background commands.
Without job control, which needs a terminal the wrapper does not have,
a shell cannot forward a signal to a child it waits for in the
foreground, and execing into script would report 0 for an interrupted
command.

The integration test now checks that the elevated process is gone after
a cancel over native SSH. The openssh protocol only stops the local
client, so there the remote command outlives the context with or
without sudo.TTY.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 10:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

The sudo requiretty fallback has no active review issues.

No open issues 🎉

Review effort: Balanced

The OpenSSH 9 servers of Debian 12 and Ubuntu 24.04 ignore the signal
request rig sends when a context ends, for a root login, so even an
unelevated command outlives its context there and the check failed on
CI. Require the elevated command to stop only where an unelevated one
does, and kill whichever outlives its context.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 11:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Cancellation and disconnect handling across shell sessions and privileged sudo processes need validation over both SSH transports, since unit tests fake sudo and SSH integration may skip process-termination assertions.

No open issues 🎉

Review effort: Balanced

…over openssh

doas gives up the user's real uid before running the command, so the
signal the server sends as the user when a context ends is not allowed
to reach it, and the Alpine regular user runs, which escalate with doas,
failed the check. Require the elevated command to stop only when
privileges are escalated through sudo, and log why when the check does
not apply.

The requiretty run now also goes through the openssh client protocol,
so sudo.TTY is exercised over both SSH transports.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 12:15

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The fallback’s multi-layer signal forwarding and privileged-process cleanup require real-sudo/requiretty integration validation across distro and SSH variants, which fake-sudo unit tests cannot establish.

No open issues 🎉

Review effort: Balanced

…e TTY

The cancellation check only looked at the command's own process, so a
sudo, script or shell layer left behind by the wrapper went unnoticed.
The command now records its process chain up to the SSH server, and the
check requires all of it to be gone.

The requiretty run passes the new -expect-sudo-tty flag, which requires
script(1) in that chain, so the run fails rather than silently testing
plain sudo, and over native SSH it fails instead of skipping the check
when an unelevated command outlives its context.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 13:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The cancellation subtest fails on supported Darwin targets because it unconditionally reads Linux procfs files; the process-chain check needs a procfs availability guard while retaining the portable exit-status check.

1 open issue

Review effort: Balanced

Comment thread test/rig_test.go Resolved
The process chain is read from Linux /proc, which other supported
targets such as Darwin lack. Without it, check only that the cancelled
elevated command fails with the context's error and skip the rest,
unless -expect-sudo-tty asks for the check.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 13:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Human assessment is needed because the TTY-specific expectation can still allow a passing run without exercising the sudo fallback.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

On its own, -expect-sudo-tty let TestSudoExitStatus skip when rig found
no sudo runner, which passed a run that never exercised the fallback.
A host that needs sudo.TTY has sudo, so the flag now also makes the
escalation tests fail instead of skipping, and a Windows target fails.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 14:19

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Human review is needed because temporary-file cleanup by an elevated command can break the exit-status relay, causing TTY to return 130 instead of the command’s actual status.

No open issues 🎉

Review effort: Balanced

The wrapper read the relayed status back from its temporary file by
path, so an elevated command that cleaned up temporary files removed it
and the wrapper exited 130 instead of the command's own status.

Open the file for writing as descriptor 6 and for reading as descriptor
7, then remove it at once: the status travels through the open
descriptors and there is no path left to remove. Nothing is left behind
when the wrapper is killed either. sudo gets descriptor 6 closed, and
script gets descriptor 7 closed.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke requested a balanced review from Copilot November 9, 2026 14:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Real requiretty integration validation is unavailable, and fake-sudo tests cannot establish that cross-session cancellation leaves no elevated descendants over native SSH and OpenSSH.

No open issues 🎉

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

additive-change Applied by the API diff workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ssh: sudo fails on hosts with Defaults requiretty (v0.x requested a PTY, v2 does not)

2 participants