diff --git a/gvm/protocols/gmp/_gmpnext.py b/gvm/protocols/gmp/_gmpnext.py index cd92aaeef..77831ea9c 100644 --- a/gvm/protocols/gmp/_gmpnext.py +++ b/gvm/protocols/gmp/_gmpnext.py @@ -338,6 +338,96 @@ def get_agent_support_bundle( ) ) + def create_active_directory_credential( + self, + name: str, + *, + host: str, + bind_dn: str, + base_dn: str, + bind_password: str, + tls_mode: str, + comment: str | None = None, + port: int | None = None, + ca_certificate: str | None = None, + ) -> T: + """Create an Active Directory credential. + + Requires AD integration to be enabled in gvmd. + + Args: + name: Name of the credential. + host: Active Directory server hostname or IP address. + bind_dn: Distinguished name used to bind to the directory. + base_dn: Base distinguished name for directory searches. + bind_password: Password used to bind to the directory. + tls_mode: TLS mode, either "ldaps" or "starttls". + comment: Optional comment for the credential. + port: Server port (1 to 65535). Omit to use the connector default. + ca_certificate: Optional CA certificate in PEM format. + """ + return self._send_request_and_transform_response( + Credentials.create_active_directory_credential( + name=name, + host=host, + bind_dn=bind_dn, + base_dn=base_dn, + bind_password=bind_password, + tls_mode=tls_mode, + comment=comment, + port=port, + ca_certificate=ca_certificate, + ) + ) + + def modify_active_directory_credential( + self, + credential_id: EntityID, + *, + name: str | None = None, + comment: str | None = None, + host: str | None = None, + bind_dn: str | None = None, + base_dn: str | None = None, + bind_password: str | None = None, + tls_mode: str | None = None, + port: int | str | None = None, + ca_certificate: str | None = None, + ) -> T: + """Modify an Active Directory credential. + + Requires AD integration to be enabled in gvmd. Fields set to None + are left unchanged. + + Args: + credential_id: UUID of the credential to modify. + name: New name of the credential. + comment: New comment. An empty string clears the comment. + host: Active Directory server hostname or IP address. + bind_dn: Distinguished name used to bind to the directory. + base_dn: Base distinguished name for directory searches. + bind_password: Password used to bind to the directory. + tls_mode: TLS mode, either "ldaps" or "starttls". + port: Server port (1 to 65535), or an empty string to restore + the connector default. Nonempty strings are not accepted. + ca_certificate: CA certificate in PEM format. An empty string + clears the certificate. + """ + return self._send_request_and_transform_response( + Credentials.modify_active_directory_credential( + credential_id=credential_id, + name=name, + comment=comment, + host=host, + bind_dn=bind_dn, + base_dn=base_dn, + bind_password=bind_password, + tls_mode=tls_mode, + port=port, + ca_certificate=ca_certificate, + ) + ) + def create_credential_store_credential( self, name: str, diff --git a/gvm/protocols/gmp/requests/next/__init__.py b/gvm/protocols/gmp/requests/next/__init__.py index c9b70972b..d251ec9c6 100644 --- a/gvm/protocols/gmp/requests/next/__init__.py +++ b/gvm/protocols/gmp/requests/next/__init__.py @@ -13,6 +13,7 @@ from gvm.protocols.gmp.requests.next._credentials import ( Credentials, CredentialStoreCredentialType, + CredentialType, ) from gvm.protocols.gmp.requests.next._features import ( Features, @@ -76,7 +77,6 @@ CertBundAdvisories, Cpes, CredentialFormat, - CredentialType, Cves, DfnCertAdvisories, EntityType, diff --git a/gvm/protocols/gmp/requests/next/_credentials.py b/gvm/protocols/gmp/requests/next/_credentials.py index 6868e363d..171921616 100644 --- a/gvm/protocols/gmp/requests/next/_credentials.py +++ b/gvm/protocols/gmp/requests/next/_credentials.py @@ -26,7 +26,229 @@ class CredentialStoreCredentialType(Enum): PASSWORD_ONLY = "cs_pw" +class CredentialType(Enum): + """Enum for credential types""" + + CLIENT_CERTIFICATE = "cc" + SNMP = "snmp" + USERNAME_PASSWORD = "up" + USERNAME_SSH_KEY = "usk" + SMIME_CERTIFICATE = "smime" + PGP_ENCRYPTION_KEY = "pgp" + PASSWORD_ONLY = "pw" + ACTIVE_DIRECTORY = "ad" + + class Credentials(CredentialsV224): + @classmethod + def create_active_directory_credential( + cls, + name: str, + *, + host: str, + bind_dn: str, + base_dn: str, + bind_password: str, + tls_mode: str, + comment: str | None = None, + port: int | None = None, + ca_certificate: str | None = None, + ) -> Request: + """Create an Active Directory credential. + + Requires AD integration to be enabled in gvmd. + + Args: + name: Name of the credential. + host: Active Directory server hostname or IP address. + bind_dn: Distinguished name used to bind to the directory. + base_dn: Base distinguished name for directory searches. + bind_password: Password used to bind to the directory. + tls_mode: TLS mode, either "ldaps" or "starttls". + comment: Optional comment for the credential. + port: Server port (1 to 65535). Omit to use the connector default. + ca_certificate: Optional CA certificate in PEM format. + """ + if not name: + raise RequiredArgument( + function=cls.create_active_directory_credential.__name__, + argument="name", + ) + + if not host: + raise RequiredArgument( + function=cls.create_active_directory_credential.__name__, + argument="host", + ) + + if not bind_dn: + raise RequiredArgument( + function=cls.create_active_directory_credential.__name__, + argument="bind_dn", + ) + + if not base_dn: + raise RequiredArgument( + function=cls.create_active_directory_credential.__name__, + argument="base_dn", + ) + + if not bind_password: + raise RequiredArgument( + function=cls.create_active_directory_credential.__name__, + argument="bind_password", + ) + + if not tls_mode: + raise RequiredArgument( + function=cls.create_active_directory_credential.__name__, + argument="tls_mode", + ) + + if tls_mode not in ("ldaps", "starttls"): + raise InvalidArgument( + function=cls.create_active_directory_credential.__name__, + argument="tls_mode", + ) + + if port is not None and ( + not isinstance(port, int) or not 1 <= port <= 65535 + ): + raise InvalidArgument( + function=cls.create_active_directory_credential.__name__, + argument="port", + ) + + cmd = XmlCommand("create_credential") + cmd.add_element("name", name) + cmd.add_element("type", CredentialType.ACTIVE_DIRECTORY.value) + if comment is not None: + cmd.add_element("comment", comment) + cmd.add_element("host", host) + cmd.add_element("bind_dn", bind_dn) + cmd.add_element("base_dn", base_dn) + cmd.add_element("bind_password", bind_password) + cmd.add_element("tls_mode", tls_mode) + if port is not None: + cmd.add_element("port", str(port)) + if ca_certificate is not None: + cmd.add_element("ca_certificate", ca_certificate) + return cmd + + @classmethod + def modify_active_directory_credential( + cls, + credential_id: EntityID, + *, + name: str | None = None, + comment: str | None = None, + host: str | None = None, + bind_dn: str | None = None, + base_dn: str | None = None, + bind_password: str | None = None, + tls_mode: str | None = None, + port: int | str | None = None, + ca_certificate: str | None = None, + ) -> Request: + """Modify an Active Directory credential. + + Requires AD integration to be enabled in gvmd. Fields set to None + are left unchanged. + + Args: + credential_id: UUID of the credential to modify. + name: New name of the credential. + comment: New comment. An empty string clears the comment. + host: Active Directory server hostname or IP address. + bind_dn: Distinguished name used to bind to the directory. + base_dn: Base distinguished name for directory searches. + bind_password: Password used to bind to the directory. + tls_mode: TLS mode, either "ldaps" or "starttls". + port: Server port (1 to 65535), or an empty string to restore + the connector default. Nonempty strings are not accepted. + ca_certificate: CA certificate in PEM format. An empty string + clears the certificate. + """ + if not credential_id: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="credential_id", + ) + + if name is not None and not name: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="name", + ) + + if host is not None and not host: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="host", + ) + + if bind_dn is not None and not bind_dn: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="bind_dn", + ) + + if base_dn is not None and not base_dn: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="base_dn", + ) + + if bind_password is not None and not bind_password: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="bind_password", + ) + + if tls_mode is not None and not tls_mode: + raise RequiredArgument( + function=cls.modify_active_directory_credential.__name__, + argument="tls_mode", + ) + + if tls_mode is not None and tls_mode not in ("ldaps", "starttls"): + raise InvalidArgument( + function=cls.modify_active_directory_credential.__name__, + argument="tls_mode", + ) + + if ( + port is not None + and port != "" + and (not isinstance(port, int) or not 1 <= port <= 65535) + ): + raise InvalidArgument( + function=cls.modify_active_directory_credential.__name__, + argument="port", + ) + + cmd = XmlCommand("modify_credential") + cmd.set_attribute("credential_id", str(credential_id)) + if name is not None: + cmd.add_element("name", str(name)) + if comment is not None: + cmd.add_element("comment", str(comment)) + if host is not None: + cmd.add_element("host", str(host)) + if bind_dn is not None: + cmd.add_element("bind_dn", str(bind_dn)) + if base_dn is not None: + cmd.add_element("base_dn", str(base_dn)) + if bind_password is not None: + cmd.add_element("bind_password", str(bind_password)) + if tls_mode is not None: + cmd.add_element("tls_mode", str(tls_mode)) + if port is not None: + cmd.add_element("port", str(port)) + if ca_certificate is not None: + cmd.add_element("ca_certificate", str(ca_certificate)) + return cmd + @classmethod def create_credential_store_credential( cls, diff --git a/tests/protocols/gmpnext/entities/credentials/__init__.py b/tests/protocols/gmpnext/entities/credentials/__init__.py index 4a49c76ee..e1425d246 100644 --- a/tests/protocols/gmpnext/entities/credentials/__init__.py +++ b/tests/protocols/gmpnext/entities/credentials/__init__.py @@ -2,14 +2,22 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from .test_create_active_directory_credential import ( + GmpCreateActiveDirectoryCredentialTestMixin, +) from .test_create_credential_store_credential import ( GmpCreateCredentialStoreCredentialTestMixin, ) +from .test_modify_active_directory_credential import ( + GmpModifyActiveDirectoryCredentialTestMixin, +) from .test_modify_credential_store_credential import ( GmpModifyCredentialStoreCredentialTestMixin, ) __all__ = ( + "GmpCreateActiveDirectoryCredentialTestMixin", "GmpCreateCredentialStoreCredentialTestMixin", + "GmpModifyActiveDirectoryCredentialTestMixin", "GmpModifyCredentialStoreCredentialTestMixin", ) diff --git a/tests/protocols/gmpnext/entities/credentials/test_create_active_directory_credential.py b/tests/protocols/gmpnext/entities/credentials/test_create_active_directory_credential.py new file mode 100644 index 000000000..0238cd5ce --- /dev/null +++ b/tests/protocols/gmpnext/entities/credentials/test_create_active_directory_credential.py @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: 2026 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +from gvm.errors import InvalidArgument, RequiredArgument + + +class GmpCreateActiveDirectoryCredentialTestMixin: + def test_create_ad_credential(self): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + ) + + self.connection.send.has_been_called_with( + b"" + b"AD" + b"ad" + b"dc.example.org" + b"CN=Scanner,CN=Users,DC=example,DC=org" + b"DC=example,DC=org" + b"secret" + b"ldaps" + b"" + ) + + def test_create_ad_credential_with_starttls(self): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="starttls", + ) + + self.connection.send.has_been_called_with( + b"" + b"AD" + b"ad" + b"dc.example.org" + b"CN=Scanner,CN=Users,DC=example,DC=org" + b"DC=example,DC=org" + b"secret" + b"starttls" + b"" + ) + + def test_create_ad_credential_with_optional_arguments(self): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret<&", + tls_mode="ldaps", + comment="Directory", + port=636, + ca_certificate="PEM certificate", + ) + + self.connection.send.has_been_called_with( + b"" + b"AD" + b"ad" + b"Directory" + b"dc.example.org" + b"CN=Scanner,CN=Users,DC=example,DC=org" + b"DC=example,DC=org" + b"secret<&" + b"ldaps" + b"636" + b"PEM certificate" + b"" + ) + + def test_create_ad_credential_missing_name(self): + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name="", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + ) + + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name=None, + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + ) + + def test_create_ad_credential_missing_host(self): + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + ) + + def test_create_ad_credential_missing_bind_dn(self): + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + ) + + def test_create_ad_credential_missing_base_dn(self): + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="", + bind_password="secret", + tls_mode="ldaps", + ) + + def test_create_ad_credential_missing_bind_password(self): + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="", + tls_mode="ldaps", + ) + + def test_create_ad_credential_missing_tls_mode(self): + with self.assertRaises(RequiredArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="", + ) + + def test_create_ad_credential_invalid_tls_mode(self): + with self.assertRaises(InvalidArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldap", + ) + + def test_create_ad_credential_invalid_port(self): + with self.assertRaises(InvalidArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + port=0, + ) + + with self.assertRaises(InvalidArgument): + self.gmp.create_active_directory_credential( + name="AD", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret", + tls_mode="ldaps", + port=65536, + ) diff --git a/tests/protocols/gmpnext/entities/credentials/test_modify_active_directory_credential.py b/tests/protocols/gmpnext/entities/credentials/test_modify_active_directory_credential.py new file mode 100644 index 000000000..2ef9e36f1 --- /dev/null +++ b/tests/protocols/gmpnext/entities/credentials/test_modify_active_directory_credential.py @@ -0,0 +1,140 @@ +# SPDX-FileCopyrightText: 2026 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +# + +from gvm.errors import InvalidArgument, RequiredArgument + + +class GmpModifyActiveDirectoryCredentialTestMixin: + def test_modify_ad_credential(self): + self.gmp.modify_active_directory_credential( + credential_id="credential-id" + ) + + self.connection.send.has_been_called_with( + b'' + ) + + def test_modify_ad_credential_with_all_arguments(self): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", + name="AD", + comment="Directory", + host="dc.example.org", + bind_dn="CN=Scanner,CN=Users,DC=example,DC=org", + base_dn="DC=example,DC=org", + bind_password="secret<&", + tls_mode="ldaps", + port=636, + ca_certificate="PEM certificate", + ) + + self.connection.send.has_been_called_with( + b'' + b"AD" + b"Directory" + b"dc.example.org" + b"CN=Scanner,CN=Users,DC=example,DC=org" + b"DC=example,DC=org" + b"secret<&" + b"ldaps" + b"636" + b"PEM certificate" + b"" + ) + + def test_modify_ad_credential_with_bind_password(self): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", bind_password="new-secret" + ) + + self.connection.send.has_been_called_with( + b'' + b"new-secret" + b"" + ) + + def test_modify_ad_credential_clear_optional_arguments(self): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", + comment="", + port="", + ca_certificate="", + ) + + self.connection.send.has_been_called_with( + b'' + b"" + b"" + b"" + b"" + ) + + def test_modify_ad_credential_missing_credential_id(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential(None) + + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential("") + + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential(credential_id="") + + def test_modify_ad_credential_empty_name(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", name="" + ) + + def test_modify_ad_credential_empty_host(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", host="" + ) + + def test_modify_ad_credential_empty_bind_dn(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", bind_dn="" + ) + + def test_modify_ad_credential_empty_base_dn(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", base_dn="" + ) + + def test_modify_ad_credential_empty_bind_password(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", bind_password="" + ) + + def test_modify_ad_credential_empty_tls_mode(self): + with self.assertRaises(RequiredArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", tls_mode="" + ) + + def test_modify_ad_credential_invalid_tls_mode(self): + with self.assertRaises(InvalidArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", tls_mode="ldap" + ) + + def test_modify_ad_credential_invalid_port(self): + with self.assertRaises(InvalidArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", port=0 + ) + + with self.assertRaises(InvalidArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", port=65536 + ) + + with self.assertRaises(InvalidArgument): + self.gmp.modify_active_directory_credential( + credential_id="credential-id", port="636" + ) diff --git a/tests/protocols/gmpnext/entities/test_credentials.py b/tests/protocols/gmpnext/entities/test_credentials.py index 6fd67390b..27724beef 100644 --- a/tests/protocols/gmpnext/entities/test_credentials.py +++ b/tests/protocols/gmpnext/entities/test_credentials.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2023-2025 Greenbone AG +# SPDX-FileCopyrightText: 2023-2026 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later # @@ -13,7 +13,9 @@ GmpModifyCredentialTestMixin, ) from ..entities.credentials import ( + GmpCreateActiveDirectoryCredentialTestMixin, GmpCreateCredentialStoreCredentialTestMixin, + GmpModifyActiveDirectoryCredentialTestMixin, GmpModifyCredentialStoreCredentialTestMixin, ) @@ -24,6 +26,7 @@ class GMPCloneCredentialTestCase(GmpCloneCredentialTestMixin, GMPTestCase): class GMPCreateCredentialTestCase( GmpCreateCredentialTestMixin, + GmpCreateActiveDirectoryCredentialTestMixin, GmpCreateCredentialStoreCredentialTestMixin, GMPTestCase, ): @@ -44,6 +47,7 @@ class GMPGetCredentialsTestCase(GmpGetCredentialsTestMixin, GMPTestCase): class GMPModifyCredentialTestCase( GmpModifyCredentialTestMixin, + GmpModifyActiveDirectoryCredentialTestMixin, GmpModifyCredentialStoreCredentialTestMixin, GMPTestCase, ):