Skip to content

fix(packaging): start every sudoers grant's argument regex at the argument text - #480

Merged
michelhonold merged 4 commits into
mainfrom
fix/sudoers-regex-grants
Oct 25, 2026
Merged

michelhonold merged 4 commits into
mainfrom
fix/sudoers-regex-grants

Conversation

@michelhonold

@michelhonold michelhonold commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #256.

sudo reads a rule's arguments as a regular expression only when the whole argument text starts with ^. Otherwise it is a wildcard pattern in which ^, + and $ are literal. Every grant built with a regex put fixed tokens in front of it (/usr/bin/date -s ^@[0-9]+$), so none could ever match. Wherever the service user has no blanket sudo, each of these needed a password:

  • system.set_clock: the automatic time sync never set the clock;
  • the two usermod group joins (journal access, hardware groups);
  • camera.config_write, camera.overlay_load, camera.overlay_unload;
  • media.write.

The fix:

  • Each rule is now built by _regex_args, which puts the anchor in front of the whole argument text: /usr/bin/date ^-s @[0-9]+$, …write-to-media ^write /dev/sd[a-z]+[0-9]*$.
  • Inside a regex, the group list's and the camera token's commas need no sudoers escape, so the escaped constants go.
  • The probes already send a token each regex accepts.
  • The drop-in is not a conffile, so a package upgrade replaces the old rules.
  • The deprecated playbook's drop-in is mirrored to keep its sync test.

Why it went unnoticed: the OpenFollow image and the test stations give the service user blanket passwordless sudo.

Test plan

  • On a Pi 5 (sudo 1.9.16p2), for a user holding only these rules:
    • the old rules refused all 7 valid calls;
    • the new rules allow them and refuse all 11 malformed ones: a non-numeric or suffixed epoch, a date string, an extra argument, an injected -aG sudo, a widened group list, automatic for a load, a non-USB device.
  • The full rendered drop-in passes visudo -cf on the Pi.
  • Every regex rule:
    • its argument text starts with ^, ends with $ and holds no other ^;
    • the words before the regex are plain;
    • its alternation stays grouped;
    • it admits its own probe;
    • the hardware cases come out as on the Pi, modelled with re.search as sudo's regexec runs.
  • A C regcomp/regexec agrees with that model on every rule's probe and on 29 edge strings.
  • The registry-wide tests fail on the old form (25 of them); every new or changed test mutation-checked; review rounds until nothing but cosmetic findings were left.
  • make ci-remote on the testing Pi.
  • On a .deb install without blanket sudo: the Device permissions view shows these grants as passwordless, and the time sync sets the clock.
    • Checked on a Pi 5 station (0.5.0~rc2 .deb) with the blanket rule removed, timesyncd stopped and the clock set 120 s back. Control with the packaged drop-in: Device permissions listed these 7 grants as needs_password and the time sync reported "answered, but the clock was NOT set (drift 120.1s; needs the system.set_clock grant)". With the drop-in rendered from this branch (visudo -cf OK): 31 passwordless, 0 needs password, and the time sync reported "clock set (drift was 119.8s)".

🤖 Generated with Claude Code

michelhonold and others added 4 commits October 21, 2026 00:05
…ument text

sudo reads a rule's arguments as a regular expression only when the whole
argument text starts with `^`; otherwise it is a wildcard pattern in which
`^`, `+` and `$` are literal. Every grant built with a regex put fixed
tokens before it (`/usr/bin/date -s ^@[0-9]+$`), so none could match, and
each needed a password wherever the service user had no blanket sudo:

- system.set_clock, so the automatic time sync never set the clock
- the two usermod group joins
- camera.config_write, camera.overlay_load, camera.overlay_unload
- media.write

Each rule is now built by `_regex_args`, which puts the anchor in front of
the whole argument text (`/usr/bin/date ^-s @[0-9]+$`). Inside a regex
the group list's and camera token's commas need no sudoers escape, so the
escaped constants go. The probes already send a token each regex accepts.

Checked with sudo 1.9.16p2 on a Pi, for a user holding only these rules:
the old rules refused all seven valid calls; the new ones allow them and
refuse every malformed one (a non-numeric or suffixed epoch, an extra
argument, an injected `-aG sudo`, a widened group list, a non-USB device).

Tests pin, for every regex rule: the argument text starts with `^`, ends
with `$` and holds no other `^`; the words before the regex are plain;
the rule admits its own probe; and the hardware cases. The playbook's
drop-in is mirrored to keep its sync test.

Fixes #256

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The camera setup grant test still expected the escaped, regex-after-the-verb
form, so the suite failed. It now pins each rendered line whole, ending at
the newline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sudo runs regexec over the space-joined arguments, which anchors only where
the rule says so. The tests matched with re.fullmatch, which anchors both
ends itself, so a rule that lost its closing `$` (and so admitted trailing
arguments) still passed them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The words in front of a rule's regex bind to its first alternative only if
the alternation is grouped: a top-level `|` would render `^write a|b$`,
which sudo reads as "starts with write a" or "ends with b". The in-process
argv check matches the bare token and would not notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@michelhonold michelhonold added bug Something isn't working Linux Specific to Linux, including Raspberry Pi OS labels Oct 20, 2026
@michelhonold

Copy link
Copy Markdown
Contributor Author

Review: four rounds

Agent review of the whole change, fixes committed each round. Every new or changed test was mutation-checked and fails against the old form or the broken variant.

Round 1 (02ab63bb, 9063e957)

  • High: the camera grant test still expected the old escaped form of the rendered rules, so the suite was red. It now pins each new line whole.
  • Low: the regex tests modelled sudo with re.fullmatch, which supplies both anchors itself, so a rule that lost its closing $ would still have passed them. They now use re.search, as sudo runs regexec over the space-joined arguments.

Round 2: nothing beyond cosmetic. Checked:

  • every rendered rule passes visudo -cf;
  • a C regcomp/regexec (REG_EXTENDED) agrees with the Python model on every rule's probe and on 29 edge strings;
  • the sudoers rule, the in-process argv check, the probe and the real call sites agree;
  • the drop-in is not a conffile, so a package upgrade replaces the old rules.

Round 3 (independent reviewer, 6aebb3db): no high or medium findings. A test now pins that each argument regex keeps its alternation grouped: a top-level | would let sudo match any argument text ending in the second alternative.

Round 4: nothing beyond cosmetic or theoretical.

Left as is:

  • Cosmetic: a few comments and docstrings still call the tail regexes "anchored sudoers regex", or predate this change.
  • Declined:
    • Wrapping the regex body in a group inside _regex_args: it would change the rules verified on the Pi; the grouping test covers the risk.
    • A Depends: sudo (>= 1.9.10): the package already requires Debian 13, which ships 1.9.16.

Results:

  • full suite, mypy and ruff clean;
  • capabilities.py at 100 % line and branch coverage.

@michelhonold
michelhonold marked this pull request as ready for review October 25, 2026 08:07
@michelhonold
michelhonold merged commit f090211 into main Oct 25, 2026
4 checks passed
@michelhonold
michelhonold deleted the fix/sudoers-regex-grants branch October 25, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Linux Specific to Linux, including Raspberry Pi OS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(packaging): sudoers grants with a regular-expression argument never match

1 participant