Files
Sovran_SystemsOS/modules/core/sovran-journal-helper.py
T
a111de1ece Security hardening: fix all 8 blocking findings for PR #419
Fix 1: Update support.js to collect SSH public key and POST JSON
Fix 2: Legacy njalla.sh migration - parse safely, archive non-executable, replace cron with systemd timer
Fix 3: DDNS SSRF prevention - allowlist only njal.la, reject other hosts, disable curl redirects
Fix 4: Legacy root support-key removal migration (_remove_legacy_root_support_key)
Fix 5: Automatic support-key expiration (expires_at + _expire_support_if_stale)
Fix 6: Move security helpers to security_helpers.py, tests import production code
Fix 7: Real NIP-19/Bech32 npub validation (_bech32_decode + _validate_npub)
Fix 8: Replace journalctl sudo wildcard with restricted sovran-journal-helper.py
Also: Make _write_hub_overrides() atomic with tempfile+os.replace
94 tests passing

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
2026-08-11 12:07:18 +00:00

161 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""Sovran restricted journal helper.
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
in tech-support.nix.
Accepted flags:
--unit / -u <name> unit name (letters, digits, @, ., _, - only; .service suffix required)
--lines / -n <N> positive integer (max 10000)
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--output / -o <format> short | short-iso | cat | json | verbose
All other flags, paths, directories, roots, namespaces, and output
destinations are rejected with a non-zero exit code.
"""
import re
import subprocess
import sys
# ── Allowlists ────────────────────────────────────────────────────────────────
_ALLOWED_UNITS_RE = re.compile(
r'^[a-zA-Z0-9@._\-]+\.(service|socket|timer|target|mount|path|slice|scope)$'
)
_ALLOWED_PRIORITIES = frozenset([
"0", "1", "2", "3", "4", "5", "6", "7",
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
])
_ALLOWED_OUTPUT_FORMATS = frozenset([
"short", "short-iso", "cat", "json", "verbose",
])
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
_MAX_LINES = 10000
# ── Argument parser ───────────────────────────────────────────────────────────
def _die(msg: str) -> None:
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
sys.exit(1)
def _validate_unit(val: str) -> str:
if not _ALLOWED_UNITS_RE.match(val):
_die(f"rejected unit name: {val!r} (only letters/digits/@._- with a known suffix)")
return val
def _validate_lines(val: str) -> str:
try:
n = int(val)
except ValueError:
_die(f"rejected: --lines must be a positive integer, got {val!r}")
if n <= 0 or n > _MAX_LINES:
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
return str(n)
def _validate_priority(val: str) -> str:
if val not in _ALLOWED_PRIORITIES:
_die(f"rejected priority: {val!r}")
return val
def _validate_datetime(val: str) -> str:
if not _DATETIME_RE.match(val):
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
return val
def _validate_output(val: str) -> str:
if val not in _ALLOWED_OUTPUT_FORMATS:
_die(f"rejected output format: {val!r}")
return val
def main() -> None:
args = sys.argv[1:]
cmd = ["journalctl"]
i = 0
while i < len(args):
arg = args[i]
if arg in ("--unit", "-u"):
i += 1
if i >= len(args):
_die("--unit requires a value")
cmd += ["--unit", _validate_unit(args[i])]
elif arg.startswith("--unit="):
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
elif arg in ("--lines", "-n"):
i += 1
if i >= len(args):
_die("--lines requires a value")
cmd += ["--lines", _validate_lines(args[i])]
elif arg.startswith("--lines="):
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
elif re.match(r'^-n\d+$', arg):
cmd += ["--lines", _validate_lines(arg[2:])]
elif arg in ("--priority", "-p"):
i += 1
if i >= len(args):
_die("--priority requires a value")
cmd += ["--priority", _validate_priority(args[i])]
elif arg.startswith("--priority="):
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
elif arg == "--since":
i += 1
if i >= len(args):
_die("--since requires a value")
cmd += ["--since", _validate_datetime(args[i])]
elif arg.startswith("--since="):
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
elif arg == "--until":
i += 1
if i >= len(args):
_die("--until requires a value")
cmd += ["--until", _validate_datetime(args[i])]
elif arg.startswith("--until="):
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
elif arg in ("--output", "-o"):
i += 1
if i >= len(args):
_die("--output requires a value")
cmd += ["--output", _validate_output(args[i])]
elif arg.startswith("--output="):
cmd += ["--output", _validate_output(arg[len("--output="):])]
else:
_die(
f"rejected flag: {arg!r}. "
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
)
i += 1
if not cmd[1:]:
_die("at least one flag is required (try --unit <name>)")
result = subprocess.run(cmd)
sys.exit(result.returncode)
if __name__ == "__main__":
main()