← pag

Commit 5c73cfb

0
plików
+0
dodanych
-0
usuniętych
@@ -599,6 +599,47 @@ STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy E
599 599 PKG_EXT = ".pag"
600 600 REPO_CACHE_TTL = 3600
601 601 MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
602 +
603 +# ── UPRZYWILEJOWANE PLIKI ────────────────────────────────────────────────────
604 +# Domyślnie pag zdejmuje bit SUID i wymusza właściciela root:root. Niektóre
605 +# programy MUSZĄ jednak zachować setuid/setgid albo grupę inną niż root
606 +# (/usr/bin/sudo, /usr/bin/passwd, /usr/bin/unix_chkpwd,
607 +# /usr/lib/dbus-daemon-launch-helper). Bez tego tracą możliwość działania
608 +# po aktualizacji przez pag (np. użytkownik nie zmieni hasła).
609 +#
610 +# Bezpieczeństwo: zawartość pakietu jest uwierzytelniona (podpis GPG repo +
611 +# przypięty fingerprint + SHA256 całego pakietu), więc metadane w nim są
612 +# zaufane tak samo jak same pliki. Zachowujemy uprawnienia WYŁĄCZNIE dla
613 +# ścieżek z tej listy (albo z pola `privileged:` receptury, które pagbuild
614 +# zapisuje w metadata.json).
615 +PRIVILEGED_PATHS = frozenset({
616 + "/usr/bin/cdda2wav", "/usr/bin/cdrecord",
617 + "/usr/bin/chage", "/usr/bin/expiry",
618 + "/usr/bin/fcrondyn", "/usr/bin/fcronsighup", "/usr/bin/fcrontab",
619 + "/usr/bin/firejail", "/usr/bin/fping",
620 + "/usr/bin/fusermount", "/usr/bin/fusermount3",
621 + "/usr/bin/gpasswd", "/usr/bin/ksu", "/usr/bin/lockfile",
622 + "/usr/bin/mount", "/usr/bin/mount.nfs",
623 + "/usr/bin/newgidmap", "/usr/bin/newuidmap",
624 + "/usr/bin/passwd", "/usr/bin/ping", "/usr/bin/ping6",
625 + "/usr/bin/procmail", "/usr/bin/readcd", "/usr/bin/rscsi",
626 + "/usr/bin/screen", "/usr/bin/screen-5.0.2",
627 + "/usr/bin/sg", "/usr/bin/su", "/usr/bin/sudo", "/usr/bin/traceroute",
628 + "/usr/bin/umount", "/usr/bin/unix_chkpwd", "/usr/bin/wall",
629 + "/usr/lib/dbus-daemon-launch-helper",
630 + "/usr/lib/httpd/suexec",
631 + "/usr/lib/utempter/utempter",
632 + "/usr/lib/xscreensaver/xscreensaver-auth",
633 + "/usr/libexec/libgtop_server2",
634 + "/usr/libexec/qemu-bridge-helper",
635 + "/usr/libexec/ssh-keysign",
636 + "/usr/libexec/xf86-video-intel-backlight-helper",
637 + "/opt/google/chrome/chrome-sandbox",
638 + "/opt/gitkraken/chrome-sandbox",
639 + "/opt/VirtualBox/VBoxHeadless", "/opt/VirtualBox/VBoxNetAdpCtl",
640 + "/opt/VirtualBox/VBoxNetDHCP", "/opt/VirtualBox/VBoxVolInfo",
641 + "/opt/VirtualBox/VirtualBoxVM",
642 +})
602 643 ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
603 644
604 645 # Bezpieczeństwo / audyt
@@ -1080,7 +1121,8 @@ def _check_downgrade(pkg_name, new_ver, installed_db):
1080 1121 return False
1081 1122 return True
1082 1123
1083 -def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
1124 +def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True,
1125 + privileged=frozenset()):
1084 1126 """
1085 1127 Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
1086 1128
@@ -1160,7 +1202,10 @@ def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool =
1160 1202 except Exception as e:
1161 1203 print(f" ⚠ Nie rozpakowano {name}: {e}")
1162 1204 continue
1163 - _strip_suid(target)
1205 + # Pliki z listy uprzywilejowanej zachowują SUID/SGID; reszta jest
1206 + # czyszczona (ochrona przed SUID niespodziewanym w pakiecie).
1207 + if "/" + os.path.relpath(target, dest_real) not in privileged:
1208 + _strip_suid(target)
1164 1209
1165 1210
1166 1211 def _sha256_file(path: str) -> str:
@@ -1258,7 +1303,7 @@ def save_json(path, data):
1258 1303 class PackageInfo:
1259 1304 __slots__ = ("name","version","release","description","dependencies",
1260 1305 "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1261 - "provides_so","requires_so")
1306 + "provides_so","requires_so","privileged")
1262 1307 def __init__(self, d, repo=""):
1263 1308 self.name = d.get("name","?")
1264 1309 self.version = d.get("version","0")
@@ -1274,6 +1319,8 @@ class PackageInfo:
1274 1319 self.license = d.get("license", []) or []
1275 1320 self.provides_so = d.get("provides_so", []) or []
1276 1321 self.requires_so = d.get("requires_so", []) or []
1322 + # Ścieżki, którym wolno zachować setuid/setgid i grupę (z receptury).
1323 + self.privileged = d.get("privileged", []) or []
1277 1324
1278 1325 # =============================================================================
1279 1326 # REPOZYTORIA (cache, ETag, GPG)
@@ -1794,7 +1841,8 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1794 1841 staging: str, journal: list, installed_files: list,
1795 1842 deploy_dir: str = "", backup_dir: str = "",
1796 1843 backup_journal: Optional[list] = None,
1797 - old_checksums: Optional[dict] = None) -> bool:
1844 + old_checksums: Optional[dict] = None,
1845 + privileged=frozenset()) -> bool:
1798 1846 """
1799 1847 Instaluje pojedynczy plik (zwykły lub symlink).
1800 1848 Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
@@ -1916,12 +1964,23 @@ def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1916 1964 # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1917 1965 _safe_rename(src, dst)
1918 1966
1919 - # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1920 - try:
1921 - os.chown(dst, 0, 0)
1922 - except (OSError, PermissionError):
1923 - # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1924 - pass
1967 + if "/" + rel in privileged:
1968 + # Plik uprzywilejowany: zostaw właściciela, grupę i bity z pakietu
1969 + # (np. sudo 4755 root:root, qemu-bridge-helper 4750 root:kvm).
1970 + try:
1971 + st = os.stat(dst)
1972 + if st.st_mode & 0o6000:
1973 + _audit(f"privileged {dst} mode={oct(st.st_mode & 0o7777)} "
1974 + f"uid={st.st_uid} gid={st.st_gid}")
1975 + except OSError:
1976 + pass
1977 + else:
1978 + # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1979 + try:
1980 + os.chown(dst, 0, 0)
1981 + except (OSError, PermissionError):
1982 + # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1983 + pass
1925 1984
1926 1985 journal.append(("file", src, dst))
1927 1986 installed_files.append({
@@ -1942,6 +2001,8 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1942 2001 Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1943 2002 """
1944 2003 staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
2004 + # Wbudowana lista + ścieżki zadeklarowane przez recepturę (metadata.json).
2005 + priv = PRIVILEGED_PATHS | frozenset(pkg.privileged or ())
1945 2006 journal = []
1946 2007 installed_files = []
1947 2008 backup_journal: List[Tuple[str, str]] = []
@@ -1960,7 +2021,7 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1960 2021 data_staging = os.path.join(staging, "data")
1961 2022 os.makedirs(data_staging, exist_ok=True)
1962 2023 with tarfile.open(data_tar, "r:xz") as tf:
1963 - _safe_extractall(tf, data_staging)
2024 + _safe_extractall(tf, data_staging, privileged=priv)
1964 2025
1965 2026 # Wczytaj sums.json
1966 2027 sums_path = os.path.join(data_staging, "sums.json")
@@ -1998,7 +2059,8 @@ def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1998 2059 ok = _install_file(src, rel, data_staging, sums,
1999 2060 staging, journal, installed_files, deploy_dir,
2000 2061 backup_dir, backup_journal,
2001 - old_checksums=old_checksums)
2062 + old_checksums=old_checksums,
2063 + privileged=priv)
2002 2064 if not ok:
2003 2065 # Cofnij wszystkie operacje
2004 2066 _rollback_journal(journal, staging)