diff --git a/contrib/k8s/helm/prowler-api/values.yaml b/contrib/k8s/helm/prowler-api/values.yaml index 32149aeb7f..4f0ff3ae14 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -439,6 +439,11 @@ mainConfig: elbv2_min_azs: 2 # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" # aws.cloudfront_distributions_pqc_tls_enabled cloudfront_pqc_min_protocol_versions: - "TLSv1.3_2025" diff --git a/docs/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index 9f9f38a0dc..23e54317e6 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -13,11 +13,13 @@ Additionally, you can input a custom configuration file using the `--config-file ## AWS ### Configurable Checks + The following list includes all the AWS checks with configurable variables that can be changed in the configuration yaml file: | Check Name | Value | Type | |---------------------------------------------------------------|--------------------------------------------------|-----------------| | `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer | +| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings | | `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer | | `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer | | `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer | diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 731f774d1a..b2927bdd2c 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -47,6 +47,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `cloudfront_distributions_pqc_tls_enabled` check for AWS provider to verify CloudFront distributions enforce a post-quantum TLS 1.3 security policy [(#11317)](https://github.com/prowler-cloud/prowler/pull/11317) - `apigateway_domain_name_pqc_tls_enabled` check for AWS provider to verify API Gateway custom domain names use a post-quantum TLS security policy [(#11316)](https://github.com/prowler-cloud/prowler/pull/11316) - `transfer_server_pqc_ssh_kex_enabled` check for AWS provider to verify Transfer Family servers use a post-quantum hybrid SSH key exchange security policy [(#11315)](https://github.com/prowler-cloud/prowler/pull/11315) +- `acmpca_certificate_authority_pqc_key_algorithm` check and new `acmpca` service for AWS provider to verify AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm [(#11318)](https://github.com/prowler-cloud/prowler/pull/11318) ### 🔄 Changed diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 0e063de264..de529312b3 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -381,6 +381,12 @@ aws: elbv2_min_azs: 2 # AWS Post-Quantum TLS Configuration + # aws.acmpca_certificate_authority_pqc_key_algorithm + # Allowed post-quantum key algorithms for AWS Private CA certificate authorities + acmpca_pqc_key_algorithms: + - "ML_DSA_44" + - "ML_DSA_65" + - "ML_DSA_87" # aws.cloudfront_distributions_pqc_tls_enabled # Allowed CloudFront MinimumProtocolVersion values that enable post-quantum hybrid key exchange cloudfront_pqc_min_protocol_versions: diff --git a/prowler/providers/aws/services/acmpca/__init__.py b/prowler/providers/aws/services/acmpca/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json new file mode 100644 index 0000000000..6915540539 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "aws", + "CheckID": "acmpca_certificate_authority_pqc_key_algorithm", + "CheckTitle": "AWS Private CA certificate authorities use a post-quantum (ML-DSA) key algorithm", + "CheckType": [ + "Software and Configuration Checks/AWS Security Best Practices" + ], + "ServiceName": "acmpca", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "low", + "ResourceType": "AwsAcmPcaCertificateAuthority", + "ResourceGroup": "security", + "Description": "**AWS Private Certificate Authorities (Private CAs)** are assessed for use of a **post-quantum digital signature key algorithm** (`ML_DSA_44`, `ML_DSA_65`, `ML_DSA_87`). CAs that still issue certificates with RSA or ECC algorithms produce signatures vulnerable to forgery once a cryptographically relevant quantum computer is available.", + "Risk": "RSA and ECC signatures can be broken by Shor's algorithm on a sufficiently large quantum computer. A compromised CA private key would let an attacker issue arbitrary certificates trusted across the PKI, undermining identity and code-signing controls. Migrating CAs to **ML-DSA** (NIST FIPS 204) provides quantum-resistant signatures so issued certificates retain integrity in the post-quantum era.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://docs.aws.amazon.com/privateca/latest/userguide/PcaTerms.html", + "https://aws.amazon.com/about-aws/whats-new/2025/11/aws-private-ca-post-quantum-digital-certificates/", + "https://aws.amazon.com/blogs/security/post-quantum-ml-dsa-code-signing-with-aws-private-ca-and-aws-kms/", + "https://csrc.nist.gov/pubs/fips/204/final" + ], + "Remediation": { + "Code": { + "CLI": "aws acm-pca create-certificate-authority --certificate-authority-configuration '{\"KeyAlgorithm\":\"ML_DSA_65\",\"SigningAlgorithm\":\"ML_DSA_65\",\"Subject\":{...}}' --certificate-authority-type SUBORDINATE", + "NativeIaC": "```yaml\nResources:\n :\n Type: AWS::ACMPCA::CertificateAuthority\n Properties:\n Type: SUBORDINATE\n KeyAlgorithm: ML_DSA_65 # FIX: post-quantum signature algorithm\n SigningAlgorithm: ML_DSA_65\n Subject:\n CommonName: example-pqc-ca\n```", + "Other": "Existing CAs cannot have their key algorithm changed; create a new CA with KeyAlgorithm = ML_DSA_44 / ML_DSA_65 / ML_DSA_87, re-issue certificates from it, and decommission the legacy CA once dependent workloads have rotated.", + "Terraform": "```hcl\nresource \"aws_acmpca_certificate_authority\" \"\" {\n type = \"SUBORDINATE\"\n certificate_authority_configuration {\n key_algorithm = \"ML_DSA_65\" # FIX: post-quantum signature algorithm\n signing_algorithm = \"ML_DSA_65\"\n subject {\n common_name = \"example-pqc-ca\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Create new Private CAs with a **post-quantum key algorithm** (`ML_DSA_44`, `ML_DSA_65`, or `ML_DSA_87`) and migrate workloads off legacy RSA/ECC CAs. Plan crypto-agility for your PKI so that quantum-resistant trust anchors can be rolled out before threat actors gain access to a cryptographically relevant quantum computer.", + "Url": "https://hub.prowler.com/check/acmpca_certificate_authority_pqc_key_algorithm" + } + }, + "Categories": [ + "encryption" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py new file mode 100644 index 0000000000..738875baee --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm.py @@ -0,0 +1,49 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.acmpca.acmpca_client import acmpca_client + +PQC_PCA_KEY_ALGORITHMS_DEFAULT = [ + "ML_DSA_44", + "ML_DSA_65", + "ML_DSA_87", +] + + +class acmpca_certificate_authority_pqc_key_algorithm(Check): + """Verify that every AWS Private CA uses a post-quantum key algorithm. + + A Private CA PASSES when its ``KeyAlgorithm`` belongs to the configured + allowlist of post-quantum signature algorithms (ML-DSA family). + Deleted CAs are skipped. + """ + + def execute(self) -> list[Check_Report_AWS]: + """Execute the check against AWS Private CAs. + + Returns: + A list of reports with each non-deleted CA PQC key algorithm status. + """ + + findings = [] + pqc_algorithms = acmpca_client.audit_config.get( + "acmpca_pqc_key_algorithms", PQC_PCA_KEY_ALGORITHMS_DEFAULT + ) + for ca in acmpca_client.certificate_authorities.values(): + if ca.status == "DELETED": + continue + report = Check_Report_AWS(metadata=self.metadata(), resource=ca) + algorithm = ca.key_algorithm or "" + if ca.key_algorithm in pqc_algorithms: + report.status = "PASS" + report.status_extended = ( + f"AWS Private CA {ca.id} uses post-quantum key algorithm " + f"{algorithm}." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"AWS Private CA {ca.id} uses key algorithm {algorithm}, " + "which is not post-quantum (ML-DSA)." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/acmpca/acmpca_client.py b/prowler/providers/aws/services/acmpca/acmpca_client.py new file mode 100644 index 0000000000..c8e22730bc --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_client.py @@ -0,0 +1,4 @@ +from prowler.providers.aws.services.acmpca.acmpca_service import ACMPCA +from prowler.providers.common.provider import Provider + +acmpca_client = ACMPCA(Provider.get_global_provider()) diff --git a/prowler/providers/aws/services/acmpca/acmpca_service.py b/prowler/providers/aws/services/acmpca/acmpca_service.py new file mode 100644 index 0000000000..9c6fb77096 --- /dev/null +++ b/prowler/providers/aws/services/acmpca/acmpca_service.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Dict, List + +from pydantic.v1 import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.lib.scan_filters.scan_filters import is_resource_filtered +from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.lib.service.service import AWSService + + +class ACMPCA(AWSService): + """AWS Private CA service class to list certificate authorities.""" + + def __init__(self, provider: AwsProvider) -> None: + """Initialize the AWS Private CA service. + + Args: + provider: AWS provider instance with session and audit context. + """ + + # The boto3 client identifier for AWS Private CA is "acm-pca" + super().__init__("acm-pca", provider) + self.certificate_authorities: dict[str, CertificateAuthority] = {} + self.__threading_call__(self._list_certificate_authorities) + + def _list_certificate_authorities(self, regional_client: Any) -> None: + """List AWS Private CAs and their tags in a region. + + Args: + regional_client: Regional AWS Private CA client. + """ + + logger.info("ACM PCA - Listing Certificate Authorities...") + try: + paginator = regional_client.get_paginator("list_certificate_authorities") + for page in paginator.paginate(): + for ca in page.get("CertificateAuthorities", []): + arn = ca.get("Arn", "") + if not arn: + continue + if self.audit_resources and not is_resource_filtered( + arn, self.audit_resources + ): + continue + config = ca.get("CertificateAuthorityConfiguration", {}) + tags = [] + try: + tags = regional_client.list_tags( + CertificateAuthorityArn=arn + ).get("Tags", []) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + self.certificate_authorities[arn] = CertificateAuthority( + arn=arn, + id=arn.split("/")[-1], + region=regional_client.region, + status=ca.get("Status", ""), + type=ca.get("Type", ""), + usage_mode=ca.get("UsageMode", ""), + key_algorithm=config.get("KeyAlgorithm", ""), + signing_algorithm=config.get("SigningAlgorithm", ""), + tags=tags, + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class CertificateAuthority(BaseModel): + """AWS Private Certificate Authority metadata. + + Attributes: + arn: Certificate authority ARN. + id: Certificate authority identifier. + region: AWS region where the certificate authority exists. + status: Certificate authority lifecycle status. + type: Certificate authority type. + usage_mode: Certificate authority usage mode. + key_algorithm: Key algorithm configured for the certificate authority. + signing_algorithm: Signing algorithm configured for the certificate authority. + tags: Tags attached to the certificate authority. + """ + + arn: str + id: str + region: str + status: str = "" + type: str = "" + usage_mode: str = "" + key_algorithm: str = "" + signing_algorithm: str = "" + tags: List[Dict[str, str]] = Field(default_factory=list) diff --git a/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py new file mode 100644 index 0000000000..1685115755 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_certificate_authority_pqc_key_algorithm/acmpca_certificate_authority_pqc_key_algorithm_test.py @@ -0,0 +1,157 @@ +from unittest import mock + +from prowler.providers.aws.services.acmpca.acmpca_service import CertificateAuthority +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + + +def _build_client(certificate_authorities, audit_config=None): + acmpca_client = mock.MagicMock() + acmpca_client.certificate_authorities = certificate_authorities + acmpca_client.audit_config = audit_config or {} + return acmpca_client + + +def _ca(key_algorithm: str, status: str = "ACTIVE"): + return CertificateAuthority( + arn=CA_ARN, + id=CA_ID, + region=AWS_REGION_US_EAST_1, + status=status, + type="SUBORDINATE", + usage_mode="GENERAL_PURPOSE", + key_algorithm=key_algorithm, + signing_algorithm="ML_DSA_65" if "ML_DSA" in key_algorithm else "SHA256WITHRSA", + ) + + +class Test_acmpca_certificate_authority_pqc_key_algorithm: + def test_no_cas(self): + acmpca_client = _build_client({}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + assert len(result) == 0 + + def test_ml_dsa_65(self): + acmpca_client = _build_client({CA_ARN: _ca("ML_DSA_65")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert "ML_DSA_65" in result[0].status_extended + assert result[0].resource_id == CA_ID + assert result[0].resource_arn == CA_ARN + + def test_rsa_2048_fails(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "RSA_2048" in result[0].status_extended + + def test_deleted_ca_skipped(self): + acmpca_client = _build_client({CA_ARN: _ca("RSA_2048", status="DELETED")}) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 0 + + def test_configurable_allowlist(self): + acmpca_client = _build_client( + {CA_ARN: _ca("RSA_2048")}, + audit_config={"acmpca_pqc_key_algorithms": ["ML_DSA_65", "RSA_2048"]}, + ) + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm.acmpca_client", + new=acmpca_client, + ), + ): + from prowler.providers.aws.services.acmpca.acmpca_certificate_authority_pqc_key_algorithm.acmpca_certificate_authority_pqc_key_algorithm import ( + acmpca_certificate_authority_pqc_key_algorithm, + ) + + check = acmpca_certificate_authority_pqc_key_algorithm() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" diff --git a/tests/providers/aws/services/acmpca/acmpca_service_test.py b/tests/providers/aws/services/acmpca/acmpca_service_test.py new file mode 100644 index 0000000000..024df5e5a2 --- /dev/null +++ b/tests/providers/aws/services/acmpca/acmpca_service_test.py @@ -0,0 +1,65 @@ +from unittest.mock import patch + +import botocore +from moto import mock_aws + +from prowler.providers.aws.services.acmpca.acmpca_service import ( + ACMPCA, + CertificateAuthority, +) +from tests.providers.aws.utils import ( + AWS_ACCOUNT_NUMBER, + AWS_REGION_US_EAST_1, + set_mocked_aws_provider, +) + +CA_ID = "12345678-1234-1234-1234-123456789012" +CA_ARN = f"arn:aws:acm-pca:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:certificate-authority/{CA_ID}" + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "ListCertificateAuthorities": + return { + "CertificateAuthorities": [ + { + "Arn": CA_ARN, + "Status": "ACTIVE", + "Type": "SUBORDINATE", + "UsageMode": "GENERAL_PURPOSE", + "CertificateAuthorityConfiguration": { + "KeyAlgorithm": "ML_DSA_65", + "SigningAlgorithm": "ML_DSA_65", + }, + } + ] + } + if operation_name == "ListTags": + assert kwarg["CertificateAuthorityArn"] == CA_ARN + return {"Tags": [{"Key": "Environment", "Value": "test"}]} + return make_api_call(self, operation_name, kwarg) + + +class Test_ACMPCA_Service: + @mock_aws + def test_service(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert acmpca.service == "acm-pca" + + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + @mock_aws + def test_list_certificate_authorities(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + acmpca = ACMPCA(aws_provider) + assert len(acmpca.certificate_authorities) == 1 + ca = acmpca.certificate_authorities[CA_ARN] + assert isinstance(ca, CertificateAuthority) + assert ca.id == CA_ID + assert ca.region == AWS_REGION_US_EAST_1 + assert ca.status == "ACTIVE" + assert ca.type == "SUBORDINATE" + assert ca.key_algorithm == "ML_DSA_65" + assert ca.signing_algorithm == "ML_DSA_65" + assert ca.tags == [{"Key": "Environment", "Value": "test"}]