# AppArmor profile for the MspCoreX RMM server agent (headless, root).
# Intent: bound the dangerous surface (mounts, ptrace of other tasks, raw
# block devices) and document the mspcorex writable tree — NOT to tightly
# sandbox a root daemon whose job is to run apt/dpkg/usermod and open PTYs.
# Package tools transition to unconfined (Ux) so auto-update is never broken.
#
# Validate (parse-only, no enforcement): apparmor_parser -Q <this file>
# Ship in complain mode first (operator promotes to enforce after AVC-clean).

#include <tunables/global>

/opt/mspcorex/agent-server/bin/mspcorex-agent-server flags=(attach_disconnected,complain) {
  #include <abstractions/base>
  #include <abstractions/nameservice>
  #include <abstractions/ssl_certs>

  # The agent runs as root and supervises the host. NOTE: the unit does NOT set a
  # CapabilityBoundingSet (removed deliberately, pinned absent by
  # rust/crates/mspcorex-agent-server/tests/unit_file_pins.rs), so this list is the
  # only capability bound in the stack — it is not a duplicate of the unit's.
  capability net_admin,
  capability sys_admin,
  capability dac_read_search,
  capability dac_override,
  capability chown,
  capability fowner,
  capability setuid,
  capability setgid,
  capability kill,
  # The three below were added from LIVE audit data on app.mspcorex.com (2026-08-06),
  # not from source reading. The L7 audit concluded CAP_SYS_PTRACE was unnecessary by
  # grepping for ptrace(2) call sites; the kernel records `capname="sys_ptrace"`
  # anyway, because reading another process's /proc entries takes the capability path.
  # sys_resource and syslog likewise showed up only under real load.
  capability sys_ptrace,
  capability sys_resource,
  capability syslog,

  # Own binaries + writable trees. `mr` alongside `ix`: an ix child stays under this
  # profile and must mmap its own text, which `ix` alone does not grant. The live audit
  # showed exactly that class (120 file_mmap + 40 file_mprotect records). The helper
  # binaries that produced them are Ux below now, but the bundled restic stays ix on
  # purpose — restic restore writes to operator-chosen paths and we want those mediated
  # — so it is the one that still needs this.
  /opt/mspcorex/agent-server/bin/** mrix,
  /opt/mspcorex/agent-server/previous/** mrix,
  /etc/opt/mspcorex/agent-server/** rwk,
  /var/opt/mspcorex/agent-server/** rwk,
  /run/mspcorex/ rw,
  /run/mspcorex/** rwk,
  # sd_notify. The unit is Type=notify with WatchdogSec=60s and Restart=always, so if
  # this write is ever denied systemd never sees READY=1, declares the start failed and
  # restarts — forever, on every host at once. It is the single worst failure this
  # profile could cause, it costs one line, and it was invisible to source review:
  # the notify socket is written by libsystemd inside sd_notify(), not by our code.
  # Found by restarting the agent under the profile on app.mspcorex.com 2026-08-06.
  /run/systemd/notify w,
  # Pending-reboot probe (rmm-patching/src/linux_reboot.rs). The consts spell /var/run,
  # which the kernel canonicalises to /run on any merged system — the audit records say
  # /run — so match both rather than trusting either spelling. This was the last thing
  # still logging after everything above was fixed: ~4 records/min, forever, because the
  # patching module polls it on a timer.
  /{,var/}run/reboot-required{,.pkgs} r,
  # rmm-scripts script spool (SCRIPT_SPOOL_DIR). It lives here rather than under
  # /var/opt/mspcorex/agent-server because that tree is 0700 root-owned and the
  # script interpreter setuid's to mspcorex-runner before execve, so it must be
  # able to TRAVERSE every ancestor to open the staged body by path. The agent
  # creates, writes, chowns and unlinks the bodies; the interpreter itself runs
  # unconfined (bash/sh below are Ux), so it needs no rule of its own.
  # It sits in the agent's own /var/lib/mspcorex-agent tree (alongside the
  # firewall snapshot store), NOT in the shared /var/lib/mspcorex namespace.
  # Pinned by rust/crates/rmm-scripts/tests/packaging_spool_modes.rs.
  /var/lib/mspcorex-agent/ rw,
  /var/lib/mspcorex-agent/script-spool/ rw,
  /var/lib/mspcorex-agent/script-spool/** rwk,
  # Lockout-protection snapshot store (firewall_snapshot.rs SNAPSHOT_DIR): the
  # running ruleset is dumped here before every firewall Add/Edit/Delete and
  # restored if the operator doesn't confirm within 60s. Agent-only (0700 dir,
  # 0600 files) — nothing setuid's into it — but it still needs its own rules:
  # the bare `/var/lib/mspcorex-agent/` above does not match the child, so under
  # enforce the create_dir_all would EACCES and every firewall mutation would
  # abort at `write snapshot: ...`, taking the whole CRUD path down with it.
  /var/lib/mspcorex-agent/firewall-snapshots/ rw,
  /var/lib/mspcorex-agent/firewall-snapshots/** rwk,

  # Broad read for monitoring (proc/sys/etc) — a root RMM agent inventories the host.
  @{PROC}/ r,
  @{PROC}/** r,
  /sys/** r,
  # cgroup v2 delegation for remote-terminal slices (mkdir under /sys/fs/cgroup).
  /sys/fs/cgroup/** rw,
  /etc/** r,
  /etc/shadow r,
  # Ransomware-guard watch root. `/srv` is the SHIPPED default (security.rs DEFAULT_PATH),
  # so every install sets an inotify watch here at boot and needs `r` to do it. Observed
  # live on APP where /srv is empty — on a real file server the guard walks the tree, so
  # this is one record per directory at startup without the rule. ⚠️ Operator-configured
  # roots in ransomware-paths.yaml / fim-paths.yaml fall OUTSIDE this rule and will still
  # log; a path-based profile cannot enumerate them ahead of time.
  /srv/ r,
  /srv/** r,
  # …with two write exceptions: firewall_writer.rs `persist()` saves the ruleset
  # back to disk after a confirmed change so it survives reboot. iptables writes
  # rules.v4 directly, nftables rewrites nftables.conf; ufw and firewalld persist
  # through their own tools instead. `confirm()` propagates the error, so without
  # these the rule is already live in the kernel when the save is denied — applied
  # now, gone after reboot.
  /etc/iptables/rules.v4 rw,
  /etc/nftables.conf rw,

  # PTY allocation for remote terminal.
  /dev/pts/ r,
  /dev/pts/* rw,
  /dev/ptmx rw,
  /dev/null rw,
  # ptrace(read) — the single most important rule in this file, and the one the L7
  # audit got wrong. That audit grepped for ptrace(2)/process_vm_*//proc/<pid>/mem,
  # found none, and concluded no rule was needed. But the kernel routes a read of
  # ANOTHER task's /proc entries through security_ptrace_access_check(), so the
  # inventory path trips it without ever calling ptrace(2).
  # Which reads, precisely: plain /proc/<pid>/{stat,status,cmdline} are ordinary
  # file reads; the ptrace-gated ones are the READLINKS — /proc/<pid>/exe in
  # process_dispatch.rs, and far more of them, /proc/<pid>/fd/* in network.rs,
  # which walks every fd of every pid to map socket inodes back to processes.
  # The arithmetic confirms it: 18,515 records over ~300 processes is ~62 per
  # process per 60s cycle. stat+cmdline would be 2; an fd scan is ~60.
  # Live on app.mspcorex.com 2026-08-06 that was 98.8% of everything this profile
  # logged. Because complain mode records what enforce WOULD deny, the missing rule
  # was not harmless observation: it flushed the host's whole audit trail every
  # ~3 minutes (33MB ring), taking any unrelated forensic record with it.
  # `read` only — trace/write stay denied.
  ptrace (read),

  # Host tooling runs UNCONFINED. This was a hand-written 12-entry allowlist until
  # 2026-08-06; live audit data on app.mspcorex.com retired that approach on evidence,
  # not on taste:
  #   * the agent spawns ~65 distinct binaries across its crates and the list named 11.
  #     Every other one minted a `//null-<path>` child profile — an exec enforce refuses.
  #     Among the first 15 observed: ip, ufw, docker, snap, smartctl, lspci, tune2fs,
  #     findmnt, rsync, loginctl, aa-status, dpkg-query.
  #   * the paths are not guessable. Ubuntu 26.04 ships uutils, so `uname` lives at
  #     /usr/lib/cargo/bin/coreutils/uname, and `which` is /usr/bin/which.debianutils.
  #     Two of those first fifteen had a path no reviewer would have predicted.
  #   * on merged-/usr (Debian 12+, Ubuntu 22.04+) the kernel resolves /bin/X to
  #     /usr/bin/X, so the old `/bin/sh` and `/bin/bash` entries never matched anything.
  #     Someone already hit this for systemctl and doubled that one rule; the shells were
  #     missed — meaning script dispatch and the remote terminal, the two features the
  #     header cites as the reason for Ux, were leaning on rules that never fired.
  #   * the old list also granted /usr/bin/dpkg, which no crate ever spawns, while
  #     dpkg-query (4 call sites) had no rule — the one exec grant that was dead weight
  #     shadowed the tool actually used.
  # Globbing the system bin dirs is what the header already claims this profile does
  # ("these are the OS's tools"), and it cannot rot. It does not widen the real boundary:
  # running host tooling as root IS the product, and the bounds that matter — the
  # capability list, mount, ptrace-write, module load — are expressed above and are
  # unaffected by which helper binary may exec.
  /{,usr/}{,s}bin/* Ux,
  /usr/local/{,s}bin/* Ux,
  /usr/lib/cargo/bin/coreutils/* Ux,
  /opt/sophos-av/bin/savdstatus Ux,
}
