Date: Mon, 25 May 2026 17:48:23 +0000 From: =?utf-8?Q?Jes=C3=BAs?= Daniel Colmenares Oviedo <dtxdf@FreeBSD.org> To: ports-committers@FreeBSD.org, dev-commits-ports-all@FreeBSD.org, dev-commits-ports-main@FreeBSD.org Subject: git: 23c19ba170c5 - main - security/py-privleap: New port: Limited Privilege Escalation Framework Message-ID: <6a148b67.3becf.54c1c593@gitrepo.freebsd.org>
index | next in thread | raw e-mail
The branch main has been updated by dtxdf: URL: https://cgit.FreeBSD.org/ports/commit/?id=23c19ba170c5c6ce8102452cd6416a223e62c791 commit 23c19ba170c5c6ce8102452cd6416a223e62c791 Author: Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org> AuthorDate: 2026-05-25 17:46:31 +0000 Commit: Jesús Daniel Colmenares Oviedo <dtxdf@FreeBSD.org> CommitDate: 2026-05-25 17:47:38 +0000 security/py-privleap: New port: Limited Privilege Escalation Framework privleap is a privilege escalation framework similar in purpose to sudo and doas, but very different conceptually. It is designed to allow user-level applications to run very specific operations as root without allowing full root control of the machine. Unlike directly executable privilege escalation frameworks like sudo, privleap runs as a background service that listens for signals from other applications. Each signal can request a particular, pre-configured action to be taken. Signals are authenticated, and each action is taken only if the signal passes authentication. Any console output from the action is then returned to the caller. This system allows privleap to function without being SUID-root, and avoids a lot of the potential pitfalls of sudo, doas, run0, etc. WWW: https://www.kicksecure.com/wiki/Privleap --- security/Makefile | 1 + security/py-privleap/Makefile | 50 +++++++++ security/py-privleap/distinfo | 3 + security/py-privleap/files/pam.conf | 5 + security/py-privleap/files/pam_create_socket.sh | 8 ++ .../patch-auto-generated-man-pages_privleapd.1 | 11 ++ ..._lib_python3_dist-packages_privleap_privleap.py | 19 ++++ ...lib_python3_dist-packages_privleap_privleapd.py | 125 +++++++++++++++++++++ .../files/patch-usr_libexec_privleap_shim.py | 11 ++ security/py-privleap/files/privleapd.in | 23 ++++ security/py-privleap/pkg-descr | 12 ++ security/py-privleap/pkg-message | 11 ++ security/py-privleap/pkg-plist | 27 +++++ 13 files changed, 306 insertions(+) diff --git a/security/Makefile b/security/Makefile index 72e050a741ef..4b2e4e6750f5 100644 --- a/security/Makefile +++ b/security/Makefile @@ -1030,6 +1030,7 @@ SUBDIR += py-pnu-vuxml SUBDIR += py-policyuniverse SUBDIR += py-potr + SUBDIR += py-privleap SUBDIR += py-pwntools SUBDIR += py-pyaes SUBDIR += py-pyaff4 diff --git a/security/py-privleap/Makefile b/security/py-privleap/Makefile new file mode 100644 index 000000000000..802949ea9dfa --- /dev/null +++ b/security/py-privleap/Makefile @@ -0,0 +1,50 @@ +PORTNAME= privleap +DISTVERSION= 5.7-1 +CATEGORIES= security python +PKGNAMEPREFIX= ${PYTHON_PKGNAMEPREFIX} + +MAINTAINER= dtxdf@FreeBSD.org +COMMENT= Limited Privilege Escalation Framework +WWW= https://www.kicksecure.com/wiki/Privleap + +LICENSE= AGPLv3 +LICENSE_FILE= ${WRKSRC}/COPYING + +RUN_DEPENDS= ${PYTHON_PKGNAMEPREFIX}PAM>=0:security/py-PAM@${PY_FLAVOR} + +USES= python:3.13+ shebangfix +USE_GITHUB= yes +GH_ACCOUNT= Kicksecure +USE_RC_SUBR= privleapd + +SHEBANG_FILES= usr/bin/* usr/libexec/privleap/shim.py + +NO_ARCH= yes + +SUB_LIST= PYTHON_CMD=${PYTHON_CMD} + +do-build: + @${PYTHON_CMD} -OO ${PYTHON_LIBDIR}/compileall.py \ + -d ${PYTHON_SITELIBDIR} \ + -f ${WRKSRC}/usr/lib/python3/dist-packages/${PORTNAME} + +do-install: +.for script in leapctl leaprun privleapd + ${INSTALL_SCRIPT} ${WRKSRC}/usr/bin/${script} ${STAGEDIR}${PREFIX}/bin/${script} +.endfor + @${MKDIR} ${STAGEDIR}${PYTHON_SITELIBDIR}/${PORTNAME} + @cd ${WRKSRC}/usr/lib/python3/dist-packages/${PORTNAME} && \ + ${COPYTREE_SHARE} . ${STAGEDIR}${PYTHON_SITELIBDIR}/${PORTNAME} + @${MKDIR} ${STAGEDIR}${PREFIX}/libexec/${PORTNAME} + ${INSTALL_SCRIPT} ${WRKSRC}/usr/libexec/${PORTNAME}/shim.py ${STAGEDIR}${PREFIX}/libexec/${PORTNAME} + ${INSTALL_SCRIPT} ${FILESDIR}/pam_create_socket.sh ${STAGEDIR}${PREFIX}/libexec/${PORTNAME} + ${INSTALL_MAN} ${WRKSRC}/auto-generated-man-pages/leapctl.8 ${STAGEDIR}${PREFIX}/share/man/man8 + ${INSTALL_MAN} ${WRKSRC}/auto-generated-man-pages/leaprun.8 ${STAGEDIR}${PREFIX}/share/man/man8 + ${INSTALL_MAN} ${WRKSRC}/auto-generated-man-pages/privleap.conf.d.5 ${STAGEDIR}${PREFIX}/share/man/man5 + ${INSTALL_MAN} ${WRKSRC}/auto-generated-man-pages/privleapd.1 ${STAGEDIR}${PREFIX}/share/man/man1 + @${MKDIR} ${STAGEDIR}${ETCDIR}/conf.d + @cd ${WRKSRC}/etc/${PORTNAME}/conf.d && ${COPYTREE_SHARE} . ${STAGEDIR}${ETCDIR}/conf.d + @${MKDIR} ${STAGEDIR}${PREFIX}/etc/pam.d + ${INSTALL_DATA} ${FILESDIR}/pam.conf ${STAGEDIR}${PREFIX}/etc/pam.d/privleapd + +.include <bsd.port.mk> diff --git a/security/py-privleap/distinfo b/security/py-privleap/distinfo new file mode 100644 index 000000000000..95993b9ad24f --- /dev/null +++ b/security/py-privleap/distinfo @@ -0,0 +1,3 @@ +TIMESTAMP = 1779647261 +SHA256 (Kicksecure-privleap-5.7-1_GH0.tar.gz) = 6ee88c2fbe1e868691ff5634994cf22d613e91abe8eba5b82083d875ac54afb5 +SIZE (Kicksecure-privleap-5.7-1_GH0.tar.gz) = 120717 diff --git a/security/py-privleap/files/pam.conf b/security/py-privleap/files/pam.conf new file mode 100644 index 000000000000..2ba52ff0b2ac --- /dev/null +++ b/security/py-privleap/files/pam.conf @@ -0,0 +1,5 @@ +auth sufficient pam_rootok.so +auth include system +account include system +session include system +password include system diff --git a/security/py-privleap/files/pam_create_socket.sh b/security/py-privleap/files/pam_create_socket.sh new file mode 100755 index 000000000000..9571b35c7e38 --- /dev/null +++ b/security/py-privleap/files/pam_create_socket.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +if [ -n "${PAM_USER}" ] && [ ! -S "/var/run/privleapd/comm/${PAM_USER}" ]; then + exec leapctl --create "${PAM_USER}" + exit 1 +fi + +exit 0 diff --git a/security/py-privleap/files/patch-auto-generated-man-pages_privleapd.1 b/security/py-privleap/files/patch-auto-generated-man-pages_privleapd.1 new file mode 100644 index 000000000000..177dfa327d28 --- /dev/null +++ b/security/py-privleap/files/patch-auto-generated-man-pages_privleapd.1 @@ -0,0 +1,11 @@ +--- auto-generated-man-pages/privleapd.1.orig 2026-05-24 21:15:07 UTC ++++ auto-generated-man-pages/privleapd.1 +@@ -6,7 +6,7 @@ + .SH "SYNOPSIS" + \fBprivleapd [\-C|\-\-check\-config] [\-h|\-\-help|\-?]\fR + .SH "DESCRIPTION" +-The backend server for privleap\. Runs predefined code snippets as root when requested by an authorized client\. privleapd listens for connections on UNIX sockets under \fB/run/privleapd\fR, it does not send or receive information over a network\. ++The backend server for privleap\. Runs predefined code snippets as root when requested by an authorized client\. privleapd listens for connections on UNIX sockets under \fB/var/run/privleapd\fR, it does not send or receive information over a network\. + .P + You usually will not need to run this directly \- if \fBprivleap\fR was installed from your distribution's repository, it should be pre\-configured to start on boot\. The one time you usually will want to run it is when ensuring that all configuration is valid before restarting privleapd\. + .SH "OPTIONS" diff --git a/security/py-privleap/files/patch-usr_lib_python3_dist-packages_privleap_privleap.py b/security/py-privleap/files/patch-usr_lib_python3_dist-packages_privleap_privleap.py new file mode 100644 index 000000000000..8304748235a6 --- /dev/null +++ b/security/py-privleap/files/patch-usr_lib_python3_dist-packages_privleap_privleap.py @@ -0,0 +1,19 @@ +--- usr/lib/python3/dist-packages/privleap/privleap.py.orig 2026-05-24 20:47:02 UTC ++++ usr/lib/python3/dist-packages/privleap/privleap.py +@@ -1020,7 +1020,6 @@ class PrivleapSession: + """ + + assert self.backend_socket is not None +- self.backend_socket.shutdown(socket.SHUT_RDWR) + self.backend_socket.close() + self.is_session_open = False + +@@ -1223,7 +1222,7 @@ class PrivleapCommon: + Common constants and functions used throughout privleap. + """ + +- state_dir: Path = Path("/run/privleapd") ++ state_dir: Path = Path("/var/run/privleapd") + control_path: Path = Path(state_dir, "control") + comm_dir: Path = Path(state_dir, "comm") + config_file_regex: re.Pattern[str] = re.compile(r"[-A-Za-z0-9_]+\.conf\Z") diff --git a/security/py-privleap/files/patch-usr_lib_python3_dist-packages_privleap_privleapd.py b/security/py-privleap/files/patch-usr_lib_python3_dist-packages_privleap_privleapd.py new file mode 100644 index 000000000000..ef8d4a5a3a71 --- /dev/null +++ b/security/py-privleap/files/patch-usr_lib_python3_dist-packages_privleap_privleapd.py @@ -0,0 +1,125 @@ +--- usr/lib/python3/dist-packages/privleap/privleapd.py.orig 2026-05-24 19:41:39 UTC ++++ usr/lib/python3/dist-packages/privleap/privleapd.py +@@ -28,8 +28,6 @@ from dataclasses import dataclass + from typing import cast, SupportsIndex, NoReturn, Any, IO + from dataclasses import dataclass + +-import sdnotify # type: ignore +- + from .privleap import ( + ConfigData, + PrivleapAction, +@@ -116,9 +114,6 @@ class PrivleapdGlobal: + allowed_group_list: list[str] = [] + expected_disallowed_user_list: list[str] = [] + +- # Readable and writable by main thread only +- sdnotify_object: sdnotify.SystemdNotifier = sdnotify.SystemdNotifier() +- + # Thread IPC mechanisms + # control-to-main pipe read end, for main thread + ctm_read_fd: int = 0 +@@ -609,7 +604,7 @@ def run_action( + if target_user is None and target_group is None: + # Both user and group are unset, default to "root" for both. + target_user = "root" +- target_group = "root" ++ target_group = "wheel" + elif target_group is None: + # Target user is set but group is unset, set the group to the target + # user's default group. +@@ -629,14 +624,13 @@ def run_action( + + action_process: subprocess.Popen[bytes] = subprocess.Popen( + [ +- "/usr/libexec/privleap/shim.py", ++ "/usr/local/libexec/privleap/shim.py", + calling_user, + target_user, + target_group, + str(PrivleapdGlobal.old_umask), +- "/usr/bin/bash", ++ "/bin/sh", + "-c", +- "--", + desired_action.action_command, + ], + stdout=subprocess.PIPE, +@@ -841,10 +835,10 @@ def send_action_results( + assert action_process.stderr is not None + assert comm_session.backend_socket is not None + +- epoll_obj: select.epoll = select.epoll() +- epoll_obj.register(comm_session.backend_socket.fileno(), select.EPOLLIN) +- epoll_obj.register(action_process.stdout.fileno(), select.EPOLLIN) +- epoll_obj.register(action_process.stderr.fileno(), select.EPOLLIN) ++ epoll_obj: select.poll = select.poll() ++ epoll_obj.register(comm_session.backend_socket.fileno(), select.POLLIN) ++ epoll_obj.register(action_process.stdout.fileno(), select.POLLIN) ++ epoll_obj.register(action_process.stderr.fileno(), select.POLLIN) + + # Comm threads that are currently streaming stdio from a process to a + # client may be stuck waiting for the process to write something to stdout +@@ -855,7 +849,7 @@ def send_action_results( + # written to this variable (it is always a single NULL byte), we just need + # to break the epoll_obj.poll() call. + assert listen_socket_info.term_notify_read_fd != 0 +- epoll_obj.register(listen_socket_info.term_notify_read_fd, select.EPOLLIN) ++ epoll_obj.register(listen_socket_info.term_notify_read_fd, select.POLLIN) + + try: + stdout_done: bool = False +@@ -899,7 +893,6 @@ def send_action_results( + action_process.wait() + + finally: +- epoll_obj.close() + action_process.stdout.close() + action_process.stderr.close() + action_process.terminate() +@@ -1673,8 +1666,8 @@ def main_loop() -> NoReturn: + + assert PrivleapdGlobal.ctm_read_pipe is not None + epoll_fd_set: set[int] = set() +- epoll_obj: select.epoll = select.epoll() +- epoll_obj.register(PrivleapdGlobal.ctm_read_fd, select.EPOLLIN) ++ epoll_obj: select.poll = select.poll() ++ epoll_obj.register(PrivleapdGlobal.ctm_read_fd, select.POLLIN) + socket_list_changed: bool = True + + while True: +@@ -1688,14 +1681,13 @@ def main_loop() -> NoReturn: + ] + read_sock_fileno_set: set[int] = set(read_sock_fileno_list) + for register_fileno in read_sock_fileno_set - epoll_fd_set: +- epoll_obj.register(register_fileno, select.EPOLLIN) ++ epoll_obj.register(register_fileno, select.POLLIN) + epoll_fd_set.update(read_sock_fileno_set) + for remove_fileno in epoll_fd_set - read_sock_fileno_set: + epoll_fd_set.remove(remove_fileno) + socket_list_changed = False + + epoll_event_fd_list: list[int] = [x[0] for x in epoll_obj.poll(5)] +- PrivleapdGlobal.sdnotify_object.notify("WATCHDOG=1") + + if PrivleapdGlobal.ctm_read_fd in epoll_event_fd_list: + # Connection change, i.e. adding or removing a socket. The +@@ -1723,8 +1715,7 @@ def main_loop() -> NoReturn: + ready_sock_info_obj = sock_info_obj + break + if ready_sock_info_obj is None: +- logging.critical("privleapd lost track of a socket!") +- sys.exit(1) ++ continue + if ready_sock_info_obj.listen_socket.socket_type == ( + PrivleapSocketType.CONTROL + ): +@@ -1799,8 +1790,6 @@ def main() -> NoReturn: + populate_state_dir() + open_control_socket() + open_persistent_comm_sockets(in_control_thread=False) +- PrivleapdGlobal.sdnotify_object.notify("READY=1") +- PrivleapdGlobal.sdnotify_object.notify("STATUS=Fully started") + if PrivleapdGlobal.test_mode: + Path("/tmp/privleapd-ready-for-test").touch() + control_handler_thread: Thread = Thread( diff --git a/security/py-privleap/files/patch-usr_libexec_privleap_shim.py b/security/py-privleap/files/patch-usr_libexec_privleap_shim.py new file mode 100644 index 000000000000..a2a92f3ce3c7 --- /dev/null +++ b/security/py-privleap/files/patch-usr_libexec_privleap_shim.py @@ -0,0 +1,11 @@ +--- usr/libexec/privleap/shim.py.orig 2026-05-24 22:32:43 UTC ++++ usr/libexec/privleap/shim.py +@@ -100,7 +100,7 @@ action_env["LOGNAME"] = target_user_info.pw_name + action_env: dict[str, str] = os.environ.copy() + action_env["HOME"] = target_user_info.pw_dir + action_env["LOGNAME"] = target_user_info.pw_name +-action_env["SHELL"] = "/usr/bin/bash" ++action_env["SHELL"] = "/bin/sh" + action_env["PWD"] = target_user_info.pw_dir + action_env["USER"] = target_user_info.pw_name + action_env["LC_ALL"] = "C" diff --git a/security/py-privleap/files/privleapd.in b/security/py-privleap/files/privleapd.in new file mode 100755 index 000000000000..37b93ae71e07 --- /dev/null +++ b/security/py-privleap/files/privleapd.in @@ -0,0 +1,23 @@ +#!/bin/sh +# +# PROVIDE: privleapd +# REQUIRE: LOGIN +# KEYWORD: shutdown + +. /etc/rc.subr + +name=privleapd +desc="Limited Privilege Escalation Framework" +rcvar="${name}_enable" + +load_rc_config "${name}" + +: ${privleapd_enable:=NO} + +pidfile="/var/run/${name}.pid" +procname="%%PREFIX%%/bin/${name}" +command_interpreter="%%PYTHON_CMD%%" +command="/usr/sbin/daemon" +command_args="-cS -l authpriv -t \"${desc}\" -T \"${name}\" -p \"${pidfile}\" \"${procname}\"" + +run_rc_command "$1" diff --git a/security/py-privleap/pkg-descr b/security/py-privleap/pkg-descr new file mode 100644 index 000000000000..098f56fb78b2 --- /dev/null +++ b/security/py-privleap/pkg-descr @@ -0,0 +1,12 @@ +privleap is a privilege escalation framework similar in purpose to +sudo and doas, but very different conceptually. It is designed to +allow user-level applications to run very specific operations as +root without allowing full root control of the machine. Unlike +directly executable privilege escalation frameworks like sudo, +privleap runs as a background service that listens for signals from +other applications. Each signal can request a particular, pre-configured +action to be taken. Signals are authenticated, and each action is +taken only if the signal passes authentication. Any console output +from the action is then returned to the caller. This system allows +privleap to function without being SUID-root, and avoids a lot of +the potential pitfalls of sudo, doas, run0, etc. diff --git a/security/py-privleap/pkg-message b/security/py-privleap/pkg-message new file mode 100644 index 000000000000..72a4265e0c43 --- /dev/null +++ b/security/py-privleap/pkg-message @@ -0,0 +1,11 @@ +[ +{ type: install + message: <<EOM +pam_exec(8) could be used to call leapctl(8) to create the +communication socket required by leaprun(8) by simply adding +the following to a PAM policy file: + + session optional pam_exec.so /usr/local/libexec/privleap/pam_create_socket.sh +EOM +} +] diff --git a/security/py-privleap/pkg-plist b/security/py-privleap/pkg-plist new file mode 100644 index 000000000000..2ee26aa4acbc --- /dev/null +++ b/security/py-privleap/pkg-plist @@ -0,0 +1,27 @@ +bin/leapctl +bin/leaprun +bin/privleapd +etc/pam.d/privleapd +%%ETCDIR%%/conf.d/privleap.conf +%%PYTHON_SITELIBDIR%%/privleap/__init__.py +%%PYTHON_SITELIBDIR%%/privleap/__pycache__/__init__%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/__pycache__/leapctl%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/__pycache__/leaprun%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/__pycache__/privleap%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/__pycache__/privleapd%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/leapctl.py +%%PYTHON_SITELIBDIR%%/privleap/leaprun.py +%%PYTHON_SITELIBDIR%%/privleap/privleap.py +%%PYTHON_SITELIBDIR%%/privleap/privleapd.py +%%PYTHON_SITELIBDIR%%/privleap/tests/__init__.py +%%PYTHON_SITELIBDIR%%/privleap/tests/__pycache__/__init__%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/tests/__pycache__/run_test%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/tests/__pycache__/run_test_util%%PYTHON_TAG%%.opt-2.pyc +%%PYTHON_SITELIBDIR%%/privleap/tests/run_test.py +%%PYTHON_SITELIBDIR%%/privleap/tests/run_test_util.py +libexec/privleap/shim.py +libexec/privleap/pam_create_socket.sh +share/man/man1/privleapd.1.gz +share/man/man5/privleap.conf.d.5.gz +share/man/man8/leapctl.8.gz +share/man/man8/leaprun.8.gzhome | help
Want to link to this message? Use this
URL: <https://mail-archive.FreeBSD.org/cgi/mid.cgi?6a148b67.3becf.54c1c593>
