Blueforce-Linux/bfos/runner.py
emirhanmamak-blueforce e28783e563 feat(offline-repo): build the APT index so installs work without network
The package pool was committed but the installer still stopped at "no local
package repository": it requires the compiled bundle (Packages, Packages.gz,
packages.lock.tsv, SHA256SUMS), and building that had never run.

The bundle
- 1321 packages, 884 MiB, Ubuntu 26.04 amd64, built with apt-ftparchive on the
  fleet's own release so the closure matches the devices.
- built/pool/ is gitignored: those .deb files are already tracked once under
  provisioning/offline-repo/packages/, and the same ~890 MB does not need a
  second path. What the installer reads from the bundle is the index.
- The output directory is chmod 0755: mktemp -d created it 0700 and left it
  so, which made a valid bundle unreadable to the installer's probe.

dpkg-deb output was parsed wrong in four places
- `dpkg-deb -f <file> Package Version` prints "<Field>: <value>", and the
  callers kept the whole line, so the value carried its label. build-index.sh
  therefore accepted no package at all and reported an empty pool;
  add-packages.sh rejected every real .deb as an invalid name, missed version
  conflicts, and copied a duplicate; build-offline-repo.sh never matched a pin.
- The test suite could not see any of this: its dpkg-deb stub emitted bare
  values. The stub now prints the labels exactly as the real tool does, which
  is what surfaced the remaining three sites.

Tests
- The secret scan covers the files we author, not apt-ftparchive's index over
  third-party packages: polkit's own description reads "...asked for their
  password:", which is prose in the packages, not a leak. Any secret reaching a
  manifest, README or tool is still caught.
- The "test build must not touch the bundle" assertion now compares the
  directory before and after instead of demanding it be empty, so a
  maintainer's real build is not reported as test spill.
2026-09-16 21:42:27 +03:00

314 lines
11 KiB
Python

"""Command execution and the audit log for the bfos console.
Separation of duties:
* :mod:`bfos.operations` decides *what* may run and produces an argv list,
* this module decides *whether* it runs (confirmation, dry-run, check mode),
executes it with :mod:`subprocess`, reports the exit code, and writes the
audit record.
Guarantees kept from the bash console (``admin/bf-menu``):
* the command is always printed **before** it is confirmed and run,
* ``--dry-run`` executes nothing at all,
* the audit log is mode 600 (directories 700) and falls back to
``$XDG_STATE_HOME/blueforce/`` when ``/var/log`` is not writable,
* no secret reaches the log: a single sanitizer redacts
``password=``/``token=``/``secret=``/``api_key=``/``private_key=``/``psk=``
style values and whole PEM blocks,
* raw command output is shown on screen and never copied into the log.
"""
from __future__ import annotations
import os
import re
import shlex
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Callable, List, Optional, Sequence
from . import config
# ---------------------------------------------------------------------------
# Secret redaction -- the single choke point before anything is logged
# ---------------------------------------------------------------------------
_SECRET_KEY = r"(?:password|passwd|passphrase|secret|token|api[_-]?key|private[_-]?key|preshared[_-]?key|psk|credential)"
_SECRET_ASSIGN_RE = re.compile(
r"((?:" + _SECRET_KEY + r")[A-Za-z0-9_.-]*\s*[=:])\s*(\S+)",
re.IGNORECASE,
)
_PEM_LINE_RE = re.compile(r"BEGIN [A-Z0-9 ]*PRIVATE KEY")
_PEM_BLOCK_RE = re.compile(
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
re.DOTALL,
)
REDACTED = "<redacted>"
def redact(text: str) -> str:
"""Return TEXT with secret-looking values and PEM blocks removed.
Deliberately blunt: log records are written for every command, so the
sanitizer must be safe for text that was never meant to be a secret. A PEM
header without its matching footer (truncated line, interrupted paste) is
treated as the start of a secret: everything from the header on is dropped,
because a half-copied key is still a key.
"""
if not text:
return text
cleaned = _PEM_BLOCK_RE.sub(REDACTED, text)
header = _PEM_LINE_RE.search(cleaned)
if header:
cleaned = cleaned[:header.start()] + REDACTED
cleaned = _SECRET_ASSIGN_RE.sub(lambda match: match.group(1) + REDACTED, cleaned)
return cleaned
# ---------------------------------------------------------------------------
# Audit log
# ---------------------------------------------------------------------------
def fallback_log_path(state_home: Optional[str] = None, home: Optional[str] = None) -> str:
base = state_home or os.environ.get("XDG_STATE_HOME") or os.path.join(
home or os.path.expanduser("~"), ".local", "state"
)
return os.path.join(base, *config.LOG_FALLBACK_SUBDIR)
def _prepare_log_file(path: str) -> bool:
"""Create or tighten PATH to mode 600; True when it is usable."""
directory = os.path.dirname(path) or "."
try:
if os.path.exists(path):
if not os.path.isfile(path) or not os.access(path, os.W_OK):
return False
os.chmod(path, config.LOG_FILE_MODE)
else:
if not os.path.isdir(directory):
os.makedirs(directory, mode=config.LOG_DIR_MODE, exist_ok=True)
if not os.access(directory, os.W_OK):
return False
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, config.LOG_FILE_MODE)
os.close(fd)
os.chmod(path, config.LOG_FILE_MODE)
except OSError:
return False
return _is_private(path)
def _is_private(path: str) -> bool:
"""True when only the owner can read the log (never log into a world-readable file)."""
try:
mode = os.stat(path).st_mode
except OSError:
return False
return (mode & 0o077) == 0
@dataclass
class AuditLog:
"""Append-only audit trail. Never receives raw command output."""
requested: str = config.DEFAULT_LOG_FILE
state_home: Optional[str] = None
home: Optional[str] = None
path: Optional[str] = None
ready: bool = False
warning: str = ""
def initialise(self) -> None:
for candidate in (self.requested, fallback_log_path(self.state_home, self.home)):
if not candidate:
continue
if _prepare_log_file(candidate):
self.path = candidate
self.ready = True
if candidate != self.requested:
self.warning = config.MSG_LOG_FALLBACK.format(
requested=self.requested, fallback=candidate
)
return
self.ready = False
self.warning = config.MSG_LOG_NONE
def write(self, level: str, message: str, timestamp: Optional[str] = None) -> None:
if not self.ready or not self.path:
return
stamp = timestamp or _utc_now()
record = "{0} [{1}] {2}\n".format(stamp, level, redact(message))
try:
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(record)
except OSError:
# A failing log must never break an operator's session.
self.ready = False
def _utc_now() -> str:
import datetime
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
RUN_MODE_RUN = "run"
RUN_MODE_CHECK = "check"
RUN_MODE_CANCEL = "cancel"
@dataclass
class RunResult:
argv: List[str] = field(default_factory=list)
executed: bool = False
dry_run: bool = False
check: bool = False
cancelled: bool = False
rc: Optional[int] = None
signal_name: str = ""
@property
def ok(self) -> bool:
return self.executed and self.rc == 0
def command_string(argv: Sequence[str]) -> str:
"""A copy/pasteable shell line (like ``printf %q`` in the bash console)."""
return " ".join(shlex.quote(part) for part in argv)
def default_spawn(argv: Sequence[str]) -> int:
"""Run ARGV with inherited stdio and return its exit code."""
try:
completed = subprocess.run(list(argv), check=False)
return completed.returncode
except FileNotFoundError:
return 127
except PermissionError:
return 126
except KeyboardInterrupt:
return 130
class Runner:
"""Show, confirm and execute commands; record every decision."""
def __init__(
self,
console,
audit: Optional[AuditLog] = None,
dry_run: bool = False,
check_mode: bool = False,
spawn: Optional[Callable[[Sequence[str]], int]] = None,
) -> None:
self.console = console
self.audit = audit or AuditLog()
self.dry_run = dry_run
self.check_mode = check_mode
self._spawn = spawn or default_spawn
# -- helpers -----------------------------------------------------------
def show_command(self, argv: Sequence[str], title: str = config.MSG_COMMAND_TITLE) -> str:
printable = command_string(argv)
self.console.section(title)
self.console.write(" " + printable)
self.console.write("")
return printable
def _check_flag(self, argv: List[str]) -> List[str]:
if "--check" not in argv:
return argv + ["--check"]
return argv
def _spawn_exit_code(self, argv: Sequence[str]) -> RunResult:
try:
rc = self._spawn(argv)
except KeyboardInterrupt:
return RunResult(argv=list(argv), executed=True, rc=130, signal_name="SIGINT")
return RunResult(argv=list(argv), executed=True, rc=rc)
# -- public API --------------------------------------------------------
def run_readonly(self, argv: Sequence[str], title: str) -> RunResult:
"""Run a read-only tool: printed, not confirmed (mirrors bf_show_local_cmd)."""
self.show_command(argv, title=title)
if self.dry_run:
self.console.warn(config.MSG_DRY_RUN_WARN)
self.audit.write(config.AUDIT_DRYRUN, command_string(argv))
return RunResult(argv=list(argv), dry_run=True)
self.audit.write(config.AUDIT_RUN, command_string(argv))
result = self._spawn_exit_code(argv)
if result.rc == 0:
self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(argv))
else:
self.console.warn(config.MSG_RUN_FAIL.format(rc=result.rc))
self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(argv)))
return result
def confirm_and_run(
self,
argv: Sequence[str],
label: str,
destructive: bool = False,
check_mode: Optional[bool] = None,
) -> RunResult:
"""Print the command, ask, then run it (or cancel / dry-run)."""
self.show_command(argv)
if self.dry_run:
self.console.warn(config.MSG_DRY_RUN_WARN)
self.audit.write(config.AUDIT_DRYRUN, command_string(argv))
return RunResult(argv=list(argv), dry_run=True)
if destructive:
self.console.warn(config.MSG_DESTRUCTIVE_WARN.format(label=label))
self.console.warn(config.MSG_DESTRUCTIVE_SUB)
mode = self.console.ask_run(config.MSG_RUN_ASK)
if mode == RUN_MODE_RUN:
final = list(argv)
elif mode == RUN_MODE_CHECK:
final = self._check_flag(list(argv))
self.console.info(config.MSG_CHECK_MODE)
else:
self.console.info(config.MSG_CANCELLED_RUN)
self.audit.write(config.AUDIT_CANCEL, "operatör iptal etti :: " + command_string(argv))
return RunResult(argv=list(argv), cancelled=True)
# NOTE: this method does not enforce root. The terminal console gates a
# destructive operation earlier (App.require_root, config.MSG_ROOT_REQUIRED)
# and the GUI gates it at its execution choke point (ui_gui.run_plan).
# Enforcing here as well double-guarded the same rule and fired before
# the console's own message, so the operator saw the wrong explanation.
self.audit.write(config.AUDIT_RUN, command_string(final))
result = self._spawn_exit_code(final)
result.check = "--check" in final
if result.rc == 0:
self.console.ok(config.MSG_RUN_OK)
self.audit.write(config.AUDIT_OK, "exit=0 :: " + command_string(final))
else:
self.console.error(config.MSG_RUN_FAIL.format(rc=result.rc))
self.audit.write(config.AUDIT_FAIL, "exit={0} :: {1}".format(result.rc, command_string(final)))
return result
# -- checks ------------------------------------------------------------
@staticmethod
def have_ansible() -> bool:
return _which("ansible-playbook") is not None
@staticmethod
def have_python() -> bool:
return sys.version_info >= config.MIN_PYTHON
@staticmethod
def is_root() -> bool:
return os.geteuid() == 0
def _which(name: str) -> Optional[str]:
for directory in os.environ.get("PATH", "").split(os.pathsep):
candidate = os.path.join(directory, name)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None