diff --git a/changelog/66322.added.md b/changelog/66322.added.md new file mode 100644 index 000000000000..21bf06ff2130 --- /dev/null +++ b/changelog/66322.added.md @@ -0,0 +1 @@ +New state to provide human-readable info regarding changes of files containing certificates diff --git a/doc/ref/states/all/index.rst b/doc/ref/states/all/index.rst index 924979985fa2..fcf7b0c946d2 100644 --- a/doc/ref/states/all/index.rst +++ b/doc/ref/states/all/index.rst @@ -238,6 +238,7 @@ state modules pcs pdbedit pecl + pem pip_state pkg pkgbuild diff --git a/doc/ref/states/all/salt.states.pem.rst b/doc/ref/states/all/salt.states.pem.rst new file mode 100644 index 000000000000..f7fe686e4324 --- /dev/null +++ b/doc/ref/states/all/salt.states.pem.rst @@ -0,0 +1,5 @@ +salt.states.pem +================ + +.. automodule:: salt.states.pem + :members: diff --git a/salt/states/pem.py b/salt/states/pem.py new file mode 100644 index 000000000000..e3ac6865d7be --- /dev/null +++ b/salt/states/pem.py @@ -0,0 +1,341 @@ +""" +Provide easier operations on certificate files +===================================================================== + +Provides human-readable information regarding changes of files containing certificates. + +Requires `cryptography` python package. + +.. code-block:: yaml + +/etc/ssl/postfix/mydomain/mydomain.pem: + pem.managed: + - source: salt://files/etc/ssl/mydomain.pem + - user: root + - group: postfix + - dir_mode: 711 + - makedirs: True + - template: jinja +""" + +import logging +import os +from collections.abc import Iterable, Mapping + +import salt.utils.files + +try: + from cryptography import x509 + + HAS_CRYPTOGRAPHY = True +except ImportError: + HAS_CRYPTOGRAPHY = False + +__NOT_FOUND = object() + +__virtualname__ = "pem" + + +def __virtual__(): + if not HAS_CRYPTOGRAPHY: + return (False, "Could not load cryptography") + return __virtualname__ + + +log = logging.getLogger(__name__) + + +def _validate_str_list(arg, encoding=None): + """ + ensure ``arg`` is a list of strings + """ + if isinstance(arg, bytes): + ret = [salt.utils.stringutils.to_unicode(arg, encoding=encoding)] + elif isinstance(arg, str): + ret = [arg] + elif isinstance(arg, Iterable) and not isinstance(arg, Mapping): + ret = [] + for item in arg: + if isinstance(item, str): + ret.append(item) + else: + ret.append(str(item)) + else: + ret = [str(arg)] + return ret + + +def managed( + name, + source=None, + source_hash=None, + source_hash_name=None, + user=None, + group=None, + mode=None, + skip_verify=None, + defaults=None, + attrs=None, + context=None, + saltenv="base", + template=None, + allow_empty=False, + contents=None, + contents_pillar=None, + contents_grains=None, + contents_delimiter=":", + contents_newline=True, + encoding=None, + skip_conditions=False, + **kwargs, +): + """ + Manage certificates as files and provide certificate expiration and common name in the comment. + + It can handle one certificate or full privkey->cert->chain files. + + Conditions provides an easy way to match against the certificate's Common Name + or to make sure that only newer certificates are copied down. + + State can handle everything that file.managed can handle, + because it is used underneath to process changes to files. + + For all parameters refer to file.managed documentation: + https://docs.saltproject.io/en/master/ref/states/all/salt.states.file.html#salt.states.file.managed + + Args: + + skip_conditions (bool): Do not check expiration or Common name match (default: False) + Also pillar can be used: pillar="{skip_conditions: True}" + """ + + skip_conditions = __pillar__.get("skip_conditions", skip_conditions) + ret = {"name": name, "changes": {}, "result": False, "comment": ""} + existing_cert_info = "" + new_cert_info = "" + source_content = None + + # contents, contents_pillar and content_grains management + if contents_pillar is not None: + if isinstance(contents_pillar, list): + list_contents = [] + for nextp in contents_pillar: + nextc = __salt__["pillar.get"]( + nextp, __NOT_FOUND, delimiter=contents_delimiter + ) + if nextc is __NOT_FOUND: + return _error(ret, f"Pillar {nextp} does not exist") + list_contents.append(nextc) + use_contents = os.linesep.join(list_contents) + else: + use_contents = __salt__["pillar.get"]( + contents_pillar, __NOT_FOUND, delimiter=contents_delimiter + ) + if use_contents is __NOT_FOUND: + return _error(ret, f"Pillar {contents_pillar} does not exist") + + elif contents_grains is not None: + if isinstance(contents_grains, list): + list_contents = [] + for nextg in contents_grains: + nextc = __salt__["grains.get"]( + nextg, __NOT_FOUND, delimiter=contents_delimiter + ) + if nextc is __NOT_FOUND: + return _error(ret, f"Grain {nextc} does not exist") + list_contents.append(nextc) + use_contents = os.linesep.join(list_contents) + else: + use_contents = __salt__["grains.get"]( + contents_grains, __NOT_FOUND, delimiter=contents_delimiter + ) + if use_contents is __NOT_FOUND: + return _error(ret, f"Grain {contents_grains} does not exist") + + elif contents is not None: + use_contents = contents + + else: + use_contents = None + + if use_contents is not None: + if not allow_empty and not use_contents: + if contents_pillar: + contents_id = f"contents_pillar {contents_pillar}" + elif contents_grains: + contents_id = f"contents_grains {contents_grains}" + else: + contents_id = "'contents'" + return _error( + ret, + "{} value would result in empty contents. Set allow_empty " + "to True to allow the managed file to be empty.".format(contents_id), + ) + + try: + validated_contents = _validate_str_list(use_contents, encoding=encoding) + if not validated_contents: + return _error( + ret, + "Contents specified by contents/contents_pillar/" + "contents_grains is not a string or list of strings, and " + "is not binary data. SLS is likely malformed.", + ) + source_content = "" + for part in validated_contents: + for line in part.splitlines(): + source_content += line.rstrip("\n").rstrip("\r") + os.linesep + if not contents_newline: + # If source_content newline is set to False, strip out the newline + # character and carriage return character + source_content = source_content.rstrip("\n").rstrip("\r") + + except UnicodeDecodeError: + # Either something terrible happened, or we have binary data. + if template: + return _error( + ret, + "Contents specified by source_content/contents_pillar/" + "contents_grains appears to be binary data, and" + " as will not be able to be treated as a Jinja" + " template.", + ) + source_content = use_contents + + # If no contents specified, get content from salt + if source_content is None: + try: + source_content = __salt__["cp.get_file_str"]( + path=source, + saltenv=saltenv, + ) + except Exception as exc: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] = f"Unable to get file str: {exc}" + return ret + + # Apply template + if template: + source_content = __salt__["file.apply_template_on_contents"]( + source_content, + template=template, + context=context, + defaults=defaults, + saltenv=saltenv, + ) + if not isinstance(source_content, str): + if "result" in source_content: + ret["result"] = source_content["result"] + else: + ret["result"] = False + if "comment" in source_content: + ret["comment"] = source_content["comment"] + else: + ret["comment"] = "Error while applying template on source_content" + return ret + + if source_content is None: + return _error(ret, "source_content is empty") + + try: + new_cert = x509.load_pem_x509_certificate(source_content.encode()) + new_cert_info = f"+ Subject: {new_cert.subject.rfc4514_string()}\n+ Not valid after: {new_cert.not_valid_after}" + except ValueError as val_err: + # This is not a certificate, but we can still continue with file.managed backend + log.debug("pem: %s", val_err) + log.debug("pem: Value error found, continue normally as file.managed state") + skip_conditions = True + except Exception as exc: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] = f"Problem with source file: {exc}" + return ret + + # Load existing certificate + try: + with salt.utils.files.fopen(name, "rb") as existing_cert_file: + existing_cert = x509.load_pem_x509_certificate(existing_cert_file.read()) + existing_cert_info = f"- Subject: {existing_cert.subject.rfc4514_string()}\n- Not valid after: {existing_cert.not_valid_after}" + except FileNotFoundError: + # Old certificate initialy does not need to exist if it is a first time state is running + skip_conditions = True + except ValueError as val_err: + # This is not a certificate, but we can still continue with file.managed backend + log.debug("pem: %s", val_err) + log.debug("pem: Value error found, continue normally as file.managed state") + skip_conditions = True + except Exception as exc: # pylint: disable=broad-except + ret["result"] = False + ret["comment"] = f"Unable to determine existing file: {exc}" + return ret + + if existing_cert_info == "" and new_cert_info == "": + log.debug( + "pem: No certificate information was found - state is running as normal file.managed state" + ) + elif existing_cert_info != "" and new_cert_info != "": + if ( + new_cert.subject.rfc4514_string() == existing_cert.subject.rfc4514_string() + and new_cert.not_valid_after == existing_cert.not_valid_after + ): + ret["comment"] = f"Certificates are the same:\n{existing_cert_info}\n" + elif existing_cert_info == "" and new_cert_info != "": + ret["comment"] = f"New cert info:\n{new_cert_info}\n" + else: + ret["comment"] = ( + f"Existing cert info:\n{existing_cert_info}\nNew cert info:\n{new_cert_info}\n" + ) + + # Conditions when certificates are salted + if skip_conditions: + log.debug("pem: Certificate conditions are skipped") + else: + failed_conditions = False + + if new_cert.not_valid_after < existing_cert.not_valid_after: + ret[ + "comment" + ] += "New certificate expires sooner than existing one (skip with pillar='{skip_conditions: True}')\n" + failed_conditions = True + if new_cert.subject.rfc4514_string() != existing_cert.subject.rfc4514_string(): + ret[ + "comment" + ] += "Certificates CN does not match (skip with pillar='{skip_conditions: True}')\n" + failed_conditions = True + + if failed_conditions: + ret["result"] = False + return ret + + result = __states__["file.managed"]( + name=name, + source=source, + source_hash=source_hash, + source_hash_name=source_hash_name, + user=user, + group=group, + mode=mode, + skip_verify=skip_verify, + defaults=defaults, + attrs=attrs, + context=context, + template=template, + allow_empty=allow_empty, + contents=contents, + contents_pillar=contents_pillar, + contents_grains=contents_grains, + contents_delimiter=contents_delimiter, + contents_newline=contents_newline, + **kwargs, + ) + + ret["changes"] = result["changes"] + ret["result"] = result["result"] + ret["comment"] += result["comment"] + + return ret + + +def _error(ret, err_msg): + ret["result"] = False + ret["comment"] = err_msg + return ret diff --git a/tests/pytests/unit/states/test_pem.py b/tests/pytests/unit/states/test_pem.py new file mode 100644 index 000000000000..80da47d91236 --- /dev/null +++ b/tests/pytests/unit/states/test_pem.py @@ -0,0 +1,226 @@ +# tests/pytests/unit/states/test_pem.py + +from datetime import datetime + +import pytest + +import salt.states.pem as pem +from tests.support.mock import MagicMock, mock_open, patch + +GOOD_CERT = """ + -----BEGIN CERTIFICATE----- + MIIG6zCCBdOgAwIBAgIQD5enH9rVhbYSQrtpPBDjsDANBgkqhkiG9w0BAQsFADBZ + MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMTMwMQYDVQQDEypE + aWdpQ2VydCBHbG9iYWwgRzIgVExTIFJTQSBTSEEyNTYgMjAyMCBDQTEwHhcNMjMx + MDA2MDAwMDAwWhcNMjQxMTA1MjM1OTU5WjBsMQswCQYDVQQGEwJVUzEVMBMGA1UE + CBMMUGVubnN5bHZhbmlhMQ4wDAYDVQQHEwVQYW9saTEbMBkGA1UEChMSRHVjayBE + dWNrIEdvLCBJbmMuMRkwFwYDVQQDDBAqLmR1Y2tkdWNrZ28uY29tMIIBIjANBgkq + hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtqZKa5gE0IO5367vb9MGypqNzDdicz3y + RAF938TbQMUA0ZCyEbAFnngUkJYLR+YmSazWHKt0kW9c2SOsu230E/YQtsdzayYu + dlHnwOuXVBeFzUCFSICMNhrRW5gw4kIh9Lw3PS8RBxafzvO4gRcTtk+odRxElH+I + BcXF4DA22mKdkA9p8Nh7HYBpsuRKniyjiUVTDC2u1uNJ3YRcWsug3Dkq/M7tw57o + dp59JNpIWOybtTfwppTrUyQGOqMzW+zt/uJYRk2ZB1Hd98hIBAqy8Q7EkEGw/V1f + CRpM+byhYgq/otBMd9XAGP9zpHD3F4qiYCQOyK+RKd1iMNXs1+CtowIDAQABo4ID + mjCCA5YwHwYDVR0jBBgwFoAUdIWAwGbH3zfez70pN6oDHb7tzRcwHQYDVR0OBBYE + FIaIVeEXZBu4TesZG6lpf+zmOty9MCsGA1UdEQQkMCKCECouZHVja2R1Y2tnby5j + b22CDmR1Y2tkdWNrZ28uY29tMD4GA1UdIAQ3MDUwMwYGZ4EMAQICMCkwJwYIKwYB + BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC + BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMIGfBgNVHR8EgZcwgZQw + SKBGoESGQmh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbEcy + VExTUlNBU0hBMjU2MjAyMENBMS0xLmNybDBIoEagRIZCaHR0cDovL2NybDQuZGln + aWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEu + Y3JsMIGHBggrBgEFBQcBAQR7MHkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp + Z2ljZXJ0LmNvbTBRBggrBgEFBQcwAoZFaHR0cDovL2NhY2VydHMuZGlnaWNlcnQu + Y29tL0RpZ2lDZXJ0R2xvYmFsRzJUTFNSU0FTSEEyNTYyMDIwQ0ExLTEuY3J0MAwG + A1UdEwEB/wQCMAAwggF8BgorBgEEAdZ5AgQCBIIBbASCAWgBZgB1AO7N0GTV2xrO + xVy3nbTNE6Iyh0Z8vOzew1FIWUZxH7WbAAABiwRZRLwAAAQDAEYwRAIgQSl6sqy2 + uIt1vG+7EHKLkToASFvYY5NV9Np8runSdAYCIBQ0qDazjm9FwrGunk8C9rEaw3QV + +hb8juCsd90A+9DXAHUASLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMA + AAGLBFlEbwAABAMARjBEAiANubHLJUmCBphlEmTf4PR5TYBHnNLDTDGTEKsXDF+N + LQIgNwPp1iM7kwUT8g+nxkrPyNhNh/kQVmrfuMrBaLxwr3UAdgDatr9rP7W2Ip+b + wrtca+hwkXFsu1GEhTS9pD0wSNf7qwAAAYsEWURZAAAEAwBHMEUCIGE1BSUI8i/1 + apqkN6hdrlvo0le3RYCu36BLbb9qqzn8AiEAkib0gR04diUH4Rta1EY2nyrXoTxZ + XuaT9SL5tW5aw+YwDQYJKoZIhvcNAQELBQADggEBADLm6XLJ1/uPtSDFB0rtHYVK + tKjSYqmjP2m/7xUFsc05qxmd7xBuD17wArDRZSMfnfSb4ZL1kyMmGHtUmaPLUEh1 + r9jioPvdHI09afqhLSzbGCaP9bN9hCz++m0vKVT1jyo91NuDfubYjF5IYwFpCanw + ccNUo9RHaJ78Umd697/4z5lIgNTy/EUoyOMLM77JNoYnRsgZwYuy/OmsZDLagyEy + YX4VHgyZ0mbjZ3wLhxLaR7bpXm3xaXhkT+aYhxAz41VLnTbrrd8tWndpUBZxZIOo + QzrWHN1s5ktSh2ThhyA4d3hanaxrohNFFWPqpk0WX1PZwJeNPAL8P8d8B6VPzMs= + -----END CERTIFICATE----- +""" + +BAD_CERT = """ + -----BEGIN CERTIFICATE----- + MIICEjCCAXsCAg36MA0GCSqGSIb3DQEBBQUAMIGbMQswCQYDVQQGEwJKUDEOMAwG + A1UECBMFVG9reW8xEDAOBgNVBAcTB0NodW8ta3UxETAPBgNVBAoTCEZyYW5rNERE + MRgwFgYDVQQLEw9XZWJDZXJ0IFN1cHBvcnQxGDAWBgNVBAMTD0ZyYW5rNEREIFdl + YiBDQTEjMCEGCSqGSIb3DQEJARYUc3VwcG9ydEBmcmFuazRkZC5jb20wHhcNMTIw + ODIyMDUyNjU0WhcNMTcwODIxMDUyNjU0WjBKMQswCQYDVQQGEwJKUDEOMAwGA1UE + CAwFVG9reW8xETAPBgNVBAoMCEZyYW5rNEREMRgwFgYDVQQDDA93d3cuZXhhbXBs + ZS5jb20wXDANBgkqhkiG9w0BAQEFAANLADBIAkEAm/xmkHmEQrurE/0re/jeFRLl + 8ZPjBop7uLHhnia7lQG/5zDtZIUC3RVpqDSwBuw/NTweGyuP+o8AG98HxqxTBwID + AQABMA0GCSqGSIb3DQEBBQUAA4GBABS2TLuBeTPmcaTaUW/LCB2NYOy8GMdzR1mx + 8iBIu2H6/E2tiY3RIevV2OW61qY2/XRQg7YPxx3ffeUugX9F4J/iPnnu1zAxxyBy + 2VguKv4SWjRFoRkIfIlHX0qVviMhSlNy2ioFLy7JcPZb+v3ftDGywUqcBiVDoea0 + Hn+GmxZA + -----END CERTIFICATE----- +""" + + +@pytest.fixture +def configure_loader_modules(): + return { + pem: { + "__opts__": {"test": False}, + "__salt__": {}, + "__states__": { + "file.managed": MagicMock( + return_value={"result": True, "changes": {}, "comment": ""} + ) + }, + } + } + + +def test_managed(): + """ + Test pem.managed state + """ + name = "/tmp/example.crt" + source = "salt://example.crt" + user = "root" + group = "root" + mode = "0600" + + # Mock cp.get_file_str to return local tmp file + with patch.dict( + pem.__salt__, + {"cp.get_file_str": MagicMock(return_value=GOOD_CERT)}, + ): + + # Local existing cert was not found + with patch( + "salt.utils.files.fopen", MagicMock(side_effect=FileNotFoundError()) + ): + ret = pem.managed( + name=name, source=source, user=user, group=group, mode=mode + ) + assert ret["result"] is True + assert ( + ret["comment"] + == "New cert info:\n+ Subject: CN=*.duckduckgo.com,O=Duck Duck Go\\, Inc.,L=Paoli,ST=Pennsylvania,C=US\n+ Not valid after: 2024-11-05 23:59:59\n" + ) + + # Test mode + with patch.dict(pem.__opts__, {"test": True}), patch( + "salt.utils.files.fopen", mock_open(read_data=BAD_CERT.encode()) + ): + ret = pem.managed( + name=name, source=source, user=user, group=group, mode=mode + ) + assert ret["result"] is False + assert ( + ret["comment"] + == "Certificates CN does not match (skip with pillar='{skip_conditions: True}')\n" + ) + assert ret["changes"] == {} + + +def test_managed_with_templating_and_cp_get_file_str(): + """ + Test pem.managed state with templating and cp.get_file_str + """ + name = "/tmp/example.crt" + source = "salt://example.crt" + user = "root" + group = "root" + mode = "0600" + template = "jinja" + + # Mock cp.get_file_str to return the good certificate + with patch.dict( + pem.__salt__, {"cp.get_file_str": MagicMock(return_value=GOOD_CERT)} + ), patch.dict( + pem.__salt__, + {"file.apply_template_on_contents": MagicMock(return_value=GOOD_CERT)}, + ), patch( + "salt.utils.files.fopen", mock_open(read_data=GOOD_CERT.encode()) + ): + + # Call the managed function with the template argument + ret = pem.managed( + name=name, + source=source, + user=user, + group=group, + mode=mode, + template=template, + ) + + # Assertions to ensure the template was applied and cp.get_file_str was called + pem.__salt__["file.apply_template_on_contents"].assert_called_with( + GOOD_CERT, + template=template, + context=None, + defaults=None, + saltenv="base", + ) + pem.__salt__["cp.get_file_str"].assert_called_with(path=source, saltenv="base") + + # Check the result + assert ret["result"] is True + assert "Certificates are the same:" in ret["comment"] + assert ret["changes"] == {} + + +def test_managed_failed_conditions(): + """ + Test failure conditions in pem.managed state + """ + + name = "/tmp/example.crt" + source = "salt://example.crt" + user = "root" + group = "root" + mode = "0600" + + existing_cert = MagicMock() + existing_cert.subject.rfc4514_string.return_value = "CN=existing.com" + # existing_cert.not_valid_after.isoformat.return_value = "2023-01-01T00:00:00" + existing_cert.not_valid_after = datetime(2023, 1, 1) + new_cert = MagicMock() + new_cert.subject.rfc4514_string.return_value = "CN=new.com" + # new_cert.not_valid_after.isoformat.return_value = "2022-01-01T00:00:00" + new_cert.not_valid_after = datetime(2022, 1, 1) + + with patch.dict( + pem.__salt__, + {"cp.get_file_str": MagicMock(return_value=new_cert)}, + ), patch("salt.utils.files.fopen", MagicMock()), patch( + "cryptography.x509.load_pem_x509_certificate", + MagicMock(side_effect=[new_cert, existing_cert]), + ): + ret = pem.managed(name=name, source=source, user=user, group=group, mode=mode) + assert ret["result"] is False + assert "New certificate expires sooner than existing one" in ret["comment"] + assert "Certificates CN does not match" in ret["comment"] + assert ret["changes"] == {} + + # Skip coditions + with patch.dict( + pem.__salt__, + {"cp.get_file_str": MagicMock(return_value=new_cert)}, + ), patch("salt.utils.files.fopen", MagicMock()), patch( + "cryptography.x509.load_pem_x509_certificate", + MagicMock(side_effect=[new_cert, existing_cert]), + ): + ret = pem.managed( + name=name, + source=source, + user=user, + group=group, + mode=mode, + skip_conditions=True, + ) + assert ret["result"] is True + assert ret["changes"] == {}