From 97c16953878bbb293289f0e0364c0eb6a3d838a8 Mon Sep 17 00:00:00 2001 From: eabdullin Date: Fri, 17 Jan 2025 09:45:37 +0300 Subject: [PATCH] Import OL ipa-4.12.2-1.0.1.el9_5.3 --- ...with-an-expired-OTP-token-to-log-in-.patch | 265 +++ SOURCES/0004-Add-ipa-idrange-fix.patch | 1501 +++++++++++++++++ ...sing-comma-in-test_idrange_no_rid_ba.patch | 36 + ...-Fixes-for-ipa-idrange-fix-testsuite.patch | 35 + .../0007-ipalib-x509-support-PyCA-44.0.patch | 166 ++ ...pt-import-paths-for-TripleDES-cipher.patch | 120 ++ ...xtop-clarify-OTP-use-over-LDAP-binds.patch | 134 ++ ...ing-ipaAllowedOperations-objectclass.patch | 45 + SOURCES/0011-CVE-2024-11029.patch | 1124 ++++++++++++ SPECS/freeipa.spec | 33 +- 10 files changed, 3457 insertions(+), 2 deletions(-) create mode 100644 SOURCES/0003-Do-not-let-user-with-an-expired-OTP-token-to-log-in-.patch create mode 100644 SOURCES/0004-Add-ipa-idrange-fix.patch create mode 100644 SOURCES/0005-ipatests-Add-missing-comma-in-test_idrange_no_rid_ba.patch create mode 100644 SOURCES/0006-ipatests-Fixes-for-ipa-idrange-fix-testsuite.patch create mode 100644 SOURCES/0007-ipalib-x509-support-PyCA-44.0.patch create mode 100644 SOURCES/0008-pyca-adapt-import-paths-for-TripleDES-cipher.patch create mode 100644 SOURCES/0009-ipa-pwd-extop-clarify-OTP-use-over-LDAP-binds.patch create mode 100644 SOURCES/0010-adtrust-add-missing-ipaAllowedOperations-objectclass.patch create mode 100644 SOURCES/0011-CVE-2024-11029.patch diff --git a/SOURCES/0003-Do-not-let-user-with-an-expired-OTP-token-to-log-in-.patch b/SOURCES/0003-Do-not-let-user-with-an-expired-OTP-token-to-log-in-.patch new file mode 100644 index 0000000..b498de6 --- /dev/null +++ b/SOURCES/0003-Do-not-let-user-with-an-expired-OTP-token-to-log-in-.patch @@ -0,0 +1,265 @@ +From 18303b94bea4e08a0c889fc357df6ba2f308fa0d Mon Sep 17 00:00:00 2001 +From: Mark Reynolds +Date: Wed, 2 Oct 2024 21:26:34 -0400 +Subject: [PATCH] Do not let user with an expired OTP token to log in if only + OTP is allowed + +If only OTP authentication is allowed, and a user tries to login with an +expired token, do not let them log in with their password. Forcing the +admin to intervene. If the user does not have an OTP token then allow +them to log in with a password until an OTP token is configured + +Fixes: https://pagure.io/freeipa/issue/9387 + +Signed-off-by: Mark Reynolds +Reviewed-By: Rob Crittenden +Reviewed-By: Alexander Bokovoy +Reviewed-By: Julien Rische +--- + daemons/ipa-kdb/ipa_kdb_principals.c | 63 +++++++++++-- + .../ipa-slapi-plugins/ipa-pwd-extop/prepost.c | 3 +- + ipatests/test_integration/test_otp.py | 94 ++++++++++++++++++- + 3 files changed, 151 insertions(+), 9 deletions(-) + +diff --git a/daemons/ipa-kdb/ipa_kdb_principals.c b/daemons/ipa-kdb/ipa_kdb_principals.c +index 14603e528b43acb29234c425e97ad297ac6724a7..114957b884786dd3ca3b01c47f6bb82e8a040beb 100644 +--- a/daemons/ipa-kdb/ipa_kdb_principals.c ++++ b/daemons/ipa-kdb/ipa_kdb_principals.c +@@ -107,7 +107,6 @@ static char *std_principal_obj_classes[] = { + "krbprincipal", + "krbprincipalaux", + "krbTicketPolicyAux", +- + NULL + }; + +@@ -338,14 +337,16 @@ static void ipadb_validate_otp(struct ipadb_context *ipactx, + if (dn == NULL) + return; + count = asprintf(&filter, ftmpl, dn, datetime, datetime); +- ldap_memfree(dn); +- if (count < 0) ++ if (count < 0) { ++ ldap_memfree(dn); + return; ++ } + + /* Fetch the active token list. */ + kerr = ipadb_simple_search(ipactx, ipactx->base, LDAP_SCOPE_SUBTREE, + filter, (char**) attrs, &res); + free(filter); ++ filter = NULL; + if (kerr != 0 || res == NULL) + return; + +@@ -353,10 +354,60 @@ static void ipadb_validate_otp(struct ipadb_context *ipactx, + count = ldap_count_entries(ipactx->lcontext, res); + ldap_msgfree(res); + +- /* If the user is configured for OTP, but has no active tokens, remove +- * OTP from the list since the user obviously can't log in this way. */ +- if (count == 0) ++ /* ++ * If there are no valid tokens then we need to remove the OTP flag, ++ * unless OTP is the only auth type allowed... ++ */ ++ if (count == 0) { ++ /* Remove the OTP flag for now */ + *ua &= ~IPADB_USER_AUTH_OTP; ++ ++ if (*ua == 0) { ++ /* ++ * Ok, we "only" allow OTP, so if there is an expired/disabled ++ * token then add back the OTP flag as the server will double ++ * check the validity and reject the entire bind. Otherwise, this ++ * is the first time the user is authenticating and the user ++ * should be allowed to bind using its password ++ */ ++ static const char *expired_ftmpl = "(&" ++ "(objectClass=ipaToken)(ipatokenOwner=%s)" ++ "(|(ipatokenNotAfter<=%s)(!(ipatokenNotAfter=*))" ++ "(ipatokenDisabled=True))" ++ ")"; ++ if (asprintf(&filter, expired_ftmpl, dn, datetime) < 0) { ++ ldap_memfree(dn); ++ return; ++ } ++ ++ krb5_klog_syslog(LOG_INFO, ++ "Entry (%s) does not have a valid token and only OTP " ++ "authentication is supported, checking for expired tokens...", ++ dn); ++ ++ kerr = ipadb_simple_search(ipactx, ipactx->base, LDAP_SCOPE_SUBTREE, ++ filter, (char**) attrs, &res); ++ free(filter); ++ if (kerr != 0 || res == NULL) { ++ ldap_memfree(dn); ++ return; ++ } ++ ++ if (ldap_count_entries(ipactx->lcontext, res) > 0) { ++ /* ++ * Ok we only allow OTP, and there are expired/disabled tokens ++ * so add the OTP flag back, and the server will reject the ++ * bind ++ */ ++ krb5_klog_syslog(LOG_INFO, ++ "Entry (%s) does have an expired/disabled token so this " ++ "user can not fall through to password auth", dn); ++ *ua |= IPADB_USER_AUTH_OTP; ++ } ++ ldap_msgfree(res); ++ } ++ } ++ ldap_memfree(dn); + } + + static void ipadb_validate_radius(struct ipadb_context *ipactx, +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +index c967e2cfffbd920280639f3188783ec150523b47..1c1340e31ac30cb01412a7065ea339cb5461e839 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +@@ -1528,7 +1528,8 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) + if (!syncreq && (otpreq == OTP_IS_NOT_REQUIRED)) { + ret = ipapwd_gen_checks(pb, &errMesg, &krbcfg, IPAPWD_CHECK_ONLY_CONFIG); + if (ret != 0) { +- LOG_FATAL("ipapwd_gen_checks failed!?\n"); ++ LOG_FATAL("ipapwd_gen_checks failed for '%s': %s\n", ++ slapi_sdn_get_dn(sdn), errMesg); + slapi_entry_free(entry); + slapi_sdn_free(&sdn); + return 0; +diff --git a/ipatests/test_integration/test_otp.py b/ipatests/test_integration/test_otp.py +index 350371bfe1e4c1cc6dcc89f6584f813fcb0d32a0..878b4fb560ba8d7768ead54b065656462545babd 100644 +--- a/ipatests/test_integration/test_otp.py ++++ b/ipatests/test_integration/test_otp.py +@@ -10,6 +10,7 @@ import re + import time + import textwrap + from urllib.parse import urlparse, parse_qs ++from paramiko import AuthenticationException + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import hashes +@@ -83,7 +84,7 @@ def kinit_otp(host, user, *, password, otp, success=True): + ) + + +-def ssh_2f(hostname, username, answers_dict, port=22): ++def ssh_2f(hostname, username, answers_dict, port=22, unwanted_prompt=""): + """ + :param hostname: hostname + :param username: username +@@ -103,6 +104,10 @@ def ssh_2f(hostname, username, answers_dict, port=22): + logger.info("Prompt is: '%s'", prmpt_str) + logger.info( + "Answer to ssh prompt is: '%s'", answers_dict[prmpt_str]) ++ if unwanted_prompt and prmpt_str == unwanted_prompt: ++ # We should not see this prompt ++ raise ValueError("We got an unwanted prompt: " ++ + answers_dict[prmpt_str]) + return resp + + import paramiko +@@ -193,7 +198,8 @@ class TestOTPToken(IntegrationTest): + + # skipping too many OTP fails + otp1 = hotp.generate(10).decode("ascii") +- kinit_otp(self.master, USER, password=PASSWORD, otp=otp1, success=False) ++ kinit_otp(self.master, USER, password=PASSWORD, otp=otp1, ++ success=False) + # Now the token is desynchronized + yield (otpuid, hotp) + +@@ -536,3 +542,87 @@ class TestOTPToken(IntegrationTest): + finally: + master.run_command(['ipa', 'pwpolicy-mod', '--minlife', '1']) + master.run_command(['ipa', 'user-del', USER1]) ++ ++ def test_totp_expired_ldap(self): ++ master = self.master ++ basedn = master.domain.basedn ++ USER1 = 'user-expired-otp' ++ TMP_PASSWORD = 'Secret1234509' ++ binddn = DN(f"uid={USER1},cn=users,cn=accounts,{basedn}") ++ controls = [ ++ BooleanControl( ++ controlType="2.16.840.1.113730.3.8.10.7", ++ booleanValue=True) ++ ] ++ ++ tasks.kinit_admin(master) ++ master.run_command(['ipa', 'pwpolicy-mod', '--minlife', '0']) ++ tasks.user_add(master, USER1, password=TMP_PASSWORD) ++ # Enforce use of OTP token for this user ++ master.run_command(['ipa', 'user-mod', USER1, ++ '--user-auth-type=otp']) ++ try: ++ # Change initial password through the IPA endpoint ++ url = f'https://{master.hostname}/ipa/session/change_password' ++ master.run_command(['curl', '-d', f'user={USER1}', ++ '-d', f'old_password={TMP_PASSWORD}', ++ '-d', f'new_password={PASSWORD}', ++ '--referer', f'https://{master.hostname}/ipa', ++ url]) ++ conn = master.ldap_connect() ++ # First, attempt authenticating with a password but without LDAP ++ # control to enforce OTP presence and without server-side ++ # enforcement of the OTP presence check. ++ conn.simple_bind(binddn, f"{PASSWORD}") ++ ++ # Add an OTP token and then modify it to be expired ++ otpuid, totp = add_otptoken(master, USER1, otptype="totp") ++ ++ # Make sure OTP auth is working ++ otpvalue = totp.generate(int(time.time())).decode("ascii") ++ conn = master.ldap_connect() ++ conn.simple_bind(binddn, f"{PASSWORD}{otpvalue}", ++ client_controls=controls) ++ conn.unbind() ++ ++ # Modfy token so that is now expired ++ args = [ ++ "ipa", ++ "otptoken-mod", ++ otpuid, ++ "--not-after", ++ "20241001010000Z", ++ ] ++ master.run_command(args) ++ ++ # Next, authenticate with Password+OTP again and with the LDAP ++ # control this operation should now fail ++ time.sleep(45) ++ otpvalue = totp.generate(int(time.time())).decode("ascii") ++ ++ conn = master.ldap_connect() ++ with pytest.raises(errors.ACIError): ++ conn.simple_bind(binddn, f"{PASSWORD}{otpvalue}", ++ client_controls=controls) ++ ++ # Sleep to make sure we are going to use a different token value ++ time.sleep(45) ++ ++ # Use OTP token again but authenticate over ssh and make sure it ++ # doesn't fallthrough to asking for a password ++ otpvalue = totp.generate(int(time.time())).decode("ascii") ++ answers = { ++ 'Enter first factor:': PASSWORD, ++ 'Enter second factor:': otpvalue ++ } ++ with pytest.raises(AuthenticationException): ++ # ssh should fail and NOT ask for a password ++ ssh_2f(master.hostname, USER1, answers, ++ unwanted_prompt="Password:") ++ ++ # Remove token ++ del_otptoken(self.master, otpuid) ++ ++ finally: ++ master.run_command(['ipa', 'pwpolicy-mod', '--minlife', '1']) ++ master.run_command(['ipa', 'user-del', USER1]) +-- +2.47.0 + diff --git a/SOURCES/0004-Add-ipa-idrange-fix.patch b/SOURCES/0004-Add-ipa-idrange-fix.patch new file mode 100644 index 0000000..c626995 --- /dev/null +++ b/SOURCES/0004-Add-ipa-idrange-fix.patch @@ -0,0 +1,1501 @@ +From aa4651526e6697e15ce4960bf1d15d1389889c7f Mon Sep 17 00:00:00 2001 +From: "asharov@redhat.com" +Date: Mon, 24 Jun 2024 15:33:34 +0200 +Subject: [PATCH] Add ipa-idrange-fix + +ipa-idrange-fix is a tool for analysis of existing IPA ranges, users +and groups outside of those ranges, and functionality to propose +and apply remediations to make sure as much users and groups as +possible end up in the IPA-managed ranges. + +Fixes: https://pagure.io/freeipa/issue/9612 + +Signed-off-by: Aleksandr Sharov +Reviewed-By: Rob Crittenden +Reviewed-By: Rob Crittenden +--- + freeipa.spec.in | 2 + + install/tools/Makefile.am | 2 + + install/tools/ipa-idrange-fix.in | 8 + + install/tools/man/Makefile.am | 1 + + install/tools/man/ipa-idrange-fix.1 | 111 ++ + ipaserver/install/ipa_idrange_fix.py | 1085 +++++++++++++++++ + .../test_integration/test_ipa_idrange_fix.py | 189 +++ + 7 files changed, 1398 insertions(+) + create mode 100644 install/tools/ipa-idrange-fix.in + create mode 100644 install/tools/man/ipa-idrange-fix.1 + create mode 100644 ipaserver/install/ipa_idrange_fix.py + create mode 100644 ipatests/test_integration/test_ipa_idrange_fix.py + +diff --git a/freeipa.spec.in b/freeipa.spec.in +index e370290bc74d92ab239bf11e88b3fa7e4faef415..171b6ad27b57553fdd46c7d041715949bb00b163 100755 +--- a/freeipa.spec.in ++++ b/freeipa.spec.in +@@ -1517,6 +1517,7 @@ fi + %{_sbindir}/ipa-pkinit-manage + %{_sbindir}/ipa-crlgen-manage + %{_sbindir}/ipa-cert-fix ++%{_sbindir}/ipa-idrange-fix + %{_sbindir}/ipa-acme-manage + %{_sbindir}/ipa-migrate + %if 0%{?fedora} >= 38 +@@ -1596,6 +1597,7 @@ fi + %{_mandir}/man1/ipa-pkinit-manage.1* + %{_mandir}/man1/ipa-crlgen-manage.1* + %{_mandir}/man1/ipa-cert-fix.1* ++%{_mandir}/man1/ipa-idrange-fix.1* + %{_mandir}/man1/ipa-acme-manage.1* + %{_mandir}/man1/ipa-migrate.1* + +diff --git a/install/tools/Makefile.am b/install/tools/Makefile.am +index c454fad9795c79f88e1d72688f1d15c5234cc113..ca484ec37969c9c06ae7b408b55fa30cd4e8e4fe 100644 +--- a/install/tools/Makefile.am ++++ b/install/tools/Makefile.am +@@ -31,6 +31,7 @@ dist_noinst_DATA = \ + ipa-pkinit-manage.in \ + ipa-crlgen-manage.in \ + ipa-cert-fix.in \ ++ ipa-idrange-fix.in \ + ipa-custodia.in \ + ipa-custodia-check.in \ + ipa-httpd-kdcproxy.in \ +@@ -68,6 +69,7 @@ nodist_sbin_SCRIPTS = \ + ipa-pkinit-manage \ + ipa-crlgen-manage \ + ipa-cert-fix \ ++ ipa-idrange-fix \ + ipa-acme-manage \ + ipa-migrate \ + $(NULL) +diff --git a/install/tools/ipa-idrange-fix.in b/install/tools/ipa-idrange-fix.in +new file mode 100644 +index 0000000000000000000000000000000000000000..5994bd28b15e247c5a086238f36b16cc75ff24c3 +--- /dev/null ++++ b/install/tools/ipa-idrange-fix.in +@@ -0,0 +1,8 @@ ++#!/usr/bin/python3 ++# ++# Copyright (C) 2024 FreeIPA Contributors see COPYING for license ++# ++ ++from ipaserver.install.ipa_idrange_fix import IPAIDRangeFix ++ ++IPAIDRangeFix.run_cli() +diff --git a/install/tools/man/Makefile.am b/install/tools/man/Makefile.am +index 34f359863afca7b6c1e792a53afc25bb8eb41fd3..e9542a77bbbb88054eae1e64311d6e9ec5bee499 100644 +--- a/install/tools/man/Makefile.am ++++ b/install/tools/man/Makefile.am +@@ -29,6 +29,7 @@ dist_man1_MANS = \ + ipa-pkinit-manage.1 \ + ipa-crlgen-manage.1 \ + ipa-cert-fix.1 \ ++ ipa-idrange-fix.1 \ + ipa-acme-manage.1 \ + ipa-migrate.1 \ + $(NULL) +diff --git a/install/tools/man/ipa-idrange-fix.1 b/install/tools/man/ipa-idrange-fix.1 +new file mode 100644 +index 0000000000000000000000000000000000000000..178d2e88779e135a65f3285de62d2dc3b19c175a +--- /dev/null ++++ b/install/tools/man/ipa-idrange-fix.1 +@@ -0,0 +1,111 @@ ++.\" ++.\" Copyright (C) 2024 FreeIPA Contributors see COPYING for license ++.\" ++.TH "ipa-idrange-fix" "1" "May 26 2024" "IPA" "IPA Manual Pages" ++.SH "NAME" ++ipa\-idrange\-fix \- Analyse and fix IPA ID ranges ++.SH "SYNOPSIS" ++ipa\-idrange\-fix [options] ++.SH "DESCRIPTION" ++ ++\fIipa-idrange-fix\fR is a tool for analysis of existing IPA ranges, users and ++groups outside of those ranges, and functionality to propose and apply ++remediations to make sure as many users and groups as possible end up in the ++IPA-managed ranges. Before any changes are applied, a full backup of the system ++is \fBSTRONGLY RECOMMENDED\fR. ++ ++Do not use this program in unattended mode unless you are absolutely sure ++you are consenting to the tool's proposals. ++ ++You can apply the proposals manually via \fIipa idrange(1)\fR commands. ++ ++This tool requires it to be run as \fBroot\fR and does not require a kerberos ++ticket. The directory server needs to be running. ++ ++\fIipa-idrange-fix\fR will read current ranges from LDAP, then check their ++basic constraints, RID bases, etc. If it finds critical issues with ranges, ++manual adjustment will be required. ++ ++After analyzing existing ranges, the tool will search for users and groups that ++are outside of ipa-local ranges. Then it will attempt to propose new ipa-local ++ranges in order to cover users and groups found. ++ ++Finally, the tool will summarize the analysis, and, if there are proposed ++changes, will ask if the user wants to apply those. Please read the ++proposals carefully before proceeding with changes! ++ ++Important note: By default, \fIipa-idrange-fix\fR will not cover the users and ++groups that have IDs under 1000 as these IDs are reserved for system and ++service users and groups. We \fBdon't recommend\fR using IDs under 1000 for ++IPA users and groups as they can possibly overlap with local ones. Please ++consider moving those users out of the range 1..1000, unless they are ++absolutely needed. ++ ++.SH "OPTIONS" ++.TP ++\fB\-\-version\fR ++Show the program's version and exit. ++.TP ++\fB\-h\fR, \fB\-\-help\fR ++Show the help for this program. ++.TP ++\fB\-\-ridoffset \fIINT\fR ++An offset for newly proposed base RIDs for ranges. We introduce offset in order ++to have an ability to increase ranges in the future, increase to more than ++offset will result in RID bases overlapping, and will be denied. If set to 0, ++there will be no offset, proposed RID ranges will start directly one after ++another. ++ ++Default - \fI100000\fR, allowed values - from \fI0\fR to \fI2^31\fR. ++.TP ++\fB\-\-rangegap \fIINT\fR ++A number of IDs between out of ranges IDs to be considered too big to be inside ++a proposed range. If the gap is bigger than this attribute, a new range will be ++started. If set to 0, every entity will get its own range, if allowed by ++\fI--minrange\fR. ++ ++Default - \fI200000\fR, allowed values - from \fI0\fR to \fI2^31\fR. ++.TP ++\fB\-\-minrange \fIINT\fR ++A minimal amount of IDs the tool considers to be a valid range. All IDs that ++would form a range with less than this number will be considered outliers, not ++worth creating an IDrange for, and will be listed explicitly to be moved ++manually. If set to 1, a range will be proposed for every entity, even if the ++entity is single in the middle of an empty space. ++ ++Default - \fI10\fR, allowed values - from \fI1\fR to \fI2^31\fR. ++.TP ++\fB\-\-allowunder1000\fR ++A flag to allow proposing ranges that start with IDs lower than \fI1000\fR. ++Remember, this is not recommended - IDs under 1000 are reserved for system and ++service users and groups. IDranges with these low IDs may result with ++overlapping of IPA and system local users and groups, which can be a serious ++security issue and generally produce a lot of issues around these entities' ++resolution. ++.TP ++\fB\-\-norounding\fR ++A flag to turn off idrange starting id and size rounding - e.g. if we find ++ID 1234, and the size 567, it will stay that way, the proposed range will ++start at ID 1234, and have a 567 size. If not specified, basic rounding to ++outer margins will be applied. Rounding will be 10^size of the proposed range. ++.TP ++\fB\-\-unattended\fR ++Run the tool in unattended mode, if any changes would be proposed, they will ++be applied automatically. ++.TP ++\fB\-v\fR, \fB\-\-verbose\fR ++Print debugging information. ++.TP ++\fB\-q\fR, \fB\-\-quiet\fR ++Output only errors (output from child processes may still be shown). ++.TP ++\fB\-\-log\-file\fR=\fIFILE\fR ++Log to the given file. ++.SH "EXIT STATUS" ++0 if the command was successful ++ ++1 if an error occurred ++ ++.SH "SEE ALSO" ++.BR ipa\ idrange-mod(1) ++.BR ipa\ idrange-add(1) +diff --git a/ipaserver/install/ipa_idrange_fix.py b/ipaserver/install/ipa_idrange_fix.py +new file mode 100644 +index 0000000000000000000000000000000000000000..c6c67ae9330e2d0184efc09d09a84216ef0772a6 +--- /dev/null ++++ b/ipaserver/install/ipa_idrange_fix.py +@@ -0,0 +1,1085 @@ ++"""Tool to analyze and fix IPA ID ranges""" ++# ++# Copyright (C) 2024 FreeIPA Contributors see COPYING for license ++# ++ ++import logging ++import ldap ++ ++from ipalib import api, errors ++from ipapython.admintool import AdminTool ++from ipapython.dn import DN ++from ipapython import ipautil ++from typing import List, Tuple ++ ++logger = logging.getLogger(__name__) ++ ++ ++class IDRange: ++ """Class for ID range entity""" ++ ++ def __init__(self): ++ self.last_id: int = None ++ self.last_base_rid: int = None ++ self.last_secondary_rid: int = None ++ self.name: str = None ++ self.size: int = None ++ self.first_id: int = None ++ self.base_rid: int = None ++ self.secondary_base_rid: int = None ++ self.type: str = None ++ self.suffix: str = None ++ self.dn: str = None ++ ++ def _count(self) -> None: ++ """Function to calculate last IDs for the range""" ++ self.last_id = self.first_id + self.size - 1 ++ if self.type == "ipa-local": ++ self.last_base_rid = ( ++ self.base_rid + self.size ++ if self.base_rid is not None ++ else None ++ ) ++ self.last_secondary_rid = ( ++ self.secondary_base_rid + self.size ++ if self.secondary_base_rid is not None ++ else None ++ ) ++ ++ def __repr__(self): ++ return ( ++ f"IDRange(name='{self.name}', " ++ f"type={self.type}, " ++ f"size={self.size}, " ++ f"first_id={self.first_id}, " ++ f"base_rid={self.base_rid}, " ++ f"secondary_base_rid={self.secondary_base_rid})" ++ ) ++ ++ def __eq__(self, other): ++ return self.first_id == other.first_id ++ ++ ++class IDentity: ++ """A generic class for ID entity - users or groups""" ++ ++ def __init__(self, **kwargs): ++ self.dn: str = kwargs.get('dn') ++ self.name: str = kwargs.get('name') ++ self.user: str = kwargs.get('user') ++ self.number: int = kwargs.get('number') ++ ++ def __str__(self): ++ if self.user: ++ return (f"user '{self.name}', uid={self.number}") ++ return (f"group '{self.name}', gid={self.number}") ++ ++ def debug(self): ++ if self.user: ++ return ( ++ f"user(username='{self.name}', " ++ f"uid={self.number}, " ++ f"{self.dn})" ++ ) ++ return ( ++ f"group(groupname='{self.name}', " ++ f"gid={self.number}, " ++ f"{self.dn})" ++ ) ++ ++ def __eq__(self, other): ++ return self.number == other.number and self.user == other.user ++ ++ ++class IPAIDRangeFix(AdminTool): ++ """Tool to analyze and fix IPA ID ranges""" ++ ++ command_name = "ipa-idrange-fix" ++ log_file_name = "/var/log/ipa-idrange-fix.log" ++ usage = "%prog" ++ description = "Analyze and fix IPA ID ranges" ++ ++ @classmethod ++ def add_options(cls, parser, debug_option=False): ++ super(IPAIDRangeFix, cls).add_options(parser) ++ parser.add_option( ++ "--ridoffset", ++ dest="ridoffset", ++ type=int, ++ default=100000, ++ metavar=100000, ++ help="Offset for a next base RID from previous RID range. \ ++Needed for future range size expansions. Has to be > 0", ++ ) ++ parser.add_option( ++ "--rangegap", ++ dest="rangegap", ++ type=int, ++ default=200000, ++ metavar=200000, ++ help="Threshold for a gap between out-of-range IDs to be \ ++considered a different range. Has to be > 0", ++ ) ++ parser.add_option( ++ "--minrange", ++ dest="minrange", ++ type=int, ++ default=10, ++ metavar=10, ++ help="Minimal considered range size for out-of-range IDs.\ ++All ranges with amount of IDs lower than this number will be discarded and \ ++IDs will be listed to be moved manually. Has to be > 1", ++ ) ++ parser.add_option( ++ "--allowunder1000", ++ dest="allowunder1000", ++ action="store_true", ++ default=False, ++ help="Allow idranges to start below 1000. Be careful to not \ ++overlap IPA users/groups with existing system-local ones!", ++ ) ++ parser.add_option( ++ "--norounding", ++ dest="norounding", ++ action="store_true", ++ default=False, ++ help="Disable IDrange rounding attempt in order to get ranges \ ++exactly covering just IDs provided", ++ ) ++ parser.add_option( ++ "--unattended", ++ dest="unattended", ++ action="store_true", ++ default=False, ++ help="Automatically fix all range issues found without asking \ ++for confirmation", ++ ) ++ ++ def __init__(self, *args, **kwargs): ++ super().__init__(*args, **kwargs) ++ self.realm: str = None ++ self.suffix: DN = None ++ self.proposals_rid: List[IDRange] = [] ++ self.proposals_new: List[IDRange] = [] ++ self.outliers: List[IDentity] = [] ++ self.under1000: List[IDentity] = [] ++ self.id_ranges: List[IDRange] = [] ++ ++ def validate_options(self, needs_root=True): ++ super().validate_options(needs_root) ++ ++ def run(self): ++ api.bootstrap(in_server=True) ++ api.finalize() ++ ++ self.realm = api.env.realm ++ self.suffix = ipautil.realm_to_suffix(self.realm) ++ try: ++ api.Backend.ldap2.connect() ++ ++ # Reading range data ++ self.id_ranges = read_ranges(self.suffix) ++ ++ # Evaluating existing ranges, if something is off, exit ++ if self.evaluate_ranges() != 0: ++ return 1 ++ ++ # reading out of range IDs ++ ids_out_of_range = read_outofrange_identities( ++ self.suffix, self.id_ranges ++ ) ++ ++ # Evaluating out of range IDs ++ self.evaluate_identities(ids_out_of_range) ++ ++ # Print the proposals ++ self.print_intentions() ++ ++ # If there are no proposals, we have nothing to do, exiting ++ if (len(self.proposals_rid) == 0 ++ and len(self.proposals_new) == 0): ++ logger.info("\nNo changes proposed, nothing to do.") ++ return 0 ++ ++ logger.info("\nID ranges table after proposed changes:") ++ draw_ascii_table(self.id_ranges) ++ ++ if self.options.unattended: ++ logger.info( ++ "Unattended mode, proceeding with applying changes!" ++ ) ++ else: ++ response = ipautil.user_input('Enter "yes" to proceed') ++ if response.lower() != "yes": ++ logger.info("Not proceeding.") ++ return 0 ++ logger.info("Proceeding.") ++ ++ # Applying changes ++ for id_range in self.proposals_rid: ++ apply_ridbases(id_range) ++ ++ for id_range in self.proposals_new: ++ create_range(id_range) ++ ++ logger.info("All changes applied successfully!") ++ ++ finally: ++ if api.Backend.ldap2.isconnected(): ++ api.Backend.ldap2.disconnect() ++ ++ return 0 ++ ++ def evaluate_ranges(self) -> int: ++ """Function to evaluate existing ID ranges""" ++ if len(self.id_ranges) == 0: ++ logger.error("No ID ranges found!") ++ return 1 ++ ++ draw_ascii_table(self.id_ranges) ++ ++ if not ranges_overlap_check(self.id_ranges): ++ logger.error( ++ "Ranges overlap detected, cannot proceed! Please adjust \ ++existing ranges manually." ++ ) ++ return 1 ++ ++ # Checking RID bases for existing ranges ++ id_ranges_nobase = get_ranges_no_base(self.id_ranges) ++ ++ if len(id_ranges_nobase) > 0: ++ logger.info( ++ "Found %s ranges without base RIDs", len(id_ranges_nobase) ++ ) ++ for id_range in id_ranges_nobase: ++ logger.debug( ++ "Range '%s' has RID base %s and secondary RID base %s", ++ id_range.name, ++ id_range.base_rid, ++ id_range.secondary_base_rid, ++ ) ++ propose_rid_ranges( ++ self.id_ranges, ++ self.options.ridoffset, ++ self.proposals_rid ++ ) ++ else: ++ logger.info( ++ "All ID ranges have base RIDs set, RID adjustments are \ ++not needed." ++ ) ++ return 0 ++ ++ def evaluate_identities(self, ids_out_of_range: List[IDentity]) -> None: ++ """Function to evaluate out of range IDs""" ++ if len(ids_out_of_range) == 0: ++ logger.info("No out of range IDs found!") ++ else: ++ logger.info( ++ "Found overall %s IDs out of existing ID ranges.\n", ++ len(ids_out_of_range), ++ ) ++ # ruling out IDs under 1000 if flag is not set ++ if not self.options.allowunder1000: ++ self.under1000, ids_out_of_range = separate_under1000( ++ ids_out_of_range ++ ) ++ if len(self.under1000) > 0: ++ logger.info( ++ "Found IDs under 1000, which is not recommeneded \ ++(if you definitely need ranges proposed for those, use --allowunder1000):" ++ ) ++ for identity in self.under1000: ++ logger.info("%s", identity) ++ ++ # Get initial divide of IDs into groups ++ groups = group_identities_by_threshold( ++ ids_out_of_range, self.options.rangegap ++ ) ++ ++ # Get outliers from too small groups and clean groups for ++ # further processing ++ self.outliers, cleangroups = separate_ranges_and_outliers( ++ groups, self.options.minrange ++ ) ++ ++ # Print the outliers, they have to be moved manually ++ if len(self.outliers) > 0: ++ logger.info( ++ "\nIdentities that don't fit the criteria to get a new " ++ "range found! Current attributes:\n" ++ "Minimal range size: %s\n" ++ "Maximum gap between IDs: %s\n" ++ "Try adjusting --minrange, --rangegap or move the " ++ "following identities into already existing ranges:", ++ self.options.minrange, ++ self.options.rangegap ++ ) ++ for identity in self.outliers: ++ logger.info("%s", identity) ++ ++ if len(cleangroups) > 0: ++ # Get IDrange name base ++ basename = get_rangename_base(self.id_ranges) ++ ++ # Create proposals for new ranges from groups ++ for group in cleangroups: ++ newrange = propose_range( ++ group, ++ self.id_ranges, ++ self.options.ridoffset, ++ basename, ++ self.options.norounding, ++ self.options.allowunder1000 ++ ) ++ if newrange is not None: ++ self.proposals_new.append(newrange) ++ self.id_ranges.append(newrange) ++ self.id_ranges.sort(key=lambda x: x.first_id) ++ else: ++ logger.info( ++ "\nNo IDs fit the criteria for a new ID range to propose!" ++ ) ++ ++ def print_intentions(self) -> None: ++ """Function to print out the summary of the proposed changes""" ++ logger.info("\nSummary:") ++ ++ if len(self.outliers) > 0: ++ logger.info("Outlier IDs that are too far away to get a range:") ++ for identity in self.outliers: ++ logger.info("%s", identity) ++ ++ if len(self.under1000) > 0: ++ if self.options.allowunder1000: ++ logger.info("IDs under 1000 were treated like normal IDs.") ++ else: ++ logger.info("IDs under 1000:") ++ for identity in self.under1000: ++ logger.info("%s", identity) ++ else: ++ logger.info("No IDs under 1000 found.") ++ ++ if len(self.proposals_rid) > 0: ++ logger.info("Proposed changes to existing ranges:") ++ for id_range in self.proposals_rid: ++ logger.info( ++ "Range '%s' - base RID: %s, secondary base RID: %s", ++ id_range.name, ++ id_range.base_rid, ++ id_range.secondary_base_rid, ++ ) ++ else: ++ logger.info("No changes proposed for existing ranges.") ++ ++ if len(self.proposals_new) > 0: ++ logger.info("Proposed new ranges:") ++ for id_range in self.proposals_new: ++ logger.info("%s", id_range) ++ else: ++ logger.info("No new ranges proposed.") ++ ++# Working with output ++# region ++ ++ ++def draw_ascii_table(id_ranges: List[IDRange], stdout: bool = False) -> None: ++ """Function to draw a table with ID ranges in ASCII""" ++ table: str = "\n" ++ # Calculate the maximum width required for each column using column names ++ max_widths = { ++ column: max( ++ len(str(column)), ++ max( ++ ( ++ len(str(getattr(id_range, column))) ++ if getattr(id_range, column) is not None ++ else 0 ++ ) ++ for id_range in id_ranges ++ ), ++ ) ++ for column in [ ++ "name", ++ "type", ++ "size", ++ "first_id", ++ "last_id", ++ "base_rid", ++ "last_base_rid", ++ "secondary_base_rid", ++ "last_secondary_rid", ++ ] ++ } ++ ++ # Draw the table header ++ header = "| " ++ for column, width in max_widths.items(): ++ header += f"{column.ljust(width)} | " ++ horizontal_line = "-" * (len(header) - 1) ++ table += horizontal_line + "\n" ++ table += header + "\n" ++ table += horizontal_line + "\n" ++ ++ # Draw the table rows ++ for id_range in id_ranges: ++ row = "| " ++ for column, width in max_widths.items(): ++ value = getattr(id_range, column) ++ if value is not None: ++ row += f"{str(value).rjust(width)} | " ++ else: ++ # Adding the separator ++ row += " " * (width + 1) + "| " ++ table += row + "\n" ++ table += horizontal_line + "\n" ++ if stdout: ++ print(table) ++ else: ++ logger.info(table) ++# endregion ++# Reading from LDAP ++# region ++ ++ ++def read_ranges(suffix) -> List[IDRange]: ++ """Function to read ID ranges from LDAP""" ++ id_ranges: IDRange = [] ++ try: ++ ranges = api.Backend.ldap2.get_entries( ++ DN(api.env.container_ranges, suffix), ++ ldap.SCOPE_ONELEVEL, ++ "(objectclass=ipaIDRange)", ++ ) ++ except errors.NotFound: ++ logger.error("LDAPError: No ranges found!") ++ except errors.ExecutionError as e: ++ logger.error("Exception while reading users: %s", e) ++ else: ++ for entry in ranges: ++ sv = entry.single_value ++ id_range = IDRange() ++ id_range.name = sv.get("cn") ++ id_range.size = int(sv.get("ipaidrangesize")) ++ id_range.first_id = int(sv.get("ipabaseid")) ++ id_range.base_rid = ( ++ int(sv.get("ipabaserid")) if sv.get("ipabaserid") else None ++ ) ++ id_range.secondary_base_rid = ( ++ int(sv.get("ipasecondarybaserid")) ++ if sv.get("ipasecondarybaserid") ++ else None ++ ) ++ id_range.suffix = suffix ++ id_range.type = sv.get("iparangetype") ++ id_range.dn = entry.dn ++ ++ id_range._count() ++ logger.debug("ID range found: %s", id_range) ++ ++ id_ranges.append(id_range) ++ ++ id_ranges.sort(key=lambda x: x.first_id) ++ return id_ranges ++ ++ ++def read_outofrange_identities(suffix, id_ranges) -> List[IDentity]: ++ """Function to read out of range users and groups from LDAP""" ++ users_outofrange = read_ldap_ids( ++ DN(api.env.container_user, suffix), ++ True, ++ id_ranges ++ ) ++ logger.info("Users out of range found: %s", len(users_outofrange)) ++ del_outofrange = read_ldap_ids( ++ DN(api.env.container_deleteuser, suffix), ++ True, ++ id_ranges ++ ) ++ logger.info("Preserved users out of range found: %s", len(del_outofrange)) ++ groups_outofrange = read_ldap_ids( ++ DN(api.env.container_group, suffix), ++ False, ++ id_ranges ++ ) ++ logger.info("Groups out of range found: %s", len(groups_outofrange)) ++ outofrange = users_outofrange + del_outofrange + groups_outofrange ++ outofrange.sort(key=lambda x: x.number) ++ return outofrange ++ ++ ++def read_ldap_ids(container_dn, user: bool, id_ranges) -> List[IDentity]: ++ """Function to read IDs from containter in LDAP""" ++ id_entities = [] ++ if user: ++ id_name = "user" ++ ldap_filter = get_outofrange_filter( ++ id_ranges, ++ "posixaccount", ++ "uidNumber" ++ ) ++ else: ++ id_name = "group" ++ ldap_filter = get_outofrange_filter( ++ id_ranges, ++ "posixgroup", ++ "gidNumber" ++ ) ++ ++ logger.debug("Searching %ss in %s with filter: %s", id_name, container_dn, ++ ldap_filter) ++ try: ++ identities = api.Backend.ldap2.get_entries( ++ container_dn, ++ ldap.SCOPE_ONELEVEL, ++ ldap_filter, ++ ) ++ for entry in identities: ++ id_entities.append(read_identity(entry, user)) ++ except errors.NotFound: ++ logger.debug("No out of range %ss found in %s!", id_name, container_dn) ++ except errors.ExecutionError as e: ++ logger.error("Exception while reading %s: %s", container_dn, e) ++ return id_entities ++ ++ ++def read_identity(ldapentry, user: bool = True) -> IDentity: ++ """Function to convert LDAP entry to IDentity object""" ++ sv = ldapentry.single_value ++ id_entity = IDentity() ++ id_entity.dn = ldapentry.dn ++ id_entity.name = sv.get("cn") ++ id_entity.number = ( ++ int(sv.get("uidNumber")) if user else int(sv.get("gidNumber")) ++ ) ++ id_entity.user = user ++ logger.debug("Out of range found: %s", id_entity.debug()) ++ return id_entity ++ ++ ++def get_outofrange_filter( ++ id_ranges_all: List[IDRange], object_class: str, posix_id: str ++) -> str: ++ """Function to create LDAP filter for out of range users and groups""" ++ # we need to look only for ipa-local ranges ++ id_ranges = get_ipa_local_ranges(id_ranges_all) ++ ++ ldap_filter = f"(&(objectClass={object_class})(|" ++ ++ # adding gaps in ranges to the filter ++ for i in range(len(id_ranges) + 1): ++ if i == 0: ++ start_condition = f"({posix_id}>=1)" ++ else: ++ start_condition = f"({posix_id}>={id_ranges[i - 1].last_id + 1})" ++ ++ if i < len(id_ranges): ++ end_condition = f"({posix_id}<={id_ranges[i].first_id - 1})" ++ else: ++ end_condition = f"({posix_id}<=2147483647)" ++ ++ ldap_filter += f"(&{start_condition}{end_condition})" ++ ++ ldap_filter += "))" ++ ++ return ldap_filter ++# endregion ++# Writing to LDAP ++# region ++ ++ ++def apply_ridbases(id_range: IDRange) -> None: ++ """Funtion to apply RID bases to the range in LDAP""" ++ try: ++ api.Backend.ldap2.modify_s( ++ id_range.dn, ++ [ ++ (ldap.MOD_ADD, "ipaBaseRID", str(id_range.base_rid)), ++ ( ++ ldap.MOD_ADD, ++ "ipaSecondaryBaseRID", ++ str(id_range.secondary_base_rid), ++ ), ++ ], ++ ) ++ logger.info("RID bases updated for range '%s'", id_range.name) ++ ++ except ldap.CONSTRAINT_VIOLATION as e: ++ logger.error( ++ "Failed to add RID bases to the range '%s': %s", ++ id_range.name, ++ e ++ ) ++ raise RuntimeError("Constraint violation.\n") from e ++ ++ except Exception as e: ++ logger.error( ++ "Exception while updating RID bases for range '%s': %s", ++ id_range.name, ++ e, ++ ) ++ raise RuntimeError("Failed to update RID bases.\n") from e ++ ++ ++def create_range(id_range: IDRange) -> None: ++ """Function to create a new range in LDAP""" ++ try: ++ logger.info("Creating range '%s'...", id_range.name) ++ ++ entry = api.Backend.ldap2.make_entry( ++ DN(id_range.dn), ++ objectclass=["ipaIDRange", "ipaDomainIDRange"], ++ ipaidrangesize=[str(id_range.size)], ++ ipabaseid=[str(id_range.first_id)], ++ ipabaserid=[str(id_range.base_rid)], ++ ipasecondarybaserid=[str(id_range.secondary_base_rid)], ++ iparangetype=[id_range.type], ++ ) ++ ++ api.Backend.ldap2.add_entry(entry) ++ logger.info("Range '%s' created successfully", id_range.name) ++ except Exception as e: ++ logger.error( ++ "Exception while creating range '%s': %s", ++ id_range.name, ++ e ++ ) ++ raise RuntimeError("Failed to create range.\n") from e ++# endregion ++# Working with ranges ++# region ++ ++ ++def get_ipa_local_ranges(id_ranges: List[IDRange]) -> List[IDRange]: ++ """Function to get only ipa-local ranges from the list of ranges""" ++ ipa_local_ranges = [] ++ ++ for id_range in id_ranges: ++ if id_range.type == "ipa-local": ++ ipa_local_ranges.append(id_range) ++ ++ return ipa_local_ranges ++ ++ ++def range_overlap_check( ++ range1_start: int, range1_end: int, range2_start: int, range2_end: int ++) -> bool: ++ """Function to check if two ranges overlap""" ++ # False when overlapping ++ return not (range1_start <= range2_end and range2_start <= range1_end) ++ ++ ++def range_overlap_check_idrange(range1: IDRange, range2: IDRange) -> bool: ++ """Function to check if two ranges overlap""" ++ # False when overlapping ++ return range_overlap_check( ++ range1.first_id, range1.last_id, range2.first_id, range2.last_id) ++ ++ ++def newrange_overlap_check( ++ id_ranges: List[IDRange], newrange: IDRange ++) -> bool: ++ """Function to check if proposed range overlaps with existing ones""" ++ for id_range in id_ranges: ++ if not range_overlap_check_idrange(id_range, newrange): ++ return False ++ return True ++ ++ ++def ranges_overlap_check(id_ranges: List[IDRange]) -> bool: ++ """Function to check if any of the existing ranges overlap""" ++ if len(id_ranges) < 2: ++ return True ++ for i in range(len(id_ranges) - 1): ++ for j in range(i + 1, len(id_ranges)): ++ if not range_overlap_check_idrange(id_ranges[i], id_ranges[j]): ++ logger.error( ++ "Ranges '%s' and '%s' overlap!", ++ id_ranges[i].name, ++ id_ranges[j].name, ++ ) ++ return False ++ return True ++# endregion ++# Working with RID bases ++# region ++ ++ ++def propose_rid_ranges( ++ id_ranges: List[IDRange], delta: int, proposals: List[IDRange] ++) -> None: ++ """ ++ Function to propose RID bases for ranges that don't have them set. ++ ++ - delta represents how far we start new base off existing range, ++ used in order to allow for future expansion of existing ranges up ++ to [delta] IDs. ++ """ ++ ipa_local_ranges = get_ipa_local_ranges(id_ranges) ++ ++ for id_range in ipa_local_ranges: ++ proposed_base_rid = 0 ++ proposed_secondary_base_rid = 0 ++ ++ # Calculate proposed base RID and secondary base RID ++ if id_range.base_rid is None: ++ result, proposed_base_rid = propose_rid_base( ++ id_range, ipa_local_ranges, delta, True ++ ) ++ if result: ++ id_range.base_rid = proposed_base_rid ++ id_range.last_base_rid = proposed_base_rid + id_range.size ++ else: ++ # if this fails too, we print the warning and abandon the idea ++ logger.warning( ++ "Warning: Proposed base RIDs %s for '%s' both failed, \ ++please adjust manually", ++ proposed_base_rid, ++ id_range.name, ++ ) ++ continue ++ ++ if id_range.secondary_base_rid is None: ++ result, proposed_secondary_base_rid = propose_rid_base( ++ id_range, ipa_local_ranges, delta, False, proposed_base_rid ++ ) ++ if result: ++ id_range.secondary_base_rid = proposed_secondary_base_rid ++ id_range.last_secondary_rid = ( ++ proposed_secondary_base_rid + id_range.size ++ ) ++ else: ++ # if this fails too, we print the warning and abandon the idea ++ logger.warning( ++ "Warning: Proposed secondary base RIDs %s for '%s' \ ++both failed, please adjust manually", ++ proposed_secondary_base_rid, ++ id_range.name, ++ ) ++ continue ++ ++ # Add range to the proposals if we changed something successfully ++ if proposed_base_rid > 0 or proposed_secondary_base_rid > 0: ++ logger.debug( ++ "Proposed RIDs for range '%s': pri %s, sec %s", ++ id_range.name, ++ proposed_base_rid, ++ proposed_secondary_base_rid, ++ ) ++ proposals.append(id_range) ++ ++ ++def propose_rid_base( ++ idrange: IDRange, ++ ipa_local_ranges: List[IDRange], ++ delta: int, ++ primary: bool = True, ++ previous_base_rid: int = -1 ++) -> Tuple[bool, str]: ++ """ ++ Function to propose a base RID for a range, primary or secondary. ++ We are getting the biggest base RID + size + delta and try ++ if it's a viable option, check same kind first, then the other. ++ """ ++ proposed_base_rid = max_rid(ipa_local_ranges, primary) + delta ++ if proposed_base_rid == previous_base_rid: ++ proposed_base_rid += idrange.size + delta ++ if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size): ++ return True, proposed_base_rid ++ ++ # if we fail, we try the same with biggest of a different kind ++ proposed_base_rid_orig = proposed_base_rid ++ proposed_base_rid = max_rid(ipa_local_ranges, not primary) + delta ++ if proposed_base_rid == previous_base_rid: ++ proposed_base_rid += idrange.size + delta ++ if check_rid_base(ipa_local_ranges, proposed_base_rid, idrange.size): ++ return True, proposed_base_rid ++ ++ # if it fails, we return both RID proposals for the range ++ return False, f"{proposed_base_rid_orig} and {proposed_base_rid}" ++ ++ ++def max_rid(id_ranges: List[IDRange], primary: bool = True) -> int: ++ """Function to get maximum RID of primary or secondary RIDs""" ++ maximum_rid = 0 ++ for id_range in id_ranges: ++ ++ # looking only for primary RIDs ++ if primary: ++ if id_range.last_base_rid is not None: ++ maximum_rid = max(maximum_rid, id_range.last_base_rid) ++ # looking only for secondary RIDs ++ else: ++ if id_range.last_secondary_rid is not None: ++ maximum_rid = max(maximum_rid, id_range.last_secondary_rid) ++ ++ return maximum_rid ++ ++ ++def check_rid_base(id_ranges: List[IDRange], base: int, size: int) -> bool: ++ """Function to check if proposed RID base is viable""" ++ end = base + size + 1 ++ ++ # Checking sanity of RID range ++ if base + size > 2147483647: ++ return False ++ if base < 1000: ++ return False ++ ++ # Checking RID range overlaps ++ for id_range in id_ranges: ++ # we are interested only in ipa-local ranges ++ if id_range.type != "ipa-local": ++ continue ++ ++ # if there is no base rid set, there is no secondary base rid set, ++ # so nothing to overlap with ++ if id_range.base_rid is None: ++ continue ++ ++ # checking for an overlap ++ if not range_overlap_check( ++ base, end, id_range.base_rid, id_range.last_base_rid ++ ): ++ logger.debug( ++ "RID check failure: proposed Primary %s + %s, \ ++intersects with %s-%s from range '%s'", ++ base, ++ size, ++ id_range.base_rid, ++ id_range.last_base_rid, ++ id_range.name, ++ ) ++ return False ++ ++ # if there is no secondary base rid set, nothing to overlap with ++ if id_range.secondary_base_rid is None: ++ continue ++ ++ # if either start of end of the range fails inside existing range, ++ # or existing range is inside proposed one, we have an overlap ++ if not range_overlap_check( ++ base, end, id_range.secondary_base_rid, id_range.last_secondary_rid ++ ): ++ logger.debug( ++ "RID check failure: proposed Secondary %s + %s, \ ++intersects with %s-%s from range '%s'", ++ base, ++ size, ++ id_range.secondary_base_rid, ++ id_range.last_secondary_rid, ++ id_range.name, ++ ) ++ return False ++ ++ return True ++ ++ ++def get_ranges_no_base(id_ranges: List[IDRange]) -> List[IDRange]: ++ """Function to get ranges without either of base RIDs set""" ++ ipa_local_ranges = get_ipa_local_ranges(id_ranges) ++ ranges_no_base = [] ++ for id_range in ipa_local_ranges: ++ if id_range.base_rid is None or id_range.secondary_base_rid is None: ++ ranges_no_base.append(id_range) ++ ++ return ranges_no_base ++# endregion ++# Working with IDentities out of range ++# region ++ ++ ++def group_identities_by_threshold( ++ identities: List[IDentity], threshold: int ++) -> List[List[IDentity]]: ++ """Function to group out of range IDs by threshold""" ++ groups: List[List[IDentity]] = [] ++ currentgroup: List[IDentity] = [] ++ if len(identities) == 0: ++ return groups ++ ++ for i in range(len(identities) - 1): ++ # add id to current group ++ currentgroup.append(identities[i]) ++ ++ # If the difference with the next one is greater than the threshold, ++ # start a new group ++ if identities[i + 1].number - identities[i].number > threshold: ++ groups.append(currentgroup) ++ currentgroup = [] ++ ++ # Add the last ID number to the last group ++ currentgroup.append(identities[-1]) ++ groups.append(currentgroup) ++ ++ return groups ++ ++ ++def separate_under1000( ++ identities: List[IDentity], ++) -> Tuple[List[IDentity], List[IDentity]]: ++ """Function to separate IDs under 1000, expects sorted list""" ++ for i, identity in enumerate(identities): ++ if identity.number >= 1000: ++ return identities[:i], identities[i:] ++ return identities, [] ++ ++ ++def separate_ranges_and_outliers( ++ groups: List[List[IDentity]], minrangesize=int ++) -> Tuple[List[List[IDentity]], List[List[IDentity]]]: ++ """Function to separate IDs into outliers and IDs that can get ranges""" ++ outliers = [] ++ cleangroups = [] ++ for group in groups: ++ # if group is smaller than minrangesize, add it's memebers to ourliers ++ if group[-1].number - group[0].number + 1 < minrangesize: ++ for identity in group: ++ outliers.append(identity) ++ # if the group is OK, add it to cleaned groups ++ else: ++ cleangroups.append(group) ++ ++ return outliers, cleangroups ++ ++ ++def round_idrange(start: int, end: int, under1000: bool) -> Tuple[int, int]: ++ """Function to round up range margins to look pretty""" ++ # calculating power of the size ++ sizepower = len(str(end - start + 1)) ++ # multiplier for the nearest rounded number ++ multiplier = 10 ** (sizepower - 1) ++ # getting rounded range margins ++ rounded_start = (start // multiplier) * multiplier ++ if not under1000: ++ rounded_start = max(rounded_start, 1000) ++ else: ++ rounded_start = max(rounded_start, 1) ++ rounded_end = ((end + multiplier) // multiplier) * multiplier - 1 ++ ++ return rounded_start, rounded_end ++ ++ ++def get_rangename_base(id_ranges: List[IDRange]) -> str: ++ """Function to get a base name for new range proposals""" ++ base_name = "" ++ # we want to use default range name as a base for new ranges ++ for id_range in id_ranges: ++ if id_range.base_rid == 1000: ++ base_name = id_range.name ++ ++ # if we didn't find it, propose generic name ++ if base_name == "": ++ base_name = "Auto_added_range" ++ ++ return base_name ++ ++ ++def get_rangename(id_ranges: List[IDRange], basename: str) -> str: ++ """ ++ Function to get a new range name, we add the counter as 3-digit number ++ extension and make sure it's unique ++ """ ++ counter = 1 ++ full_name = f"{basename}_{counter:03}" ++ while any(id_range.name == full_name for id_range in id_ranges): ++ counter += 1 ++ full_name = f"{basename}_{counter:03}" ++ return full_name ++ ++ ++def propose_range( ++ group: List[IDentity], ++ id_ranges: List[IDRange], ++ delta: int, ++ basename: str, ++ norounding: bool, ++ allowunder1000: bool ++) -> IDRange: ++ """Function to propose a new range for group of IDs out of ranges""" ++ startid = group[0].number ++ endid = group[-1].number ++ ++ logger.debug( ++ "Proposing a range for existing IDs out of ranges with start id %s \ ++and end id %s...", ++ startid, ++ endid, ++ ) ++ ++ # creating new range ++ newrange = IDRange() ++ newrange.type = "ipa-local" ++ newrange.name = get_rangename(id_ranges, basename) ++ newrange.suffix = id_ranges[0].suffix ++ newrange.dn = f"cn={newrange.name},cn=ranges,cn=etc,{newrange.suffix}" ++ ++ if norounding: ++ newrange.first_id = startid ++ newrange.last_id = endid ++ newrange.size = newrange.last_id - newrange.first_id + 1 ++ else: ++ # first trying to round up ranges to look pretty ++ newrange.first_id, newrange.last_id = round_idrange( ++ startid, ++ endid, ++ allowunder1000 ++ ) ++ newrange.size = newrange.last_id - newrange.first_id + 1 ++ ++ # if this creates an overlap, try without rounding ++ if not newrange_overlap_check(id_ranges, newrange): ++ newrange.first_id = startid ++ newrange.last_id = endid ++ newrange.size = newrange.last_id - newrange.first_id + 1 ++ # if we still failed, abandon idea ++ if not newrange_overlap_check(id_ranges, newrange): ++ logger.error( ++ "ERROR! Failed to create idrange for existing IDs out of \ ++ranges with start id %s and end id %s, it overlaps with existing range!", ++ startid, ++ endid, ++ ) ++ return None ++ ++ # creating RID bases ++ ipa_local_ranges = get_ipa_local_ranges(id_ranges) ++ ++ result, proposed_base_rid = propose_rid_base( ++ newrange, ipa_local_ranges, delta, True ++ ) ++ if result: ++ newrange.base_rid = proposed_base_rid ++ newrange.last_base_rid = proposed_base_rid + newrange.size ++ else: ++ # if this fails we print the warning ++ logger.warning( ++ "Warning! Proposed base RIDs %s for new range start id %s and \ ++end id %s both failed, please adjust manually", ++ proposed_base_rid, ++ newrange.first_id, ++ newrange.last_id, ++ ) ++ ++ result, proposed_secondary_base_rid = propose_rid_base( ++ newrange, ipa_local_ranges, delta, False, proposed_base_rid ++ ) ++ if result: ++ newrange.secondary_base_rid = proposed_secondary_base_rid ++ newrange.last_secondary_rid = ( ++ proposed_secondary_base_rid + newrange.size ++ ) ++ else: ++ # if this fails we print the warning ++ logger.warning( ++ "Warning! Proposed secondary base RIDs %s for new range start id \ ++%s and end id %s both failed, please adjust manually", ++ proposed_secondary_base_rid, ++ newrange.first_id, ++ newrange.last_id, ++ ) ++ ++ logger.debug("Proposed range: %s", newrange) ++ return newrange ++# endregion +diff --git a/ipatests/test_integration/test_ipa_idrange_fix.py b/ipatests/test_integration/test_ipa_idrange_fix.py +new file mode 100644 +index 0000000000000000000000000000000000000000..de3da9bfd221ce74f1d1bbb0dbe12e4db08b8daa +--- /dev/null ++++ b/ipatests/test_integration/test_ipa_idrange_fix.py +@@ -0,0 +1,189 @@ ++# ++# Copyright (C) 2024 FreeIPA Contributors see COPYING for license ++# ++ ++""" ++Module provides tests for ipa-idrange-fix CLI. ++""" ++ ++import logging ++import re ++ ++from ipatests.pytest_ipa.integration import tasks ++from ipatests.test_integration.base import IntegrationTest ++ ++ ++logger = logging.getLogger(__name__) ++ ++ ++class TestIpaIdrangeFix(IntegrationTest): ++ @classmethod ++ def install(cls, mh): ++ super(TestIpaIdrangeFix, cls).install(mh) ++ tasks.kinit_admin(cls.master) ++ ++ def test_no_issues(self): ++ """Test ipa-idrange-fix command with no issues.""" ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ ++ expected_under1000 = "No IDs under 1000 found" ++ expected_nochanges = "No changes proposed for existing ranges" ++ expected_newrange = "No new ranges proposed" ++ expected_noissues = "No changes proposed, nothing to do." ++ assert expected_under1000 in result.stderr_text ++ assert expected_nochanges in result.stderr_text ++ assert expected_newrange in result.stderr_text ++ assert expected_noissues in result.stderr_text ++ ++ def test_idrange_no_rid_bases(self): ++ """Test ipa-idrange-fix command with IDrange with no RID bases.""" ++ self.master.run_command([ ++ "ipa", ++ "idrange-add", ++ "idrange_no_rid_bases", ++ "--base-id", '10000', ++ "--range-size", '20000', ++ ]) ++ ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ expected_text = "RID bases updated for range 'idrange_no_rid_bases'" ++ ++ # Remove IDrange with no rid bases ++ self.master.run_command(["ipa", "idrange-del", "idrange_no_rid_bases"]) ++ ++ assert expected_text in result.stderr_text ++ ++ def test_idrange_no_rid_bases_reversed(self): ++ """ ++ Test ipa-idrange-fix command with IDrange with no RID bases, but we ++ previously had a range with RID bases reversed - secondary lower than ++ primary. It is a valid configuration, so we should fix no-RID range. ++ """ ++ self.master.run_command([ ++ "ipa", ++ "idrange-add", ++ "idrange_no_rid_bases", ++ "--base-id", '10000', ++ "--range-size", '20000', ++ ]) ++ self.master.run_command([ ++ "ipa", ++ "idrange-add", ++ "idrange_reversed", ++ "--base-id", '50000', ++ "--range-size", '20000', ++ "--rid-base", '100300000' ++ "--secondary-rid-base", '301000' ++ ]) ++ ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ expected_text = "RID bases updated for range 'idrange_no_rid_bases'" ++ ++ # Remove test IDranges ++ self.master.run_command(["ipa", "idrange-del", "idrange_no_rid_bases"]) ++ self.master.run_command(["ipa", "idrange-del", "idrange_reversed"]) ++ ++ assert expected_text in result.stderr_text ++ ++ def test_users_outofrange(self): ++ """Test ipa-idrange-fix command with users out of range.""" ++ for i in range(1, 20): ++ self.master.run_command([ ++ "ipa", ++ "user-add", ++ "testuser{}".format(i), ++ "--first", "Test", ++ "--last", "User {}".format(i), ++ "--uid", str(100000 + i * 10), ++ ]) ++ ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ expected_text = r"Range '[\w\.]+_id_range_\d{3}' created successfully" ++ match = re.search(expected_text, result.stderr_text) ++ ++ # Remove users out of range and created IDrange ++ for i in range(1, 20): ++ self.master.run_command([ ++ "ipa", ++ "user-del", ++ "testuser{}".format(i) ++ ]) ++ if match is not None: ++ self.master.run_command([ ++ "ipa", ++ "idrange-del", ++ match.group(0).split(" ")[1].replace("'", "") ++ ]) ++ ++ assert match is not None ++ ++ def test_user_outlier(self): ++ """Test ipa-idrange-fix command with outlier user.""" ++ self.master.run_command([ ++ "ipa", ++ "user-add", ++ "testuser_outlier", ++ "--first", "Outlier", ++ "--last", "User", ++ "--uid", '500000', ++ ]) ++ ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ expected_text = "Identities that don't fit the criteria to get a new \ ++range found!" ++ expected_user = "user 'Outlier User', uid=500000" ++ ++ # Remove outlier user ++ self.master.run_command(["ipa", "user-del", "testuser_outlier"]) ++ ++ assert expected_text in result.stderr_text ++ assert expected_user in result.stderr_text ++ ++ def test_user_under1000(self): ++ """Test ipa-idrange-fix command with user under 1000.""" ++ self.master.run_command([ ++ "ipa", ++ "user-add", ++ "testuser_under1000", ++ "--first", "Under", ++ "--last", "1000", ++ "--uid", '999', ++ ]) ++ ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ expected_text = "IDs under 1000:" ++ expected_user = "user 'Under 1000', uid=999" ++ ++ # Remove user under 1000 ++ self.master.run_command(["ipa", "user-del", "testuser_under1000"]) ++ ++ assert expected_text in result.stderr_text ++ assert expected_user in result.stderr_text ++ ++ def test_user_preserved(self): ++ """Test ipa-idrange-fix command with preserved user.""" ++ self.master.run_command([ ++ "ipa", ++ "user-add", ++ "testuser_preserved", ++ "--first", "Preserved", ++ "--last", "User", ++ "--uid", '9999', ++ ]) ++ self.master.run_command([ ++ "ipa", ++ "user-del", ++ "testuser_preserved", ++ "--preserve" ++ ]) ++ ++ result = self.master.run_command(["ipa-idrange-fix", "--unattended"]) ++ expected_text = "Identities that don't fit the criteria to get a new \ ++range found!" ++ expected_user = "user 'Preserved User', uid=9999" ++ ++ # Remove preserved user ++ self.master.run_command(["ipa", "user-del", "testuser_preserved"]) ++ ++ assert expected_text in result.stderr_text ++ assert expected_user in result.stderr_text +-- +2.47.0 + diff --git a/SOURCES/0005-ipatests-Add-missing-comma-in-test_idrange_no_rid_ba.patch b/SOURCES/0005-ipatests-Add-missing-comma-in-test_idrange_no_rid_ba.patch new file mode 100644 index 0000000..2e4b77a --- /dev/null +++ b/SOURCES/0005-ipatests-Add-missing-comma-in-test_idrange_no_rid_ba.patch @@ -0,0 +1,36 @@ +From 4fef80aeaaf017b286bd12ebfc30529f6a65a80e Mon Sep 17 00:00:00 2001 +From: Florence Blanc-Renaud +Date: Mon, 2 Sep 2024 18:28:27 +0200 +Subject: [PATCH] ipatests: Add missing comma in + test_idrange_no_rid_bases_reversed + +The test is calling ipa idrange-add but is missing a comma in +the arguments list. +The resulting call is using "--rid-base 100300000--secondary-rid-base". +Add the missing comma to build the command with +"--rid-base 100300000 --secondary-rid-base" + +Fixes: https://pagure.io/freeipa/issue/9656 + +Signed-off-by: Florence Blanc-Renaud +Reviewed-By: Alexander Bokovoy +--- + ipatests/test_integration/test_ipa_idrange_fix.py | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/ipatests/test_integration/test_ipa_idrange_fix.py b/ipatests/test_integration/test_ipa_idrange_fix.py +index de3da9bfd221ce74f1d1bbb0dbe12e4db08b8daa..ff8fbdac9d028d26fc55f5e357f89af879a61723 100644 +--- a/ipatests/test_integration/test_ipa_idrange_fix.py ++++ b/ipatests/test_integration/test_ipa_idrange_fix.py +@@ -72,7 +72,7 @@ class TestIpaIdrangeFix(IntegrationTest): + "idrange_reversed", + "--base-id", '50000', + "--range-size", '20000', +- "--rid-base", '100300000' ++ "--rid-base", '100300000', + "--secondary-rid-base", '301000' + ]) + +-- +2.47.0 + diff --git a/SOURCES/0006-ipatests-Fixes-for-ipa-idrange-fix-testsuite.patch b/SOURCES/0006-ipatests-Fixes-for-ipa-idrange-fix-testsuite.patch new file mode 100644 index 0000000..bf52648 --- /dev/null +++ b/SOURCES/0006-ipatests-Fixes-for-ipa-idrange-fix-testsuite.patch @@ -0,0 +1,35 @@ +From ae4c2ad6cd966d48c063814f494dcc16cf0ccd4c Mon Sep 17 00:00:00 2001 +From: Sudhir Menon +Date: Tue, 24 Sep 2024 13:46:48 +0530 +Subject: [PATCH] ipatests: Fixes for ipa-idrange-fix testsuite + +This patch adds the line tasks.install_master(cls.master). +The kinit admin command fails with the below error as the +IPA is not configured on the test system + +'ipa: ERROR: stderr: kinit: Configuration file does not specify default +realm when parsing name admin' + +Signed-off-by: Sudhir Menon +Reviewed-By: Rob Crittenden +--- + ipatests/test_integration/test_ipa_idrange_fix.py | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/ipatests/test_integration/test_ipa_idrange_fix.py b/ipatests/test_integration/test_ipa_idrange_fix.py +index ff8fbdac9d028d26fc55f5e357f89af879a61723..0c915bd0931ed11a3aa86c533ee8748aa8a7ec07 100644 +--- a/ipatests/test_integration/test_ipa_idrange_fix.py ++++ b/ipatests/test_integration/test_ipa_idrange_fix.py +@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__) + + + class TestIpaIdrangeFix(IntegrationTest): ++ ++ topology = 'line' ++ + @classmethod + def install(cls, mh): + super(TestIpaIdrangeFix, cls).install(mh) +-- +2.47.0 + diff --git a/SOURCES/0007-ipalib-x509-support-PyCA-44.0.patch b/SOURCES/0007-ipalib-x509-support-PyCA-44.0.patch new file mode 100644 index 0000000..2b3672f --- /dev/null +++ b/SOURCES/0007-ipalib-x509-support-PyCA-44.0.patch @@ -0,0 +1,166 @@ +From d4d56a6705c870901bc73882e4804367f7c9c91a Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Sun, 1 Dec 2024 20:16:54 +0200 +Subject: [PATCH] ipalib/x509: support PyCA 44.0 + +PyCA made x509.Certificate class concrete, it cannot be extended anymore +by Python code. The intent is to use helper functions to instantiate +certificate objects and never create them directly. + +FreeIPA wraps PyCA's x509.Certificate class and provides own shim +on top of it. In most cases we load the certificate content via the +helper functions and don't really need to derive from the certificate +class. + +Move IPACertificate to be a normal Python object class that stores +x509.Certificate internally. The only place where this breaks is when +IPACertificate object needs to be passed to a code that expects +x509.Certificate (Dogtag PKI). In such cases, expose the underlying +certificate instance via IPACertificate.cert property. + +Fixes: https://pagure.io/freeipa/issue/9708 + +Signed-off-by: Alexander Bokovoy +Reviewed-By: Florence Blanc-Renaud +--- + ipalib/ipajson.py | 4 ++-- + ipalib/x509.py | 10 +++++++++- + ipapython/ipaldap.py | 15 +++++++-------- + ipaserver/plugins/dogtag.py | 3 ++- + 4 files changed, 20 insertions(+), 12 deletions(-) + +diff --git a/ipalib/ipajson.py b/ipalib/ipajson.py +index 5551d12e5fec7e458fa6fe85560664b2fd897337..fd99c8219c722c52321336f28ff27e1573e906c7 100644 +--- a/ipalib/ipajson.py ++++ b/ipalib/ipajson.py +@@ -9,7 +9,7 @@ from decimal import Decimal + import json + import six + from ipalib.constants import LDAP_GENERALIZED_TIME_FORMAT +-from ipalib import capabilities ++from ipalib import capabilities, x509 + from ipalib.x509 import Encoding as x509_Encoding + from ipapython.dn import DN + from ipapython.dnsutil import DNSName +@@ -72,7 +72,7 @@ class _JSONPrimer(dict): + list: self._enc_list, + tuple: self._enc_list, + dict: self._enc_dict, +- crypto_x509.Certificate: self._enc_certificate, ++ x509.IPACertificate: self._enc_certificate, + crypto_x509.CertificateSigningRequest: self._enc_certificate, + }) + +diff --git a/ipalib/x509.py b/ipalib/x509.py +index fd08238962b2b5e9cd056fb13c0a81ee8f31b092..6780bead00b50efdf03c62ce717572eeb9df2e5f 100644 +--- a/ipalib/x509.py ++++ b/ipalib/x509.py +@@ -88,7 +88,7 @@ SAN_UPN = '1.3.6.1.4.1.311.20.2.3' + SAN_KRB5PRINCIPALNAME = '1.3.6.1.5.2.2' + + +-class IPACertificate(crypto_x509.Certificate): ++class IPACertificate: + """ + A proxy class wrapping a python-cryptography certificate representation for + IPA purposes +@@ -205,6 +205,10 @@ class IPACertificate(crypto_x509.Certificate): + """ + return self._cert.fingerprint(algorithm) + ++ @property ++ def cert(self): ++ return self._cert ++ + @property + def serial_number(self): + return self._cert.serial_number +@@ -457,6 +461,8 @@ def load_pem_x509_certificate(data): + :returns: a ``IPACertificate`` object. + :raises: ``ValueError`` if unable to load the certificate. + """ ++ if isinstance(data, IPACertificate): ++ return data + return IPACertificate( + crypto_x509.load_pem_x509_certificate(data, backend=default_backend()) + ) +@@ -469,6 +475,8 @@ def load_der_x509_certificate(data): + :returns: a ``IPACertificate`` object. + :raises: ``ValueError`` if unable to load the certificate. + """ ++ if isinstance(data, IPACertificate): ++ return data + return IPACertificate( + crypto_x509.load_der_x509_certificate(data, backend=default_backend()) + ) +diff --git a/ipapython/ipaldap.py b/ipapython/ipaldap.py +index 1888e40916aa6e641542f08fb30ff2b0d4b850b1..5bb81c1bc844fce9b14251d3702e09099d85cdb5 100644 +--- a/ipapython/ipaldap.py ++++ b/ipapython/ipaldap.py +@@ -33,7 +33,6 @@ import warnings + + from collections import OrderedDict + +-from cryptography import x509 as crypto_x509 + from cryptography.hazmat.primitives import serialization + + import ldap +@@ -748,10 +747,10 @@ class LDAPClient: + 'dnszoneidnsname': DNSName, + 'krbcanonicalname': Principal, + 'krbprincipalname': Principal, +- 'usercertificate': crypto_x509.Certificate, +- 'usercertificate;binary': crypto_x509.Certificate, +- 'cACertificate': crypto_x509.Certificate, +- 'cACertificate;binary': crypto_x509.Certificate, ++ 'usercertificate': x509.IPACertificate, ++ 'usercertificate;binary': x509.IPACertificate, ++ 'cACertificate': x509.IPACertificate, ++ 'cACertificate;binary': x509.IPACertificate, + 'nsds5replicalastupdatestart': unicode, + 'nsds5replicalastupdateend': unicode, + 'nsds5replicalastinitstart': unicode, +@@ -1000,7 +999,7 @@ class LDAPClient: + return dct + elif isinstance(val, datetime): + return val.strftime(LDAP_GENERALIZED_TIME_FORMAT).encode('utf-8') +- elif isinstance(val, crypto_x509.Certificate): ++ elif isinstance(val, x509.IPACertificate): + return val.public_bytes(x509.Encoding.DER) + elif val is None: + return None +@@ -1027,7 +1026,7 @@ class LDAPClient: + return DNSName.from_text(val.decode('utf-8')) + elif target_type in (DN, Principal): + return target_type(val.decode('utf-8')) +- elif target_type is crypto_x509.Certificate: ++ elif target_type is x509.IPACertificate: + return x509.load_der_x509_certificate(val) + else: + return target_type(val) +@@ -1381,7 +1380,7 @@ class LDAPClient: + ] + return cls.combine_filters(flts, rules) + elif value is not None: +- if isinstance(value, crypto_x509.Certificate): ++ if isinstance(value, x509.IPACertificate): + value = value.public_bytes(serialization.Encoding.DER) + if isinstance(value, bytes): + value = binascii.hexlify(value).decode('ascii') +diff --git a/ipaserver/plugins/dogtag.py b/ipaserver/plugins/dogtag.py +index 78afb279795ecf74f296cbbb8724505075a6e4a9..ee6d0e347d640a2664e38ba64785c3d8af54bbad 100644 +--- a/ipaserver/plugins/dogtag.py ++++ b/ipaserver/plugins/dogtag.py +@@ -1581,7 +1581,8 @@ class kra(Backend): + + crypto = cryptoutil.CryptographyCryptoProvider( + transport_cert_nick="ra_agent", +- transport_cert=x509.load_certificate_from_file(paths.RA_AGENT_PEM) ++ transport_cert=x509.load_certificate_from_file( ++ paths.RA_AGENT_PEM).cert + ) + + # TODO: obtain KRA host & port from IPA service list or point to KRA load balancer +-- +2.47.1 + diff --git a/SOURCES/0008-pyca-adapt-import-paths-for-TripleDES-cipher.patch b/SOURCES/0008-pyca-adapt-import-paths-for-TripleDES-cipher.patch new file mode 100644 index 0000000..04ae1fd --- /dev/null +++ b/SOURCES/0008-pyca-adapt-import-paths-for-TripleDES-cipher.patch @@ -0,0 +1,120 @@ +From 8dfec28647f7c17e47fbfc96a1720dcde1592386 Mon Sep 17 00:00:00 2001 +From: Stanislav Levin +Date: Mon, 2 Dec 2024 15:04:30 +0300 +Subject: [PATCH] pyca: adapt import paths for TripleDES cipher + +https://cryptography.io/en/latest/hazmat/primitives/symmetric-encryption/#cryptography.hazmat.primitives.ciphers.algorithms.TripleDES + +> This algorithm has been deprecated and moved to the Decrepit + cryptography module. If you need to continue using it then update your + code to use the new module path. It will be removed from this namespace + in 48.0.0. + +Fixes: https://pagure.io/freeipa/issue/9708 +Signed-off-by: Stanislav Levin +Reviewed-By: Florence Blanc-Renaud +--- + ipaclient/plugins/vault.py | 8 +++++++- + ipalib/constants.py | 24 +++++++++++------------- + ipaserver/install/ipa_otptoken_import.py | 8 +++++++- + 3 files changed, 25 insertions(+), 15 deletions(-) + +diff --git a/ipaclient/plugins/vault.py b/ipaclient/plugins/vault.py +index 75415c03a57242ae674636fa31a72db2fa56d6ea..6af7297936924dfb80e7f79924b570421da65c97 100644 +--- a/ipaclient/plugins/vault.py ++++ b/ipaclient/plugins/vault.py +@@ -34,6 +34,12 @@ from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes ++try: ++ # cryptography>=43.0.0 ++ from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES ++except ImportError: ++ # will be removed from this module in cryptography 48.0.0 ++ from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES + from cryptography.hazmat.primitives.padding import PKCS7 + from cryptography.hazmat.primitives.serialization import ( + load_pem_public_key, load_pem_private_key) +@@ -661,7 +667,7 @@ class ModVaultData(Local): + if name == constants.VAULT_WRAPPING_AES128_CBC: + return algorithms.AES(os.urandom(128 // 8)) + elif name == constants.VAULT_WRAPPING_3DES: +- return algorithms.TripleDES(os.urandom(196 // 8)) ++ return TripleDES(os.urandom(196 // 8)) + else: + # unreachable + raise ValueError(name) +diff --git a/ipalib/constants.py b/ipalib/constants.py +index b657e5a9065d115d0eff2dbfffff49e992006536..c90caa22149ec3d93d45fcb5480f7401e4555799 100644 +--- a/ipalib/constants.py ++++ b/ipalib/constants.py +@@ -25,20 +25,19 @@ All constants centralised in one file. + import os + import string + import uuid +-import warnings +- +-warnings.filterwarnings( +- "ignore", +- "TripleDES has been moved to " +- "cryptography.hazmat.decrepit.ciphers.algorithms.TripleDES and " +- "will be removed from this module in 48.0.0", +- category=UserWarning) + + from ipaplatform.constants import constants as _constants + from ipapython.dn import DN + from ipapython.fqdn import gethostfqdn + from ipapython.version import VERSION, API_VERSION +-from cryptography.hazmat.primitives.ciphers import algorithms, modes ++from cryptography.hazmat.primitives.ciphers import modes ++try: ++ # cryptography>=43.0.0 ++ from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES ++except ImportError: ++ # will be removed from this module in cryptography 48.0.0 ++ from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES ++ + from cryptography.hazmat.backends.openssl.backend import backend + + +@@ -389,7 +388,6 @@ VAULT_WRAPPING_SUPPORTED_ALGOS = ( + VAULT_WRAPPING_DEFAULT_ALGO = VAULT_WRAPPING_AES128_CBC + + # Add 3DES for backwards compatibility if supported +-if getattr(algorithms, 'TripleDES', None): +- if backend.cipher_supported(algorithms.TripleDES( +- b"\x00" * 8), modes.CBC(b"\x00" * 8)): +- VAULT_WRAPPING_SUPPORTED_ALGOS += (VAULT_WRAPPING_3DES,) ++if backend.cipher_supported(TripleDES( ++ b"\x00" * 8), modes.CBC(b"\x00" * 8)): ++ VAULT_WRAPPING_SUPPORTED_ALGOS += (VAULT_WRAPPING_3DES,) +diff --git a/ipaserver/install/ipa_otptoken_import.py b/ipaserver/install/ipa_otptoken_import.py +index 279a7502d2f305309252b3b291e32b772a51a1d3..17457f6c5b81ab70a0ecee13bf744e242ec88ff0 100644 +--- a/ipaserver/install/ipa_otptoken_import.py ++++ b/ipaserver/install/ipa_otptoken_import.py +@@ -37,6 +37,12 @@ from cryptography.hazmat.primitives import hashes, hmac + from cryptography.hazmat.primitives.padding import PKCS7 + from cryptography.hazmat.primitives.kdf import pbkdf2 + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes ++try: ++ # cryptography>=43.0.0 ++ from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES ++except ImportError: ++ # will be removed from this module in cryptography 48.0.0 ++ from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES + from cryptography.hazmat.backends import default_backend + + from ipaplatform.paths import paths +@@ -169,7 +175,7 @@ def convertAlgorithm(value): + # in the list of the vault wrapping algorithms, we cannot use 3DES anywhere + if VAULT_WRAPPING_3DES in VAULT_WRAPPING_SUPPORTED_ALGOS: + supported_algs["http://www.w3.org/2001/04/xmlenc#tripledes-cbc"] = ( +- algorithms.TripleDES, modes.CBC, 64) ++ TripleDES, modes.CBC, 64) + + return supported_algs.get(value.lower(), (None, None, None)) + +-- +2.47.1 + diff --git a/SOURCES/0009-ipa-pwd-extop-clarify-OTP-use-over-LDAP-binds.patch b/SOURCES/0009-ipa-pwd-extop-clarify-OTP-use-over-LDAP-binds.patch new file mode 100644 index 0000000..2f77fbc --- /dev/null +++ b/SOURCES/0009-ipa-pwd-extop-clarify-OTP-use-over-LDAP-binds.patch @@ -0,0 +1,134 @@ +From 3e7ec3dc49d0f559bdbe330e52019e59f0b57c18 Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Tue, 3 Dec 2024 18:06:45 +0200 +Subject: [PATCH] ipa-pwd-extop: clarify OTP use over LDAP binds + +OTP use during LDAP bind can be enforced either explicitly via client +specifying a control with OID 2.16.840.1.113730.3.8.10.7 and no payload +or implicitly through the global IPA configuration with EnforceLDAPOTP. + +OTP token enforcement overrides IPA user authentication types +requirements: + +If OTP enforcement is required: + + - if user authentication types still allow password authentication, + authentication with just a password is denied, regardless whether OTP + tokens are associated with the user or not. + +If OTP enforcement is not required: + + - if user has no OTP tokens but user authentication types require OTP + use, authentication with just a password is allowed until a token is + added. + + - if user has OTP tokens and user authentication types require OTP use + but not password, authentication with just a password is denied. + +Additionally, enforcement of OTP only applies to LDAP objects which +don't use 'simpleSecurityObject' objectclass. This allows system service +accounts to continue authenticate with a password regardless of the +OTP enforcement. + +Fixes: https://pagure.io/freeipa/issue/9699 +Fixes: https://pagure.io/freeipa/issue/9711 + +Signed-off-by: Alexander Bokovoy +Reviewed-By: Rob Crittenden +--- + .../ipa-slapi-plugins/ipa-pwd-extop/prepost.c | 38 +++++++++++++++---- + 1 file changed, 30 insertions(+), 8 deletions(-) + +diff --git a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +index 1c1340e31ac30cb01412a7065ea339cb5461e839..42e880fd0a5c8b4708b145b340209eb218f60c4e 100644 +--- a/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c ++++ b/daemons/ipa-slapi-plugins/ipa-pwd-extop/prepost.c +@@ -1219,12 +1219,10 @@ typedef enum { + } otp_req_enum; + static bool ipapwd_pre_bind_otp(const char *bind_dn, Slapi_Entry *entry, + struct berval *creds, otp_req_enum otpreq, +- bool *notokens) ++ bool *notokens, uint32_t *auth_types) + { +- uint32_t auth_types; +- + /* Get the configured authentication types. */ +- auth_types = otp_config_auth_types(otp_config, entry); ++ *auth_types = otp_config_auth_types(otp_config, entry); + *notokens = false; + + /* +@@ -1237,7 +1235,8 @@ static bool ipapwd_pre_bind_otp(const char *bind_dn, Slapi_Entry *entry, + * 2. If PWD is enabled or OTP succeeded, fall through to PWD validation. + */ + +- if (auth_types & OTP_CONFIG_AUTH_TYPE_OTP) { ++ if ((*auth_types & OTP_CONFIG_AUTH_TYPE_OTP) || ++ (otpreq != OTP_IS_NOT_REQUIRED)) { + struct otp_token **tokens = NULL; + + LOG_PLUGIN_NAME(IPAPWD_PLUGIN_NAME, +@@ -1270,7 +1269,7 @@ static bool ipapwd_pre_bind_otp(const char *bind_dn, Slapi_Entry *entry, + otp_token_free_array(tokens); + } + +- return (auth_types & OTP_CONFIG_AUTH_TYPE_PASSWORD) && ++ return (*auth_types & OTP_CONFIG_AUTH_TYPE_PASSWORD) && + (otpreq == OTP_IS_NOT_REQUIRED); + } + +@@ -1451,6 +1450,7 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) + struct ipapwd_krbcfg *krbcfg = NULL; + struct berval *credentials = NULL; + Slapi_Entry *entry = NULL; ++ Slapi_Value *objectclass = NULL; + Slapi_DN *target_sdn = NULL; + Slapi_DN *sdn = NULL; + const char *dn = NULL; +@@ -1465,6 +1465,7 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) + int rc = LDAP_INVALID_CREDENTIALS; + char *errMesg = NULL; + bool notokens = false; ++ uint32_t auth_types = 0; + + /* get BIND parameters */ + ret |= slapi_pblock_get(pb, SLAPI_BIND_TARGET_SDN, &target_sdn); +@@ -1538,12 +1539,33 @@ static int ipapwd_pre_bind(Slapi_PBlock *pb) + otpreq = OTP_IS_REQUIRED_IMPLICITLY; + } + } ++ /* we only apply OTP policy to Kerberos principals */ ++ objectclass = slapi_value_new_string("krbprincipalaux"); ++ if (objectclass == NULL) { ++ goto invalid_creds; ++ } ++ if (!slapi_entry_attr_has_syntax_value(entry, SLAPI_ATTR_OBJECTCLASS, ++ objectclass)) { ++ otpreq = OTP_IS_NOT_REQUIRED; ++ } ++ slapi_value_free(&objectclass); ++ + if (!syncreq && !ipapwd_pre_bind_otp(dn, entry, +- credentials, otpreq, ¬okens)) { ++ credentials, otpreq, ++ ¬okens, &auth_types)) { + /* We got here because ipapwd_pre_bind_otp() returned false, + * it means that either token verification failed or + * a rule for empty tokens failed current policy. */ +- if (!(notokens || (otpreq == OTP_IS_NOT_REQUIRED))) ++ ++ /* Check if there were any tokens associated, thus ++ * OTP token verification has really failed */ ++ if (notokens == false) ++ goto invalid_creds; ++ ++ /* No tokens, check if auth type does not include OTP but OTP is ++ * enforced by the current policy */ ++ if (!(auth_types & OTP_CONFIG_AUTH_TYPE_OTP) && ++ (otpreq != OTP_IS_NOT_REQUIRED)) + goto invalid_creds; + } + +-- +2.47.1 + diff --git a/SOURCES/0010-adtrust-add-missing-ipaAllowedOperations-objectclass.patch b/SOURCES/0010-adtrust-add-missing-ipaAllowedOperations-objectclass.patch new file mode 100644 index 0000000..6921ab0 --- /dev/null +++ b/SOURCES/0010-adtrust-add-missing-ipaAllowedOperations-objectclass.patch @@ -0,0 +1,45 @@ +From 477dbba18bf987bf4461fdfdfba0d497159db7ce Mon Sep 17 00:00:00 2001 +From: Stanislav Levin +Date: Wed, 4 Dec 2024 19:56:51 +0300 +Subject: [PATCH] adtrust: add missing ipaAllowedOperations objectclass + +Per @abbra explanation: +> When expected Kerberos principal names for this object were flipped to + follow requirements for cross-realm krbtgt objects expected by Active + Directory, trusted object changed its canonical Kerberos principal name. + The keytab for this Kerberos principal name is fetched by SSSD and it + needs to be permitted to read the key. We added the virtual permission + to allow the keytab retrieval but didn't add the objectclass that + actually allows adding an LDAP attribute to express the permission. When + an attribute is added to an LDAP object, objectclasses of the object + must allow presence of that attribute. + +This is the followup to #9471 and fixes the upgrade. + +Thanks @abbra! + +Related: https://pagure.io/freeipa/issue/9471 +Fixes: https://pagure.io/freeipa/issue/9712 +Signed-off-by: Stanislav Levin +Reviewed-By: Alexander Bokovoy +--- + ipaserver/install/plugins/adtrust.py | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/ipaserver/install/plugins/adtrust.py b/ipaserver/install/plugins/adtrust.py +index e6d49cb2512bff7dcce57f019ecb6c497d11ed52..ab3d427ef561aeb26eb098270446640ba451c8ad 100644 +--- a/ipaserver/install/plugins/adtrust.py ++++ b/ipaserver/install/plugins/adtrust.py +@@ -705,7 +705,8 @@ class update_tdo_to_new_layout(Updater): + self.set_krb_principal([tgt_principal, nbt_principal], + passwd_incoming, + t_dn, +- flags=self.KRB_PRINC_CREATE_DEFAULT) ++ flags=self.KRB_PRINC_CREATE_DEFAULT ++ | self.KRB_PRINC_CREATE_AGENT_PERMISSION) + + # 3. INBOUND: krbtgt/@ must exist + trust_principal = self.tgt_principal_template.format( +-- +2.47.1 + diff --git a/SOURCES/0011-CVE-2024-11029.patch b/SOURCES/0011-CVE-2024-11029.patch new file mode 100644 index 0000000..105a6ea --- /dev/null +++ b/SOURCES/0011-CVE-2024-11029.patch @@ -0,0 +1,1124 @@ +From 363857b47de1b56e449c9ba635e57ede2b9c22c8 Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Fri, 13 Dec 2024 13:42:36 +0200 +Subject: [PATCH 1/3] Unify use of option parsers + +Do not use direct optparse references, instead import IPAOptionParser + +Signed-off-by: Alexander Bokovoy +--- + install/tools/ipa-adtrust-install.in | 4 +--- + install/tools/ipa-managed-entries.in | 3 ++- + ipaclient/install/ipa_client_automount.py | 4 ++-- + ipaclient/install/ipa_client_samba.py | 4 ++-- + ipalib/cli.py | 21 ++++++++++++--------- + ipalib/plugable.py | 8 ++++---- + ipapython/admintool.py | 3 +-- + ipapython/config.py | 18 +++++++++++------- + ipapython/install/cli.py | 9 ++++----- + ipaserver/install/ipa_acme_manage.py | 6 ++---- + ipaserver/install/ipa_backup.py | 5 ++--- + ipaserver/install/ipa_cacert_manage.py | 9 ++++----- + ipaserver/install/ipa_kra_install.py | 5 ++--- + ipaserver/install/ipa_restore.py | 5 ++--- + ipaserver/install/ipa_server_certinstall.py | 7 +++---- + ipatests/i18n.py | 8 ++++---- + makeapi.in | 5 ++--- + 17 files changed, 60 insertions(+), 64 deletions(-) + +diff --git a/install/tools/ipa-adtrust-install.in b/install/tools/ipa-adtrust-install.in +index cb2b78e50..e7b0e3692 100644 +--- a/install/tools/ipa-adtrust-install.in ++++ b/install/tools/ipa-adtrust-install.in +@@ -29,8 +29,6 @@ import sys + + import six + +-from optparse import SUPPRESS_HELP # pylint: disable=deprecated-module +- + from ipalib.install import sysrestore + from ipaserver.install import adtrust, service + from ipaserver.install.installutils import ( +@@ -41,7 +39,7 @@ from ipapython.admintool import ScriptError + from ipapython import version + from ipapython import ipautil + from ipalib import api, errors, krb_utils +-from ipapython.config import IPAOptionParser ++from ipapython.config import IPAOptionParser, SUPPRESS_HELP + from ipaplatform.paths import paths + from ipapython.ipa_log_manager import standard_logging_setup + +diff --git a/install/tools/ipa-managed-entries.in b/install/tools/ipa-managed-entries.in +index e9be41b7a..e3f121943 100644 +--- a/install/tools/ipa-managed-entries.in ++++ b/install/tools/ipa-managed-entries.in +@@ -39,7 +39,8 @@ logger = logging.getLogger(os.path.basename(__file__)) + def parse_options(): + usage = "%prog [options] \n" + usage += "%prog [options]\n" +- parser = OptionParser(usage=usage, formatter=config.IPAFormatter()) ++ parser = config.IPAOptionParser(usage=usage, ++ formatter=config.IPAFormatter()) + + parser.add_option("-d", "--debug", action="store_true", dest="debug", + help="Display debugging information about the update(s)") +diff --git a/ipaclient/install/ipa_client_automount.py b/ipaclient/install/ipa_client_automount.py +index 4439932bd..9f49ff9ed 100644 +--- a/ipaclient/install/ipa_client_automount.py ++++ b/ipaclient/install/ipa_client_automount.py +@@ -34,7 +34,6 @@ import SSSDConfig + + from six.moves.urllib.parse import urlsplit + +-from optparse import OptionParser # pylint: disable=deprecated-module + from ipapython import ipachangeconf + from ipaclient import discovery + from ipaclient.install.client import ( +@@ -52,6 +51,7 @@ from ipaplatform.tasks import tasks + from ipaplatform import services + from ipaplatform.paths import paths + from ipapython.admintool import ScriptError ++from ipapython.config import IPAOptionParser + + + logger = logging.getLogger(os.path.basename(__file__)) +@@ -59,7 +59,7 @@ logger = logging.getLogger(os.path.basename(__file__)) + + def parse_options(): + usage = "%prog [options]\n" +- parser = OptionParser(usage=usage) ++ parser = IPAOptionParser(usage=usage) + parser.add_option("--server", dest="server", help="FQDN of IPA server") + parser.add_option( + "--location", +diff --git a/ipaclient/install/ipa_client_samba.py b/ipaclient/install/ipa_client_samba.py +index 81d670c34..5c33abb4c 100755 +--- a/ipaclient/install/ipa_client_samba.py ++++ b/ipaclient/install/ipa_client_samba.py +@@ -9,7 +9,6 @@ import logging + import os + import gssapi + from urllib.parse import urlsplit +-from optparse import OptionParser # pylint: disable=deprecated-module + from contextlib import contextmanager + + from ipaclient import discovery +@@ -31,6 +30,7 @@ from ipaplatform.constants import constants + from ipaplatform import services + from ipapython.admintool import ScriptError + from samba import generate_random_password ++from ipapython.config import IPAOptionParser + + logger = logging.getLogger(os.path.basename(__file__)) + logger.setLevel(logging.DEBUG) +@@ -68,7 +68,7 @@ def use_api_as_principal(principal, keytab): + + def parse_options(): + usage = "%prog [options]\n" +- parser = OptionParser(usage=usage) ++ parser = IPAOptionParser(usage=usage) + parser.add_option( + "--server", + dest="server", +diff --git a/ipalib/cli.py b/ipalib/cli.py +index d9c2ac165..667b213fd 100644 +--- a/ipalib/cli.py ++++ b/ipalib/cli.py +@@ -30,7 +30,6 @@ import textwrap + import sys + import getpass + import code +-import optparse # pylint: disable=deprecated-module + import os + import pprint + import fcntl +@@ -71,6 +70,8 @@ from ipalib.text import _ + from ipalib import api + from ipapython.dnsutil import DNSName + from ipapython.admintool import ScriptError ++from ipapython.config import (IPAOptionParser, IPAFormatter, ++ OptionGroup, make_option) + + import datetime + +@@ -1121,7 +1122,8 @@ class Collector: + def __todict__(self): + return dict(self.__options) + +-class CLIOptionParserFormatter(optparse.IndentedHelpFormatter): ++ ++class CLIOptionParserFormatter(IPAFormatter): + def format_argument(self, name, help_string): + result = [] + opt_width = self.help_position - self.current_indent - 2 +@@ -1141,7 +1143,8 @@ class CLIOptionParserFormatter(optparse.IndentedHelpFormatter): + result.append("\n") + return "".join(result) + +-class CLIOptionParser(optparse.OptionParser): ++ ++class CLIOptionParser(IPAOptionParser): + """ + This OptionParser subclass adds an ability to print positional + arguments in CLI help. Custom formatter is used to format the argument +@@ -1151,13 +1154,13 @@ class CLIOptionParser(optparse.OptionParser): + self._arguments = [] + if 'formatter' not in kwargs: + kwargs['formatter'] = CLIOptionParserFormatter() +- optparse.OptionParser.__init__(self, *args, **kwargs) ++ IPAOptionParser.__init__(self, *args, **kwargs) + + def format_option_help(self, formatter=None): + """ + Prepend argument help to standard OptionParser's option help + """ +- option_help = optparse.OptionParser.format_option_help(self, formatter) ++ option_help = IPAOptionParser.format_option_help(self, formatter) + + if isinstance(formatter, CLIOptionParserFormatter): + heading = unicode(_("Positional arguments")) +@@ -1272,7 +1275,7 @@ class cli(backend.Executioner): + """Get or create an option group for the given name""" + option_group = option_groups.get(group_name) + if option_group is None: +- option_group = optparse.OptionGroup(parser, group_name) ++ option_group = OptionGroup(parser, group_name) + parser.add_option_group(option_group) + option_groups[group_name] = option_group + return option_group +@@ -1298,7 +1301,7 @@ class cli(backend.Executioner): + option_names = ['--%s' % cli_name] + if option.cli_short_name: + option_names.append('-%s' % option.cli_short_name) +- opt = optparse.make_option(*option_names, **kw) ++ opt = make_option(*option_names, **kw) + if option.option_group is None: + parser.add_option(opt) + else: +@@ -1312,7 +1315,7 @@ class cli(backend.Executioner): + group = _get_option_group(unicode(_('Deprecated options'))) + for alias in option.deprecated_cli_aliases: + name = '--%s' % alias +- group.add_option(optparse.make_option(name, **new_kw)) ++ group.add_option(make_option(name, **new_kw)) + + for arg in cmd.args(): + name = self.__get_arg_name(arg, format_name=False) +@@ -1442,7 +1445,7 @@ class cli(backend.Executioner): + ) + + +-class IPAHelpFormatter(optparse.IndentedHelpFormatter): ++class IPAHelpFormatter(IPAFormatter): + """Formatter suitable for printing IPA command help + + The default help formatter reflows text to fit the terminal, but it +diff --git a/ipalib/plugable.py b/ipalib/plugable.py +index 2e2861df0..a87e6e891 100644 +--- a/ipalib/plugable.py ++++ b/ipalib/plugable.py +@@ -33,7 +33,6 @@ import sys + import threading + import os + from os import path +-import optparse # pylint: disable=deprecated-module + import textwrap + import collections + import importlib +@@ -47,6 +46,7 @@ from ipalib.util import classproperty + from ipalib.base import ReadOnly, lock, islocked + from ipalib.constants import DEFAULT_CONFIG + from ipapython import ipa_log_manager, ipautil ++from ipapython.config import IPAOptionParser, IPAFormatter + from ipapython.ipa_log_manager import ( + LOGGING_FORMAT_FILE, + LOGGING_FORMAT_STDERR) +@@ -526,7 +526,7 @@ class API(ReadOnly): + + def build_global_parser(self, parser=None, context=None): + """ +- Add global options to an optparse.OptionParser instance. ++ Add global options to an IPAOptionParser instance. + """ + def config_file_callback(option, opt, value, parser): + if not os.path.isfile(value): +@@ -536,7 +536,7 @@ class API(ReadOnly): + parser.values.conf = value + + if parser is None: +- parser = optparse.OptionParser( ++ parser = IPAOptionParser( + add_help_option=False, + formatter=IPAHelpFormatter(), + usage='%prog [global-options] COMMAND [command-options]', +@@ -821,7 +821,7 @@ class API(ReadOnly): + return self.__next[plugin] + + +-class IPAHelpFormatter(optparse.IndentedHelpFormatter): ++class IPAHelpFormatter(IPAFormatter): + def format_epilog(self, epilog): + text_width = self.width - self.current_indent + indent = " " * self.current_indent +diff --git a/ipapython/admintool.py b/ipapython/admintool.py +index fdb4400d8..dff9112eb 100644 +--- a/ipapython/admintool.py ++++ b/ipapython/admintool.py +@@ -26,7 +26,6 @@ import logging + import sys + import os + import traceback +-from optparse import OptionGroup # pylint: disable=deprecated-module + + from ipaplatform.osinfo import osinfo + from ipapython import version +@@ -113,7 +112,7 @@ class AdminTool: + :param parser: The parser to add options to + :param debug_option: Add a --debug option as an alias to --verbose + """ +- group = OptionGroup(parser, "Logging and output options") ++ group = config.OptionGroup(parser, "Logging and output options") + group.add_option("-v", "--verbose", dest="verbose", default=False, + action="store_true", help="print debugging information") + if debug_option: +diff --git a/ipapython/config.py b/ipapython/config.py +index f53d0f998..7af4dfdeb 100644 +--- a/ipapython/config.py ++++ b/ipapython/config.py +@@ -18,9 +18,9 @@ + # + from __future__ import absolute_import + +-# pylint: disable=deprecated-module +-from optparse import ( +- Option, Values, OptionParser, IndentedHelpFormatter, OptionValueError) ++# pylint: disable=deprecated-module, disable=unused-import ++from optparse import (Option, Values, OptionGroup, OptionParser, SUPPRESS_HELP, ++ IndentedHelpFormatter, OptionValueError, make_option) + # pylint: enable=deprecated-module + from copy import copy + from configparser import ConfigParser as SafeConfigParser +@@ -113,10 +113,14 @@ class IPAOptionParser(OptionParser): + description=None, + formatter=None, + add_help_option=True, +- prog=None): +- OptionParser.__init__(self, usage, option_list, option_class, +- version, conflict_handler, description, +- formatter, add_help_option, prog) ++ prog=None, ++ epilog=None): ++ OptionParser.__init__(self, usage=usage, option_list=option_list, ++ option_class=option_class, version=version, ++ conflict_handler=conflict_handler, ++ description=description, formatter=formatter, ++ add_help_option=add_help_option, prog=prog, ++ epilog=epilog) + + def get_safe_opts(self, opts): + """ +diff --git a/ipapython/install/cli.py b/ipapython/install/cli.py +index ab212be4e..a048b3c7c 100644 +--- a/ipapython/install/cli.py ++++ b/ipapython/install/cli.py +@@ -9,12 +9,11 @@ Command line support. + import collections + import enum + import logging +-import optparse # pylint: disable=deprecated-module + import signal + + import six + +-from ipapython import admintool ++from ipapython import admintool, config + from ipapython.ipa_log_manager import standard_logging_setup + from ipapython.ipautil import (CheckedIPAddress, CheckedIPAddressLoopback, + private_ccache) +@@ -158,7 +157,7 @@ class ConfigureTool(admintool.AdminTool): + try: + opt_group = groups[group_cls] + except KeyError: +- opt_group = groups[group_cls] = optparse.OptionGroup( ++ opt_group = groups[group_cls] = config.OptionGroup( + parser, "{0} options".format(group_cls.description)) + parser.add_option_group(opt_group) + +@@ -232,7 +231,7 @@ class ConfigureTool(admintool.AdminTool): + if not hidden: + help = knob_cls.description + else: +- help = optparse.SUPPRESS_HELP ++ help = config.SUPPRESS_HELP + + opt_group.add_option( + *opt_strs, +@@ -256,7 +255,7 @@ class ConfigureTool(admintool.AdminTool): + + # fake option parser to parse positional arguments + # (because optparse does not support positional argument parsing) +- fake_option_parser = optparse.OptionParser() ++ fake_option_parser = config.IPAOptionParser() + self.add_options(fake_option_parser, True) + + fake_option_map = {option.dest: option +diff --git a/ipaserver/install/ipa_acme_manage.py b/ipaserver/install/ipa_acme_manage.py +index dc2359f49..0decab394 100644 +--- a/ipaserver/install/ipa_acme_manage.py ++++ b/ipaserver/install/ipa_acme_manage.py +@@ -7,14 +7,12 @@ import enum + import pki.util + import logging + +-from optparse import OptionGroup # pylint: disable=deprecated-module +- + from ipalib import api, errors, x509 + from ipalib import _ + from ipalib.facts import is_ipa_configured + from ipaplatform.paths import paths + from ipapython.admintool import AdminTool +-from ipapython import cookie, dogtag ++from ipapython import cookie, dogtag, config + from ipapython.ipautil import run + from ipapython.certdb import NSSDatabase, EXTERNAL_CA_TRUST_FLAGS + from ipaserver.install import cainstance +@@ -143,7 +141,7 @@ class IPAACMEManage(AdminTool): + @classmethod + def add_options(cls, parser): + +- group = OptionGroup(parser, 'Pruning') ++ group = config.OptionGroup(parser, 'Pruning') + group.add_option( + "--enable", dest="enable", action="store_true", + default=False, help="Enable certificate pruning") +diff --git a/ipaserver/install/ipa_backup.py b/ipaserver/install/ipa_backup.py +index 982e5dfc4..b6af63813 100644 +--- a/ipaserver/install/ipa_backup.py ++++ b/ipaserver/install/ipa_backup.py +@@ -20,7 +20,6 @@ + from __future__ import absolute_import, print_function + + import logging +-import optparse # pylint: disable=deprecated-module + import os + import shutil + import sys +@@ -32,7 +31,7 @@ import six + from ipaplatform.paths import paths + from ipaplatform import services + from ipalib import api, errors +-from ipapython import version ++from ipapython import version, config + from ipapython.ipautil import run, write_tmp_file + from ipapython import admintool, certdb + from ipapython.dn import DN +@@ -245,7 +244,7 @@ class Backup(admintool.AdminTool): + + parser.add_option( + "--gpg-keyring", dest="gpg_keyring", +- help=optparse.SUPPRESS_HELP) ++ help=config.SUPPRESS_HELP) + parser.add_option( + "--gpg", dest="gpg", action="store_true", + default=False, help="Encrypt the backup") +diff --git a/ipaserver/install/ipa_cacert_manage.py b/ipaserver/install/ipa_cacert_manage.py +index f6ab736fa..048245237 100644 +--- a/ipaserver/install/ipa_cacert_manage.py ++++ b/ipaserver/install/ipa_cacert_manage.py +@@ -22,14 +22,13 @@ from __future__ import print_function, absolute_import + import datetime + import logging + import os +-from optparse import OptionGroup # pylint: disable=deprecated-module + import gssapi + + from ipalib.constants import ( + RENEWAL_CA_NAME, RENEWAL_REUSE_CA_NAME, RENEWAL_SELFSIGNED_CA_NAME, + IPA_CA_CN) + from ipalib.install import certmonger, certstore +-from ipapython import admintool, ipautil ++from ipapython import admintool, ipautil, config + from ipapython.certdb import (EMPTY_TRUST_FLAGS, + EXTERNAL_CA_TRUST_FLAGS, + TrustFlags, +@@ -61,7 +60,7 @@ class CACertManage(admintool.AdminTool): + "-p", "--password", dest='password', + help="Directory Manager password") + +- renew_group = OptionGroup(parser, "Renew options") ++ renew_group = config.OptionGroup(parser, "Renew options") + renew_group.add_option( + "--self-signed", dest='self_signed', + action='store_true', +@@ -89,7 +88,7 @@ class CACertManage(admintool.AdminTool): + "certificate chain") + parser.add_option_group(renew_group) + +- install_group = OptionGroup(parser, "Install options") ++ install_group = config.OptionGroup(parser, "Install options") + install_group.add_option( + "-n", "--nickname", dest='nickname', + help="Nickname for the certificate") +@@ -98,7 +97,7 @@ class CACertManage(admintool.AdminTool): + help="Trust flags for the certificate in certutil format") + parser.add_option_group(install_group) + +- delete_group = OptionGroup(parser, "Delete options") ++ delete_group = config.OptionGroup(parser, "Delete options") + delete_group.add_option( + "-f", "--force", action='store_true', + help="Force removing the CA even if chain validation fails") +diff --git a/ipaserver/install/ipa_kra_install.py b/ipaserver/install/ipa_kra_install.py +index 3e4cd67fa..8a09179f7 100644 +--- a/ipaserver/install/ipa_kra_install.py ++++ b/ipaserver/install/ipa_kra_install.py +@@ -22,13 +22,12 @@ from __future__ import print_function, absolute_import + import logging + import sys + import tempfile +-from optparse import SUPPRESS_HELP # pylint: disable=deprecated-module + + from textwrap import dedent + from ipalib import api + from ipalib.constants import DOMAIN_LEVEL_1 + from ipaplatform.paths import paths +-from ipapython import admintool ++from ipapython import admintool, config + from ipaserver.install import service + from ipaserver.install import cainstance + from ipaserver.install import custodiainstance +@@ -73,7 +72,7 @@ class KRAInstall(admintool.AdminTool): + parser.add_option( + "--uninstall", + dest="uninstall", action="store_true", default=False, +- help=SUPPRESS_HELP) ++ help=config.SUPPRESS_HELP) + + parser.add_option( + "--pki-config-override", dest="pki_config_override", +diff --git a/ipaserver/install/ipa_restore.py b/ipaserver/install/ipa_restore.py +index 57ad8dd05..8d75a0e6b 100644 +--- a/ipaserver/install/ipa_restore.py ++++ b/ipaserver/install/ipa_restore.py +@@ -20,7 +20,6 @@ + from __future__ import absolute_import, print_function + + import logging +-import optparse # pylint: disable=deprecated-module + import os + import shutil + import sys +@@ -34,7 +33,7 @@ import six + from ipaclient.install.client import update_ipa_nssdb + from ipalib import api, errors + from ipalib.constants import FQDN +-from ipapython import version, ipautil ++from ipapython import version, ipautil, config + from ipapython.ipautil import run, user_input + from ipapython import admintool, certdb + from ipapython.dn import DN +@@ -190,7 +189,7 @@ class Restore(admintool.AdminTool): + help="Directory Manager password") + parser.add_option( + "--gpg-keyring", dest="gpg_keyring", +- help=optparse.SUPPRESS_HELP) ++ help=config.SUPPRESS_HELP) + parser.add_option( + "--data", dest="data_only", action="store_true", + default=False, help="Restore only the data") +diff --git a/ipaserver/install/ipa_server_certinstall.py b/ipaserver/install/ipa_server_certinstall.py +index e29f00ec3..e9f680b1d 100644 +--- a/ipaserver/install/ipa_server_certinstall.py ++++ b/ipaserver/install/ipa_server_certinstall.py +@@ -22,12 +22,11 @@ from __future__ import print_function, absolute_import + import os + import os.path + import tempfile +-import optparse # pylint: disable=deprecated-module + + from ipalib import x509 + from ipalib.install import certmonger + from ipaplatform.paths import paths +-from ipapython import admintool, dogtag ++from ipapython import admintool, dogtag, config + from ipapython.certdb import NSSDatabase, get_ca_nickname + from ipapython.dn import DN + from ipapython import ipaldap +@@ -65,8 +64,8 @@ class ServerCertInstall(admintool.AdminTool): + help="The password of the PKCS#12 file") + parser.add_option( + "--dirsrv_pin", "--http_pin", +- dest="pin", +- help=optparse.SUPPRESS_HELP) ++ dest="pin", sensitive=True, ++ help=config.SUPPRESS_HELP) + parser.add_option( + "--cert-name", + dest="cert_name", metavar="NAME", +diff --git a/ipatests/i18n.py b/ipatests/i18n.py +index 49f5c4c32..57915c286 100644 +--- a/ipatests/i18n.py ++++ b/ipatests/i18n.py +@@ -22,7 +22,6 @@ from __future__ import print_function + + # WARNING: Do not import ipa modules, this is also used as a + # stand-alone script (invoked from po Makefile). +-import optparse # pylint: disable=deprecated-module + import sys + import gettext + import re +@@ -30,6 +29,7 @@ import os + import traceback + import polib + from collections import namedtuple ++from ipapython import config + + import six + +@@ -722,9 +722,9 @@ usage =''' + def main(): + global verbose, print_traceback, pedantic, show_strings + +- parser = optparse.OptionParser(usage=usage) ++ parser = config.IPAOptionParser(usage=usage) + +- mode_group = optparse.OptionGroup(parser, 'Operational Mode', ++ mode_group = config.OptionGroup(parser, 'Operational Mode', + 'You must select one these modes to run in') + + mode_group.add_option('-g', '--test-gettext', action='store_const', const='test_gettext', dest='mode', +@@ -748,7 +748,7 @@ def main(): + parser.add_option('--traceback', action='store_true', dest='print_traceback', default=False, + help='print the traceback when an exception occurs') + +- param_group = optparse.OptionGroup(parser, 'Run Time Parameters', ++ param_group = config.OptionGroup(parser, 'Run Time Parameters', + 'These may be used to modify the run time defaults') + + param_group.add_option('--test-lang', action='store', dest='test_lang', default='test', +diff --git a/makeapi.in b/makeapi.in +index a801b9253..8fc87d23d 100644 +--- a/makeapi.in ++++ b/makeapi.in +@@ -38,6 +38,7 @@ from ipalib.parameters import Param + from ipalib.output import Output + from ipalib.text import Gettext, NGettext, ConcatenatedLazyText + from ipalib.capabilities import capabilities ++from ipapython import config + + API_FILE='API.txt' + +@@ -84,9 +85,7 @@ OUTPUT_IGNORED_ATTRIBUTES = ( + ) + + def parse_options(): +- from optparse import OptionParser # pylint: disable=deprecated-module +- +- parser = OptionParser() ++ parser = config.IPAOptionParser() + parser.add_option("--validate", dest="validate", action="store_true", + default=False, help="Validate the API vs the stored API") + +-- +2.47.1 + + +From 6c9186404e683be27289a86982fe1fcabe9ebef3 Mon Sep 17 00:00:00 2001 +From: Alexander Bokovoy +Date: Fri, 8 Nov 2024 14:59:20 +0200 +Subject: [PATCH 2/3] ipa tools: remove sensitive material from the commandline + +When command line tools accept passwords, remove them from the command +line so that they don't get visible in '/proc/pid/commandline'. + +There is no common method to access the original ARGV vector and modify +it from Python. Since this mostly affects Linux systems where IPA +services run, we expect use of GNU libc and thus can rely on internal +glibc symbols. If they aren't available, the code will skip removing +passwords. + +Fixes: CVE-2024-11029 + +Signed-off-by: Alexander Bokovoy +--- + .../com.redhat.idm.trust-fetch-domains.in | 5 ++- + install/tools/ipa-adtrust-install.in | 3 +- + install/tools/ipa-ca-install.in | 2 + + install/tools/ipa-compat-manage.in | 6 ++- + install/tools/ipa-csreplica-manage.in | 12 +++--- + install/tools/ipa-managed-entries.in | 5 ++- + install/tools/ipa-replica-conncheck.in | 7 ++-- + install/tools/ipa-replica-manage.in | 10 +++-- + ipapython/admintool.py | 40 +++++++++++++++++++ + ipaserver/install/ipa_migrate.py | 17 +++++++- + ipaserver/install/ipa_restore.py | 2 +- + ipaserver/install/ipa_server_certinstall.py | 2 +- + 12 files changed, 90 insertions(+), 21 deletions(-) + +diff --git a/install/oddjob/com.redhat.idm.trust-fetch-domains.in b/install/oddjob/com.redhat.idm.trust-fetch-domains.in +index 45c1f1463..b86be0212 100644 +--- a/install/oddjob/com.redhat.idm.trust-fetch-domains.in ++++ b/install/oddjob/com.redhat.idm.trust-fetch-domains.in +@@ -15,6 +15,7 @@ import six + import gssapi + + from ipalib.install.kinit import kinit_keytab, kinit_password ++from ipapython.admintool import admin_cleanup_global_argv + + if six.PY3: + unicode = str +@@ -52,11 +53,13 @@ def parse_options(): + "--password", + action="store", + dest="password", +- help="Display debugging information", ++ help="Password for Active Directory administrator", ++ sensitive=True + ) + + options, args = parser.parse_args() + safe_options = parser.get_safe_opts(options) ++ admin_cleanup_global_argv(parser, options, sys.argv) + + # We only use first argument of the passed args but as D-BUS interface + # in oddjobd cannot expose optional, we fill in empty slots from IPA side +diff --git a/install/tools/ipa-adtrust-install.in b/install/tools/ipa-adtrust-install.in +index e7b0e3692..1efccdb67 100644 +--- a/install/tools/ipa-adtrust-install.in ++++ b/install/tools/ipa-adtrust-install.in +@@ -35,7 +35,7 @@ from ipaserver.install.installutils import ( + read_password, + check_server_configuration, + run_script) +-from ipapython.admintool import ScriptError ++from ipapython.admintool import ScriptError, admin_cleanup_global_argv + from ipapython import version + from ipapython import ipautil + from ipalib import api, errors, krb_utils +@@ -93,6 +93,7 @@ def parse_options(): + + options, _args = parser.parse_args() + safe_options = parser.get_safe_opts(options) ++ admin_cleanup_global_argv(parser, options, sys.argv) + + return safe_options, options + +diff --git a/install/tools/ipa-ca-install.in b/install/tools/ipa-ca-install.in +index 3c27a6b27..319b75a56 100644 +--- a/install/tools/ipa-ca-install.in ++++ b/install/tools/ipa-ca-install.in +@@ -42,6 +42,7 @@ from ipalib.constants import DOMAIN_LEVEL_1 + from ipapython.config import IPAOptionParser + from ipapython.ipa_log_manager import standard_logging_setup + from ipaplatform.paths import paths ++from ipapython.admintool import admin_cleanup_global_argv + + logger = logging.getLogger(os.path.basename(__file__)) + +@@ -132,6 +133,7 @@ def parse_options(): + + options, args = parser.parse_args() + safe_options = parser.get_safe_opts(options) ++ admin_cleanup_global_argv(parser, options, sys.argv) + + if args: + parser.error("Too many arguments provided") +diff --git a/install/tools/ipa-compat-manage.in b/install/tools/ipa-compat-manage.in +index 70dd7c451..fb25c22ed 100644 +--- a/install/tools/ipa-compat-manage.in ++++ b/install/tools/ipa-compat-manage.in +@@ -24,7 +24,6 @@ from __future__ import print_function + import sys + from ipaplatform.paths import paths + try: +- from optparse import OptionParser # pylint: disable=deprecated-module + from ipapython import ipautil, config + from ipaserver.install import installutils + from ipaserver.install.ldapupdate import LDAPUpdate +@@ -32,6 +31,7 @@ try: + from ipalib import api, errors + from ipapython.ipa_log_manager import standard_logging_setup + from ipapython.dn import DN ++ from ipapython.admintool import admin_cleanup_global_argv + except ImportError as e: + print("""\ + There was a problem importing one of the required Python modules. The +@@ -47,7 +47,8 @@ nis_config_dn = DN(('cn', 'NIS Server'), ('cn', 'plugins'), ('cn', 'config')) + def parse_options(): + usage = "%prog [options] \n" + usage += "%prog [options]\n" +- parser = OptionParser(usage=usage, formatter=config.IPAFormatter()) ++ parser = config.IPAOptionParser(usage=usage, ++ formatter=config.IPAFormatter()) + + parser.add_option("-d", "--debug", action="store_true", dest="debug", + help="Display debugging information about the update(s)") +@@ -56,6 +57,7 @@ def parse_options(): + + config.add_standard_options(parser) + options, args = parser.parse_args() ++ admin_cleanup_global_argv(parser, options, sys.argv) + + return options, args + +diff --git a/install/tools/ipa-csreplica-manage.in b/install/tools/ipa-csreplica-manage.in +index 6f248cc50..2fab27a94 100644 +--- a/install/tools/ipa-csreplica-manage.in ++++ b/install/tools/ipa-csreplica-manage.in +@@ -32,8 +32,8 @@ from ipaserver.install import (replication, installutils, bindinstance, + from ipalib import api, errors + from ipalib.constants import FQDN + from ipalib.util import has_managed_topology, print_replication_status +-from ipapython import ipautil, ipaldap, version +-from ipapython.admintool import ScriptError ++from ipapython import ipautil, ipaldap, version, config ++from ipapython.admintool import admin_cleanup_global_argv, ScriptError + from ipapython.dn import DN + + logger = logging.getLogger(os.path.basename(__file__)) +@@ -54,11 +54,10 @@ commands = { + + + def parse_options(): +- from optparse import OptionParser # pylint: disable=deprecated-module +- +- parser = OptionParser(version=version.VERSION) ++ parser = config.IPAOptionParser(version=version.VERSION) + parser.add_option("-H", "--host", dest="host", help="starting host") +- parser.add_option("-p", "--password", dest="dirman_passwd", help="Directory Manager password") ++ parser.add_option("-p", "--password", dest="dirman_passwd", sensitive=True, ++ help="Directory Manager password") + parser.add_option("-v", "--verbose", dest="verbose", action="store_true", default=False, + help="provide additional information") + parser.add_option("-f", "--force", dest="force", action="store_true", default=False, +@@ -66,6 +65,7 @@ def parse_options(): + parser.add_option("--from", dest="fromhost", help="Host to get data from") + + options, args = parser.parse_args() ++ admin_cleanup_global_argv(parser, options, sys.argv) + + valid_syntax = False + +diff --git a/install/tools/ipa-managed-entries.in b/install/tools/ipa-managed-entries.in +index e3f121943..ff2fd6a58 100644 +--- a/install/tools/ipa-managed-entries.in ++++ b/install/tools/ipa-managed-entries.in +@@ -24,7 +24,6 @@ import logging + import os + import re + import sys +-from optparse import OptionParser # pylint: disable=deprecated-module + + from ipaplatform.paths import paths + from ipapython import config +@@ -32,6 +31,7 @@ from ipaserver.install import installutils + from ipalib import api, errors + from ipapython.ipa_log_manager import standard_logging_setup + from ipapython.dn import DN ++from ipapython.admintool import admin_cleanup_global_argv + + logger = logging.getLogger(os.path.basename(__file__)) + +@@ -51,9 +51,10 @@ def parse_options(): + action="store_true", + help="List available Managed Entries") + parser.add_option("-p", "--password", dest="dirman_password", +- help="Directory Manager password") ++ sensitive=True, help="Directory Manager password") + + options, args = parser.parse_args() ++ admin_cleanup_global_argv(parser, options, sys.argv) + + return options, args + +diff --git a/install/tools/ipa-replica-conncheck.in b/install/tools/ipa-replica-conncheck.in +index 8eee82483..81b7d13ac 100644 +--- a/install/tools/ipa-replica-conncheck.in ++++ b/install/tools/ipa-replica-conncheck.in +@@ -23,15 +23,15 @@ from __future__ import print_function + import logging + + from ipapython import ipachangeconf +-from ipapython.config import IPAOptionParser ++from ipapython.config import (IPAOptionParser, OptionGroup, ++ OptionValueError) ++from ipapython.admintool import admin_cleanup_global_argv + from ipapython.dn import DN + from ipapython import version + from ipapython import ipautil, certdb + from ipalib import api, errors, x509 + from ipalib.constants import FQDN + from ipaserver.install import installutils +-# pylint: disable=deprecated-module +-from optparse import OptionGroup, OptionValueError + # pylint: enable=deprecated-module + from ipapython.ipa_log_manager import standard_logging_setup + import copy +@@ -189,6 +189,7 @@ def parse_options(): + + options, _args = parser.parse_args() + safe_options = parser.get_safe_opts(options) ++ admin_cleanup_global_argv(parser, options, sys.argv) + + if options.master and options.replica: + parser.error("on-master and on-replica options are mutually exclusive!") +diff --git a/install/tools/ipa-replica-manage.in b/install/tools/ipa-replica-manage.in +index d6e6ef57c..7e5b31a59 100644 +--- a/install/tools/ipa-replica-manage.in ++++ b/install/tools/ipa-replica-manage.in +@@ -43,6 +43,7 @@ from ipalib.util import ( + print_replication_status, + verify_host_resolvable, + ) ++from ipapython.admintool import admin_cleanup_global_argv + from ipapython.ipa_log_manager import standard_logging_setup + from ipapython.dn import DN + from ipapython.config import IPAOptionParser +@@ -84,7 +85,8 @@ class NoRUVsFound(Exception): + def parse_options(): + parser = IPAOptionParser(version=version.VERSION) + parser.add_option("-H", "--host", dest="host", help="starting host") +- parser.add_option("-p", "--password", dest="dirman_passwd", help="Directory Manager password") ++ parser.add_option("-p", "--password", dest="dirman_passwd", sensitive=True, ++ help="Directory Manager password") + parser.add_option("-v", "--verbose", dest="verbose", action="store_true", default=False, + help="provide additional information") + parser.add_option("-d", "--debug", dest="debug", action="store_true", default=False, +@@ -95,7 +97,7 @@ def parse_options(): + help="DANGER: clean up references to a ghost master") + parser.add_option("--binddn", dest="binddn", default=None, type="dn", + help="Bind DN to use with remote server") +- parser.add_option("--bindpw", dest="bindpw", default=None, ++ parser.add_option("--bindpw", dest="bindpw", default=None, sensitive=True, + help="Password for Bind DN to use with remote server") + parser.add_option("--winsync", dest="winsync", action="store_true", default=False, + help="This is a Windows Sync Agreement") +@@ -103,13 +105,15 @@ def parse_options(): + help="Full path and filename of CA certificate to use with TLS/SSL to the remote server") + parser.add_option("--win-subtree", dest="win_subtree", default=None, + help="DN of Windows subtree containing the users you want to sync (default cn=Users, ...', add two args ++ _argc = len(argv) + 2 ++ all_options = [] ++ if '_get_all_options' in dir(option_parser): ++ # OptParse parser ++ all_options = option_parser._get_all_options() ++ elif '_actions' in dir(option_parser): ++ # ArgParse parser ++ all_options = option_parser._actions ++ ++ for opt in all_options: ++ if getattr(opt, 'sensitive', False): ++ v = getattr(options, opt.dest) ++ for i in range(0, _argc): ++ vi = ctypes.cast(_argv[i], ++ ctypes.c_char_p ++ ).value.decode('utf-8') ++ if vi == v: ++ ctypes.memset(_argv[i], ord('X'), len(v)) ++ except Exception: ++ pass ++ ++ + class ScriptError(Exception): + """An exception that records an error message and a return value + """ +@@ -148,6 +187,7 @@ class AdminTool: + cls._option_parsers[cls] = cls.option_parser + + options, args = cls.option_parser.parse_args(argv[1:]) ++ admin_cleanup_global_argv(cls.option_parser, options, argv) + + command_class = cls.get_command_class(options, args) + command = command_class(options, args) +diff --git a/ipaserver/install/ipa_migrate.py b/ipaserver/install/ipa_migrate.py +index 17cc859db..ffee6b11c 100644 +--- a/ipaserver/install/ipa_migrate.py ++++ b/ipaserver/install/ipa_migrate.py +@@ -28,6 +28,7 @@ from ipaplatform.paths import paths + from ipapython.dn import DN + from ipapython.ipaldap import LDAPClient, LDAPEntry, realm_to_ldapi_uri + from ipapython.ipa_log_manager import standard_logging_setup ++from ipapython.admintool import admin_cleanup_global_argv + from ipaserver.install.ipa_migrate_constants import ( + DS_CONFIG, DB_OBJECTS, DS_INDEXES, BIND_DN, LOG_FILE_NAME, + STRIP_OP_ATTRS, STRIP_ATTRS, STRIP_OC, PROD_ATTRS, +@@ -284,6 +285,18 @@ class LDIFParser(ldif.LDIFParser): + self.mc.process_db_entry(entry_dn=dn, entry_attrs=entry_attrs) + + ++class SensitiveStoreAction(argparse._StoreAction): ++ def __init__(self, *, sensitive, **options): ++ super(SensitiveStoreAction, self).__init__(**options) ++ self.sensitive = sensitive ++ ++ def _get_kwargs(self): ++ names = super(SensitiveStoreAction, self)._get_kwargs() ++ sensitive_name = 'sensitive' ++ names.extend((sensitive_name, getattr(self, sensitive_name))) ++ return names ++ ++ + # + # Migrate IPA to IPA Class + # +@@ -344,7 +357,8 @@ class IPAMigrate(): + help='Password for the Bind DN. If a password ' + 'is not provided then the user will be ' + 'prompted to enter it', +- default=None) ++ default=None, sensitive=True, ++ action=SensitiveStoreAction) + parser.add_argument('-j', '--bind-pw-file', + help='A text file containing the clear text ' + 'password for the Bind DN', default=None) +@@ -2105,6 +2119,7 @@ class IPAMigrate(): + parser = argparse.ArgumentParser(description=desc, allow_abbrev=True) + self.add_options(parser) + self.validate_options() ++ admin_cleanup_global_argv(parser, self.args, sys.argv) + + # Check for dryrun mode + if self.args.dryrun or self.args.dryrun_record is not None: +diff --git a/ipaserver/install/ipa_restore.py b/ipaserver/install/ipa_restore.py +index 8d75a0e6b..539501ab4 100644 +--- a/ipaserver/install/ipa_restore.py ++++ b/ipaserver/install/ipa_restore.py +@@ -185,7 +185,7 @@ class Restore(admintool.AdminTool): + super(Restore, cls).add_options(parser, debug_option=True) + + parser.add_option( +- "-p", "--password", dest="password", ++ "-p", "--password", dest="password", sensitive=True, + help="Directory Manager password") + parser.add_option( + "--gpg-keyring", dest="gpg_keyring", +diff --git a/ipaserver/install/ipa_server_certinstall.py b/ipaserver/install/ipa_server_certinstall.py +index e9f680b1d..76ad37ca7 100644 +--- a/ipaserver/install/ipa_server_certinstall.py ++++ b/ipaserver/install/ipa_server_certinstall.py +@@ -72,7 +72,7 @@ class ServerCertInstall(admintool.AdminTool): + help="Name of the certificate to install") + parser.add_option( + "-p", "--dirman-password", +- dest="dirman_password", ++ dest="dirman_password", sensitive=True, + help="Directory Manager password") + + def validate_options(self): +-- +2.47.1 + + +From d7f750fdc1ad28967b2a0df89d4ebf24f63a03f2 Mon Sep 17 00:00:00 2001 +From: Sumit Bose +Date: Wed, 27 Nov 2024 12:16:09 +0100 +Subject: [PATCH 3/3] ipa-otpd: use oidc_child's --client-secret-stdin option + +To remove the client secret from the command line where it would be +visible e.g. when calling ps it is now passed via stdin to oidc_child. + +Fixes: CVE-2024-11029 + +Signed-off-by: Sumit Bose +--- + daemons/ipa-otpd/oauth2.c | 24 ++++++++++++++---------- + 1 file changed, 14 insertions(+), 10 deletions(-) + +diff --git a/daemons/ipa-otpd/oauth2.c b/daemons/ipa-otpd/oauth2.c +index a33cf5171..52d7d7c9c 100644 +--- a/daemons/ipa-otpd/oauth2.c ++++ b/daemons/ipa-otpd/oauth2.c +@@ -31,6 +31,7 @@ + #include + #include + #include ++#include + + #include "internal.h" + +@@ -93,6 +94,7 @@ static void oauth2_on_child_writable(verto_ctx *vctx, verto_ev *ev) + (void)vctx; /* Unused */ + ssize_t io; + struct child_ctx *child_ctx; ++ struct iovec iov[3]; + + child_ctx = verto_get_private(ev); + if (child_ctx == NULL) { +@@ -102,15 +104,18 @@ static void oauth2_on_child_writable(verto_ctx *vctx, verto_ev *ev) + } + + if (child_ctx->oauth2_state == OAUTH2_GET_DEVICE_CODE) { +- /* no input needed */ +- verto_del(ev); +- return; +- } +- ++ io = write(verto_get_fd(ev), child_ctx->item->idp.ipaidpClientSecret, ++ strlen(child_ctx->item->idp.ipaidpClientSecret)); ++ } else { ++ iov[0].iov_base = child_ctx->item->idp.ipaidpClientSecret; ++ iov[0].iov_len = strlen(child_ctx->item->idp.ipaidpClientSecret); ++ iov[1].iov_base = "\n"; ++ iov[1].iov_len = 1; ++ iov[2].iov_base = child_ctx->saved_item->oauth2.device_code_reply; ++ iov[2].iov_len = strlen(child_ctx->saved_item->oauth2.device_code_reply); + +- io = write(verto_get_fd(ev), +- child_ctx->saved_item->oauth2.device_code_reply, +- strlen(child_ctx->saved_item->oauth2.device_code_reply)); ++ io = writev(verto_get_fd(ev), iov, 3); ++ } + otpd_queue_item_free(child_ctx->saved_item); + + if (io < 0) { +@@ -429,8 +434,7 @@ int oauth2(struct otpd_queue_item **item, enum oauth2_state oauth2_state) + args[args_idx++] = (*item)->idp.ipaidpClientID; + + if ((*item)->idp.ipaidpClientSecret) { +- args[args_idx++] = "--client-secret"; +- args[args_idx++] = (*item)->idp.ipaidpClientSecret; ++ args[args_idx++] = "--client-secret-stdin"; + } + + if ((*item)->idp.ipaidpScope) { +-- +2.47.1 + diff --git a/SPECS/freeipa.spec b/SPECS/freeipa.spec index f055694..2c43cbe 100644 --- a/SPECS/freeipa.spec +++ b/SPECS/freeipa.spec @@ -224,7 +224,7 @@ Name: %{package_name} Version: %{IPA_VERSION} -Release: 1%{?rc_version:.%rc_version}%{?dist} +Release: 1%{?rc_version:.%rc_version}.0.1%{?dist}.3 Summary: The Identity, Policy and Audit system License: GPL-3.0-or-later @@ -250,6 +250,15 @@ Patch1002: 1002-Revert-freeipa.spec-depend-on-bind-dnssec-utils.patch %if 0%{?rhel} == 9 Patch0001: 0001-Revert-Replace-netifaces-with-ifaddr.patch Patch0002: 0002-Revert-custodia-do-not-use-deprecated-jwcrypto-wrapp.patch +Patch0003: 0003-Do-not-let-user-with-an-expired-OTP-token-to-log-in-.patch +Patch0004: 0004-Add-ipa-idrange-fix.patch +Patch0005: 0005-ipatests-Add-missing-comma-in-test_idrange_no_rid_ba.patch +Patch0006: 0006-ipatests-Fixes-for-ipa-idrange-fix-testsuite.patch +Patch0007: 0007-ipalib-x509-support-PyCA-44.0.patch +Patch0008: 0008-pyca-adapt-import-paths-for-TripleDES-cipher.patch +Patch0009: 0009-ipa-pwd-extop-clarify-OTP-use-over-LDAP-binds.patch +Patch0010: 0010-adtrust-add-missing-ipaAllowedOperations-objectclass.patch +Patch0011: 0011-CVE-2024-11029.patch Patch1001: 1001-Change-branding-to-IPA-and-Identity-Management.patch %endif %endif @@ -578,6 +587,7 @@ BuildArch: noarch Requires: %{name}-client-common = %{version}-%{release} Requires: httpd >= %{httpd_version} Requires: systemd-units >= %{systemd_version} +Requires: bind >= %{bind_version} %if 0%{?rhel} >= 8 && ! 0%{?eln} Requires: system-logos-ipa >= 80.4 %endif @@ -1029,7 +1039,8 @@ autoreconf -ivf %{enable_server_option} \ %{with_ipatests_option} \ %{with_ipa_join_xml_option} \ - %{linter_options} + %{linter_options} \ + --with-ipaplatform=rhel # run build in default dir # -Onone is workaround for https://bugzilla.redhat.com/show_bug.cgi?id=1398405 @@ -1496,6 +1507,7 @@ fi %{_sbindir}/ipa-pkinit-manage %{_sbindir}/ipa-crlgen-manage %{_sbindir}/ipa-cert-fix +%{_sbindir}/ipa-idrange-fix %{_sbindir}/ipa-acme-manage %{_sbindir}/ipa-migrate %if 0%{?fedora} >= 38 @@ -1575,6 +1587,7 @@ fi %{_mandir}/man1/ipa-pkinit-manage.1* %{_mandir}/man1/ipa-crlgen-manage.1* %{_mandir}/man1/ipa-cert-fix.1* +%{_mandir}/man1/ipa-idrange-fix.1* %{_mandir}/man1/ipa-acme-manage.1* %{_mandir}/man1/ipa-migrate.1* @@ -1863,6 +1876,22 @@ fi %endif %changelog +* Wed Jan 15 2025 Pooja Senthil Kumar - 4.12.2-1.0.1.3 +- Set IPAPLATFORM=rhel when build on Oracle Linux [Orabug: 29516674] +- Add bind to ipa-server-common Requires [Orabug: 36518596] + +* Tue Dec 17 2024 Florence Blanc-Renaud - 4.12.2-1.3 +- Resolves: RHEL-69928 add support for python cryptography 44.0.0 +- Resolves: RHEL-70258 Upgrade to ipa-server-4.12.2-1.el9 OTP-based bind to LDAP without enforceldapotp is broken +- Resolves: RHEL-70482 ipa-server-upgrade fails after established trust with ad +- Resolves: RHEL-67192 CVE-2024-11029 ipa: Administrative user data leaked through systemd journal + +* Wed Nov 27 2024 Florence Blanc-Renaud - 4.12.2-1.2 +- Resolves: RHEL-69294 add a tool to quickly detect and fix issues with IPA ID ranges + +* Fri Nov 08 2024 Florence Blanc-Renaud - 4.12.2-1.1 +- Resolves: RHEL-66173 Last expired OTP token would be considered as still assigned to the user + * Wed Aug 21 2024 Florence Blanc-Renaud - 4.12.2-1 - Resolves: RHEL-54546 Covscan issues: Resource Leak - Resolves: RHEL-49602 misleading warning for missing ipa-selinux-nfast package on luna hsm h/w