1556 lines
62 KiB
Python
Executable File
1556 lines
62 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
SysDeck - Policy & Permissions Bridge
|
|
Author: Jeremy Anderson (https://dcos.net)
|
|
|
|
v0.0.32 NEW MODULE — modern policy management and permissions
|
|
manager for groups. The user directive:
|
|
|
|
"modern policy management and permissions manager for groups.
|
|
such as acl, cgroups, vlans, ebpf namespace separation and
|
|
related policies. we can skip selinux its native. we can
|
|
implement apparmor but its not default on my machine so make
|
|
it optional for sure."
|
|
|
|
This bridge surfaces five concerns, each with its own subcommand
|
|
namespace. SELinux is skipped (the user has it natively). AppArmor
|
|
is optional — the bridge auto-detects whether it is compiled into
|
|
the kernel and active; if not, the apparmor subcommands return
|
|
{available: false, reason: "..."} and the panel renders an install
|
|
hint instead of an empty table.
|
|
|
|
Subcommands:
|
|
summary — one-shot overview of all five concerns
|
|
acl-list <path> — list POSIX ACLs on a file/dir (getfacl -p)
|
|
acl-set <path> <entry> — add/replace one ACL entry (setfacl -m)
|
|
acl-remove <path> <entry> — remove one ACL entry (setfacl -x)
|
|
acl-default <path> <entry> — set default ACL for new files in dir
|
|
cgroup-list — list cgroups v2 unified hierarchy
|
|
cgroup-show <path> — show one cgroup's controllers + processes
|
|
cgroup-procs <path> — list PIDs in a cgroup
|
|
cgroup-create <path> — mkdir a new cgroup
|
|
cgroup-move <pid> <path> — write a PID into a cgroup's cgroup.procs
|
|
cgroup-set <path> <ctrl> <value> — write a control file
|
|
vlan-list — list VLANs on host interfaces (ip -d link)
|
|
vlan-show <iface> — show one interface's VLAN info
|
|
vlan-create <iface> <vid> — create a VLAN on an interface
|
|
vlan-delete <iface> <vid> — delete a VLAN
|
|
ebpf-list — list loaded BPF programs (bpftool prog show)
|
|
ebpf-show <id> — show one BPF program's metadata + maps
|
|
ebpf-maps — list BPF maps (bpftool map show)
|
|
ebpf-pin <id> <path> — pin a BPF program to a bpffs path
|
|
ns-list — list namespaces on the host (lsns)
|
|
ns-show <nsid> — show one namespace's processes
|
|
apparmor-status — show AppArmor enforcement state (aa-status)
|
|
apparmor-profiles — list loaded AppArmor profiles
|
|
apparmor-enforce <profile> — switch a profile to enforce mode
|
|
apparmor-complain <profile> — switch a profile to complain mode
|
|
|
|
Cockpit way (per v0.0.31 pattern): mutating ops run via subprocess
|
|
in this bridge; the JS panel passes { superuser: 'try' } to
|
|
cockpit.spawn so the cockpit bridge prompts the operator via polkit
|
|
for the org.sysdeck.policy.modify action (added in this release —
|
|
authorizes /usr/bin/setfacl, /usr/bin/getfacl, /bin/mkdir, /bin/mount,
|
|
/usr/bin/ip, /usr/sbin/ip, /usr/bin/vlan, /usr/bin/bpftool,
|
|
/usr/sbin/bpftool, /usr/bin/aa-enforce, /usr/bin/aa-complain,
|
|
/usr/bin/lsns). No `sudo` shell-out from JS — the bridge runs as
|
|
the cockpit user and gets root privileges via polkit when the
|
|
operator authenticates.
|
|
|
|
Usage:
|
|
python3 /usr/lib/sysdeck/bridge/policy.py summary
|
|
python3 /usr/lib/sysdeck/bridge/policy.py acl-list /var/www
|
|
python3 /usr/lib/sysdeck/bridge/policy.py cgroup-list
|
|
python3 /usr/lib/sysdeck/bridge/policy.py vlan-list
|
|
python3 /usr/lib/sysdeck/bridge/policy.py ebpf-list
|
|
python3 /usr/lib/sysdeck/bridge/policy.py apparmor-status
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
# ── Subprocess helper ────────────────────────────────────────────────
|
|
#
|
|
# _run() never raises — every caller gets (rc, stdout, stderr) back.
|
|
# Failures are surfaced in the JSON response so the JS panel can show
|
|
# the operator what went wrong rather than the cockpit spawn channel
|
|
# throwing an unhandled exception.
|
|
|
|
|
|
def _run(cmd: list[str], timeout: int = 15) -> tuple[int, str, str]:
|
|
"""Run cmd, return (rc, stdout, stderr). Never raises."""
|
|
try:
|
|
r = subprocess.run(
|
|
cmd, capture_output=True, text=True, check=False, timeout=timeout,
|
|
)
|
|
return r.returncode, r.stdout or "", r.stderr or ""
|
|
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
|
|
return 127, "", str(exc)
|
|
|
|
|
|
def _have(binary: str) -> bool:
|
|
"""True if binary is on PATH."""
|
|
return shutil.which(binary) is not None
|
|
|
|
|
|
# ── ACLs ─────────────────────────────────────────────────────────────
|
|
#
|
|
# POSIX ACLs extend the traditional unix permission model with
|
|
# per-user and per-group entries. getfacl/setfacl are the standard
|
|
# tools (provided by the 'acl' package on Arch/Debian). On most
|
|
# modern filesystems (ext4, xfs, btrfs) ACLs are enabled by default.
|
|
#
|
|
# The bridge deliberately does NOT validate paths — setfacl will
|
|
# reject bad paths with a clear error message, which the bridge
|
|
# surfaces in the JSON response. Operators are responsible for
|
|
# typing correct paths; the panel can offer a path picker for
|
|
# common directories (/var/www, /home, /etc, etc.) but the bridge
|
|
# treats paths as opaque strings.
|
|
|
|
ACL_ENTRY_RE = re.compile(
|
|
r"^(?P<kind>default:)?(?P<who>user|group|other|mask):(?:[^:]+:)?" # default: | user: | group: | other: | mask:
|
|
r"(?P<perms>[r-][w-][x-])$"
|
|
)
|
|
|
|
|
|
def _acl_binary() -> str:
|
|
"""Return 'getfacl' / 'setfacl' if available, else ''."""
|
|
return shutil.which("getfacl") or shutil.which("setfacl") or ""
|
|
|
|
|
|
def cmd_acl_list(args: list[str]) -> dict[str, Any]:
|
|
"""List POSIX ACLs on a path (getfacl -p <path>)."""
|
|
if not args:
|
|
return {"error": "path required"}
|
|
path = args[0]
|
|
if not _have("getfacl"):
|
|
return {"available": False, "reason": "getfacl not installed",
|
|
"install": "pacman -S acl # Arch\\napart install acl # Debian"}
|
|
if not os.path.exists(path):
|
|
return {"error": f"path {path} does not exist"}
|
|
rc, out, err = _run(["getfacl", "-p", path])
|
|
# Parse the output into structured entries.
|
|
entries: list[dict[str, str]] = []
|
|
base_perms = ""
|
|
for line in out.splitlines():
|
|
if not line or line.startswith("#"):
|
|
# Capture the file-level perms as a separate field.
|
|
if line.startswith("# file:"):
|
|
pass
|
|
elif line.startswith("# owner:"):
|
|
base_perms += f"owner={line.split(':', 1)[1].strip()} "
|
|
elif line.startswith("# group:"):
|
|
base_perms += f"group={line.split(':', 1)[1].strip()} "
|
|
continue
|
|
# Real ACL entry: user:foo:rwx, group:bar:r-x, mask::rwx, other::r--,
|
|
# default:user:foo:rwx
|
|
parts = line.split(":")
|
|
if len(parts) >= 3:
|
|
entry = {
|
|
"raw": line,
|
|
"default": parts[0] == "default",
|
|
"kind": parts[1] if parts[0] != "default" else parts[1],
|
|
"name": parts[2] if len(parts) > 3 else "",
|
|
"perms": parts[-1],
|
|
}
|
|
entries.append(entry)
|
|
return {
|
|
"available": True,
|
|
"path": path,
|
|
"entries": entries,
|
|
"base": base_perms.strip(),
|
|
"raw": out,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
def cmd_acl_set(args: list[str]) -> dict[str, Any]:
|
|
"""Add or replace an ACL entry on a path (setfacl -m)."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: acl-set <path> <entry> (e.g. acl-set /var/www group:www-data:rwx)"}
|
|
path, entry = args[0], args[1]
|
|
if not _have("setfacl"):
|
|
return {"available": False, "reason": "setfacl not installed"}
|
|
if not os.path.exists(path):
|
|
return {"error": f"path {path} does not exist"}
|
|
rc, out, err = _run(["setfacl", "-m", entry, path])
|
|
return {
|
|
"applied": rc == 0,
|
|
"path": path,
|
|
"entry": entry,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
def cmd_acl_remove(args: list[str]) -> dict[str, Any]:
|
|
"""Remove an ACL entry from a path (setfacl -x)."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: acl-remove <path> <entry>"}
|
|
path, entry = args[0], args[1]
|
|
if not _have("setfacl"):
|
|
return {"available": False, "reason": "setfacl not installed"}
|
|
rc, _, err = _run(["setfacl", "-x", entry, path])
|
|
return {
|
|
"removed": rc == 0,
|
|
"path": path,
|
|
"entry": entry,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
def cmd_acl_default(args: list[str]) -> dict[str, Any]:
|
|
"""Set a default ACL on a directory (setfacl -d -m).
|
|
|
|
Default ACLs are inherited by new files in the directory.
|
|
"""
|
|
if len(args) < 2:
|
|
return {"error": "usage: acl-default <path> <entry> (e.g. acl-default /var/www group:www-data:r-x)"}
|
|
path, entry = args[0], args[1]
|
|
if not _have("setfacl"):
|
|
return {"available": False, "reason": "setfacl not installed"}
|
|
if not os.path.isdir(path):
|
|
return {"error": f"path {path} is not a directory (default ACLs require a directory)"}
|
|
rc, _, err = _run(["setfacl", "-d", "-m", entry, path])
|
|
return {
|
|
"applied": rc == 0,
|
|
"path": path,
|
|
"entry": entry,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
# ── cgroups v2 unified hierarchy ──────────────────────────────────
|
|
#
|
|
# cgroups v2 (the unified hierarchy) lives at /sys/fs/cgroup/. Each
|
|
# cgroup is a directory containing cgroup.procs, cgroup.controllers,
|
|
# and per-controller files (memory.max, cpu.weight, io.max, etc.).
|
|
#
|
|
# v0.0.32 expects the host to be running cgroups v2 (the default on
|
|
# Arch, Debian 12+, and Fedora 31+). If the host is on v1 only, the
|
|
# cgroup-list subcommand returns {available: false, reason: "cgroups
|
|
# v2 not mounted at /sys/fs/cgroup/"}.
|
|
|
|
|
|
CGROUP_ROOT = Path("/sys/fs/cgroup")
|
|
|
|
|
|
def _cgroup_path_ok(path: Path) -> bool:
|
|
"""v0.1.4 SECURITY: True if (resolved) path stays inside the unified
|
|
cgroup hierarchy. cmd_cgroup_create has always enforced a prefix
|
|
check, but the cgroup-* siblings didn't — cgroup-set wrote to
|
|
`<any-path>/<control-file>` as root (arbitrary file overwrite:
|
|
`cgroup-set /etc/cron.d x '* * * * * root curl ...'` was a one-prompt
|
|
persistent-root primitive; found by the 0.3.0 security audit). All
|
|
cgroup subcommands now resolve + bound-check the same way."""
|
|
try:
|
|
path.resolve().relative_to(CGROUP_ROOT.resolve())
|
|
return True
|
|
except (ValueError, RuntimeError, OSError):
|
|
return False
|
|
|
|
|
|
# v0.1.4 SECURITY: cgroup control files are a closed vocabulary (cgroup.*
|
|
# + controller knobs). Restricting the filename to this shape blocks
|
|
# using cgroup-set as an arbitrary-named file writer.
|
|
_CGROUP_CTRL_RE = re.compile(r"^(cgroup\.(procs|controllers|subtree_control|type|freeze|kill)|"
|
|
r"(memory|cpu|io|pids|rdma|misc|hugetlb)\.[A-Za-z0-9_.-]{1,32})$")
|
|
|
|
|
|
def _cgroup_v2_available() -> bool:
|
|
"""True if /sys/fs/cgroup/ is a cgroups v2 unified hierarchy."""
|
|
return (CGROUP_ROOT / "cgroup.controllers").is_file()
|
|
|
|
|
|
def _walk_cgroups(root: Path, max_depth: int = 3, _depth: int = 0) -> list[dict[str, Any]]:
|
|
"""Walk cgroup tree under root, return list of {path, controllers, procs}."""
|
|
out: list[dict[str, Any]] = []
|
|
if not root.is_dir():
|
|
return out
|
|
controllers_file = root / "cgroup.controllers"
|
|
controllers = ""
|
|
if controllers_file.is_file():
|
|
try:
|
|
controllers = controllers_file.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError):
|
|
pass
|
|
procs_file = root / "cgroup.procs"
|
|
proc_count = 0
|
|
if procs_file.is_file():
|
|
try:
|
|
proc_count = sum(1 for _ in procs_file.read_text(encoding="utf-8").splitlines() if _.strip())
|
|
except (PermissionError, OSError):
|
|
pass
|
|
subtree = ""
|
|
subtree_file = root / "cgroup.subtree_control"
|
|
if subtree_file.is_file():
|
|
try:
|
|
subtree = subtree_file.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError):
|
|
pass
|
|
out.append({
|
|
"path": str(root),
|
|
"name": root.name or "/",
|
|
"controllers": controllers,
|
|
"subtree_control": subtree,
|
|
"proc_count": proc_count,
|
|
"depth": _depth,
|
|
})
|
|
if _depth >= max_depth:
|
|
return out
|
|
for child in sorted(root.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
if child.name.startswith("cgroup."):
|
|
continue
|
|
out.extend(_walk_cgroups(child, max_depth, _depth + 1))
|
|
return out
|
|
|
|
|
|
def cmd_cgroup_list(_args: list[str]) -> dict[str, Any]:
|
|
"""List cgroups v2 unified hierarchy."""
|
|
if not _cgroup_v2_available():
|
|
return {
|
|
"available": False,
|
|
"reason": "cgroups v2 not mounted at /sys/fs/cgroup/",
|
|
"hint": "modern Arch/Debian/Fedora systems default to cgroups v2.",
|
|
}
|
|
return {
|
|
"available": True,
|
|
"root": str(CGROUP_ROOT),
|
|
"cgroups": _walk_cgroups(CGROUP_ROOT, max_depth=3),
|
|
}
|
|
|
|
|
|
def cmd_cgroup_show(args: list[str]) -> dict[str, Any]:
|
|
"""Show one cgroup's controllers + processes."""
|
|
if not args:
|
|
return {"error": "cgroup path required"}
|
|
path = Path(args[0])
|
|
# v0.1.4 SECURITY: cgroup paths are client-supplied; every cgroup
|
|
# subcommand must keep them inside the unified hierarchy — see the
|
|
# _cgroup_path_ok guard comment below cmd_cgroup_create.
|
|
if not _cgroup_path_ok(path):
|
|
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
|
|
if not path.is_dir():
|
|
return {"error": f"{path} is not a directory"}
|
|
info: dict[str, Any] = {"path": str(path), "name": path.name}
|
|
for fname in ("cgroup.controllers", "cgroup.subtree_control",
|
|
"cgroup.procs", "memory.max", "memory.current",
|
|
"cpu.weight", "cpu.max", "io.max", "pids.max", "pids.current"):
|
|
f = path / fname
|
|
if f.is_file():
|
|
try:
|
|
val = f.read_text(encoding="utf-8").strip()
|
|
info[fname.replace(".", "_")] = val
|
|
except (PermissionError, OSError):
|
|
pass
|
|
# List processes (first 50 to cap response size).
|
|
procs_file = path / "cgroup.procs"
|
|
procs: list[dict[str, str]] = []
|
|
if procs_file.is_file():
|
|
try:
|
|
for line in procs_file.read_text(encoding="utf-8").splitlines():
|
|
pid = line.strip()
|
|
if not pid:
|
|
continue
|
|
# Get process name via /proc/<pid>/comm
|
|
try:
|
|
comm = (Path("/proc") / pid / "comm").read_text(encoding="utf-8").strip()
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
comm = ""
|
|
procs.append({"pid": pid, "comm": comm})
|
|
if len(procs) >= 50:
|
|
break
|
|
except (PermissionError, OSError):
|
|
pass
|
|
info["processes"] = procs
|
|
info["process_count_truncated"] = len(procs) >= 50
|
|
return info
|
|
|
|
|
|
def cmd_cgroup_procs(args: list[str]) -> dict[str, Any]:
|
|
"""List PIDs in a cgroup (just the PIDs, no metadata)."""
|
|
if not args:
|
|
return {"error": "cgroup path required"}
|
|
if not _cgroup_path_ok(Path(args[0])):
|
|
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
|
|
procs_file = Path(args[0]) / "cgroup.procs"
|
|
if not procs_file.is_file():
|
|
return {"error": f"{procs_file} not found"}
|
|
try:
|
|
pids = [p.strip() for p in procs_file.read_text(encoding="utf-8").splitlines() if p.strip()]
|
|
except (PermissionError, OSError) as exc:
|
|
return {"error": str(exc)}
|
|
return {"path": args[0], "pids": pids, "count": len(pids)}
|
|
|
|
|
|
def cmd_cgroup_create(args: list[str]) -> dict[str, Any]:
|
|
"""Create a new cgroup by mkdir."""
|
|
if not args:
|
|
return {"error": "cgroup path required"}
|
|
path = Path(args[0])
|
|
if not _cgroup_v2_available():
|
|
return {"available": False, "reason": "cgroups v2 not mounted"}
|
|
# v0.1.4 SECURITY: upgraded from a str().startswith() prefix check
|
|
# (which "/sys/fs/cgroup-evil" would satisfy) to resolve + bound.
|
|
if not _cgroup_path_ok(path):
|
|
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
|
|
try:
|
|
path.mkdir(parents=True, exist_ok=False)
|
|
return {"created": True, "path": str(path)}
|
|
except FileExistsError:
|
|
return {"created": False, "error": f"{path} already exists"}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"created": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.policy.modify)"}
|
|
|
|
|
|
def cmd_cgroup_move(args: list[str]) -> dict[str, Any]:
|
|
"""Move a PID into a cgroup (write to cgroup.procs)."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: cgroup-move <pid> <cgroup-path>"}
|
|
pid, cgrp = args[0], args[1]
|
|
if not _cgroup_path_ok(Path(cgrp)):
|
|
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
|
|
procs_file = Path(cgrp) / "cgroup.procs"
|
|
if not procs_file.is_file():
|
|
return {"error": f"{procs_file} not found"}
|
|
try:
|
|
with procs_file.open("a", encoding="utf-8") as fh:
|
|
fh.write(pid + "\n")
|
|
return {"moved": True, "pid": pid, "cgroup": cgrp}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"moved": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.policy.modify)"}
|
|
|
|
|
|
def cmd_cgroup_set(args: list[str]) -> dict[str, Any]:
|
|
"""Write a value to a cgroup control file."""
|
|
if len(args) < 3:
|
|
return {"error": "usage: cgroup-set <path> <control-file> <value>"}
|
|
path, control, value = args[0], args[1], args[2]
|
|
# v0.1.4 SECURITY: this command writes as root. Both halves of the
|
|
# target are client-supplied, so BOTH are validated: the cgroup path
|
|
# must resolve under /sys/fs/cgroup (was missing — arbitrary root
|
|
# file overwrite, see _cgroup_path_ok) and the control file must be
|
|
# a real cgroup controller knob name, not a traversal/probe.
|
|
if not _cgroup_path_ok(Path(path)):
|
|
return {"error": f"cgroup path must be under {CGROUP_ROOT}"}
|
|
if not _CGROUP_CTRL_RE.match(control):
|
|
return {"error": f"'{control}' is not a cgroup control file name "
|
|
"(expected e.g. memory.max, cpu.weight, cgroup.procs)"}
|
|
# control is a filename like 'memory.max' or 'cpu.weight'
|
|
target = Path(path) / control
|
|
if not target.parent.is_dir():
|
|
return {"error": f"cgroup {path} does not exist"}
|
|
try:
|
|
target.write_text(value, encoding="utf-8")
|
|
return {"set": True, "path": str(target), "value": value}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"set": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.policy.modify)"}
|
|
|
|
|
|
# ── VLANs ────────────────────────────────────────────────────────────
|
|
#
|
|
# VLANs are managed via the `ip` command (iproute2). The bridge uses
|
|
# `ip -d link show` to enumerate, `ip link add link <iface> name
|
|
# <iface>.<vid> type vlan id <vid>` to create, and `ip link del` to
|
|
# delete. iproute2 is universally installed on Arch/Debian/Fedora.
|
|
|
|
VLAN_RE = re.compile(r"vlan id (\d+) .* protocol (\S+)")
|
|
|
|
|
|
def cmd_vlan_list(_args: list[str]) -> dict[str, Any]:
|
|
"""List VLANs on host interfaces (ip -d link show)."""
|
|
if not _have("ip"):
|
|
return {"available": False, "reason": "ip (iproute2) not installed"}
|
|
rc, out, _ = _run(["ip", "-d", "link", "show"])
|
|
vlans: list[dict[str, Any]] = []
|
|
current_iface = ""
|
|
for line in out.splitlines():
|
|
m = re.match(r"^\d+:\s+(\S+):", line)
|
|
if m:
|
|
current_iface = m.group(1).rstrip("@")
|
|
# Check if this line itself contains vlan info.
|
|
if "vlan id" in line:
|
|
vm = VLAN_RE.search(line)
|
|
if vm:
|
|
vlans.append({"interface": current_iface, "vid": int(vm.group(1)),
|
|
"protocol": vm.group(2)})
|
|
elif "vlan id" in line and current_iface:
|
|
vm = VLAN_RE.search(line)
|
|
if vm:
|
|
vlans.append({"interface": current_iface, "vid": int(vm.group(1)),
|
|
"protocol": vm.group(2)})
|
|
return {
|
|
"available": True,
|
|
"vlans": vlans,
|
|
"count": len(vlans),
|
|
}
|
|
|
|
|
|
def cmd_vlan_show(args: list[str]) -> dict[str, Any]:
|
|
"""Show one interface's VLAN info."""
|
|
if not args:
|
|
return {"error": "interface name required"}
|
|
iface = args[0]
|
|
if not _have("ip"):
|
|
return {"available": False, "reason": "ip (iproute2) not installed"}
|
|
rc, out, _ = _run(["ip", "-d", "link", "show", iface])
|
|
return {
|
|
"interface": iface,
|
|
"raw": out,
|
|
"is_vlan": "vlan id" in out,
|
|
}
|
|
|
|
|
|
def cmd_vlan_create(args: list[str]) -> dict[str, Any]:
|
|
"""Create a VLAN on an interface."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: vlan-create <iface> <vid>"}
|
|
iface, vid = args[0], args[1]
|
|
if not _have("ip"):
|
|
return {"available": False, "reason": "ip (iproute2) not installed"}
|
|
# Validate vid is 1-4094
|
|
try:
|
|
vid_n = int(vid)
|
|
if vid_n < 1 or vid_n > 4094:
|
|
return {"error": f"vid {vid} out of range (1-4094)"}
|
|
except ValueError:
|
|
return {"error": f"vid {vid} is not numeric"}
|
|
subif = f"{iface}.{vid}"
|
|
rc, _, err = _run(["ip", "link", "add", "link", iface, "name", subif,
|
|
"type", "vlan", "id", vid])
|
|
if rc != 0:
|
|
return {"created": False, "rc": rc, "stderr": err.strip(),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.policy.modify)"}
|
|
# Bring up the sub-interface.
|
|
_run(["ip", "link", "set", subif, "up"])
|
|
return {"created": True, "interface": subif, "parent": iface, "vid": vid_n}
|
|
|
|
|
|
def cmd_vlan_delete(args: list[str]) -> dict[str, Any]:
|
|
"""Delete a VLAN sub-interface."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: vlan-delete <iface> <vid>"}
|
|
iface, vid = args[0], args[1]
|
|
if not _have("ip"):
|
|
return {"available": False, "reason": "ip (iproute2) not installed"}
|
|
subif = f"{iface}.{vid}"
|
|
rc, _, err = _run(["ip", "link", "del", subif])
|
|
return {
|
|
"deleted": rc == 0,
|
|
"interface": subif,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
# ── eBPF + namespaces ───────────────────────────────────────────────
|
|
#
|
|
# eBPF programs are managed via bpftool (provided by 'bpftool' on Arch
|
|
# and 'linux-tools' / 'linux-tools-common' on Debian). Namespaces are
|
|
# enumerated via lsns (util-linux — universally installed).
|
|
|
|
def cmd_ebpf_list(_args: list[str]) -> dict[str, Any]:
|
|
"""List loaded BPF programs (bpftool prog show -j)."""
|
|
bpftool = shutil.which("bpftool")
|
|
if not bpftool:
|
|
return {"available": False, "reason": "bpftool not installed",
|
|
"install": "pacman -S bpftool # Arch\\napart install linux-tools-common # Debian"}
|
|
# Try JSON output first (more structured).
|
|
rc, out, err = _run([bpftool, "prog", "show", "-j"])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
progs = json.loads(out)
|
|
return {"available": True, "programs": progs, "format": "json"}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Fall back to plain text.
|
|
rc2, out2, _ = _run([bpftool, "prog", "show"])
|
|
return {"available": True, "raw": out2, "format": "text"}
|
|
|
|
|
|
def cmd_ebpf_show(args: list[str]) -> dict[str, Any]:
|
|
"""Show one BPF program's metadata + maps."""
|
|
if not args:
|
|
return {"error": "program id required"}
|
|
pid = args[0]
|
|
bpftool = shutil.which("bpftool")
|
|
if not bpftool:
|
|
return {"available": False, "reason": "bpftool not installed"}
|
|
rc, out, _ = _run([bpftool, "prog", "show", "id", pid, "-j"])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
info = json.loads(out)
|
|
# Also fetch the program's maps.
|
|
rc2, out2, _ = _run([bpftool, "prog", "show", "id", pid, "-m"])
|
|
return {"available": True, "info": info, "maps_raw": out2}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Fall back to plain text.
|
|
rc2, out2, _ = _run([bpftool, "prog", "show", "id", pid])
|
|
return {"available": True, "raw": out2, "id": pid}
|
|
|
|
|
|
def cmd_ebpf_maps(_args: list[str]) -> dict[str, Any]:
|
|
"""List BPF maps (bpftool map show -j)."""
|
|
bpftool = shutil.which("bpftool")
|
|
if not bpftool:
|
|
return {"available": False, "reason": "bpftool not installed"}
|
|
rc, out, _ = _run([bpftool, "map", "show", "-j"])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
maps = json.loads(out)
|
|
return {"available": True, "maps": maps, "format": "json"}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
rc2, out2, _ = _run([bpftool, "map", "show"])
|
|
return {"available": True, "raw": out2, "format": "text"}
|
|
|
|
|
|
def cmd_ebpf_pin(args: list[str]) -> dict[str, Any]:
|
|
"""Pin a BPF program to a bpffs path (bpftool prog pin)."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: ebpf-pin <prog-id> <path>"}
|
|
prog_id, pin_path = args[0], args[1]
|
|
bpftool = shutil.which("bpftool")
|
|
if not bpftool:
|
|
return {"available": False, "reason": "bpftool not installed"}
|
|
# bpffs typically mounted at /sys/fs/bpf/.
|
|
rc, _, err = _run([bpftool, "prog", "pin", "id", prog_id, pin_path])
|
|
return {
|
|
"pinned": rc == 0,
|
|
"prog_id": prog_id,
|
|
"path": pin_path,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
def cmd_ns_list(_args: list[str]) -> dict[str, Any]:
|
|
"""List namespaces on the host (lsns -J)."""
|
|
lsns = shutil.which("lsns")
|
|
if not lsns:
|
|
return {"available": False, "reason": "lsns (util-linux) not installed"}
|
|
rc, out, _ = _run([lsns, "-J"])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
data = json.loads(out)
|
|
return {"available": True, "namespaces": data.get("namespaces", []),
|
|
"format": "json"}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
rc2, out2, _ = _run([lsns])
|
|
return {"available": True, "raw": out2, "format": "text"}
|
|
|
|
|
|
def cmd_ns_show(args: list[str]) -> dict[str, Any]:
|
|
"""Show one namespace's processes (lsns -p <nsid>)."""
|
|
if not args:
|
|
return {"error": "namespace id required"}
|
|
nsid = args[0]
|
|
lsns = shutil.which("lsns")
|
|
if not lsns:
|
|
return {"available": False, "reason": "lsns (util-linux) not installed"}
|
|
rc, out, _ = _run([lsns, "-J", "-t", nsid])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
data = json.loads(out)
|
|
return {"available": True, "info": data, "format": "json"}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
rc2, out2, _ = _run([lsns, "-t", nsid])
|
|
return {"available": True, "raw": out2, "format": "text"}
|
|
|
|
|
|
# ── AppArmor (optional) ──────────────────────────────────────────────
|
|
#
|
|
# AppArmor is a Linux MAC (mandatory access control) system. The
|
|
# user said "we can implement apparmor but its not default on my
|
|
# machine so make it optional for sure." The bridge auto-detects
|
|
# AppArmor via /sys/kernel/security/apparmor/. If the directory
|
|
# doesn't exist, apparmor-status returns {available: false, reason:
|
|
# "AppArmor not compiled into kernel"} and the panel renders an
|
|
# install hint. aa-status is the canonical probe; aa-enforce and
|
|
# aa-complain switch profile modes.
|
|
|
|
|
|
def _apparmor_available() -> bool:
|
|
"""True if AppArmor is compiled into the kernel and active."""
|
|
return Path("/sys/kernel/security/apparmor").is_dir()
|
|
|
|
|
|
def cmd_apparmor_status(_args: list[str]) -> dict[str, Any]:
|
|
"""Show AppArmor enforcement state (aa-status --json)."""
|
|
if not _apparmor_available():
|
|
return {
|
|
"available": False,
|
|
"reason": "AppArmor not compiled into kernel (or securityfs not mounted)",
|
|
"install_arch": "AppArmor is built into the kernel; the 'apparmor' package provides userspace tools (aa-status, aa-enforce, aa-complain)",
|
|
"install_debian": "apt install apparmor apparmor-utils",
|
|
"note": "AppArmor is optional in SysDeck — the panel renders an install hint when absent.",
|
|
}
|
|
aa_status = shutil.which("aa-status")
|
|
if not aa_status:
|
|
return {
|
|
"available": True,
|
|
"kernel_active": True,
|
|
"reason": "AppArmor kernel is active but aa-status not installed (install apparmor-utils)",
|
|
"install_arch": "pacman -S apparmor",
|
|
"install_debian": "apt install apparmor-utils",
|
|
}
|
|
rc, out, err = _run([aa_status, "--json"])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
return {"available": True, "kernel_active": True, "status": json.loads(out)}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
# Fall back to plain text.
|
|
rc2, out2, _ = _run([aa_status])
|
|
return {"available": True, "kernel_active": True, "raw": out2}
|
|
|
|
|
|
def cmd_apparmor_profiles(_args: list[str]) -> dict[str, Any]:
|
|
"""List loaded AppArmor profiles."""
|
|
if not _apparmor_available():
|
|
return {"available": False, "reason": "AppArmor not compiled into kernel"}
|
|
aa_status = shutil.which("aa-status")
|
|
if not aa_status:
|
|
return {"available": True, "reason": "aa-status not installed"}
|
|
rc, out, _ = _run([aa_status, "--json"])
|
|
if rc == 0 and out.strip():
|
|
try:
|
|
data = json.loads(out)
|
|
profiles = data.get("profiles", {})
|
|
return {"available": True, "profiles": profiles}
|
|
except json.JSONDecodeError:
|
|
pass
|
|
rc2, out2, _ = _run([aa_status, "--profiled"])
|
|
return {"available": True, "raw": out2}
|
|
|
|
|
|
def cmd_apparmor_enforce(args: list[str]) -> dict[str, Any]:
|
|
"""Switch a profile to enforce mode."""
|
|
if not args:
|
|
return {"error": "profile name required"}
|
|
profile = args[0]
|
|
if not _apparmor_available():
|
|
return {"available": False, "reason": "AppArmor not compiled into kernel"}
|
|
aa_enforce = shutil.which("aa-enforce")
|
|
if not aa_enforce:
|
|
return {"available": True, "reason": "aa-enforce not installed (install apparmor-utils)"}
|
|
rc, out, err = _run([aa_enforce, profile])
|
|
return {
|
|
"enforced": rc == 0,
|
|
"profile": profile,
|
|
"rc": rc,
|
|
"output": out.strip(),
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
def cmd_apparmor_complain(args: list[str]) -> dict[str, Any]:
|
|
"""Switch a profile to complain mode."""
|
|
if not args:
|
|
return {"error": "profile name required"}
|
|
profile = args[0]
|
|
if not _apparmor_available():
|
|
return {"available": False, "reason": "AppArmor not compiled into kernel"}
|
|
aa_complain = shutil.which("aa-complain")
|
|
if not aa_complain:
|
|
return {"available": True, "reason": "aa-complain not installed (install apparmor-utils)"}
|
|
rc, out, err = _run([aa_complain, profile])
|
|
return {
|
|
"complain": rc == 0,
|
|
"profile": profile,
|
|
"rc": rc,
|
|
"output": out.strip(),
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
# ── Linux Security Modules (LSMs) — beyond AppArmor ───────────────
|
|
#
|
|
# v0.0.33 expands the policy module with the rest of the modern Linux
|
|
# LSM stack per user directive: "lets now add smack, tomoyo, yama and
|
|
# others as well to the same policy module."
|
|
#
|
|
# The kernel exposes the active LSM stack at /sys/kernel/security/lsm
|
|
# as a comma-separated list (e.g. "lockdown,capability,yama,...").
|
|
# Each LSM has its own management surface:
|
|
#
|
|
# Smack /sys/kernel/security/smack/ + userspace tools
|
|
# (smackload, smackcipsos, smackcipso) — Tizen / IoT
|
|
# Tomoyo /sys/kernel/security/tomoyo/ + userspace (tomoyo-* )
|
|
# Yama /proc/sys/kernel/yama/ptrace_scope (read+write)
|
|
# LoadPin /sys/kernel/security/loadpin/ — no userspace config
|
|
# Lockdown /sys/kernel/security/lockdown — UEFI secure boot mode
|
|
# BPF-LSM the bpf LSM hook stack; programs visible via bpftool
|
|
# Landlock /sys/kernel/security/landlock/ + libcapLandlock rules
|
|
#
|
|
# File capabilities (setcap/getcap) are managed separately — they
|
|
# predate the LSM stack but compose with it for fine-grained
|
|
# privilege delegation. They are part of the "policy & permissions"
|
|
# umbrella even though they are not strictly an LSM.
|
|
|
|
SECURITY_FS = Path("/sys/kernel/security")
|
|
LSM_LIST_FILE = SECURITY_FS / "lsm"
|
|
|
|
# Lookup table for the per-LSM kernel probe — each entry is
|
|
# (id, pretty_name, sub_dir_under_security_fs). Each probe just checks
|
|
# whether the directory exists. The user-facing summary iterates over
|
|
# this table; the per-LSM command functions do the deeper probing.
|
|
#
|
|
# PEP 868: dict-of-tuples is a typed, static lookup; replacing nested
|
|
# ifs with this table keeps cyclomatic complexity low and makes
|
|
# adding a new LSM a one-line change.
|
|
LSM_PROBES: list[tuple[str, str, str]] = [
|
|
("smack", "Smack", "smack"),
|
|
("tomoyo", "TOMOYO", "tomoyo"),
|
|
("yama", "Yama", "yama"), # also /proc/sys/kernel/yama
|
|
("loadpin", "LoadPin", "loadpin"),
|
|
("lockdown", "Lockdown", "lockdown"),
|
|
("landlock", "Landlock", "landlock"),
|
|
("bpf", "BPF-LSM", "bpf"), # programs via bpftool
|
|
("apparmor", "AppArmor", "apparmor"),
|
|
("capability","Capabilities", "capability"),
|
|
]
|
|
|
|
|
|
def _read_lsm_stack() -> list[str]:
|
|
"""Read /sys/kernel/security/lsm and return the active LSM list.
|
|
|
|
The file is a comma-separated list of LSM names in the order the
|
|
kernel stacked them. Returns [] if the file is unreadable (older
|
|
kernels or securityfs not mounted).
|
|
"""
|
|
try:
|
|
return [s for s in LSM_LIST_FILE.read_text(encoding="utf-8").strip().split(",") if s]
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
return []
|
|
|
|
|
|
def _lsm_dir_active(sub: str) -> bool:
|
|
"""True if the LSM's securityfs directory exists."""
|
|
return (SECURITY_FS / sub).is_dir()
|
|
|
|
|
|
# ── LSM stack summary ────────────────────────────────────────────────
|
|
|
|
|
|
def cmd_lsm_status(_args: list[str]) -> dict[str, Any]:
|
|
"""Top-level LSM framework status — what is the kernel stacking?
|
|
|
|
Reads /sys/kernel/security/lsm and cross-references the per-LSM
|
|
probes. Each entry is {id, name, active_in_kernel, dir_present}.
|
|
The panel renders this as the LSM capability matrix.
|
|
"""
|
|
stack = _read_lsm_stack()
|
|
entries = [
|
|
{
|
|
"id": lsm_id,
|
|
"name": pretty,
|
|
"active_in_stack": lsm_id in stack,
|
|
"dir_present": _lsm_dir_active(sub),
|
|
"path": str(SECURITY_FS / sub),
|
|
}
|
|
for lsm_id, pretty, sub in LSM_PROBES
|
|
]
|
|
return {
|
|
"lsm_list_file": str(LSM_LIST_FILE),
|
|
"active_stack": stack,
|
|
"active_count": len(stack),
|
|
"entries": entries,
|
|
"selinux": {
|
|
"skipped": True,
|
|
"reason": "SELinux is native to the host distro — not managed by SysDeck.",
|
|
},
|
|
}
|
|
|
|
|
|
# ── Smack ────────────────────────────────────────────────────────────
|
|
#
|
|
# Smack (Simplified Mandatory Access Control Kernel) is the LSM used
|
|
# by Tizen, AGL (Automotive Grade Linux), and embedded systems. It
|
|
# labels processes and objects with simple text labels and enforces
|
|
# access rules between them. Userspace tools: smackload (load rules),
|
|
# smackcipsos (manage CIPSO mappings), smackcipso. The smack library
|
|
# is libsmack; the kernel side ships in mainline since 2.6.30.
|
|
|
|
SMACK_DIR = SECURITY_FS / "smack"
|
|
|
|
|
|
def cmd_smack_status(_args: list[str]) -> dict[str, Any]:
|
|
"""Smack enforcement state — read /sys/kernel/security/smack/."""
|
|
if not SMACK_DIR.is_dir():
|
|
return {
|
|
"available": False,
|
|
"reason": "Smack directory not present at /sys/kernel/security/smack/",
|
|
"install_arch": "Smack is built into the kernel; enable with the kernel cmdline 'security=smack' or 'lsm=...,smack'",
|
|
"install_debian": "Smack is built into the kernel; enable with the kernel cmdline 'security=smack' or 'lsm=...,smack'",
|
|
"note": "Smack is optional in SysDeck — the panel renders an enable hint when absent.",
|
|
}
|
|
info: dict[str, Any] = {"available": True, "path": str(SMACK_DIR)}
|
|
# Lookup table: (filename, key_in_response). Iterated in one pass;
|
|
# failures are silently skipped because some files only exist when
|
|
# specific Smack features are enabled.
|
|
for fname, key in SMACK_FILE_MAP:
|
|
p = SMACK_DIR / fname
|
|
if not p.is_file():
|
|
continue
|
|
try:
|
|
info[key] = p.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError):
|
|
continue
|
|
info["smackload"] = _have("smackload")
|
|
info["smackcipsos"] = _have("smackcipsos")
|
|
return info
|
|
|
|
|
|
# Smack securityfs files of interest, mapped to the JSON key in the
|
|
# response. Pulled out as a module-level constant so the loop body
|
|
# stays simple and the table is easy to extend.
|
|
SMACK_FILE_MAP: list[tuple[str, str]] = [
|
|
("logging", "logging"),
|
|
("load", "load"),
|
|
("load2", "load2"),
|
|
("revoke-subject", "revoke_subject"),
|
|
("change-rule", "change_rule"),
|
|
("onlycap", "onlycap"),
|
|
("cipso2", "cipso2"),
|
|
("access2", "access2"),
|
|
("access", "access"),
|
|
("mapped", "mapped"),
|
|
("network-queue-length", "net_queue_len"),
|
|
("ptrace", "ptrace"),
|
|
]
|
|
|
|
|
|
def cmd_smack_labels(_args: list[str]) -> dict[str, Any]:
|
|
"""List Smack labels in use on the host.
|
|
|
|
Walks /proc/<pid>/attr/current for every running PID. The set
|
|
is deduplicated. Each entry is {label, pids: [...]}.
|
|
"""
|
|
if not SMACK_DIR.is_dir():
|
|
return {"available": False, "reason": "Smack not active"}
|
|
labels: dict[str, list[str]] = {}
|
|
proc = Path("/proc")
|
|
for pid_dir in proc.iterdir():
|
|
if not pid_dir.name.isdigit():
|
|
continue
|
|
attr = pid_dir / "attr" / "current"
|
|
try:
|
|
label = attr.read_text(encoding="utf-8").strip("\x00").strip()
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
continue
|
|
if not label:
|
|
continue
|
|
labels.setdefault(label, []).append(pid_dir.name)
|
|
return {
|
|
"available": True,
|
|
"labels": [{"label": k, "pid_count": len(v), "pids": v[:50]} for k, v in sorted(labels.items())],
|
|
"label_count": len(labels),
|
|
}
|
|
|
|
|
|
def cmd_smack_load(args: list[str]) -> dict[str, Any]:
|
|
"""Load Smack access rules from a file (smackload <rules-file>).
|
|
|
|
Reads a smack rule file (one rule per line) and writes it to
|
|
/sys/kernel/security/smack/load. Rules use the format:
|
|
subject object access [flags]
|
|
where access is one of the 12-char masks (rwxat...) or '-'
|
|
"""
|
|
if not args:
|
|
return {"error": "rules file path required"}
|
|
rules_file = Path(args[0])
|
|
if not rules_file.is_file():
|
|
return {"error": f"{rules_file} is not a file"}
|
|
if not SMACK_DIR.is_dir():
|
|
return {"available": False, "reason": "Smack not active"}
|
|
load_target = SMACK_DIR / "load"
|
|
try:
|
|
load_target.write_text(rules_file.read_text(encoding="utf-8"), encoding="utf-8")
|
|
return {"loaded": True, "rules_file": str(rules_file), "target": str(load_target)}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"loaded": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.policy.modify)"}
|
|
|
|
|
|
# ── TOMOYO ──────────────────────────────────────────────────────────
|
|
#
|
|
# TOMOYO Linux focuses on process-behavior analysis: it learns what
|
|
# a process should do and enforces it. Userspace tools: tomoyo-init,
|
|
# tomoyo-set-profile, tomoyo-set-exception, tomoyo-save-policy, etc.
|
|
# Kernel-side lives at /sys/kernel/security/tomoyo/.
|
|
|
|
TOMOYO_DIR = SECURITY_FS / "tomoyo"
|
|
|
|
# TOMOYO control files surfaced by the status subcommand. Pulled out
|
|
# as a module-level constant so the file list is easy to extend.
|
|
TOMOYO_FILES: tuple[str, ...] = (
|
|
"profile", "exception_policy", "domain_policy", "manager",
|
|
"query", "grant_log", "reject_log", "status", "version",
|
|
)
|
|
|
|
|
|
def cmd_tomoyo_status(_args: list[str]) -> dict[str, Any]:
|
|
"""TOMOYO enforcement state — read /sys/kernel/security/tomoyo/."""
|
|
if not TOMOYO_DIR.is_dir():
|
|
return {
|
|
"available": False,
|
|
"reason": "TOMOYO directory not present at /sys/kernel/security/tomoyo/",
|
|
"install_arch": "TOMOYO is built into the kernel; enable with 'security=tomoyo' or 'lsm=...,tomoyo' on the kernel cmdline",
|
|
"install_debian": "TOMOYO is built into the kernel; enable with 'security=tomoyo' or 'lsm=...,tomoyo' on the kernel cmdline",
|
|
"note": "TOMOYO is optional in SysDeck — the panel renders an enable hint when absent.",
|
|
}
|
|
info: dict[str, Any] = {"available": True, "path": str(TOMOYO_DIR)}
|
|
# Probe TOMOYO's standard control files — single pass over the
|
|
# TOMOYO_FILES lookup table; each value is truncated to 500 chars
|
|
# to keep the JSON response small.
|
|
for f in TOMOYO_FILES:
|
|
p = TOMOYO_DIR / f
|
|
if not p.is_file():
|
|
continue
|
|
try:
|
|
val = p.read_text(encoding="utf-8").strip()
|
|
info[f] = val[:500] if len(val) > 500 else val
|
|
except (PermissionError, OSError):
|
|
continue
|
|
info["tomoyo_tools"] = _have("tomoyo-setprofile") or _have("tomoyo-set-profile")
|
|
return info
|
|
|
|
|
|
def cmd_tomoyo_profiles(_args: list[str]) -> dict[str, Any]:
|
|
"""List TOMOYO profiles (read /sys/kernel/security/tomoyo/profile)."""
|
|
if not TOMOYO_DIR.is_dir():
|
|
return {"available": False, "reason": "TOMOYO not active"}
|
|
p = TOMOYO_DIR / "profile"
|
|
if not p.is_file():
|
|
return {"available": False, "reason": "profile file not present"}
|
|
try:
|
|
raw = p.read_text(encoding="utf-8")
|
|
except (PermissionError, OSError) as exc:
|
|
return {"available": True, "error": str(exc)}
|
|
return {"available": True, "raw": raw, "profiles": raw.splitlines()}
|
|
|
|
|
|
def cmd_tomoyo_save_policy(args: list[str]) -> dict[str, Any]:
|
|
"""Save TOMOYO policy snapshot to a file.
|
|
|
|
TOMOYO exposes its policy as text files under /sys/kernel/security/
|
|
tomoyo/. This command reads each one and writes the snapshot to
|
|
the operator's chosen path.
|
|
"""
|
|
if not args:
|
|
return {"error": "output file path required"}
|
|
if not TOMOYO_DIR.is_dir():
|
|
return {"available": False, "reason": "TOMOYO not active"}
|
|
out_path = Path(args[0])
|
|
snapshot: list[str] = []
|
|
for f in ("profile", "exception_policy", "domain_policy", "manager"):
|
|
src = TOMOYO_DIR / f
|
|
if not src.is_file():
|
|
continue
|
|
try:
|
|
snapshot.append(f"# === {f} ===")
|
|
snapshot.append(src.read_text(encoding="utf-8"))
|
|
except (PermissionError, OSError) as exc:
|
|
snapshot.append(f"# {f}: read failed: {exc}")
|
|
try:
|
|
out_path.write_text("\n".join(snapshot), encoding="utf-8")
|
|
return {"saved": True, "path": str(out_path), "size": len("\n".join(snapshot))}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"saved": False, "error": str(exc)}
|
|
|
|
|
|
# ── Yama ─────────────────────────────────────────────────────────────
|
|
#
|
|
# Yama is a small LSM that only does ptrace-scope restrictions. The
|
|
# only knob is /proc/sys/kernel/yama/ptrace_scope — a 0-3 integer:
|
|
# 0 = disabled (any process can ptrace any same-uid process)
|
|
# 1 = restricted ptrace (default on most distros)
|
|
# 2 = admin-only ptrace
|
|
# 3 = no ptrace at all
|
|
|
|
YAMA_SCOPE_FILE = Path("/proc/sys/kernel/yama/ptrace_scope")
|
|
|
|
YAMA_SCOPE_NAMES: dict[int, str] = {
|
|
0: "disabled",
|
|
1: "restricted (default)",
|
|
2: "admin-only",
|
|
3: "no-ptrace",
|
|
}
|
|
|
|
|
|
def cmd_yama_status(_args: list[str]) -> dict[str, Any]:
|
|
"""Yama ptrace-scope status — read /proc/sys/kernel/yama/ptrace_scope."""
|
|
if not YAMA_SCOPE_FILE.is_file():
|
|
return {
|
|
"available": False,
|
|
"reason": "Yama not active on this kernel",
|
|
"note": "Yama is built into the kernel; enable with 'lsm=...,yama' on the kernel cmdline.",
|
|
}
|
|
try:
|
|
raw = YAMA_SCOPE_FILE.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError) as exc:
|
|
return {"available": True, "error": str(exc)}
|
|
try:
|
|
scope = int(raw)
|
|
except ValueError:
|
|
scope = -1
|
|
return {
|
|
"available": True,
|
|
"scope": scope,
|
|
"scope_name": YAMA_SCOPE_NAMES.get(scope, "unknown"),
|
|
"file": str(YAMA_SCOPE_FILE),
|
|
}
|
|
|
|
|
|
def cmd_yama_set_scope(args: list[str]) -> dict[str, Any]:
|
|
"""Set Yama ptrace scope (write to /proc/sys/kernel/yama/ptrace_scope)."""
|
|
if not args:
|
|
return {"error": "scope value required (0=disabled, 1=restricted, 2=admin-only, 3=no-ptrace)"}
|
|
try:
|
|
scope = int(args[0])
|
|
except ValueError:
|
|
return {"error": f"scope must be numeric 0-3, got {args[0]}"}
|
|
if scope not in YAMA_SCOPE_NAMES:
|
|
return {"error": f"scope {scope} out of range (0-3)"}
|
|
if not YAMA_SCOPE_FILE.is_file():
|
|
return {"available": False, "reason": "Yama not active on this kernel"}
|
|
try:
|
|
YAMA_SCOPE_FILE.write_text(str(scope), encoding="utf-8")
|
|
return {
|
|
"set": True,
|
|
"scope": scope,
|
|
"scope_name": YAMA_SCOPE_NAMES[scope],
|
|
"file": str(YAMA_SCOPE_FILE),
|
|
}
|
|
except (PermissionError, OSError) as exc:
|
|
return {"set": False, "error": str(exc),
|
|
"hint": "run via cockpit superuser channel (polkit org.sysdeck.policy.modify)"}
|
|
|
|
|
|
# ── LoadPin ──────────────────────────────────────────────────────────
|
|
#
|
|
# LoadPin ensures that all kernel-loaded modules come from a single
|
|
# pinned filesystem (typically the root fs). It has no userspace
|
|
# management tool — the only knob is whether it is enabled.
|
|
|
|
LOADPIN_DIR = SECURITY_FS / "loadpin"
|
|
|
|
|
|
def cmd_loadpin_status(_args: list[str]) -> dict[str, Any]:
|
|
"""LoadPin status — read /sys/kernel/security/loadpin/."""
|
|
if not LOADPIN_DIR.is_dir():
|
|
return {
|
|
"available": False,
|
|
"reason": "LoadPin directory not present at /sys/kernel/security/loadpin/",
|
|
"note": "LoadPin is built into the kernel; enable with 'lsm=...,loadpin' on the kernel cmdline.",
|
|
}
|
|
info: dict[str, Any] = {"available": True, "path": str(LOADPIN_DIR)}
|
|
# LoadPin exposes a 'enforce' file (0/1) when active.
|
|
enforce_file = LOADPIN_DIR / "enforce"
|
|
if enforce_file.is_file():
|
|
try:
|
|
info["enforce"] = enforce_file.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError):
|
|
pass
|
|
# LoadPin also exposes a 'exclude' list file.
|
|
exclude_file = LOADPIN_DIR / "exclude"
|
|
if exclude_file.is_file():
|
|
try:
|
|
info["exclude"] = exclude_file.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError):
|
|
pass
|
|
return info
|
|
|
|
|
|
# ── Lockdown ────────────────────────────────────────────────────────
|
|
#
|
|
# Lockdown is the UEFI secure-boot kernel lockdown mode. It has four
|
|
# states (none, integrity, confidentiality, none-confidentiality —
|
|
# depending on kernel version). Exposed at /sys/kernel/security/
|
|
# lockdown.
|
|
|
|
LOCKDOWN_DIR = SECURITY_FS / "lockdown"
|
|
|
|
|
|
def cmd_lockdown_status(_args: list[str]) -> dict[str, Any]:
|
|
"""Lockdown mode status — read /sys/kernel/security/lockdown."""
|
|
if not LOCKDOWN_DIR.is_dir():
|
|
return {
|
|
"available": False,
|
|
"reason": "Lockdown directory not present at /sys/kernel/security/lockdown/",
|
|
"note": "Lockdown is enabled by UEFI secure boot; the file appears only when the kernel was built with CONFIG_SECURITY_LOCKDOWN_LSM.",
|
|
}
|
|
info: dict[str, Any] = {"available": True, "path": str(LOCKDOWN_DIR)}
|
|
for f in LOCKDOWN_DIR.iterdir():
|
|
if not f.is_file():
|
|
continue
|
|
try:
|
|
info[f.name] = f.read_text(encoding="utf-8").strip()
|
|
except (PermissionError, OSError):
|
|
continue
|
|
return info
|
|
|
|
|
|
# ── BPF-LSM ──────────────────────────────────────────────────────────
|
|
#
|
|
# The BPF LSM is a stackable hook framework: programs attached to
|
|
# security_hook_fname() hooks at runtime. Programs are loaded via
|
|
# libbpf's bpf_attach_btf_id, and queried via bpftool. The kernel
|
|
# exposes the active hooks under /sys/kernel/security/bpf/.
|
|
|
|
BPFLSM_DIR = SECURITY_FS / "bpf"
|
|
|
|
|
|
def cmd_bpflsm_status(_args: list[str]) -> dict[str, Any]:
|
|
"""BPF-LSM status — directory presence + bpftool prog listing (BPF_PROG_TYPE_LSM)."""
|
|
info: dict[str, Any] = {
|
|
"available": (BPFLSM_DIR.is_dir() or _have("bpftool")),
|
|
"dir_present": BPFLSM_DIR.is_dir(),
|
|
"dir_path": str(BPFLSM_DIR),
|
|
"bpftool": _have("bpftool"),
|
|
}
|
|
# If bpftool is present, list BPF_PROG_TYPE_LSM programs.
|
|
bpftool = shutil.which("bpftool")
|
|
if not bpftool:
|
|
return info
|
|
rc, out, _ = _run([bpftool, "prog", "show", "-j"])
|
|
if rc != 0 or not out.strip():
|
|
return info
|
|
try:
|
|
progs = json.loads(out)
|
|
except json.JSONDecodeError:
|
|
return info
|
|
# Filter to LSM-typed programs (bpftool's "type" field is "lsm").
|
|
lsm_progs = [p for p in progs if isinstance(p, dict) and p.get("type") == "lsm"]
|
|
info["lsm_program_count"] = len(lsm_progs)
|
|
info["lsm_programs"] = lsm_progs[:50] # cap to avoid huge responses
|
|
return info
|
|
|
|
|
|
# ── Landlock ─────────────────────────────────────────────────────────
|
|
#
|
|
# Landlock is the modern unprivileged sandboxing LSM (Linux 5.13+).
|
|
# Each process can voluntarily restrict itself with a Landlock
|
|
# ruleset (filesystem paths, TCP bind/connect, etc.). The kernel
|
|
# exposes /sys/kernel/security/landlock/. The rulesets are per-
|
|
# process and best enumerated via /proc/<pid>/status's "Landlock"
|
|
# line.
|
|
|
|
LANDLOCK_DIR = SECURITY_FS / "landlock"
|
|
|
|
|
|
def cmd_landlock_status(_args: list[str]) -> dict[str, Any]:
|
|
"""Landlock status — directory presence + per-process ruleset summary."""
|
|
info: dict[str, Any] = {
|
|
"available": LANDLOCK_DIR.is_dir(),
|
|
"dir_present": LANDLOCK_DIR.is_dir(),
|
|
"dir_path": str(LANDLOCK_DIR),
|
|
}
|
|
if not LANDLOCK_DIR.is_dir():
|
|
info["reason"] = "Landlock not active — requires Linux 5.13+ and 'lsm=...,landlock' on the kernel cmdline."
|
|
return info
|
|
# Walk /proc/<pid>/status looking for a "Landlock:" line.
|
|
rulesets: list[dict[str, Any]] = []
|
|
proc = Path("/proc")
|
|
for pid_dir in proc.iterdir():
|
|
if not pid_dir.name.isdigit():
|
|
continue
|
|
status = pid_dir / "status"
|
|
try:
|
|
for line in status.read_text(encoding="utf-8").splitlines():
|
|
if line.startswith("Landlock:"):
|
|
val = line.split(":", 1)[1].strip()
|
|
if val and val != "0":
|
|
comm = (pid_dir / "comm").read_text(encoding="utf-8").strip()
|
|
rulesets.append({"pid": pid_dir.name, "comm": comm, "landlock": val})
|
|
break
|
|
except (FileNotFoundError, PermissionError, OSError):
|
|
continue
|
|
info["processes_with_rulesets"] = len(rulesets)
|
|
info["rulesets"] = rulesets[:50]
|
|
return info
|
|
|
|
|
|
# ── File capabilities (setcap / getcap) ────────────────────────────
|
|
#
|
|
# File capabilities predate the LSM stack but compose with it for
|
|
# fine-grained privilege delegation. They allow binaries to hold
|
|
# specific capabilities without needing to run as root.
|
|
#
|
|
# getcap -r / # find all binaries with caps (recursive)
|
|
# getcap <path> # show caps on one file
|
|
# setcap 'cap_net_bind+ep' <path> # grant cap_net_bind to a binary
|
|
# setcap -r <path> # remove all caps from a file
|
|
|
|
CAP_TOOLS = ("setcap", "getcap")
|
|
|
|
|
|
def cmd_filecaps_list(_args: list[str]) -> dict[str, Any]:
|
|
"""List binaries on the system that have file capabilities.
|
|
|
|
Runs `getcap -r /` to walk the root filesystem. The output is
|
|
capped at 200 entries to avoid blowing up the JSON response on
|
|
huge filesystems.
|
|
"""
|
|
getcap = shutil.which("getcap")
|
|
if not getcap:
|
|
return {
|
|
"available": False,
|
|
"reason": "getcap not installed",
|
|
"install": "pacman -S libcap # Arch\\napart install libcap-bin # Debian",
|
|
}
|
|
rc, out, err = _run([getcap, "-r", "/"], timeout=30)
|
|
if rc != 0:
|
|
return {"available": True, "error": err or "getcap failed", "rc": rc}
|
|
# Output format: "/path/to/binary cap_name,cap_other=ep"
|
|
entries = []
|
|
for line in out.splitlines():
|
|
if not line.strip():
|
|
continue
|
|
path, _, caps = line.partition(" ")
|
|
entries.append({"path": path, "caps": caps.strip()})
|
|
if len(entries) >= 200:
|
|
break
|
|
return {
|
|
"available": True,
|
|
"entries": entries,
|
|
"count": len(entries),
|
|
"truncated": len(entries) >= 200,
|
|
}
|
|
|
|
|
|
def cmd_filecaps_show(args: list[str]) -> dict[str, Any]:
|
|
"""Show file capabilities on a single path (getcap <path>)."""
|
|
if not args:
|
|
return {"error": "path required"}
|
|
getcap = shutil.which("getcap")
|
|
if not getcap:
|
|
return {"available": False, "reason": "getcap not installed"}
|
|
path = args[0]
|
|
rc, out, err = _run([getcap, path])
|
|
return {
|
|
"available": True,
|
|
"path": path,
|
|
"caps": out.strip(),
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
def cmd_filecaps_set(args: list[str]) -> dict[str, Any]:
|
|
"""Set file capabilities on a path (setcap '<caps>' <path>)."""
|
|
if len(args) < 2:
|
|
return {"error": "usage: filecaps-set <caps> <path> (e.g. 'cap_net_bind_service+ep' /usr/bin/python3)"}
|
|
caps, path = args[0], args[1]
|
|
setcap = shutil.which("setcap")
|
|
if not setcap:
|
|
return {"available": False, "reason": "setcap not installed"}
|
|
rc, out, err = _run([setcap, caps, path])
|
|
return {
|
|
"set": rc == 0,
|
|
"caps": caps,
|
|
"path": path,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
"output": out.strip(),
|
|
}
|
|
|
|
|
|
def cmd_filecaps_remove(args: list[str]) -> dict[str, Any]:
|
|
"""Remove all file capabilities from a path (setcap -r <path>)."""
|
|
if not args:
|
|
return {"error": "path required"}
|
|
path = args[0]
|
|
setcap = shutil.which("setcap")
|
|
if not setcap:
|
|
return {"available": False, "reason": "setcap not installed"}
|
|
rc, out, err = _run([setcap, "-r", path])
|
|
return {
|
|
"removed": rc == 0,
|
|
"path": path,
|
|
"rc": rc,
|
|
"stderr": err.strip(),
|
|
}
|
|
|
|
|
|
# ── Summary ──────────────────────────────────────────────────────────
|
|
#
|
|
# v0.0.33 refactor: the summary command previously hand-built the
|
|
# per-concern dict with nested ifs. The dict now iterates over a
|
|
# lookup table (LSM_PROBES) for the LSM family, and a static table
|
|
# (NON_LSM_CONCERNS) for the non-LSM concerns (ACLs, cgroups, VLANs,
|
|
# eBPF, namespaces). The lookup-table pattern means a new concern
|
|
# is a one-line addition to a table, not a new code path.
|
|
|
|
|
|
# Non-LSM concerns, expressed as a static table for the summary
|
|
# iteration. Each entry: (id, probe_fn) — the probe_fn returns a
|
|
# {available: bool, ...} dict to embed in the summary.
|
|
def _probe_acl() -> dict[str, Any]:
|
|
return {
|
|
"available": _have("getfacl") and _have("setfacl"),
|
|
"binaries": {"getfacl": _have("getfacl"), "setfacl": _have("setfacl")},
|
|
}
|
|
|
|
|
|
def _probe_cgroups() -> dict[str, Any]:
|
|
return {"available": _cgroup_v2_available(), "root": str(CGROUP_ROOT)}
|
|
|
|
|
|
def _probe_vlans() -> dict[str, Any]:
|
|
return {"available": _have("ip")}
|
|
|
|
|
|
def _probe_ebpf() -> dict[str, Any]:
|
|
return {"available": _have("bpftool"), "bpftool_path": shutil.which("bpftool") or ""}
|
|
|
|
|
|
def _probe_namespaces() -> dict[str, Any]:
|
|
return {"available": _have("lsns"), "lsns_path": shutil.which("lsns") or ""}
|
|
|
|
|
|
# PEP 868: list-of-tuples keeps the order stable and the type clear.
|
|
NON_LSM_CONCERNS: list[tuple[str, str, callable]] = [
|
|
("acl", "ACLs", _probe_acl),
|
|
("cgroups", "cgroups v2", _probe_cgroups),
|
|
("vlans", "VLANs", _probe_vlans),
|
|
("ebpf", "eBPF", _probe_ebpf),
|
|
("namespaces", "Namespaces", _probe_namespaces),
|
|
]
|
|
|
|
|
|
def cmd_summary(_args: list[str]) -> dict[str, Any]:
|
|
"""One-shot overview of all policy & permissions concerns.
|
|
|
|
Iterates over the LSM_PROBES table and the NON_LSM_CONCERNS table
|
|
to build the full capability matrix in one pass. Adding a new
|
|
concern is a one-line addition to either table — no nested ifs,
|
|
no new code paths.
|
|
"""
|
|
stack = _read_lsm_stack()
|
|
lsm_summary = {
|
|
lsm_id: {
|
|
"name": pretty,
|
|
"active_in_stack": lsm_id in stack,
|
|
"dir_present": _lsm_dir_active(sub),
|
|
}
|
|
for lsm_id, pretty, sub in LSM_PROBES
|
|
}
|
|
non_lsm_summary = {
|
|
cid: {**probe_fn(), "name": pretty}
|
|
for cid, pretty, probe_fn in NON_LSM_CONCERNS
|
|
}
|
|
return {
|
|
"lsms": lsm_summary,
|
|
"lsm_stack": stack,
|
|
"lsm_stack_file": str(LSM_LIST_FILE),
|
|
"concerns": non_lsm_summary,
|
|
"selinux": {
|
|
"skipped": True,
|
|
"reason": "SELinux is native to the host distro — not managed by SysDeck.",
|
|
},
|
|
}
|
|
|
|
|
|
# ── Dispatch table ───────────────────────────────────────────────────
|
|
|
|
COMMANDS = {
|
|
# summary
|
|
"summary": lambda _args: cmd_summary([]),
|
|
|
|
# ACLs
|
|
"acl-list": lambda args: cmd_acl_list(args),
|
|
"acl-set": lambda args: cmd_acl_set(args),
|
|
"acl-remove": lambda args: cmd_acl_remove(args),
|
|
"acl-default": lambda args: cmd_acl_default(args),
|
|
|
|
# cgroups v2
|
|
"cgroup-list": lambda _args: cmd_cgroup_list([]),
|
|
"cgroup-show": lambda args: cmd_cgroup_show(args),
|
|
"cgroup-procs": lambda args: cmd_cgroup_procs(args),
|
|
"cgroup-create": lambda args: cmd_cgroup_create(args),
|
|
"cgroup-move": lambda args: cmd_cgroup_move(args),
|
|
"cgroup-set": lambda args: cmd_cgroup_set(args),
|
|
|
|
# VLANs
|
|
"vlan-list": lambda _args: cmd_vlan_list([]),
|
|
"vlan-show": lambda args: cmd_vlan_show(args),
|
|
"vlan-create": lambda args: cmd_vlan_create(args),
|
|
"vlan-delete": lambda args: cmd_vlan_delete(args),
|
|
|
|
# eBPF
|
|
"ebpf-list": lambda _args: cmd_ebpf_list([]),
|
|
"ebpf-show": lambda args: cmd_ebpf_show(args),
|
|
"ebpf-maps": lambda _args: cmd_ebpf_maps([]),
|
|
"ebpf-pin": lambda args: cmd_ebpf_pin(args),
|
|
|
|
# namespaces
|
|
"ns-list": lambda _args: cmd_ns_list([]),
|
|
"ns-show": lambda args: cmd_ns_show(args),
|
|
|
|
# AppArmor (optional — the v0.0.32 LSM)
|
|
"apparmor-status": lambda _args: cmd_apparmor_status([]),
|
|
"apparmor-profiles": lambda _args: cmd_apparmor_profiles([]),
|
|
"apparmor-enforce": lambda args: cmd_apparmor_enforce(args),
|
|
"apparmor-complain": lambda args: cmd_apparmor_complain(args),
|
|
|
|
# v0.0.33: additional LSMs — Smack, TOMOYO, Yama, LoadPin,
|
|
# Lockdown, BPF-LSM, Landlock + file capabilities. Each is
|
|
# optional; the bridge auto-detects whether the LSM is compiled
|
|
# into the kernel and the panel renders an install/enable hint
|
|
# when absent.
|
|
"lsm-status": lambda _args: cmd_lsm_status([]),
|
|
"smack-status": lambda _args: cmd_smack_status([]),
|
|
"smack-labels": lambda _args: cmd_smack_labels([]),
|
|
"smack-load": lambda args: cmd_smack_load(args),
|
|
"tomoyo-status": lambda _args: cmd_tomoyo_status([]),
|
|
"tomoyo-profiles": lambda _args: cmd_tomoyo_profiles([]),
|
|
"tomoyo-save-policy": lambda args: cmd_tomoyo_save_policy(args),
|
|
"yama-status": lambda _args: cmd_yama_status([]),
|
|
"yama-set-scope": lambda args: cmd_yama_set_scope(args),
|
|
"loadpin-status": lambda _args: cmd_loadpin_status([]),
|
|
"lockdown-status": lambda _args: cmd_lockdown_status([]),
|
|
"bpflsm-status": lambda _args: cmd_bpflsm_status([]),
|
|
"landlock-status": lambda _args: cmd_landlock_status([]),
|
|
"filecaps-list": lambda _args: cmd_filecaps_list([]),
|
|
"filecaps-show": lambda args: cmd_filecaps_show(args),
|
|
"filecaps-set": lambda args: cmd_filecaps_set(args),
|
|
"filecaps-remove": lambda args: cmd_filecaps_remove(args),
|
|
}
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if not argv or argv[0] in ("-h", "--help"):
|
|
print(__doc__)
|
|
return 0
|
|
cmd = COMMANDS.get(argv[0])
|
|
if not cmd:
|
|
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
|
|
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
|
|
return 2
|
|
print(json.dumps(cmd(argv[1:]), indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|