From 62471bc255d6cbc0c6f9d6001256d94d0260b8c7 Mon Sep 17 00:00:00 2001 From: luk-kop Date: Fri, 27 Feb 2026 21:49:17 +0100 Subject: [PATCH 1/2] feat: added cert data validation --- .github/workflows/main.yml | 12 +- .pre-commit-config.yaml | 2 +- README.md | 49 +++-- lambdas/certbot/lambda_function.py | 114 +++++++++-- pyproject.toml | 12 +- tests/test_certificate_manager.py | 312 +++++++++++++++++++++++++++-- uv.lock | 12 +- 7 files changed, 439 insertions(+), 74 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 32c0893..4861695 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,13 +17,13 @@ on: env: LAMBDAS_DIR: 'lambdas' UV_VERSION: '0.9.26' - RUFF_VERSION: '0.14.13' - BANDIT_VERSION: '1.9.3' + RUFF_VERSION: '0.15.4' + BANDIT_VERSION: '1.9.4' TF_VERSION: '1.12.1' TF_LINT_VERSION: 'latest' TF_DOCS_VERSION: 'latest' TF_WORKING_DIR: 'terraform/' - TRIVY_VERSION: 'v0.68.2' + TRIVY_VERSION: 'v0.69.1' jobs: python-lint: @@ -111,7 +111,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 + uses: hashicorp/setup-terraform@v4 with: terraform_version: ${{ env.TF_VERSION }} @@ -159,7 +159,7 @@ jobs: version: ${{ env.TRIVY_VERSION }} - name: Run Trivy security scan - uses: aquasecurity/trivy-action@0.33.1 + uses: aquasecurity/trivy-action@0.34.1 with: scan-type: 'fs' scan-ref: '.' @@ -194,7 +194,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup Terraform - uses: hashicorp/setup-terraform@v3 + uses: hashicorp/setup-terraform@v4 with: terraform_version: ${{ env.TF_VERSION }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7851b7..17a9e25 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: - id: detect-private-key exclude: ^tests/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.13 + rev: v0.15.4 hooks: # Run the linter. - id: ruff-check diff --git a/README.md b/README.md index a237ed7..742086f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Serverless TLS certificate renewal using Let's Encrypt ACME protocol. Runs as an ## Architecture -``` +```txt ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ EventBridge │────▶│ Lambda │────▶│ Let's Encrypt │ │ (Schedule) │ │ (Python 3.11) │ │ ACME Server │ @@ -45,7 +45,7 @@ Serverless TLS certificate renewal using Let's Encrypt ACME protocol. Runs as an ## Lambda Function Logic -``` +```text START │ ▼ @@ -121,13 +121,14 @@ START END ``` -**Certificate renewal triggers:** +### Certificate renewal triggers: + - Secret is empty (first run) - Certificate field is missing or invalid - Certificate expires within 30 days (configurable via `RENEWAL_DAYS_BEFORE_EXPIRY`) - `force_renewal: true` in event payload -**Note:** Expiry is always determined by parsing the actual certificate PEM, not the stored `expiry` field. This ensures the certificate itself is the source of truth. If the stored `expiry` field doesn't match the actual certificate expiry, a warning is logged. +> **Note:** Expiry is always determined by parsing the actual certificate PEM, not the stored `expiry` field. This ensures the certificate itself is the source of truth. If the stored `expiry` field doesn't match the actual certificate expiry, a warning is logged. ## Secrets Manager @@ -140,7 +141,7 @@ Secrets created by Terraform: | `{project}-{env}-certificate` | Certificate JSON (see format below) | Stores the TLS certificate, private key, and chain | Always | | `{project}-{env}-account-key` | PEM-encoded RSA private key | ACME account key for Let's Encrypt registration | Only if `acme_persist_account_key = true` (default) | -**Certificate Secret Tags** +### Certificate Secret Tags The certificate secret is automatically tagged with metadata on each renewal: @@ -152,7 +153,7 @@ The certificate secret is automatically tagged with metadata on each renewal: These tags enable monitoring and alerting without decrypting the secret value. -**ACME Account Key Persistence** +### ACME Account Key Persistence You can control whether the ACME account key is persisted using the `acme_persist_account_key` Terraform variable: @@ -169,7 +170,7 @@ You can control whether the ACME account key is persisted using the `acme_persis - Useful for testing or specific use cases - **Warning**: May hit Let's Encrypt rate limits with frequent renewals -**Certificate JSON format:** +### Certificate JSON format ```json { @@ -197,18 +198,21 @@ You can control whether the ACME account key is persisted using the `acme_persis The Lambda function requires Python dependencies (`acme`, `cryptography`, `josepy`) packaged as a Lambda layer. Terraform builds this layer locally during `terraform apply` using `uv pip install` with the `--python-platform x86_64-manylinux2014` flag to ensure compatibility with the Lambda runtime. -**Why local building?** +### Why local building? + - Simple setup - no Docker or CI/CD pipeline required - Automatic rebuild when `pyproject.toml` changes - Suitable for single-function deployments -**Requirements:** +### Requirements + - Python 3.11 and [uv](https://docs.astral.sh/uv/) installed locally - Internet access to download packages from PyPI **Known limitation:** Terraform uses `local-exec` provisioner to build the layer, which runs during `apply` phase. However, Terraform reads `layer.zip` during `plan` phase to compute hashes. If the file doesn't exist (fresh clone, path changes), `terraform plan` will fail. **Manual build** (when needed): + ```bash # From project root: test -f uv.lock || uv lock @@ -216,7 +220,7 @@ uv export --package certbot-lambda --no-hashes --no-dev --frozen --no-emit-proje cd lambdas/certbot rm -rf python layer.zip mkdir -p python -uv pip install -r requirements.txt --target python/ --python-platform x86_64-manylinux2014 --only-binary :all: --python-version 3.11 +uv pip install -r requirements.txt --target python/ --python-platform x86_64-manylinux2014 --only-binary :all: --python-version 3.11 # use aarch64-manylinux2014 for arm64 rm requirements.txt zip -r layer.zip python ``` @@ -320,7 +324,7 @@ aws logs filter-log-events \ ## Configuration Options -### ACME Account Key Persistence +### Configuring ACME Account Key Persistence Set `acme_persist_account_key = false` in your `terraform.tfvars` to use ephemeral account keys: @@ -329,21 +333,34 @@ acme_persist_account_key = false ``` This will: + - Skip creating the account key secret in Secrets Manager - Generate a new account key on every certificate renewal - Reduce AWS costs slightly (one less secret) -**When to use ephemeral mode:** +### When to use ephemeral mode + - Testing and development environments - One-time certificate generation - When you don't need certificate revocation capabilities -**When to use persistent mode (default):** +### When to use persistent mode (default) + - Production environments - Frequent certificate renewals - When you need to revoke certificates - To avoid Let's Encrypt rate limits +### Lambda Architecture + +Set `lambda_architecture` in your `terraform.tfvars` to target arm64 (Graviton) instead of the default x86_64: + +```hcl +lambda_architecture = "arm64" +``` + +Valid values are `x86_64` (default) and `arm64`. When using arm64, Terraform automatically builds the Lambda layer with the correct `aarch64-manylinux2014` platform target. + ### SNS Notifications Enable SNS notifications for certificate renewal events: @@ -354,6 +371,7 @@ notification_email = "admin@example.com" ``` Notifications are sent for: + - Successful certificate renewals - Failed certificate renewals @@ -365,9 +383,10 @@ Publish certificate events to EventBridge for integration with other AWS service eb_bus_name = "default" # or your custom event bus name ``` -**Event Details:** +### Event Details Success event (`Certificate Renewed`): + ```json { "status": "success", @@ -379,6 +398,7 @@ Success event (`Certificate Renewed`): ``` Failure event (`Certificate Renewal Failed`): + ```json { "status": "failed", @@ -426,6 +446,7 @@ uv run pytest tests/ -v --cov=lambdas/certbot --cov-report=term-missing ``` Test coverage includes: + - `CertificateManager` class (initialization, account keys, CSR generation, DNS challenges, certificate issuance/storage/retrieval) - `retry_with_backoff` decorator - `_validate_config` function diff --git a/lambdas/certbot/lambda_function.py b/lambdas/certbot/lambda_function.py index 74fab5e..8f21de7 100644 --- a/lambdas/certbot/lambda_function.py +++ b/lambdas/certbot/lambda_function.py @@ -512,17 +512,25 @@ def issue_certificate(self, domains: list[str]) -> CertificateData: logger.info("Certificate issued successfully") # Parse certificate to extract expiry and separate chain - certs = fullchain_pem.split("-----END CERTIFICATE-----") - certificate = certs[0] + "-----END CERTIFICATE-----\n" - chain = "-----END CERTIFICATE-----".join(certs[1:]).strip() - if chain: - chain = chain + "\n" - - # Extract expiry from certificate - cert = x509.load_pem_x509_certificate( - certificate.encode(), default_backend() + certs = x509.load_pem_x509_certificates(fullchain_pem.encode()) + if not certs: + raise ValueError("No certificates found in fullchain PEM") + certificate = certs[0].public_bytes(serialization.Encoding.PEM).decode() + chain = "".join( + c.public_bytes(serialization.Encoding.PEM).decode() for c in certs[1:] ) - expiry = cert.not_valid_after_utc.isoformat() + + # Validate PEM format and consistency before trusting any cert data + cert_data: CertificateData = { + "private_key": private_key_pem.decode(), + "certificate": certificate, + "chain": chain, + "fullchain": fullchain_pem, + "expiry": certs[0].not_valid_after_utc.isoformat(), + "domains": domains, + "issued_at": datetime.now(timezone.utc).isoformat(), + } + self._validate_certificate_data(cert_data) finally: # Cleanup DNS records @@ -530,15 +538,7 @@ def issue_certificate(self, domains: list[str]) -> CertificateData: for domain, validation in validations.items(): self._cleanup_dns_record(domain, validation) - return { - "private_key": private_key_pem.decode(), - "certificate": certificate, - "chain": chain, - "fullchain": fullchain_pem, - "expiry": expiry, - "domains": domains, - "issued_at": datetime.now(timezone.utc).isoformat(), - } + return cert_data @retry_with_backoff( max_attempts=2, base_delay=3, exceptions=(ClientError, IOError, ValueError) @@ -614,6 +614,82 @@ def get_current_certificate(self) -> Optional[CertificateData]: logger.error(f"Error parsing certificate data: {e}") return None + def _validate_certificate_data(self, cert_data: CertificateData) -> None: + """Validate PEM format and consistency of issued certificate data. + + Checks that: + - Private key PEM is loadable and is an RSA key + - Certificate PEM is loadable and contains all requested domains in SANs + - Private key matches the certificate's public key + - Chain PEM (if present) contains valid certificates + + Args: + cert_data: CertificateData containing private_key, certificate, + chain, and domains fields. + + Raises: + ValueError: If any PEM component is malformed or inconsistent. + """ + private_key_pem = cert_data["private_key"].encode() + certificate = cert_data["certificate"] + chain = cert_data.get("chain", "") + domains = cert_data["domains"] + + if not private_key_pem or not certificate or not domains: + raise ValueError( + "cert_data must contain non-empty private_key, certificate, and domains" + ) + + # Validate private key + try: + key = serialization.load_pem_private_key( + private_key_pem, password=None, backend=default_backend() + ) + except (ValueError, TypeError, UnicodeDecodeError) as e: + raise ValueError(f"Invalid private key PEM: {e}") from e + + if not isinstance(key, rsa.RSAPrivateKey): + raise ValueError(f"Expected RSA private key, got {type(key).__name__}") + + # Validate leaf certificate + try: + cert = x509.load_pem_x509_certificate(certificate.encode()) + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid certificate PEM: {e}") from e + + # Check private key matches certificate public key + if cert.public_key().public_numbers() != key.public_key().public_numbers(): + raise ValueError("Private key does not match certificate public key") + + # Check all requested domains are in the certificate SANs + try: + san_ext = cert.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ) + cert_domains = set(san_ext.value.get_values_for_type(x509.DNSName)) + except x509.ExtensionNotFound: + cert_domains = set() + + missing = set(domains) - cert_domains + if missing: + raise ValueError(f"Certificate missing SANs for domains: {missing}") + + logger.info(f"Certificate SANs validated for domains: {cert_domains}") + + # Validate chain certs if present + if chain.strip(): + try: + chain_certs = x509.load_pem_x509_certificates(chain.encode()) + except (ValueError, TypeError) as e: + raise ValueError(f"Invalid chain PEM: {e}") from e + + if not chain_certs: + raise ValueError("Chain PEM contains no valid certificates") + + logger.info( + f"Chain validated: {len(chain_certs)} intermediate certificate(s)" + ) + def needs_renewal(self, cert_data: Optional[CertificateData]) -> bool: """Determine if certificate requires renewal based on expiry date. diff --git a/pyproject.toml b/pyproject.toml index 7bd4706..0abdc9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,16 @@ [project] name = "aws-certbot-lambda" -version = "0.1.0" +version = "0.1.1" description = "Serverless TLS certificate renewal using Let's Encrypt ACME protocol" requires-python = ">=3.11" [project.optional-dependencies] test = [ - "pytest>=7.4", - "pytest-cov>=4.1", - "pytest-mock>=3.12", - "moto[secretsmanager,route53]>=4.2", - "aws-lambda-powertools>=2.31", + "pytest>=9.0", + "pytest-cov>=7.0", + "pytest-mock>=3.15", + "moto[secretsmanager,route53]>=5.1", + "aws-lambda-powertools>=3.24", ] [tool.uv.workspace] diff --git a/tests/test_certificate_manager.py b/tests/test_certificate_manager.py index 606821a..c61aa77 100644 --- a/tests/test_certificate_manager.py +++ b/tests/test_certificate_manager.py @@ -983,35 +983,36 @@ def client_factory(service_name, **kwargs): with patch.object( CertificateManager, "_perform_dns_challenge" ) as mock_challenge: - mock_acme_client = Mock() + with patch.object(CertificateManager, "_validate_certificate_data"): + mock_acme_client = Mock() - # Create mock order with authorization - mock_authz = Mock() - mock_authz.body.identifier.value = "example.com" - mock_authz.body.status.name = "valid" + # Create mock order with authorization + mock_authz = Mock() + mock_authz.body.identifier.value = "example.com" + mock_authz.body.status.name = "valid" - mock_order = Mock() - mock_order.authorizations = [mock_authz] - mock_order.fullchain_pem = sample_certificate_pem + mock_order = Mock() + mock_order.authorizations = [mock_authz] + mock_order.fullchain_pem = sample_certificate_pem - mock_acme_client.new_order.return_value = mock_order - mock_acme_client.poll_authorizations.return_value = mock_order - mock_acme_client.finalize_order.return_value = mock_order - mock_register.return_value = mock_acme_client + mock_acme_client.new_order.return_value = mock_order + mock_acme_client.poll_authorizations.return_value = mock_order + mock_acme_client.finalize_order.return_value = mock_order + mock_register.return_value = mock_acme_client - mock_challenge.return_value = "test-validation" + mock_challenge.return_value = "test-validation" - manager = CertificateManager( - certificate_secret_name="test-cert", - acme_account_key_secret_name="test-key", - ) + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) - result = manager.issue_certificate(["example.com"]) + result = manager.issue_certificate(["example.com"]) - assert "private_key" in result - assert "certificate" in result - assert "domains" in result - assert result["domains"] == ["example.com"] + assert "private_key" in result + assert "certificate" in result + assert "domains" in result + assert result["domains"] == ["example.com"] @patch("lambda_function.boto3.client") @patch("lambda_function.time.sleep") @@ -1070,3 +1071,270 @@ def client_factory(service_name, **kwargs): if call[1]["ChangeBatch"]["Changes"][0]["Action"] == "DELETE" ] assert len(delete_calls) >= 1 + + +class TestValidateCertificatePem: + """Test PEM format validation.""" + + @patch("lambda_function.boto3.client") + def test_validate_valid_pem( + self, + mock_boto_client, + secrets_manager, + sample_private_key, + sample_certificate_pem, + sample_private_key_pem, + ): + """Test validation passes for valid matching key and certificate.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + # Should not raise + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": sample_certificate_pem, + "chain": "", + "domains": ["example.com", "*.example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_invalid_private_key_pem( + self, mock_boto_client, secrets_manager, sample_certificate_pem + ): + """Test validation raises on invalid private key PEM.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="Invalid private key PEM"): + manager._validate_certificate_data( + { + "private_key": "not-a-valid-pem", + "certificate": sample_certificate_pem, + "chain": "", + "domains": ["example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_invalid_certificate_pem( + self, mock_boto_client, secrets_manager, sample_private_key_pem + ): + """Test validation raises on invalid certificate PEM.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="Invalid certificate PEM"): + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": "not-a-valid-cert", + "chain": "", + "domains": ["example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_key_mismatch( + self, mock_boto_client, secrets_manager, sample_certificate_pem + ): + """Test validation raises when private key doesn't match certificate.""" + mock_boto_client.return_value = secrets_manager + + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + + other_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048, backend=default_backend() + ) + other_key_pem = other_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="Private key does not match"): + manager._validate_certificate_data( + { + "private_key": other_key_pem, + "certificate": sample_certificate_pem, + "chain": "", + "domains": ["example.com", "*.example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_missing_san( + self, + mock_boto_client, + secrets_manager, + sample_private_key_pem, + sample_certificate_pem, + ): + """Test validation raises when certificate is missing requested domains in SANs.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="Certificate missing SANs"): + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": sample_certificate_pem, + "chain": "", + "domains": ["example.com", "*.example.com", "other.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_invalid_chain_pem( + self, + mock_boto_client, + secrets_manager, + sample_private_key_pem, + sample_certificate_pem, + ): + """Test validation raises on invalid chain certificate PEM.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="Invalid chain PEM"): + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": sample_certificate_pem, + "chain": "-----BEGIN CERTIFICATE-----\nbaddata\n-----END CERTIFICATE-----\n", + "domains": ["example.com", "*.example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_valid_chain( + self, + mock_boto_client, + secrets_manager, + sample_private_key_pem, + sample_certificate_pem, + ): + """Test validation passes with valid chain certificate.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": sample_certificate_pem, + "chain": sample_certificate_pem, + "domains": ["example.com", "*.example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_empty_private_key( + self, mock_boto_client, secrets_manager, sample_certificate_pem + ): + """Test validation raises when private_key is empty.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="non-empty"): + manager._validate_certificate_data( + { + "private_key": "", + "certificate": sample_certificate_pem, + "chain": "", + "domains": ["example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_empty_certificate( + self, mock_boto_client, secrets_manager, sample_private_key_pem + ): + """Test validation raises when certificate is empty.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="non-empty"): + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": "", + "chain": "", + "domains": ["example.com"], + } + ) + + @patch("lambda_function.boto3.client") + def test_validate_empty_domains( + self, + mock_boto_client, + secrets_manager, + sample_private_key_pem, + sample_certificate_pem, + ): + """Test validation raises when domains list is empty.""" + mock_boto_client.return_value = secrets_manager + + with patch.object(CertificateManager, "_get_or_create_account_key"): + manager = CertificateManager( + certificate_secret_name="test-cert", + acme_account_key_secret_name="test-key", + ) + + with pytest.raises(ValueError, match="non-empty"): + manager._validate_certificate_data( + { + "private_key": sample_private_key_pem, + "certificate": sample_certificate_pem, + "chain": "", + "domains": [], + } + ) diff --git a/uv.lock b/uv.lock index db4b11b..a5bdc63 100644 --- a/uv.lock +++ b/uv.lock @@ -26,7 +26,7 @@ wheels = [ [[package]] name = "aws-certbot-lambda" -version = "0.1.0" +version = "0.1.1" source = { virtual = "." } [package.optional-dependencies] @@ -40,11 +40,11 @@ test = [ [package.metadata] requires-dist = [ - { name = "aws-lambda-powertools", marker = "extra == 'test'", specifier = ">=2.31" }, - { name = "moto", extras = ["secretsmanager", "route53"], marker = "extra == 'test'", specifier = ">=4.2" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1" }, - { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12" }, + { name = "aws-lambda-powertools", marker = "extra == 'test'", specifier = ">=3.24" }, + { name = "moto", extras = ["secretsmanager", "route53"], marker = "extra == 'test'", specifier = ">=5.1" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.15" }, ] provides-extras = ["test"] From 7e1d296978e0e692b267f624d239825664059295 Mon Sep 17 00:00:00 2001 From: luk-kop Date: Fri, 27 Feb 2026 21:51:52 +0100 Subject: [PATCH 2/2] ci: bumped uv --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4861695..2cf3b4f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -16,7 +16,7 @@ on: env: LAMBDAS_DIR: 'lambdas' - UV_VERSION: '0.9.26' + UV_VERSION: '0.10.7' RUFF_VERSION: '0.15.4' BANDIT_VERSION: '1.9.4' TF_VERSION: '1.12.1'