Compare commits

..
Author SHA1 Message Date
ChuckBuilds fb0e869bf6 harden(install): tag the journalctl sudo grants NOEXEC
journalctl starts a pager when its output is a terminal, and from less a "!sh"
is a shell with whatever privileges journalctl was given. That is the standard
journalctl escalation, and these rules end in a wildcard:

    <user> ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u ledmatrix *

Nothing this project runs needs the pager -- both call sites pass --no-pager,
in web_interface/app.py and api_v3.py. But a sudoers rule cannot require a flag
that sits in the middle of a command line, and reasoning about what a trailing
wildcard does and does not admit is exactly the kind of subtlety that produces
a hole. sudo's NOEXEC tag stops the command executing another program at all,
which closes it without depending on that reasoning.

NOEXEC works by LD_PRELOAD, so it applies to dynamically linked binaries.
Checked on the target hardware: journalctl there is dynamically linked. The
generated rules were run through `visudo -c` -- parsed OK.

Found while auditing the pre-existing wildcard grants, prompted by review
catching a far worse one I had added myself in the same area: `iptables *`,
where --modprobe runs an arbitrary path as root.

Reachability, stated plainly: on a stock Raspberry Pi image none of this
matters, because 010_pi-nopasswd already grants the default user
`ALL=(ALL) NOPASSWD: ALL`. It matters on a hardened install, or where the
service runs as a user without that blanket rule.

Two mutation checks: dropping NOEXEC from a rule fails, and deleting the rules
rather than tagging them fails too -- that second one matters, since "make the
test pass" and "remove the feature" would otherwise look the same.
2026-08-20 02:25:01 -04:00
4 changed files with 109 additions and 97 deletions
+10 -3
View File
@@ -1419,9 +1419,16 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/
EOF EOF
if [ -n "$JOURNALCTL_PATH" ]; then if [ -n "$JOURNALCTL_PATH" ]; then
cat >> /tmp/ledmatrix_web_sudoers << EOF cat >> /tmp/ledmatrix_web_sudoers << EOF
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service * # NOEXEC, because these rules end in a wildcard and journalctl starts a pager
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix * # when its output is a terminal. From that pager (less) a "!sh" is a root
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix * # shell -- the standard journalctl escalation. The web interface always passes
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
# sits in the middle of the command line. NOEXEC stops the command executing
# another program at all, which closes the hole without depending on wildcard
# matching subtleties.
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
EOF EOF
fi fi
+12 -30
View File
@@ -195,36 +195,18 @@ class PluginManager:
continue continue
manifest_path = item / "manifest.json" manifest_path = item / "manifest.json"
if not manifest_path.exists(): if manifest_path.exists():
# A directory here that carries no manifest is not a try:
# plugin. Said once, because the alternative is a plugin with open(manifest_path, 'r', encoding='utf-8') as f:
# that is enabled in config, enabled in plugin state, manifest = json.load(f)
# present on disk, and simply absent from the running plugin_id = manifest.get('id')
# process with nothing anywhere to say why. Working that if plugin_id:
# out afterwards means reading cache-file mtimes. plugin_ids.append(plugin_id)
self.logger.warning( new_manifests[plugin_id] = manifest
"Skipping %s: no manifest.json, so it cannot be loaded " new_directories[plugin_id] = item
"as a plugin", item.name) except (json.JSONDecodeError, PermissionError, OSError) as e:
continue self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
try: continue
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (json.JSONDecodeError, PermissionError, OSError) as e:
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
continue
plugin_id = manifest.get('id')
if not plugin_id:
# Parsed but unusable. This was the quietest path of all:
# the manifest is read successfully and then dropped.
self.logger.warning(
"Skipping %s: its manifest.json has no \"id\", so there "
"is nothing to register it under", item.name)
continue
plugin_ids.append(plugin_id)
new_manifests[plugin_id] = manifest
new_directories[plugin_id] = item
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True) self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
"""Discovery must say when it skips a directory.
A plugin can be enabled in config, enabled in plugin state, present on disk
with a valid entry point -- and simply absent from the running process, with
nothing in the journal to say why. Working that out afterwards meant comparing
cache-file mtimes to find when it had last run.
Two paths were silent. A directory with no manifest.json was ignored, and --
quieter still -- a manifest that parsed but carried no "id" was read
successfully and then dropped on the floor.
"""
import json
import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
def _manager(tmp_path):
pm = PluginManager.__new__(PluginManager)
pm.plugins_dir = tmp_path
pm.logger = logging.getLogger("test.discovery")
pm.plugin_manifests = {}
pm.plugin_directories = {}
pm._discovery_lock = __import__("threading").RLock()
pm.schema_manager = MagicMock()
return pm
def test_a_directory_without_a_manifest_is_reported(tmp_path, caplog):
(tmp_path / "not-a-plugin").mkdir()
pm = _manager(tmp_path)
with caplog.at_level(logging.WARNING, logger="test.discovery"):
pm._scan_directory_for_plugins(tmp_path)
joined = " ".join(r.message for r in caplog.records)
assert "not-a-plugin" in joined and "manifest" in joined, (
f"skip was silent; log said: {joined!r}")
def test_a_manifest_without_an_id_is_reported(tmp_path, caplog):
d = tmp_path / "idless"
d.mkdir()
(d / "manifest.json").write_text(json.dumps({"name": "No Id", "version": "1.0.0"}))
pm = _manager(tmp_path)
with caplog.at_level(logging.WARNING, logger="test.discovery"):
pm._scan_directory_for_plugins(tmp_path)
joined = " ".join(r.message for r in caplog.records)
assert "idless" in joined and "id" in joined, (
f"a parsed-but-unusable manifest vanished silently; log said: {joined!r}")
def test_a_good_plugin_still_registers(tmp_path, caplog):
d = tmp_path / "real-plugin"
d.mkdir()
(d / "manifest.json").write_text(json.dumps(
{"id": "real-plugin", "name": "Real", "version": "1.0.0"}))
pm = _manager(tmp_path)
pm._scan_directory_for_plugins(tmp_path)
assert "real-plugin" in pm.plugin_manifests, "a valid plugin was not registered"
+87
View File
@@ -0,0 +1,87 @@
"""Wildcard grants to commands that start a pager must carry NOEXEC.
`journalctl` runs a pager when its output is a terminal, and from `less` a
`!sh` is a shell with the privileges journalctl was given. That is the standard
journalctl privilege escalation, and the installer's rules end in a wildcard:
<user> ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u ledmatrix *
The web interface always passes --no-pager -- both call sites do, in app.py and
api_v3.py -- so nothing the project runs needs the pager. But a sudoers rule
cannot require a flag that sits in the middle of the command line, and reasoning
about what a trailing `*` does or does not admit is exactly the kind of
subtlety that produces a hole.
sudo's NOEXEC tag stops the command executing another program at all, which
closes it without depending on that reasoning. It works by LD_PRELOAD, so it
applies to dynamically linked binaries; journalctl is one.
On a stock Raspberry Pi image none of this is reachable, because
/etc/sudoers.d/010_pi-nopasswd already grants the default user
`ALL=(ALL) NOPASSWD: ALL`. It matters on a hardened install, or where the
service runs as a user without that blanket rule.
"""
import re
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
INSTALLERS = (
ROOT / "first_time_install.sh",
ROOT / "scripts" / "install" / "configure_wifi_permissions.sh",
)
#: Commands that will start another program of their own accord -- a pager, an
#: editor, a shell -- and so must not be granted the ability to do so.
SPAWNS_A_PROGRAM = ("journalctl", "systemctl", "less", "more", "man", "git")
def _grant_lines():
lines = []
for installer in INSTALLERS:
if not installer.is_file():
continue
for line in installer.read_text(encoding="utf-8", errors="replace").splitlines():
stripped = line.strip()
if "NOPASSWD" in stripped and not stripped.startswith("#"):
lines.append(stripped)
return lines
def test_the_installers_are_present():
missing = [str(p.relative_to(ROOT)) for p in INSTALLERS if not p.is_file()]
assert not missing, f"installer(s) missing: {missing}"
def test_wildcard_pager_grants_carry_noexec():
offenders = []
for rule in _grant_lines():
command = rule.split("NOPASSWD", 1)[1]
if not command.rstrip().endswith("*"):
continue
tool = command.replace("_PATH", "").replace("$", "").lower()
for name in SPAWNS_A_PROGRAM:
if re.search(rf"(^|/|\s){name}(\s|$)", tool):
if "NOEXEC" not in rule:
offenders.append(rule)
break
assert not offenders, (
"wildcard grant to a command that can start a pager or shell, without "
"NOEXEC:\n " + "\n ".join(offenders))
def test_journalctl_is_granted_at_all():
"""Guard against 'fixing' the above by deleting the rules."""
text = "\n".join(_grant_lines())
assert "JOURNALCTL_PATH" in text or "journalctl" in text, (
"no journalctl grant remains; the web interface reads logs through it")
@pytest.mark.parametrize("unit", ["ledmatrix.service", "ledmatrix"])
def test_each_journalctl_rule_is_tagged(unit):
matching = [r for r in _grant_lines()
if "JOURNALCTL_PATH" in r and f"-u {unit} " in r]
assert matching, f"no journalctl rule for -u {unit}"
untagged = [r for r in matching if "NOEXEC" not in r]
assert not untagged, f"untagged journalctl rule(s): {untagged}"