From 09e509fd6c594adf90813ee080545df8f2e6d468 Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Tue, 20 Jan 2026 16:19:48 +0000 Subject: [PATCH 1/2] CMM-13767: move file scanner lambda CMM-13767: Update package-lock.json with npm install --- .../terraform/components/dl/README.md | 4 + ...dwatch_event_rule_guardduty_scan_result.tf | 21 + ...ource_mapping_move_scanned_files_lambda.tf | 10 + .../dl/module_lambda_file_scanner.tf | 8 +- .../components/dl/module_lambda_mesh_poll.tf | 2 +- .../dl/module_lambda_move_scanned_files.tf | 150 ++++++ .../dl/module_lambda_print_analyser.tf | 4 +- .../dl/module_lambda_print_status_handler.tf | 4 +- .../dl/module_s3bucket_file_quarantine.tf | 85 ++++ .../components/dl/module_sqs_core_notifier.tf | 2 +- .../dl/module_sqs_move_scanned_files.tf | 44 ++ .../dl/ssm_parameter_mesh_config.tf | 8 +- .../terraform/components/dl/variables.tf | 6 + .../move-scanned-files-lambda/jest.config.ts | 5 + .../move-scanned-files-lambda/package.json | 26 ++ .../src/__tests__/apis/sqs-handler.test.ts | 241 ++++++++++ .../__tests__/app/move-file-handler.test.ts | 435 ++++++++++++++++++ .../__tests__/app/parse-sqs-message.test.ts | 44 ++ .../src/__tests__/constants.ts | 51 ++ .../src/__tests__/container.test.ts | 85 ++++ .../src/__tests__/domain/mapper.test.ts | 122 +++++ .../src/__tests__/index.test.ts | 96 ++++ .../src/__tests__/infra/config.test.ts | 84 ++++ .../src/apis/sqs-handler.ts | 91 ++++ .../src/app/move-file-handler.ts | 256 +++++++++++ .../src/app/parse-sqs-message.ts | 24 + .../src/container.ts | 26 ++ .../src/domain/mapper.ts | 62 +++ .../move-scanned-files-lambda/src/index.ts | 10 + .../src/infra/config.ts | 37 ++ .../move-scanned-files-lambda/tsconfig.json | 11 + package-lock.json | 329 +++++++++++++ package.json | 1 + ...rs-print-file-quarantined-data.schema.yaml | 3 + .../playwright/constants/backend-constants.ts | 7 + .../move-scanned-files.component.spec.ts | 210 +++++++++ .../copy-and-delete-object-s3.test.ts | 34 ++ .../src/s3-utils/copy-and-delete-object-s3.ts | 25 + utils/utils/src/s3-utils/index.ts | 1 + 39 files changed, 2650 insertions(+), 14 deletions(-) create mode 100644 infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf create mode 100644 infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf create mode 100644 infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf create mode 100644 infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf create mode 100644 infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf create mode 100644 lambdas/move-scanned-files-lambda/jest.config.ts create mode 100644 lambdas/move-scanned-files-lambda/package.json create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/constants.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts create mode 100644 lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts create mode 100644 lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts create mode 100644 lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts create mode 100644 lambdas/move-scanned-files-lambda/src/container.ts create mode 100644 lambdas/move-scanned-files-lambda/src/domain/mapper.ts create mode 100644 lambdas/move-scanned-files-lambda/src/index.ts create mode 100644 lambdas/move-scanned-files-lambda/src/infra/config.ts create mode 100644 lambdas/move-scanned-files-lambda/tsconfig.json create mode 100644 tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts create mode 100644 utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts create mode 100644 utils/utils/src/s3-utils/copy-and-delete-object-s3.ts diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index d15e7b28..f53fcf8b 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -16,6 +16,7 @@ No requirements. | [aws\_account\_id](#input\_aws\_account\_id) | The AWS Account ID (numeric) | `string` | n/a | yes | | [component](#input\_component) | The variable encapsulating the name of this component | `string` | `"dl"` | no | | [core\_notify\_url](#input\_core\_notify\_url) | The URL used to send requests to Notify | `string` | `"https://sandbox.api.service.nhs.uk"` | no | +| [default\_cloudwatch\_event\_bus\_name](#input\_default\_cloudwatch\_event\_bus\_name) | The name of the default cloudwatch event bus. This is needed as GuardDuty Scan Result events are sent to the default bus | `string` | `"default"` | no | | [default\_tags](#input\_default\_tags) | A map of default tags to apply to all taggable resources within the component | `map(string)` | `{}` | no | | [enable\_dynamodb\_delete\_protection](#input\_enable\_dynamodb\_delete\_protection) | Enable DynamoDB Delete Protection on all Tables | `bool` | `true` | no | | [enable\_mock\_mesh](#input\_enable\_mock\_mesh) | Enable mock mesh access (dev only). Grants lambda permission to read mock-mesh prefix in non-pii bucket. | `bool` | `false` | no | @@ -49,6 +50,7 @@ No requirements. | [lambda\_lambda\_apim\_refresh\_token](#module\_lambda\_lambda\_apim\_refresh\_token) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [mesh\_download](#module\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [mesh\_poll](#module\_mesh\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | +| [move\_scanned\_files](#module\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_mock](#module\_pdm\_mock) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_poll](#module\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [pdm\_uploader](#module\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | @@ -56,6 +58,7 @@ No requirements. | [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [report\_event\_transformer](#module\_report\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | +| [s3bucket\_file\_quarantine](#module\_s3bucket\_file\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | | [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | @@ -65,6 +68,7 @@ No requirements. | [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | +| [sqs\_move\_scanned\_files](#module\_sqs\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | | [sqs\_print\_analyser](#module\_sqs\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf new file mode 100644 index 00000000..80a9daf5 --- /dev/null +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_guardduty_scan_result.tf @@ -0,0 +1,21 @@ +resource "aws_cloudwatch_event_rule" "guardduty_scan_result" { + name = "${local.csi}-guardduty_scan_result" + description = "guardduty Scan Result event rule" + event_bus_name = var.default_cloudwatch_event_bus_name + event_pattern = jsonencode({ + "source" : ["aws.guardduty"] + "detail" : { + "resourceType" : ["S3_OBJECT"], + "s3ObjectDetails" : { + "bucketName" : [local.unscanned_files_bucket], + "objectKey" : [{ "prefix" : "${local.csi}/" }] + } + } + }) +} + +resource "aws_cloudwatch_event_target" "guardduty_scan_result_move_scanned_files" { + rule = aws_cloudwatch_event_rule.guardduty_scan_result.name + arn = module.sqs_move_scanned_files.sqs_queue_arn + event_bus_name = var.default_cloudwatch_event_bus_name +} diff --git a/infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf b/infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf new file mode 100644 index 00000000..df19cb21 --- /dev/null +++ b/infrastructure/terraform/components/dl/lambda_event_source_mapping_move_scanned_files_lambda.tf @@ -0,0 +1,10 @@ +resource "aws_lambda_event_source_mapping" "move_scanned_files_lambda" { + event_source_arn = module.sqs_move_scanned_files.sqs_queue_arn + function_name = module.move_scanned_files.function_arn + batch_size = var.queue_batch_size + maximum_batching_window_in_seconds = var.queue_batch_window_seconds + + function_response_types = [ + "ReportBatchItemFailures" + ] +} diff --git a/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf b/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf index 87755068..b942dda5 100644 --- a/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf +++ b/infrastructure/terraform/components/dl/module_lambda_file_scanner.tf @@ -35,11 +35,11 @@ module "file_scanner" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - "DOCUMENT_REFERENCE_BUCKET" = module.s3bucket_pii_data.bucket - "UNSCANNED_FILES_BUCKET" = local.unscanned_files_bucket - "UNSCANNED_FILES_PATH_PREFIX" = var.environment + "DOCUMENT_REFERENCE_BUCKET" = module.s3bucket_pii_data.bucket + "UNSCANNED_FILES_BUCKET" = local.unscanned_files_bucket + "UNSCANNED_FILES_PATH_PREFIX" = var.environment "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn - "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url } } diff --git a/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf b/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf index 4fd2076f..7b96ea55 100644 --- a/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf +++ b/infrastructure/terraform/components/dl/module_lambda_mesh_poll.tf @@ -42,7 +42,7 @@ module "mesh_poll" { ENVIRONMENT = var.environment EVENT_PUBLISHER_DLQ_URL = module.sqs_event_publisher_errors.sqs_queue_url EVENT_PUBLISHER_EVENT_BUS_ARN = aws_cloudwatch_event_bus.main.arn - MAXIMUM_RUNTIME_MILLISECONDS = "240000" # 4 minutes (Lambda has 5 min timeout) + MAXIMUM_RUNTIME_MILLISECONDS = "240000" # 4 minutes (Lambda has 5 min timeout) POLLING_METRIC_NAME = "mesh-poll-successful-polls" POLLING_METRIC_NAMESPACE = "dl-mesh-poll" SSM_PREFIX = "${local.ssm_mesh_prefix}" diff --git a/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf b/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf new file mode 100644 index 00000000..25d08910 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_lambda_move_scanned_files.tf @@ -0,0 +1,150 @@ +module "move_scanned_files" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip" + + function_name = "move-scanned-files" + description = "A function that handles GuardDuty Malware Protection Object Scan Result and depending on the result moves objects from the unscanned bucket to the file safe or quarantined bucket. " + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + group = var.group + + log_retention_in_days = var.log_retention_in_days + kms_key_arn = module.kms.key_arn + + iam_policy_document = { + body = data.aws_iam_policy_document.move_scanned_files.json + } + + function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"] + function_code_base_path = local.aws_lambda_functions_dir_path + function_code_dir = "move-scanned-files-lambda/dist" + function_include_common = true + handler_function_name = "handler" + runtime = "nodejs22.x" + memory = 128 + timeout = 60 + log_level = var.log_level + + force_lambda_code_deploy = var.force_lambda_code_deploy + enable_lambda_insights = false + + log_destination_arn = local.log_destination_arn + log_subscription_role_arn = local.acct.log_subscription_role_arn + + lambda_env_vars = { + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "ENVIRONMENT" = var.environment + "KEY_PREFIX_UNSCANNED_FILES" = local.csi + "UNSCANNED_FILE_S3_BUCKET_NAME" = local.unscanned_files_bucket + "SAFE_FILE_S3_BUCKET_NAME" = module.s3bucket_file_safe.bucket + "QUARANTINE_FILE_S3_BUCKET_NAME" = module.s3bucket_file_quarantine.bucket + } +} + +data "aws_iam_policy_document" "move_scanned_files" { + statement { + sid = "KMSPermissions" + effect = "Allow" + + actions = [ + "kms:Encrypt", + "kms:Decrypt", + "kms:GenerateDataKey", + ] + + resources = [ + module.kms.key_arn, + ] + } + + statement { + sid = "SQSPermissionsFileScannerMoveScannedFiles" + effect = "Allow" + + actions = [ + "sqs:ReceiveMessage", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes", + "sqs:GetQueueUrl", + ] + + resources = [ + module.sqs_move_scanned_files.sqs_queue_arn, + ] + } + + statement { + sid = "PutEvents" + effect = "Allow" + + actions = [ + "events:PutEvents", + ] + + resources = [ + aws_cloudwatch_event_bus.main.arn, + ] + } + + statement { + sid = "SQSPermissionsDLQ" + effect = "Allow" + + actions = [ + "sqs:SendMessage", + "sqs:SendMessageBatch", + ] + + resources = [ + module.sqs_event_publisher_errors.sqs_queue_arn, + ] + } + + statement { + sid = "PermissionsToUnscannedBucket" + effect = "Allow" + + actions = [ + "s3:GetObject", + "s3:GetObjectTagging", + "s3:DeleteObject", + ] + + resources = [ + "arn:aws:s3:::${local.unscanned_files_bucket}/*", + ] + } + + statement { + sid = "PermissionsToSafeFileBucket" + effect = "Allow" + + actions = [ + "s3:PutObject", + "s3:PutObjectTagging", + ] + + resources = [ + "${module.s3bucket_file_safe.arn}/*" + ] + } + + statement { + sid = "PermissionsToQuarantineFileBucket" + effect = "Allow" + + actions = [ + "s3:PutObject", + "s3:PutObjectTagging", + ] + + resources = [ + "${module.s3bucket_file_quarantine.arn}/*" + ] + } + +} diff --git a/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf b/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf index fde104c0..a0dd3b6e 100644 --- a/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf +++ b/infrastructure/terraform/components/dl/module_lambda_print_analyser.tf @@ -35,8 +35,8 @@ module "print_analyser" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn - "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url } } diff --git a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf index 498b7285..fb1e54e7 100644 --- a/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf +++ b/infrastructure/terraform/components/dl/module_lambda_print_status_handler.tf @@ -35,8 +35,8 @@ module "print_status_handler" { log_subscription_role_arn = local.acct.log_subscription_role_arn lambda_env_vars = { - "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn - "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url + "EVENT_PUBLISHER_EVENT_BUS_ARN" = aws_cloudwatch_event_bus.main.arn + "EVENT_PUBLISHER_DLQ_URL" = module.sqs_event_publisher_errors.sqs_queue_url } } diff --git a/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf b/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf new file mode 100644 index 00000000..e0d23821 --- /dev/null +++ b/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf @@ -0,0 +1,85 @@ +module "s3bucket_file_quarantine" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + + name = "file-quarantine" + + aws_account_id = var.aws_account_id + region = var.region + project = var.project + environment = var.environment + component = local.component + + kms_key_arn = module.kms.key_arn + + policy_documents = [data.aws_iam_policy_document.s3bucket_file_quarantine.json] + + force_destroy = var.force_destroy + + lifecycle_rules = [ + { + enabled = true + + expiration = { + days = "90" + } + + noncurrent_version_transition = [ + { + noncurrent_days = "30" + storage_class = "STANDARD_IA" + } + ] + + noncurrent_version_expiration = { + noncurrent_days = "90" + } + + abort_incomplete_multipart_upload = { + days = "1" + } + } + ] +} + +data "aws_iam_policy_document" "s3bucket_file_quarantine" { + statement { + sid = "AllowManagedAccountsToList" + effect = "Allow" + + actions = [ + "s3:ListBucket", + ] + + resources = [ + module.s3bucket_file_quarantine.arn, + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } + + statement { + sid = "AllowManagedAccountsToGetPut" + effect = "Allow" + + actions = [ + "s3:GetObject", + "s3:PutObject", + ] + + resources = [ + "${module.s3bucket_file_quarantine.arn}/*", + ] + + principals { + type = "AWS" + identifiers = [ + "arn:aws:iam::${var.aws_account_id}:root" + ] + } + } +} diff --git a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf index 6aab4701..1977f919 100644 --- a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf +++ b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf @@ -38,7 +38,7 @@ data "aws_iam_policy_document" "sqs_inbound_event" { condition { test = "ArnLike" variable = "aws:SourceArn" - values = [ aws_cloudwatch_event_rule.pdm_resource_available.arn ] + values = [aws_cloudwatch_event_rule.pdm_resource_available.arn] } } } diff --git a/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf b/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf new file mode 100644 index 00000000..fd2c267a --- /dev/null +++ b/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf @@ -0,0 +1,44 @@ +module "sqs_move_scanned_files" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + + aws_account_id = var.aws_account_id + component = local.component + environment = var.environment + project = var.project + region = var.region + name = "move-scanned-files" + + sqs_kms_key_arn = module.kms.key_arn + + visibility_timeout_seconds = 60 + + create_dlq = true + + sqs_policy_overload = data.aws_iam_policy_document.sqs_move_scanned_files.json +} + +data "aws_iam_policy_document" "sqs_move_scanned_files" { + statement { + sid = "AllowEventBridgeToSendMessage" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["events.amazonaws.com"] + } + + actions = [ + "sqs:SendMessage" + ] + + resources = [ + "arn:aws:sqs:${var.region}:${var.aws_account_id}:${local.csi}-move-scanned-files-queue" + ] + + condition { + test = "ArnLike" + variable = "aws:SourceArn" + values = [aws_cloudwatch_event_rule.guardduty_scan_result.arn] + } + } +} diff --git a/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf b/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf index 35045c18..398c41dd 100644 --- a/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf +++ b/infrastructure/terraform/components/dl/ssm_parameter_mesh_config.tf @@ -10,7 +10,7 @@ resource "aws_ssm_parameter" "mesh_config" { mesh_mailbox = "mock-mailbox" mesh_mailbox_password = "mock-password" mesh_shared_key = "mock-shared-key" - }) : jsonencode({ + }) : jsonencode({ mesh_endpoint = "UNSET" mesh_mailbox = "UNSET" mesh_mailbox_password = "UNSET" @@ -18,7 +18,7 @@ resource "aws_ssm_parameter" "mesh_config" { }) tags = merge(local.default_tags, { - Backup = "true" + Backup = "true" Description = "MESH configuration" }) @@ -37,7 +37,7 @@ resource "aws_ssm_parameter" "mesh_client_cert" { value = var.enable_mock_mesh ? "mock-cert" : "UNSET" tags = merge(local.default_tags, { - Backup = "true" + Backup = "true" Description = "MESH client certificate" }) @@ -56,7 +56,7 @@ resource "aws_ssm_parameter" "mesh_client_key" { value = var.enable_mock_mesh ? "mock-key" : "UNSET" tags = merge(local.default_tags, { - Backup = "true" + Backup = "true" Description = "MESH client private key" }) diff --git a/infrastructure/terraform/components/dl/variables.tf b/infrastructure/terraform/components/dl/variables.tf index fb9b496a..3a81e659 100644 --- a/infrastructure/terraform/components/dl/variables.tf +++ b/infrastructure/terraform/components/dl/variables.tf @@ -186,3 +186,9 @@ variable "pii_data_retention_policy_days" { description = "The number of days for data retention policy for PII" default = 534 } + +variable "default_cloudwatch_event_bus_name" { + type = string + description = "The name of the default cloudwatch event bus. This is needed as GuardDuty Scan Result events are sent to the default bus" + default = "default" +} diff --git a/lambdas/move-scanned-files-lambda/jest.config.ts b/lambdas/move-scanned-files-lambda/jest.config.ts new file mode 100644 index 00000000..c02601ae --- /dev/null +++ b/lambdas/move-scanned-files-lambda/jest.config.ts @@ -0,0 +1,5 @@ +import { baseJestConfig } from '../../jest.config.base'; + +const config = baseJestConfig; + +export default config; diff --git a/lambdas/move-scanned-files-lambda/package.json b/lambdas/move-scanned-files-lambda/package.json new file mode 100644 index 00000000..923a6a41 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/package.json @@ -0,0 +1,26 @@ +{ + "dependencies": { + "aws-lambda": "^1.0.7", + "axios": "^1.13.2", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + }, + "name": "nhs-notify-digital-move-scanned-files-lambda", + "private": true, + "scripts": { + "lambda-build": "rm -rf dist && npx esbuild --bundle --minify --sourcemap --target=es2020 --platform=node --loader:.node=file --entry-names=[name] --outdir=dist src/index.ts", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test:unit": "jest", + "typecheck": "tsc --noEmit" + }, + "version": "0.0.1" +} diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts new file mode 100644 index 00000000..d56371eb --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/apis/sqs-handler.test.ts @@ -0,0 +1,241 @@ +import type { SQSEvent, SQSRecord } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import { SqsHandlerDependencies, createHandler } from 'apis/sqs-handler'; +import { EventPublisher, Logger } from 'utils'; +import { MoveFileHandler } from 'app/move-file-handler'; +import { parseSqsRecord } from 'app/parse-sqs-message'; +import { FileQuarantined, FileSafe } from 'digital-letters-events'; +import { + guardDutyNoThreadsFoundEvent, + guardDutyThreadsFoundEvent, +} from '__tests__/constants'; + +jest.mock('app/parse-sqs-message'); + +const createSqsEvent = (recordCount: number): SQSEvent => ({ + Records: Array.from( + { length: recordCount }, + (_, i): SQSRecord => ({ + messageId: `message-id-${i + 1}`, + receiptHandle: `receipt-handle-${i + 1}`, + body: JSON.stringify({ + detail: guardDutyNoThreadsFoundEvent, + }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', + }), + ), +}); + +describe('sqs-handler', () => { + const mockLogger = mock(); + const mockEventPublisher = mock(); + const mockMoveFileHandler = mock(); + const mockParseSqsRecord = jest.mocked(parseSqsRecord); + + const dependencies: SqsHandlerDependencies = { + logger: mockLogger, + eventPublisher: mockEventPublisher, + moveFileHandler: mockMoveFileHandler, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createHandler', () => { + it('creates a handler function', () => { + const handler = createHandler(dependencies); + expect(typeof handler).toBe('function'); + }); + + it('processes a single SQS record successfully', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + + mockParseSqsRecord.mockReturnValue(guardDutyNoThreadsFoundEvent.detail); + mockMoveFileHandler.handle.mockResolvedValue({ + fileSafe: { + specversion: '1.0', + id: 'test-id', + source: '/test', + type: 'uk.nhs.notify.digital.letters.print.file.safe.v1', + time: '2024-01-01T00:00:00Z', + data: { + messageReference: 'msg-ref', + senderId: 'sender-id', + letterUri: 'https://bucket/key', + createdAt: '2024-01-01T00:00:00Z', + }, + subject: 'test-subject', + traceparent: 'test-traceparent', + recordedtime: '2024-01-01T00:00:00Z', + severitynumber: 2, + }, + }); + + const result = await handler(sqsEvent); + + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Received SQS Event of 1 record(s)', + }); + expect(mockParseSqsRecord).toHaveBeenCalledWith( + sqsEvent.Records[0], + mockLogger, + ); + expect(mockMoveFileHandler.handle).toHaveBeenCalledWith( + guardDutyNoThreadsFoundEvent.detail, + ); + expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(1); + expect(result).toEqual({ batchItemFailures: [] }); + }); + + it('publishes both FileSafe and FileQuarantined events from multiple records', async () => { + const sqsEvent = createSqsEvent(2); + const handler = createHandler(dependencies); + + const mockFileSafeEvent: FileSafe = { + specversion: '1.0', + id: 'test-id-1', + source: '/test', + type: 'uk.nhs.notify.digital.letters.print.file.safe.v1', + time: '2024-01-01T00:00:00Z', + data: { + messageReference: 'msg-ref-1', + senderId: 'sender-id-1', + letterUri: 'https://bucket/key1', + createdAt: '2024-01-01T00:00:00Z', + }, + subject: 'test-subject', + traceparent: 'test-traceparent', + recordedtime: '2024-01-01T00:00:00Z', + severitynumber: 2, + }; + + const mockFileQuarantinedEvent: FileQuarantined = { + specversion: '1.0', + id: 'test-id-2', + source: '/test', + type: 'uk.nhs.notify.digital.letters.print.file.quarantined.v1', + time: '2024-01-01T00:00:00Z', + data: { + messageReference: 'msg-ref-2', + senderId: 'sender-id-2', + letterUri: 'https://bucket/key2', + createdAt: '2024-01-01T00:00:00Z', + }, + subject: 'test-subject', + traceparent: 'test-traceparent', + recordedtime: '2024-01-01T00:00:00Z', + severitynumber: 2, + }; + + mockParseSqsRecord + .mockReturnValueOnce(guardDutyNoThreadsFoundEvent.detail) + .mockReturnValueOnce(guardDutyThreadsFoundEvent.detail); + mockMoveFileHandler.handle + .mockResolvedValueOnce({ fileSafe: mockFileSafeEvent }) + .mockResolvedValueOnce({ + fileQuarantined: mockFileQuarantinedEvent, + }); + + await handler(sqsEvent); + + expect(mockEventPublisher.sendEvents).toHaveBeenCalledTimes(2); + expect(mockEventPublisher.sendEvents).toHaveBeenNthCalledWith( + 1, + [mockFileSafeEvent], + expect.any(Function), + ); + expect(mockEventPublisher.sendEvents).toHaveBeenNthCalledWith( + 2, + [mockFileQuarantinedEvent], + expect.any(Function), + ); + }); + + it('does not publish events when handler returns null', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + + mockParseSqsRecord.mockReturnValue(guardDutyNoThreadsFoundEvent.detail); + mockMoveFileHandler.handle.mockResolvedValue(null); + + await handler(sqsEvent); + + expect(mockEventPublisher.sendEvents).not.toHaveBeenCalled(); + }); + + it('returns batch item failures when processing fails', async () => { + const sqsEvent = createSqsEvent(2); + const handler = createHandler(dependencies); + + mockParseSqsRecord + .mockReturnValueOnce(guardDutyNoThreadsFoundEvent.detail) + .mockImplementationOnce(() => { + throw new Error('Parse error'); + }); + mockMoveFileHandler.handle.mockResolvedValue({ + fileSafe: { + specversion: '1.0', + id: 'test-id', + source: '/test', + type: 'uk.nhs.notify.digital.letters.print.file.safe.v1', + time: '2024-01-01T00:00:00Z', + data: { + messageReference: 'msg-ref', + senderId: 'sender-id', + letterUri: 'https://bucket/key', + createdAt: '2024-01-01T00:00:00Z', + }, + subject: 'test-subject', + traceparent: 'test-traceparent', + recordedtime: '2024-01-01T00:00:00Z', + severitynumber: 2, + }, + }); + + const result = await handler(sqsEvent); + + expect(mockLogger.warn).toHaveBeenCalledWith({ + error: 'Parse error', + description: 'Failed processing message', + messageId: 'message-id-2', + }); + expect(result.batchItemFailures).toEqual([ + { itemIdentifier: 'message-id-2' }, + ]); + expect(mockLogger.info).toHaveBeenCalledWith({ + description: '1 of 2 records processed successfully', + }); + }); + + it('handles errors from moveFileHandler', async () => { + const sqsEvent = createSqsEvent(1); + const handler = createHandler(dependencies); + + mockParseSqsRecord.mockReturnValue(guardDutyNoThreadsFoundEvent.detail); + mockMoveFileHandler.handle.mockRejectedValue(new Error('Handler error')); + + const result = await handler(sqsEvent); + + expect(mockLogger.warn).toHaveBeenCalledWith({ + error: 'Handler error', + description: 'Failed processing message', + messageId: 'message-id-1', + }); + expect(result.batchItemFailures).toEqual([ + { itemIdentifier: 'message-id-1' }, + ]); + }); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts new file mode 100644 index 00000000..27d54fc0 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/app/move-file-handler.test.ts @@ -0,0 +1,435 @@ +import { mock } from 'jest-mock-extended'; +import { Logger } from 'utils/logger'; +import { MoveFileHandler } from 'app/move-file-handler'; +import { MoveScannedFilesConfig } from 'infra/config'; +import * as utils from 'utils'; +import { + guardDutyNoThreadsFoundEvent, + guardDutyThreadsFoundEvent, +} from '__tests__/constants'; + +jest.mock('utils', () => ({ + ...jest.requireActual('utils'), + copyAndDeleteObjectS3: jest.fn(), + getS3ObjectMetadata: jest.fn(), +})); + +jest.mock('domain/mapper', () => ({ + createFileSafeEvent: jest.fn( + (messageRef, senderId, letterUri, createdAt) => ({ + specversion: '1.0', + id: 'test-id', + source: '/test', + type: 'uk.nhs.notify.digital.letters.print.file.safe.v1', + time: '2024-01-01T00:00:00Z', + data: { + messageReference: messageRef, + senderId, + letterUri, + createdAt, + }, + subject: 'test-subject', + traceparent: 'test-traceparent', + recordedtime: '2024-01-01T00:00:00Z', + severitynumber: 2, + }), + ), + createFileQuarantinedEvent: jest.fn( + (messageRef, senderId, letterUri, createdAt) => ({ + specversion: '1.0', + id: 'test-id', + source: '/test', + type: 'uk.nhs.notify.digital.letters.print.file.quarantined.v1', + time: '2024-01-01T00:00:00Z', + data: { + messageReference: messageRef, + senderId, + letterUri, + createdAt, + }, + subject: 'test-subject', + traceparent: 'test-traceparent', + recordedtime: '2024-01-01T00:00:00Z', + severitynumber: 2, + }), + ), +})); + +describe('MoveFileHandler', () => { + const mockLogger = mock(); + const mockConfig: MoveScannedFilesConfig = { + eventPublisherEventBusArn: 'arn:aws:events:test', + eventPublisherDlqUrl: 'https://sqs.test', + environment: 'test', + keyPrefixUnscannedFiles: 'dl/', + unscannedFileS3BucketName: 'unscanned-bucket', + safeFileS3BucketName: 'safe-bucket', + quarantineFileS3BucketName: 'quarantine-bucket', + }; + + const mockCopyAndDeleteObjectS3 = jest.mocked(utils.copyAndDeleteObjectS3); + const mockGetS3ObjectMetadata = jest.mocked(utils.getS3ObjectMetadata); + + let handler: MoveFileHandler; + + beforeEach(() => { + jest.clearAllMocks(); + handler = new MoveFileHandler(mockLogger, mockConfig); + mockCopyAndDeleteObjectS3.mockResolvedValue(); + mockGetS3ObjectMetadata.mockResolvedValue({ + messagereference: 'msg-ref-123', + senderid: 'sender-456', + createdat: '2024-01-01T00:00:00Z', + }); + }); + + describe('isEventForDigitalLetters', () => { + it('returns true when event is for digital letters bucket and prefix', () => { + const eventDetail = { + ...guardDutyNoThreadsFoundEvent.detail, + s3ObjectDetails: { + ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails, + bucketName: 'unscanned-bucket', + objectKey: 'dl/test-file.pdf', + }, + }; + + const result = handler.isEventForDigitalLetters(eventDetail); + + expect(result).toBe(true); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('returns false when bucket name does not match', () => { + const eventDetail = { + ...guardDutyNoThreadsFoundEvent.detail, + s3ObjectDetails: { + ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails, + bucketName: 'wrong-bucket', + objectKey: 'dl/test-file.pdf', + }, + }; + + const result = handler.isEventForDigitalLetters(eventDetail); + + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith({ + description: + 'Received scan result for file not in unscanned bucket, ignoring event.', + expectedUnscannedBucketName: 'unscanned-bucket', + expectedKeyPrefix: 'dl/', + receivedBucketName: 'wrong-bucket', + receivedObjectKey: 'dl/test-file.pdf', + }); + }); + + it('returns false when object key does not start with prefix', () => { + const eventDetail = { + ...guardDutyNoThreadsFoundEvent.detail, + s3ObjectDetails: { + ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails, + bucketName: 'unscanned-bucket', + objectKey: 'other/test-file.pdf', + }, + }; + + const result = handler.isEventForDigitalLetters(eventDetail); + + expect(result).toBe(false); + expect(mockLogger.warn).toHaveBeenCalledWith({ + description: + 'Received scan result for file not in unscanned bucket, ignoring event.', + expectedUnscannedBucketName: 'unscanned-bucket', + expectedKeyPrefix: 'dl/', + receivedBucketName: 'unscanned-bucket', + receivedObjectKey: 'other/test-file.pdf', + }); + }); + + it('returns false when both bucket and prefix are wrong', () => { + const eventDetail = { + ...guardDutyNoThreadsFoundEvent.detail, + s3ObjectDetails: { + ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails, + bucketName: 'wrong-bucket', + objectKey: 'wrong-prefix/test-file.pdf', + }, + }; + + const result = handler.isEventForDigitalLetters(eventDetail); + + expect(result).toBe(false); + }); + }); + + describe('extractMetadata', () => { + it('extracts metadata successfully when all required fields are present', async () => { + const objectDetails = { + bucketName: 'test-bucket', + objectKey: 'test-key', + }; + + mockGetS3ObjectMetadata.mockResolvedValue({ + messagereference: 'msg-ref-123', + senderid: 'sender-456', + createdat: '2024-06-01T12:00:00Z', + }); + + const result = await handler.extractMetadata(objectDetails); + + expect(mockGetS3ObjectMetadata).toHaveBeenCalledWith({ + Bucket: 'test-bucket', + Key: 'test-key', + }); + expect(result).toEqual({ + senderId: 'sender-456', + messageReference: 'msg-ref-123', + createdAt: '2024-06-01T12:00:00Z', + }); + }); + + it('returns null when message-reference is missing', async () => { + const objectDetails = { + bucketName: 'test-bucket', + objectKey: 'test-key', + }; + + mockGetS3ObjectMetadata.mockResolvedValue({ + 'sender-id': 'sender-456', + 'created-at': '2024-06-01T12:00:00Z', + }); + + const result = await handler.extractMetadata(objectDetails); + + expect(result).toBeNull(); + }); + + it('returns null when sender-id is missing', async () => { + const objectDetails = { + bucketName: 'test-bucket', + objectKey: 'test-key', + }; + + mockGetS3ObjectMetadata.mockResolvedValue({ + 'message-reference': 'msg-ref-123', + 'created-at': '2024-06-01T12:00:00Z', + }); + + const result = await handler.extractMetadata(objectDetails); + + expect(result).toBeNull(); + }); + + it('returns null when created-at is missing', async () => { + const objectDetails = { + bucketName: 'test-bucket', + objectKey: 'test-key', + }; + + mockGetS3ObjectMetadata.mockResolvedValue({ + 'message-reference': 'msg-ref-123', + 'sender-id': 'sender-456', + }); + + const result = await handler.extractMetadata(objectDetails); + + expect(result).toBeNull(); + }); + + it('returns null when metadata is undefined', async () => { + const objectDetails = { + bucketName: 'test-bucket', + objectKey: 'test-key', + }; + + // need to test the condition where no metadata is returned. leaving just undefined produces a lint error, removing undefined then causes typecheck error. + mockGetS3ObjectMetadata.mockResolvedValue(undefined as never); + + const result = await handler.extractMetadata(objectDetails); + + expect(result).toBeNull(); + }); + }); + + describe('createObjectsFromData', () => { + const metadata = { + senderId: 'sender-123', + messageReference: 'msg-ref-456', + createdAt: '2024-06-01T12:00:00Z', + }; + + it('creates FileSafe event when scan completed with no threats', () => { + const result = handler.createObjectsFromData( + 'COMPLETED', + 'dl/test-file.pdf', + guardDutyNoThreadsFoundEvent.detail, + metadata, + ); + + expect(result.source).toEqual({ + Bucket: 'unscanned-bucket', + Key: 'dl/test-file.pdf', + }); + expect(result.destination).toEqual({ + Bucket: 'safe-bucket', + Key: 'dl/test-file.pdf', + }); + expect(result.eventToPublish.fileSafe).toBeDefined(); + expect(result.eventToPublish.fileSafe!.data.messageReference).toBe( + 'msg-ref-456', + ); + expect(result.eventToPublish.fileSafe!.data.senderId).toBe('sender-123'); + expect(result.eventToPublish.fileSafe!.data.letterUri).toBe( + 's3://safe-bucket/dl/test-file.pdf', + ); + expect(result.eventToPublish.fileQuarantined).toBeUndefined(); + }); + + it('creates FileQuarantined event when threats are found', () => { + const scanDetail = { + ...guardDutyThreadsFoundEvent.detail, + scanResultDetails: { + scanResultStatus: 'THREATS_FOUND' as const, + threats: [{ name: 'EICAR-Test-File' }], + }, + }; + + const result = handler.createObjectsFromData( + 'COMPLETED', + 'dl/infected-file.pdf', + scanDetail, + metadata, + ); + + expect(result.source).toEqual({ + Bucket: 'unscanned-bucket', + Key: 'dl/infected-file.pdf', + }); + expect(result.destination).toEqual({ + Bucket: 'quarantine-bucket', + Key: 'dl/infected-file.pdf', + }); + expect(result.eventToPublish.fileQuarantined).toBeDefined(); + expect(result.eventToPublish.fileQuarantined?.data.messageReference).toBe( + 'msg-ref-456', + ); + expect(result.eventToPublish.fileQuarantined?.data.senderId).toBe( + 'sender-123', + ); + expect(result.eventToPublish.fileQuarantined?.data.letterUri).toBe( + 's3://quarantine-bucket/dl/infected-file.pdf', + ); + expect(result.eventToPublish.fileSafe).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith({ + description: 'File scan did not complete successfully', + scanStatus: 'COMPLETED', + scanResultDetails: scanDetail.scanResultDetails, + key: 'd-file.pdf', + messageReference: 'msg-ref-456', + senderId: 'sender-123', + }); + }); + + it('creates FileQuarantined event when scan status is not COMPLETED', () => { + const result = handler.createObjectsFromData( + 'FAILED', + 'dl/failed-scan.pdf', + guardDutyNoThreadsFoundEvent.detail, + metadata, + ); + + expect(result.destination.Bucket).toBe('quarantine-bucket'); + expect(result.eventToPublish.fileQuarantined).toBeDefined(); + expect(result.eventToPublish.fileSafe).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith({ + description: 'File scan did not complete successfully', + scanStatus: 'FAILED', + scanResultDetails: + guardDutyNoThreadsFoundEvent.detail.scanResultDetails, + key: 'd-scan.pdf', + messageReference: 'msg-ref-456', + senderId: 'sender-123', + }); + }); + }); + + describe('handle', () => { + it('processes safe file successfully', async () => { + const result = await handler.handle(guardDutyNoThreadsFoundEvent.detail); + + expect(result).not.toBeNull(); + expect(mockLogger.info).toHaveBeenCalled(); + expect(mockGetS3ObjectMetadata).toHaveBeenCalledWith({ + Bucket: 'unscanned-bucket', + Key: 'dl/sample.pdf', + }); + expect(mockCopyAndDeleteObjectS3).toHaveBeenCalledWith( + { Bucket: 'unscanned-bucket', Key: 'dl/sample.pdf' }, + { Bucket: 'safe-bucket', Key: 'dl/sample.pdf' }, + ); + expect(result?.fileSafe).toBeDefined(); + expect(result?.fileQuarantined).toBeUndefined(); + }); + + it('processes infected file successfully', async () => { + const result = await handler.handle(guardDutyThreadsFoundEvent.detail); + + expect(mockCopyAndDeleteObjectS3).toHaveBeenCalledWith( + { Bucket: 'unscanned-bucket', Key: 'dl/sample.pdf' }, + { Bucket: 'quarantine-bucket', Key: 'dl/sample.pdf' }, + ); + expect(result?.fileQuarantined).toBeDefined(); + expect(result?.fileSafe).toBeUndefined(); + }); + + it('returns null when event is not for digital letters', async () => { + const scanResult = { + ...guardDutyNoThreadsFoundEvent, + detail: { + ...guardDutyNoThreadsFoundEvent.detail, + s3ObjectDetails: { + ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails, + bucketName: 'wrong-bucket', + objectKey: 'dl/file.pdf', + }, + }, + }; + + const result = await handler.handle(scanResult.detail); + + expect(result).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith({ + description: 'Scan result event is not valid, skipping processing.', + sourceBucket: 'wrong-bucket', + key: 'dl/file.pdf', + }); + expect(mockCopyAndDeleteObjectS3).not.toHaveBeenCalled(); + }); + + it('returns null when metadata extraction fails', async () => { + const scanResult = { + ...guardDutyNoThreadsFoundEvent, + detail: { + ...guardDutyNoThreadsFoundEvent.detail, + s3ObjectDetails: { + ...guardDutyNoThreadsFoundEvent.detail.s3ObjectDetails, + bucketName: 'unscanned-bucket', + objectKey: 'dl/file-without-metadata.pdf', + }, + }, + }; + + mockGetS3ObjectMetadata.mockResolvedValue({}); + + const result = await handler.handle(scanResult.detail); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith({ + description: + 'Failed to extract required metadata from scanned file, skipping processing.', + subkey: 'tadata.pdf', + }); + expect(mockCopyAndDeleteObjectS3).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts new file mode 100644 index 00000000..6c5fcc2c --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/app/parse-sqs-message.test.ts @@ -0,0 +1,44 @@ +import type { SQSRecord } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import { Logger } from 'utils'; +import { parseSqsRecord } from 'app/parse-sqs-message'; +import { guardDutyNoThreadsFoundEvent } from '__tests__/constants'; + +describe('parseSqsRecord', () => { + const mockLogger = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('parses SQS record body and returns GuardDutyScanResultNotificationEvent.detail', () => { + const sqsRecord: SQSRecord = { + messageId: 'message-id-1', + receiptHandle: 'receipt-handle-1', + body: JSON.stringify({ detail: guardDutyNoThreadsFoundEvent.detail }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', + }; + + const result = parseSqsRecord(sqsRecord, mockLogger); + + expect(mockLogger.info).toHaveBeenCalledWith({ + description: 'Parsing SQS Record', + messageId: 'message-id-1', + }); + expect(mockLogger.debug).toHaveBeenCalledWith({ + description: 'Returning detail as GuardDutyScanResultNotificationEvent', + detail: guardDutyNoThreadsFoundEvent.detail, + }); + expect(result).toEqual(guardDutyNoThreadsFoundEvent.detail); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/constants.ts b/lambdas/move-scanned-files-lambda/src/__tests__/constants.ts new file mode 100644 index 00000000..7c1a5ff0 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/constants.ts @@ -0,0 +1,51 @@ +import { GuardDutyScanResultNotificationEvent } from 'aws-lambda'; + +const baseEvent: GuardDutyScanResultNotificationEvent = { + id: '72c7d362-737a-6dce-fc78-9e27a0171419', + version: '0', + source: 'aws.guardduty', + account: '111122223333', + time: '2024-02-28T01:01:01Z', + region: 'us-east-1', + resources: [ + 'arn:aws:guardduty:us-east-1:111122223333:malware-protection-plan/b4c7f464ab3a4EXAMPLE', + ], + 'detail-type': 'GuardDuty Malware Protection Object Scan Result', + detail: { + schemaVersion: '1.0', + scanStatus: 'COMPLETED', + resourceType: 'S3_OBJECT', + s3ObjectDetails: { + bucketName: 'unscanned-bucket', + objectKey: 'dl/sample.pdf', + eTag: 'ASIAI44QH8DHBEXAMPLE', + versionId: 'd41d8cd98f00b204e9800998eEXAMPLE', + s3Throttled: false, + }, + scanResultDetails: { + scanResultStatus: 'NO_THREATS_FOUND', + threats: null, + }, + }, +}; + +export const guardDutyNoThreadsFoundEvent: GuardDutyScanResultNotificationEvent = + { + ...baseEvent, + }; + +export const guardDutyThreadsFoundEvent: GuardDutyScanResultNotificationEvent = + { + ...baseEvent, + detail: { + ...baseEvent.detail, + scanResultDetails: { + scanResultStatus: 'THREATS_FOUND', + threats: [ + { + name: 'EICAR-Test-File (not a virus)', + }, + ], + }, + }, + }; diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts new file mode 100644 index 00000000..c0008c76 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/container.test.ts @@ -0,0 +1,85 @@ +import { mock } from 'jest-mock-extended'; +import { EventPublisher, logger } from 'utils'; +import { MoveFileHandler } from 'app/move-file-handler'; +import { createContainer } from 'container'; +import { loadConfig } from 'infra/config'; + +jest.mock('utils', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, + EventPublisher: jest.fn(), + eventBridgeClient: {}, + sqsClient: {}, +})); + +jest.mock('app/move-file-handler'); +jest.mock('infra/config'); + +describe('createContainer', () => { + const mockConfig = { + eventPublisherEventBusArn: + 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus', + eventPublisherDlqUrl: + 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq', + environment: 'test', + keyPrefixUnscannedFiles: 'dl/', + unscannedFileS3BucketName: 'unscanned-bucket', + safeFileS3BucketName: 'safe-bucket', + quarantineFileS3BucketName: 'quarantine-bucket', + }; + + const mockMoveFileHandler = mock(); + const mockEventPublisher = mock(); + + beforeEach(() => { + jest.clearAllMocks(); + + (loadConfig as jest.Mock).mockReturnValue(mockConfig); + (MoveFileHandler as jest.Mock).mockImplementation( + () => mockMoveFileHandler, + ); + (EventPublisher as jest.Mock).mockImplementation(() => mockEventPublisher); + }); + + it('creates and returns a container with all dependencies', async () => { + const container = await createContainer(); + + expect(container).toEqual({ + moveFileHandler: mockMoveFileHandler, + logger, + eventPublisher: mockEventPublisher, + }); + }); + + it('loads configuration', async () => { + await createContainer(); + + expect(loadConfig).toHaveBeenCalledTimes(1); + }); + + it('creates MoveFileHandler with logger and config', async () => { + await createContainer(); + + expect(MoveFileHandler).toHaveBeenCalledTimes(1); + expect(MoveFileHandler).toHaveBeenCalledWith(logger, mockConfig); + }); + + it('creates EventPublisher instance with config', async () => { + await createContainer(); + + expect(EventPublisher).toHaveBeenCalledTimes(1); + expect(EventPublisher).toHaveBeenCalledWith( + expect.objectContaining({ + eventBusArn: mockConfig.eventPublisherEventBusArn, + dlqUrl: mockConfig.eventPublisherDlqUrl, + logger, + sqsClient: expect.any(Object), + eventBridgeClient: expect.any(Object), + }), + ); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts new file mode 100644 index 00000000..f92f1ee2 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/domain/mapper.test.ts @@ -0,0 +1,122 @@ +import { createFileQuarantinedEvent, createFileSafeEvent } from 'domain/mapper'; +import fileSafeValidator from 'digital-letters-events/FileSafe.js'; +import fileQuarantinedValidator from 'digital-letters-events/FileQuarantined.js'; + +// Mock randomUUID to make tests deterministic +jest.mock('node:crypto', () => ({ + randomUUID: jest.fn(() => 'mocked-uuid-12345'), +})); + +const createdAt = '2024-01-15T10:30:00.000Z'; + +describe('mapper', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock Date to make tests deterministic + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-01-15T10:30:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('createFileSafeEvent', () => { + it('creates a FileSafe event with correct structure', () => { + const messageReference = 'msg-ref-123'; + const senderId = 'sender-456'; + const letterUri = 's3://safe-bucket/path/to/file.pdf'; + + const result = createFileSafeEvent( + messageReference, + senderId, + letterUri, + createdAt, + ); + + expect(result).toEqual({ + specversion: '1.0', + id: 'mocked-uuid-12345', + subject: `customer/${senderId}/recipient/${messageReference}`, + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/print', + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + type: 'uk.nhs.notify.digital.letters.print.file.safe.v1', + time: '2024-01-15T10:30:00.000Z', + data: { + messageReference, + senderId, + letterUri, + createdAt, + }, + recordedtime: '2024-01-15T10:30:00.000Z', + severitynumber: 2, + }); + const isValid = fileSafeValidator(result); + if (!isValid) { + throw new Error(JSON.stringify(fileSafeValidator.errors, null, 2)); + } + expect(isValid).toBe(true); + }); + + it('handles different input values correctly', () => { + const result = createFileSafeEvent( + 'different-msg-ref', + 'different-sender', + 'https://another-bucket/another/path.pdf', + createdAt, + ); + + expect(result.data.messageReference).toBe('different-msg-ref'); + expect(result.data.senderId).toBe('different-sender'); + expect(result.data.letterUri).toBe( + 'https://another-bucket/another/path.pdf', + ); + expect(result.data.createdAt).toBe(createdAt); + expect(result.type).toBe( + 'uk.nhs.notify.digital.letters.print.file.safe.v1', + ); + }); + }); + + describe('createFileQuarantinedEvent', () => { + it('creates a FileQuarantined event with correct structure', () => { + const messageReference = 'msg-ref-789'; + const senderId = 'sender-012'; + const letterUri = 's3://quarantine-bucket/path/to/infected.pdf'; + + const result = createFileQuarantinedEvent( + messageReference, + senderId, + letterUri, + createdAt, + ); + + expect(result).toEqual({ + specversion: '1.0', + id: 'mocked-uuid-12345', + subject: `customer/${senderId}/recipient/${messageReference}`, + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/print', + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + type: 'uk.nhs.notify.digital.letters.print.file.quarantined.v1', + time: '2024-01-15T10:30:00.000Z', + data: { + messageReference, + senderId, + letterUri, + createdAt, + }, + recordedtime: '2024-01-15T10:30:00.000Z', + severitynumber: 2, + }); + const isValid = fileQuarantinedValidator(result); + if (!isValid) { + throw new Error( + JSON.stringify(fileQuarantinedValidator.errors, null, 2), + ); + } + expect(isValid).toBe(true); + }); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts new file mode 100644 index 00000000..ee9c6f51 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/index.test.ts @@ -0,0 +1,96 @@ +import type { SQSEvent, SQSRecord } from 'aws-lambda'; +import { handler } from 'index'; +import { createContainer } from 'container'; +import { createHandler as createSqsHandler } from 'apis/sqs-handler'; +import type { SqsHandlerDependencies } from 'apis/sqs-handler'; +import { mock } from 'jest-mock-extended'; + +jest.mock('container'); +jest.mock('apis/sqs-handler'); + +const createSqsEvent = (recordCount: number): SQSEvent => ({ + Records: Array.from( + { length: recordCount }, + (_, i): SQSRecord => ({ + messageId: `message-id-${i + 1}`, + receiptHandle: `receipt-handle-${i + 1}`, + body: JSON.stringify({ + detail: { + id: `event-id-${i + 1}`, + source: 'test', + specversion: '1.0', + type: 'test.event', + time: '2025-12-16T10:00:00Z', + datacontenttype: 'application/json', + data: {}, + }, + }), + attributes: { + ApproximateReceiveCount: '1', + SentTimestamp: '1234567890', + SenderId: 'sender-id', + ApproximateFirstReceiveTimestamp: '1234567890', + }, + messageAttributes: {}, + md5OfBody: 'md5', + eventSource: 'aws:sqs', + eventSourceARN: 'arn:aws:sqs:region:account:queue', + awsRegion: 'eu-west-2', + }), + ), +}); + +describe('Lambda handler', () => { + const mockContainer = mock(); + const mockSqsHandler = jest.fn(); + const mockCreateContainer = jest.mocked(createContainer); + const mockCreateSqsHandler = jest.mocked(createSqsHandler); + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateContainer.mockResolvedValue(mockContainer); + mockCreateSqsHandler.mockReturnValue(mockSqsHandler); + mockSqsHandler.mockResolvedValue({ batchItemFailures: [] }); + }); + + it('creates an SQS handler with the container dependencies and handler being invoked', async () => { + const sqsEvent = createSqsEvent(1); + + await handler(sqsEvent); + + expect(mockCreateSqsHandler).toHaveBeenCalledTimes(1); + expect(mockCreateSqsHandler).toHaveBeenCalledWith(mockContainer); + expect(mockSqsHandler).toHaveBeenCalledTimes(1); + expect(mockSqsHandler).toHaveBeenCalledWith(sqsEvent); + }); + + it('when fails to process a message it returns the id of the failed message', async () => { + const sqsEvent = createSqsEvent(1); + const expectedResult = { + batchItemFailures: [{ itemIdentifier: 'message-id-1' }], + }; + mockSqsHandler.mockResolvedValue(expectedResult); + + const result = await handler(sqsEvent); + + expect(result).toEqual(expectedResult); + }); + + it('propagates errors from createContainer', async () => { + const sqsEvent = createSqsEvent(1); + const error = new Error('Failed to create container'); + mockCreateContainer.mockRejectedValue(error); + + await expect(handler(sqsEvent)).rejects.toThrow( + 'Failed to create container', + ); + }); + + it('propagates errors from the SQS handler', async () => { + const sqsEvent = createSqsEvent(1); + const error = new Error('Handler failed'); + mockSqsHandler.mockRejectedValue(error); + + await expect(handler(sqsEvent)).rejects.toThrow('Handler failed'); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts b/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts new file mode 100644 index 00000000..15b9f91b --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/__tests__/infra/config.test.ts @@ -0,0 +1,84 @@ +import { MoveScannedFilesConfig, loadConfig } from 'infra/config'; +import { defaultConfigReader } from 'utils'; + +jest.mock('utils', () => ({ + defaultConfigReader: { + getValue: jest.fn(), + }, +})); + +describe('loadConfig', () => { + const mockGetValue = jest.mocked(defaultConfigReader.getValue); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads all configuration values from environment', () => { + const mockConfig = { + eventPublisherEventBusArn: + 'arn:aws:events:eu-west-2:123456789012:event-bus/test-bus', + eventPublisherDlqUrl: + 'https://sqs.eu-west-2.amazonaws.com/123456789012/test-dlq', + environment: 'test', + keyPrefixUnscannedFiles: 'dl/', + unscannedFileS3BucketName: 'unscanned-bucket', + safeFileS3BucketName: 'safe-bucket', + quarantineFileS3BucketName: 'quarantine-bucket', + }; + + mockGetValue + .mockReturnValueOnce(mockConfig.eventPublisherEventBusArn) + .mockReturnValueOnce(mockConfig.eventPublisherDlqUrl) + .mockReturnValueOnce(mockConfig.keyPrefixUnscannedFiles) + .mockReturnValueOnce(mockConfig.unscannedFileS3BucketName) + .mockReturnValueOnce(mockConfig.safeFileS3BucketName) + .mockReturnValueOnce(mockConfig.quarantineFileS3BucketName) + .mockReturnValueOnce(mockConfig.environment); + + const result = loadConfig(); + + expect(result).toEqual(mockConfig); + expect(mockGetValue).toHaveBeenCalledTimes(7); + expect(mockGetValue).toHaveBeenNthCalledWith( + 1, + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ); + expect(mockGetValue).toHaveBeenNthCalledWith(2, 'EVENT_PUBLISHER_DLQ_URL'); + expect(mockGetValue).toHaveBeenNthCalledWith( + 3, + 'KEY_PREFIX_UNSCANNED_FILES', + ); + expect(mockGetValue).toHaveBeenNthCalledWith( + 4, + 'UNSCANNED_FILE_S3_BUCKET_NAME', + ); + expect(mockGetValue).toHaveBeenNthCalledWith(5, 'SAFE_FILE_S3_BUCKET_NAME'); + expect(mockGetValue).toHaveBeenNthCalledWith( + 6, + 'QUARANTINE_FILE_S3_BUCKET_NAME', + ); + expect(mockGetValue).toHaveBeenNthCalledWith(7, 'ENVIRONMENT'); + }); + + it('returns config with correct types', () => { + mockGetValue + .mockReturnValueOnce('arn:test') + .mockReturnValueOnce('https://dlq') + .mockReturnValueOnce('dl/') + .mockReturnValueOnce('unscanned-bucket') + .mockReturnValueOnce('safe-bucket') + .mockReturnValueOnce('quarantine-bucket') + .mockReturnValueOnce('prod'); + + const result: MoveScannedFilesConfig = loadConfig(); + + expect(typeof result.eventPublisherEventBusArn).toBe('string'); + expect(typeof result.eventPublisherDlqUrl).toBe('string'); + expect(typeof result.environment).toBe('string'); + expect(typeof result.keyPrefixUnscannedFiles).toBe('string'); + expect(typeof result.unscannedFileS3BucketName).toBe('string'); + expect(typeof result.safeFileS3BucketName).toBe('string'); + expect(typeof result.quarantineFileS3BucketName).toBe('string'); + }); +}); diff --git a/lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts b/lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts new file mode 100644 index 00000000..f142e35f --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/apis/sqs-handler.ts @@ -0,0 +1,91 @@ +import type { + GuardDutyScanResultNotificationEventDetail, + SQSBatchItemFailure, + SQSBatchResponse, + SQSEvent, + SQSRecord, +} from 'aws-lambda'; +import { EventPublisher, Logger } from 'utils'; +import { FileQuarantined, FileSafe } from 'digital-letters-events'; +import fileSafeValidator from 'digital-letters-events/FileSafe.js'; +import fileQuarantinedValidator from 'digital-letters-events/FileQuarantined.js'; +import { parseSqsRecord } from 'app/parse-sqs-message'; +import { MoveFileHandler } from 'app/move-file-handler'; + +export interface SqsHandlerDependencies { + logger: Logger; + eventPublisher: EventPublisher; + moveFileHandler: MoveFileHandler; +} + +export const createHandler = ({ + eventPublisher, + logger, + moveFileHandler, +}: SqsHandlerDependencies) => + async function handler(sqsEvent: SQSEvent): Promise { + const receivedItemCount = sqsEvent.Records.length; + + logger.info({ + description: `Received SQS Event of ${receivedItemCount} record(s)`, + }); + + const batchItemFailures: SQSBatchItemFailure[] = []; + const fileSafeEvents: FileSafe[] = []; + const fileQuarantinedEvents: FileQuarantined[] = []; + + let incoming: GuardDutyScanResultNotificationEventDetail; + + await Promise.all( + sqsEvent.Records.map(async (sqsRecord: SQSRecord) => { + try { + incoming = parseSqsRecord(sqsRecord, logger); + + const eventToPublish = await moveFileHandler.handle(incoming); + + if (eventToPublish) { + if (eventToPublish.fileSafe) { + fileSafeEvents.push(eventToPublish.fileSafe); + } + if (eventToPublish.fileQuarantined) { + fileQuarantinedEvents.push(eventToPublish.fileQuarantined); + } + } else { + // there was something wrong with the event + batchItemFailures.push({ itemIdentifier: sqsRecord.messageId }); + } + } catch (error: any) { + logger.warn({ + error: error.message, + description: 'Failed processing message', + messageId: sqsRecord.messageId, + }); + // this might be a transient error so we notify the queue to retry + batchItemFailures.push({ itemIdentifier: sqsRecord.messageId }); + } + }), + ); + + await Promise.all( + [ + fileSafeEvents.length > 0 && + eventPublisher.sendEvents( + fileSafeEvents, + fileSafeValidator, + ), + fileQuarantinedEvents.length > 0 && + eventPublisher.sendEvents( + fileQuarantinedEvents, + fileQuarantinedValidator, + ), + ].filter(Boolean), + ); + + const processedItemCount = receivedItemCount - batchItemFailures.length; + + logger.info({ + description: `${processedItemCount} of ${receivedItemCount} records processed successfully`, + }); + + return { batchItemFailures }; + }; diff --git a/lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts b/lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts new file mode 100644 index 00000000..92ea2438 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/app/move-file-handler.ts @@ -0,0 +1,256 @@ +import { Logger } from 'utils/logger'; +import { S3Location, copyAndDeleteObjectS3, getS3ObjectMetadata } from 'utils'; +import { MoveScannedFilesConfig } from 'infra/config'; +import { GuardDutyScanResultNotificationEventDetail } from 'aws-lambda'; +import { FileQuarantined, FileSafe } from 'digital-letters-events'; +import { createFileQuarantinedEvent, createFileSafeEvent } from 'domain/mapper'; + +export type MoveFileHandlerDependencies = { + logger: Logger; + scannedFilesConfig: MoveScannedFilesConfig; +}; + +export type EventToPublish = { + fileSafe?: FileSafe; + fileQuarantined?: FileQuarantined; +}; + +type ObjectMetadata = { + senderId: string; + messageReference: string; + createdAt: string; +}; + +type ObjectsFromEvent = { + eventToPublish: EventToPublish; + source: S3Location; + destination: S3Location; +}; + +const OBJECT_KEY_DISPLAY_LAST_CHARACTERS = 10; +const SCAN_STATUS_COMPLETED = 'COMPLETED'; +const SCAN_RESULT_STATUS_NO_THREATS_FOUND = 'NO_THREATS_FOUND'; + +const METADATA_CREATED_AT = 'createdat'; +const METADATA_MESSAGE_REFERENCE = 'messagereference'; +const METADATA_SENDER_ID = 'senderid'; + +/** + * Utility function to extract a subset of the object key for logging purposes. + * @param key + * @returns + */ +function getLastCharactersOfKey(key: string): string { + return key.slice(-OBJECT_KEY_DISPLAY_LAST_CHARACTERS); +} + +export class MoveFileHandler { + private readonly logger: Logger; + + private readonly keyPrefixUnscannedFiles: string; + + private readonly unscannedBucketName: string; + + private readonly safeBucketName: string; + + private readonly quarantineBucketName: string; + + constructor(logger: Logger, scannedFilesConfig: MoveScannedFilesConfig) { + this.logger = logger; + this.keyPrefixUnscannedFiles = scannedFilesConfig.keyPrefixUnscannedFiles; + this.unscannedBucketName = scannedFilesConfig.unscannedFileS3BucketName; + this.safeBucketName = scannedFilesConfig.safeFileS3BucketName; + this.quarantineBucketName = scannedFilesConfig.quarantineFileS3BucketName; + } + + /** + * As the guard duty scan result events are account wide, we need to ensure that the event is for + * digital letters files before processing it. + * @param eventDetail + * @returns + */ + isEventForDigitalLetters( + eventDetail: GuardDutyScanResultNotificationEventDetail, + ): boolean { + const objectDetails = eventDetail.s3ObjectDetails; + if ( + objectDetails.bucketName !== this.unscannedBucketName || + !objectDetails.objectKey.startsWith(this.keyPrefixUnscannedFiles) + ) { + this.logger.warn({ + description: + 'Received scan result for file not in unscanned bucket, ignoring event.', + expectedUnscannedBucketName: this.unscannedBucketName, + expectedKeyPrefix: this.keyPrefixUnscannedFiles, + receivedBucketName: objectDetails.bucketName, + receivedObjectKey: objectDetails.objectKey, + }); + return false; + } + return true; + } + + /** + * Utility function to get the required metadata fields of an object stored in S3. + * @param objectDetails + * @returns the metadata or null if one of the required fields is missing. + */ + async extractMetadata(objectDetails: { + bucketName: string; + objectKey: string; + }): Promise { + const source: S3Location = { + Bucket: objectDetails.bucketName, + Key: objectDetails.objectKey, + }; + + const metadata: Record | undefined = + await getS3ObjectMetadata(source); + + this.logger.debug({ + description: 'Fetched object metadata', + metadata, + }); + + if (!metadata) { + return null; + } + + const metadataMap = new Map(Object.entries(metadata)); + + const createdAt = metadataMap.get(METADATA_CREATED_AT); + const messageReference = metadataMap.get(METADATA_MESSAGE_REFERENCE); + const senderId = metadataMap.get(METADATA_SENDER_ID); + + if (!messageReference || !senderId || !createdAt) { + return null; + } + + this.logger.info({ + description: 'Fetched object metadata has all the required fields', + senderId, + messageReference, + createdAt, + }); + + return { + senderId, + messageReference, + createdAt, + }; + } + + createObjectsFromData( + scanStatus: string, + objectKey: string, + scanDetail: GuardDutyScanResultNotificationEventDetail, + metadata: ObjectMetadata, + ): ObjectsFromEvent { + const eventToPublish: EventToPublish = {}; + let destinationBucket; + + if ( + scanStatus === SCAN_STATUS_COMPLETED && + scanDetail.scanResultDetails.scanResultStatus === + SCAN_RESULT_STATUS_NO_THREATS_FOUND + ) { + destinationBucket = this.safeBucketName; + eventToPublish.fileSafe = createFileSafeEvent( + metadata.messageReference, + metadata.senderId, + `s3://${destinationBucket}/${objectKey}`, + metadata.createdAt, + ); + } else { + destinationBucket = this.quarantineBucketName; + this.logger.warn({ + description: 'File scan did not complete successfully', + scanStatus, + scanResultDetails: scanDetail.scanResultDetails, + key: getLastCharactersOfKey(objectKey), + messageReference: metadata.messageReference, + senderId: metadata.senderId, + }); + eventToPublish.fileQuarantined = createFileQuarantinedEvent( + metadata.messageReference, + metadata.senderId, + `s3://${destinationBucket}/${objectKey}`, + metadata.createdAt, + ); + } + + const source: S3Location = { + Bucket: this.unscannedBucketName, + Key: objectKey, + }; + + const destination: S3Location = { + Bucket: destinationBucket, + Key: objectKey, + }; + + return { + source, + destination, + eventToPublish, + }; + } + + public async handle( + scanDetail: GuardDutyScanResultNotificationEventDetail, + ): Promise { + const { scanStatus } = scanDetail; + const objectDetails = scanDetail.s3ObjectDetails; + + this.logger.info({ + description: 'Processing scan result', + scanStatus, + subkey: getLastCharactersOfKey(objectDetails.objectKey), + }); + + if (!(await this.isEventForDigitalLetters(scanDetail))) { + this.logger.warn({ + description: 'Scan result event is not valid, skipping processing.', + sourceBucket: objectDetails.bucketName, + key: objectDetails.objectKey, // Full key to make it easier when investigating issues as all events should pass this validation. + }); + return null; + } + const metadata = await this.extractMetadata(objectDetails); + if (!metadata) { + this.logger.error({ + description: + 'Failed to extract required metadata from scanned file, skipping processing.', + subkey: getLastCharactersOfKey(objectDetails.objectKey), + }); + return null; + } + + const { destination, eventToPublish, source } = this.createObjectsFromData( + scanStatus, + objectDetails.objectKey, + scanDetail, + metadata, + ); + + this.logger.debug({ + description: 'Going to move file to destination bucket', + scanStatus, + destinationLocation: destination.Bucket, + subkey: getLastCharactersOfKey(objectDetails.objectKey), + messageReference: metadata.messageReference, + senderId: metadata.senderId, + }); + + await copyAndDeleteObjectS3(source, destination); + + this.logger.info({ + description: 'Moved file to destination bucket', + scanStatus, + messageReference: metadata.messageReference, + senderId: metadata.senderId, + }); + + return eventToPublish; + } +} diff --git a/lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts b/lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts new file mode 100644 index 00000000..c92dbcf8 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/app/parse-sqs-message.ts @@ -0,0 +1,24 @@ +import { + GuardDutyScanResultNotificationEventDetail, + SQSRecord, +} from 'aws-lambda'; +import { Logger } from 'utils'; + +export const parseSqsRecord = ( + sqsRecord: SQSRecord, + logger: Logger, +): GuardDutyScanResultNotificationEventDetail => { + logger.info({ + description: 'Parsing SQS Record', + messageId: sqsRecord.messageId, + }); + + const sqsEventBody = JSON.parse(sqsRecord.body); + const sqsEventDetail = sqsEventBody.detail; + + logger.debug({ + description: 'Returning detail as GuardDutyScanResultNotificationEvent', + detail: sqsEventDetail, + }); + return sqsEventDetail as GuardDutyScanResultNotificationEventDetail; +}; diff --git a/lambdas/move-scanned-files-lambda/src/container.ts b/lambdas/move-scanned-files-lambda/src/container.ts new file mode 100644 index 00000000..868249f9 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/container.ts @@ -0,0 +1,26 @@ +import { EventPublisher, eventBridgeClient, logger, sqsClient } from 'utils'; +import type { SqsHandlerDependencies } from 'apis/sqs-handler'; +import { loadConfig } from 'infra/config'; +import { MoveFileHandler } from 'app/move-file-handler'; + +export async function createContainer(): Promise { + const config = loadConfig(); + + const { eventPublisherDlqUrl, eventPublisherEventBusArn } = config; + + const eventPublisher = new EventPublisher({ + eventBusArn: eventPublisherEventBusArn, + dlqUrl: eventPublisherDlqUrl, + logger, + sqsClient, + eventBridgeClient, + }); + + const moveFileHandler = new MoveFileHandler(logger, config); + + return { + logger, + moveFileHandler, + eventPublisher, + }; +} diff --git a/lambdas/move-scanned-files-lambda/src/domain/mapper.ts b/lambdas/move-scanned-files-lambda/src/domain/mapper.ts new file mode 100644 index 00000000..fe0d483a --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/domain/mapper.ts @@ -0,0 +1,62 @@ +import { randomUUID } from 'node:crypto'; +import { FileQuarantined, FileSafe } from 'digital-letters-events'; + +function createEventWithCommonFields( + isFileSafe: boolean, + messageReference: string, + senderId: string, + letterUri: string, + createdAt: string, +): FileSafe | FileQuarantined { + return { + specversion: '1.0', + id: randomUUID(), + subject: `customer/${senderId}/recipient/${messageReference}`, + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/print', // Note CCM-13892. + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', // Note CCM-14255. + type: isFileSafe + ? 'uk.nhs.notify.digital.letters.print.file.safe.v1' + : 'uk.nhs.notify.digital.letters.print.file.quarantined.v1', + time: new Date().toISOString(), + data: { + messageReference, + senderId, + letterUri, + createdAt, + }, + + recordedtime: new Date().toISOString(), + severitynumber: 2, + }; +} + +export function createFileSafeEvent( + messageReference: string, + senderId: string, + letterUri: string, + createdAt: string, +): FileSafe { + return createEventWithCommonFields( + true, + messageReference, + senderId, + letterUri, + createdAt, + ) as FileSafe; +} + +export function createFileQuarantinedEvent( + messageReference: string, + senderId: string, + letterUri: string, + createdAt: string, +): FileQuarantined { + return createEventWithCommonFields( + false, + messageReference, + senderId, + letterUri, + createdAt, + ) as FileQuarantined; +} diff --git a/lambdas/move-scanned-files-lambda/src/index.ts b/lambdas/move-scanned-files-lambda/src/index.ts new file mode 100644 index 00000000..655bf505 --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/index.ts @@ -0,0 +1,10 @@ +// This is a Lambda entrypoint file. +import type { SQSEvent } from 'aws-lambda'; +import { createContainer } from 'container'; +import { createHandler as createSqsHandler } from 'apis/sqs-handler'; + +export const handler = async (sqsEvent: SQSEvent) => { + const container = await createContainer(); + const sqsHandler = createSqsHandler(container); + return sqsHandler(sqsEvent); +}; diff --git a/lambdas/move-scanned-files-lambda/src/infra/config.ts b/lambdas/move-scanned-files-lambda/src/infra/config.ts new file mode 100644 index 00000000..96702b6d --- /dev/null +++ b/lambdas/move-scanned-files-lambda/src/infra/config.ts @@ -0,0 +1,37 @@ +import { defaultConfigReader } from 'utils'; + +export type MoveScannedFilesConfig = { + eventPublisherEventBusArn: string; + eventPublisherDlqUrl: string; + environment: string; + unscannedFileS3BucketName: string; + safeFileS3BucketName: string; + quarantineFileS3BucketName: string; + keyPrefixUnscannedFiles: string; +}; + +export function loadConfig(): MoveScannedFilesConfig { + return { + eventPublisherEventBusArn: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_EVENT_BUS_ARN', + ), + eventPublisherDlqUrl: defaultConfigReader.getValue( + 'EVENT_PUBLISHER_DLQ_URL', + ), + // There is a limitation of how many buckets can be scanned with GuardDuty per account. + // As DL will share the same bucket with other services, this is a safeguard to only process events for files for digital letters. + keyPrefixUnscannedFiles: defaultConfigReader.getValue( + 'KEY_PREFIX_UNSCANNED_FILES', + ), + unscannedFileS3BucketName: defaultConfigReader.getValue( + 'UNSCANNED_FILE_S3_BUCKET_NAME', + ), + safeFileS3BucketName: defaultConfigReader.getValue( + 'SAFE_FILE_S3_BUCKET_NAME', + ), + quarantineFileS3BucketName: defaultConfigReader.getValue( + 'QUARANTINE_FILE_S3_BUCKET_NAME', + ), + environment: defaultConfigReader.getValue('ENVIRONMENT'), + }; +} diff --git a/lambdas/move-scanned-files-lambda/tsconfig.json b/lambdas/move-scanned-files-lambda/tsconfig.json new file mode 100644 index 00000000..f7bcaa1f --- /dev/null +++ b/lambdas/move-scanned-files-lambda/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "baseUrl": "./src/", + "isolatedModules": true + }, + "extends": "@tsconfig/node22/tsconfig.json", + "include": [ + "src/**/*", + "jest.config.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 7b16ce83..18579492 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "lambdas/print-status-handler", "lambdas/print-analyser", "lambdas/report-event-transformer", + "lambdas/move-scanned-files-lambda", "utils/utils", "utils/sender-management", "src/cloudevents", @@ -955,6 +956,330 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "lambdas/move-scanned-files-lambda": { + "name": "nhs-notify-digital-move-scanned-files-lambda", + "version": "0.0.1", + "dependencies": { + "aws-lambda": "^1.0.7", + "axios": "^1.13.2", + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.2", + "@types/aws-lambda": "^8.10.155", + "@types/jest": "^29.5.14", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.7", + "typescript": "^5.9.3" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/move-scanned-files-lambda/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "lambdas/move-scanned-files-lambda/node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-mock-extended": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-3.0.7.tgz", + "integrity": "sha512-7lsKdLFcW9B9l5NzZ66S/yTQ9k8rFtnwYdCNuRU/81fqDWicNDVhitTSPnrGmNeNm0xyw0JHexEOShrIKRCIRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.0" + }, + "peerDependencies": { + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "lambdas/move-scanned-files-lambda/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "lambdas/pdm-mock-lambda": { "name": "nhs-notify-digital-letters-pdm-mock-lambda", "version": "0.0.1", @@ -19611,6 +19936,10 @@ "resolved": "lambdas/ttl-poll-lambda", "link": true }, + "node_modules/nhs-notify-digital-move-scanned-files-lambda": { + "resolved": "lambdas/move-scanned-files-lambda", + "link": true + }, "node_modules/nise": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", diff --git a/package.json b/package.json index a7a6e0e1..cac36007 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "lambdas/print-status-handler", "lambdas/print-analyser", "lambdas/report-event-transformer", + "lambdas/move-scanned-files-lambda", "utils/utils", "utils/sender-management", "src/cloudevents", diff --git a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml index ff69f70e..86254b2f 100644 --- a/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml +++ b/src/cloudevents/domains/digital-letters/2025-10-draft/data/digital-letters-print-file-quarantined-data.schema.yaml @@ -10,7 +10,10 @@ properties: $ref: ../defs/requests.schema.yaml#/properties/senderId letterUri: $ref: ../defs/print.schema.yaml#/properties/letterUri + createdAt: + $ref: ../defs/print.schema.yaml#/properties/createdAt required: - messageReference - senderId - letterUri + - createdAt diff --git a/tests/playwright/constants/backend-constants.ts b/tests/playwright/constants/backend-constants.ts index fae4a1df..072cbc88 100644 --- a/tests/playwright/constants/backend-constants.ts +++ b/tests/playwright/constants/backend-constants.ts @@ -23,6 +23,8 @@ export const FILE_SCANNER_DLQ_NAME = `${CSI}-scanner-dlq`; export const PRINT_STATUS_HANDLER_DLQ_NAME = `${CSI}-print-status-handler-dlq`; export const HANDLE_TTL_DLQ_NAME = `${CSI}-ttl-handle-expiry-errors-queue`; export const PRINT_ANALYSER_DLQ_NAME = `${CSI}-print-analyser-dlq`; +export const MOVE_SCANNED_FILES_NAME = `${CSI}-move-scanned-files-queue`; +export const MOVE_SCANNED_FILES_DLQ_NAME = `${CSI}-move-scanned-files-dlq`; // Queue Url Prefix export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`; @@ -38,6 +40,10 @@ export const TTL_TABLE_NAME = `${CSI}-ttl`; // S3 export const LETTERS_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-${ENV}-dl-letters`; export const FILE_SAFE_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-${ENV}-dl-file-safe`; +export const UNSCANNED_FILES_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-main-acct-digi-unscanned-files`; +export const FILE_QUARANTINE_S3_BUCKET_NAME = `nhs-${process.env.AWS_ACCOUNT_ID}-${REGION}-${ENV}-dl-file-quarantine`; +// Files that are scanned by Guardduty are in a bucket prefixed by the environment. +export const PREFIX_DL_FILES = `${CSI}/`; // Cloudwatch export const PDM_UPLOADER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-pdm-uploader`; @@ -46,3 +52,4 @@ export const CORE_NOTIFIER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-core-noti export const FILE_SCANNER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-file-scanner`; export const PRINT_STATUS_HANDLER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-status-handler`; export const PRINT_ANALYSER_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-print-analyser`; +export const MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME = `/aws/lambda/${CSI}-move-scanned-files`; diff --git a/tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts b/tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts new file mode 100644 index 00000000..b52e4acc --- /dev/null +++ b/tests/playwright/digital-letters-component-tests/move-scanned-files.component.spec.ts @@ -0,0 +1,210 @@ +import { expect, test } from '@playwright/test'; +import { + EVENT_BUS_LOG_GROUP_NAME, + FILE_QUARANTINE_S3_BUCKET_NAME, + FILE_SAFE_S3_BUCKET_NAME, + MOVE_SCANNED_FILES_DLQ_NAME, + MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME, + PREFIX_DL_FILES, + UNSCANNED_FILES_S3_BUCKET_NAME, +} from 'constants/backend-constants'; +import { SENDER_ID_SKIPS_NOTIFY } from 'constants/tests-constants'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; +import expectToPassEventually from 'helpers/expectations'; +import { expectMessageContainingString } from 'helpers/sqs-helpers'; +import { v4 as uuidv4 } from 'uuid'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { getS3ObjectMetadata, s3Client } from 'utils'; + +test.describe('Digital Letters - Move Scanned Files', () => { + test.beforeAll(async () => { + test.setTimeout(250_000); + }); + + test('given file without virus, when uploaded into S3 bucket and guardduty scan passes then a FileSafe event is triggered', async () => { + const messageReference = uuidv4(); + const createdAt = new Date().toISOString(); + const objectKey = `${PREFIX_DL_FILES}${messageReference}.txt`; + + const body = Buffer.from('test file content'); + + const params = { + Bucket: UNSCANNED_FILES_S3_BUCKET_NAME, + Key: objectKey, + Body: body, + ContentType: 'application/pdf', + Metadata: { + messageReference, + senderId: SENDER_ID_SKIPS_NOTIFY, + createdAt, + }, + }; + + await s3Client.send(new PutObjectCommand(params)); + + // Verify the event is processed and a message appears in the Lambda logs + await expectToPassEventually(async () => { + const filteredLogs = await getLogsFromCloudwatch( + MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "Moved file to destination bucket"', + '$.message.scanStatus = "COMPLETED"', + `$.message.messageReference = "${messageReference}"`, + `$.message.senderId = "${SENDER_ID_SKIPS_NOTIFY}"`, + ], + ); + + expect(filteredLogs.length).toEqual(1); + }, 240); + + // Verify the event is published in the event bus + await expectToPassEventually(async () => { + const expectedLetterUri = `s3://${FILE_SAFE_S3_BUCKET_NAME}/${objectKey}`; + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.print.file.safe.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_SKIPS_NOTIFY}\\"*"`, + `$.details.event_detail = "*\\"createdAt\\":\\"${createdAt}\\"*"`, + `$.details.event_detail = "*\\"letterUri\\":\\"${expectedLetterUri}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + + await expectToPassEventually(async () => { + const metadata = await getS3ObjectMetadata({ + Bucket: FILE_SAFE_S3_BUCKET_NAME, + Key: objectKey, + }); + expect(metadata).toBeDefined(); + expect(metadata?.messagereference).toEqual(messageReference); + expect(metadata?.senderid).toEqual(SENDER_ID_SKIPS_NOTIFY); + expect(metadata?.createdat).toEqual(createdAt); + + // check the file was deleted from the unscanned bucket + let originalMetadata; + try { + originalMetadata = await getS3ObjectMetadata({ + Bucket: UNSCANNED_FILES_S3_BUCKET_NAME, + Key: objectKey, + }); + } catch { + // expected error + } + expect(originalMetadata).toBeUndefined(); + }, 240); + }); + + test('given file with EICAR virus, when uploaded into S3 bucket and guardduty scan completes then a FileQuarantine event is triggered', async () => { + const messageReference = uuidv4(); + const createdAt = new Date().toISOString(); + const objectKey = `${PREFIX_DL_FILES}${messageReference}.txt`; + // Divided in two strings in case it could trigger any antivirus software. Lint complains about unnecessary escape \P, so ignoring it + const part1EicarFile = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$'; //eslint-disable-line + const part2EicarFile = 'EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*'; + + const body = Buffer.from(part1EicarFile + part2EicarFile); + + const params = { + Bucket: UNSCANNED_FILES_S3_BUCKET_NAME, + Key: objectKey, + Body: body, + ContentType: 'application/pdf', + Metadata: { + messageReference, + senderId: SENDER_ID_SKIPS_NOTIFY, + createdAt, + }, + }; + + await s3Client.send(new PutObjectCommand(params)); + + // Verify the event is processed and a message appears in the Lambda logs + await expectToPassEventually(async () => { + const filteredLogs = await getLogsFromCloudwatch( + MOVE_SCANNED_FILES_LAMBDA_LOG_GROUP_NAME, + [ + '$.message.description = "Moved file to destination bucket"', + '$.message.scanStatus = "COMPLETED"', + `$.message.messageReference = "${messageReference}"`, + `$.message.senderId = "${SENDER_ID_SKIPS_NOTIFY}"`, + ], + ); + + expect(filteredLogs.length).toEqual(1); + }, 240); + + // Verify the event is published in the event bus + await expectToPassEventually(async () => { + const expectedLetterUri = `s3://${FILE_QUARANTINE_S3_BUCKET_NAME}/${objectKey}`; + const eventLogEntry = await getLogsFromCloudwatch( + EVENT_BUS_LOG_GROUP_NAME, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.print.file.quarantined.v1"', + `$.details.event_detail = "*\\"messageReference\\":\\"${messageReference}\\"*"`, + `$.details.event_detail = "*\\"senderId\\":\\"${SENDER_ID_SKIPS_NOTIFY}\\"*"`, + `$.details.event_detail = "*\\"createdAt\\":\\"${createdAt}\\"*"`, + `$.details.event_detail = "*\\"letterUri\\":\\"${expectedLetterUri}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }, 240); + + await expectToPassEventually(async () => { + const metadata = await getS3ObjectMetadata({ + Bucket: FILE_QUARANTINE_S3_BUCKET_NAME, + Key: objectKey, + }); + expect(metadata).toBeDefined(); + expect(metadata?.messagereference).toEqual(messageReference); + expect(metadata?.senderid).toEqual(SENDER_ID_SKIPS_NOTIFY); + expect(metadata?.createdat).toEqual(createdAt); + + // check the file was deleted from the unscanned bucket + let originalMetadata; + try { + originalMetadata = await getS3ObjectMetadata({ + Bucket: UNSCANNED_FILES_S3_BUCKET_NAME, + Key: objectKey, + }); + } catch { + // expected error + } + expect(originalMetadata).toBeUndefined(); + }, 240); + }); + + test('given file without REQUIRED metadata, when uploaded into S3 bucket and guardduty scan passes then it goes to DLQ', async () => { + test.setTimeout(550_000); + const messageReference = uuidv4(); + const objectKey = `${PREFIX_DL_FILES}${messageReference}.txt`; + + const body = Buffer.from('test file content'); + + const params = { + Bucket: UNSCANNED_FILES_S3_BUCKET_NAME, + Key: objectKey, + Body: body, + ContentType: 'application/pdf', + Metadata: { + messageReference, + // senderId and createdAt are missing + }, + }; + + await s3Client.send(new PutObjectCommand(params)); + + // Verify there is a message in the DLQ + await expectMessageContainingString( + MOVE_SCANNED_FILES_DLQ_NAME, + objectKey, + 500, + ); + }); +}); diff --git a/utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts b/utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts new file mode 100644 index 00000000..7a65841b --- /dev/null +++ b/utils/utils/src/__tests__/s3-utils/copy-and-delete-object-s3.test.ts @@ -0,0 +1,34 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { + CopyObjectCommand, + DeleteObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { copyAndDeleteObjectS3 } from '../../s3-utils/copy-and-delete-object-s3'; + +const s3Client = mockClient(S3Client); + +it('puts data in S3', async () => { + await copyAndDeleteObjectS3( + { + Bucket: 'sourceBucket', + Key: 'sourceKey', + }, + { + Bucket: 'destinationBucket', + Key: 'destinationKey', + }, + ); + + expect(s3Client).toHaveReceivedCommandWith(CopyObjectCommand, { + Bucket: 'destinationBucket', + CopySource: '/sourceBucket/sourceKey', + Key: 'destinationKey', + }); + + expect(s3Client).toHaveReceivedCommandWith(DeleteObjectCommand, { + Bucket: 'sourceBucket', + Key: 'sourceKey', + }); +}); diff --git a/utils/utils/src/s3-utils/copy-and-delete-object-s3.ts b/utils/utils/src/s3-utils/copy-and-delete-object-s3.ts new file mode 100644 index 00000000..1ded77e9 --- /dev/null +++ b/utils/utils/src/s3-utils/copy-and-delete-object-s3.ts @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ +import { CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import type { S3Location } from './get-object-s3'; +import { s3Client } from './s3-client'; + +export async function copyAndDeleteObjectS3( + source: S3Location, + destination: S3Location, +): Promise { + try { + const copyParams = { + Bucket: destination.Bucket, + CopySource: `/${source.Bucket}/${source.Key}`, + Key: destination.Key, + }; + + await s3Client.send(new CopyObjectCommand(copyParams)); + + await s3Client.send(new DeleteObjectCommand(source)); + } catch (error) { + throw new Error( + `Move of ${source.Bucket}/${source.Key} to ${destination.Bucket}/${destination.Key} failed, error: ${error}`, + ); + } +} diff --git a/utils/utils/src/s3-utils/index.ts b/utils/utils/src/s3-utils/index.ts index 867e20cf..894af46f 100644 --- a/utils/utils/src/s3-utils/index.ts +++ b/utils/utils/src/s3-utils/index.ts @@ -2,3 +2,4 @@ export * from './get-object-s3'; export * from './s3-client'; export * from './put-data-s3'; export * from './put-file-s3'; +export * from './copy-and-delete-object-s3'; From 1f30171d9704d528a2f287b1926c02505a057fae Mon Sep 17 00:00:00 2001 From: Angel Pastor Date: Wed, 4 Feb 2026 15:54:05 +0000 Subject: [PATCH 2/2] CMM-13767: Updated versions for S3 and SQS --- .../terraform/components/dl/README.md | 34 +++++++++---------- .../components/dl/module_s3_bucket_cf_logs.tf | 2 +- .../dl/module_s3_bucket_static_assets.tf | 2 +- .../dl/module_s3bucket_file_quarantine.tf | 2 +- .../dl/module_s3bucket_file_safe.tf | 2 +- .../components/dl/module_s3bucket_letters.tf | 2 +- .../dl/module_s3bucket_non_pii_data.tf | 2 +- .../components/dl/module_s3bucket_pii_data.tf | 2 +- .../dl/module_s3bucket_reporting.tf | 2 +- .../components/dl/module_sqs_core_notifier.tf | 2 +- .../dl/module_sqs_event_publisher_errors.tf | 2 +- .../components/dl/module_sqs_mesh_download.tf | 2 +- .../dl/module_sqs_move_scanned_files.tf | 2 +- .../components/dl/module_sqs_pdm_poll.tf | 2 +- .../components/dl/module_sqs_pdm_uploader.tf | 2 +- .../components/dl/module_sqs_scanner.tf | 2 +- .../terraform/components/dl/module_sqs_ttl.tf | 2 +- .../dl/module_sqs_ttl_handle_expiry_errors.tf | 2 +- 18 files changed, 34 insertions(+), 34 deletions(-) diff --git a/infrastructure/terraform/components/dl/README.md b/infrastructure/terraform/components/dl/README.md index f53fcf8b..133f5833 100644 --- a/infrastructure/terraform/components/dl/README.md +++ b/infrastructure/terraform/components/dl/README.md @@ -57,25 +57,25 @@ No requirements. | [print\_analyser](#module\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [print\_status\_handler](#module\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [report\_event\_transformer](#module\_report\_event\_transformer) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | -| [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_file\_quarantine](#module\_s3bucket\_file\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip | n/a | -| [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_move\_scanned\_files](#module\_sqs\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | +| [s3bucket\_cf\_logs](#module\_s3bucket\_cf\_logs) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_file\_quarantine](#module\_s3bucket\_file\_quarantine) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_file\_safe](#module\_s3bucket\_file\_safe) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_letters](#module\_s3bucket\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_non\_pii\_data](#module\_s3bucket\_non\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_pii\_data](#module\_s3bucket\_pii\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_reporting](#module\_s3bucket\_reporting) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [s3bucket\_static\_assets](#module\_s3bucket\_static\_assets) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip | n/a | +| [sqs\_core\_notifier](#module\_sqs\_core\_notifier) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_event\_publisher\_errors](#module\_sqs\_event\_publisher\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_mesh\_download](#module\_sqs\_mesh\_download) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_move\_scanned\_files](#module\_sqs\_move\_scanned\_files) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_pdm\_poll](#module\_sqs\_pdm\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_pdm\_uploader](#module\_sqs\_pdm\_uploader) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_print\_analyser](#module\_sqs\_print\_analyser) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [sqs\_print\_status\_handler](#module\_sqs\_print\_status\_handler) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | -| [sqs\_scanner](#module\_sqs\_scanner) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | -| [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip | n/a | +| [sqs\_scanner](#module\_sqs\_scanner) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_ttl](#module\_sqs\_ttl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | +| [sqs\_ttl\_handle\_expiry\_errors](#module\_sqs\_ttl\_handle\_expiry\_errors) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip | n/a | | [ttl\_create](#module\_ttl\_create) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [ttl\_handle\_expiry](#module\_ttl\_handle\_expiry) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | | [ttl\_poll](#module\_ttl\_poll) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.29/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf b/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf index 8b87de19..a1833846 100644 --- a/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf +++ b/infrastructure/terraform/components/dl/module_s3_bucket_cf_logs.tf @@ -1,5 +1,5 @@ module "s3bucket_cf_logs" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" providers = { aws = aws.us-east-1 } diff --git a/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf b/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf index 81dec4a7..19c3d58d 100644 --- a/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf +++ b/infrastructure/terraform/components/dl/module_s3_bucket_static_assets.tf @@ -1,5 +1,5 @@ module "s3bucket_static_assets" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "static-assets" diff --git a/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf b/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf index e0d23821..23461158 100644 --- a/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf +++ b/infrastructure/terraform/components/dl/module_s3bucket_file_quarantine.tf @@ -1,5 +1,5 @@ module "s3bucket_file_quarantine" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "file-quarantine" diff --git a/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf b/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf index 4fabd0cc..41888079 100644 --- a/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf +++ b/infrastructure/terraform/components/dl/module_s3bucket_file_safe.tf @@ -1,5 +1,5 @@ module "s3bucket_file_safe" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "file-safe" diff --git a/infrastructure/terraform/components/dl/module_s3bucket_letters.tf b/infrastructure/terraform/components/dl/module_s3bucket_letters.tf index 63eefd2e..42d5ef87 100644 --- a/infrastructure/terraform/components/dl/module_s3bucket_letters.tf +++ b/infrastructure/terraform/components/dl/module_s3bucket_letters.tf @@ -1,5 +1,5 @@ module "s3bucket_letters" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "letters" diff --git a/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf b/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf index b7fb6284..5501fea5 100644 --- a/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf +++ b/infrastructure/terraform/components/dl/module_s3bucket_non_pii_data.tf @@ -1,5 +1,5 @@ module "s3bucket_non_pii_data" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "non-pii-data" diff --git a/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf b/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf index 4a80ec95..fe6648ba 100644 --- a/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf +++ b/infrastructure/terraform/components/dl/module_s3bucket_pii_data.tf @@ -1,5 +1,5 @@ module "s3bucket_pii_data" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "pii-data" diff --git a/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf b/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf index 739f7c99..e9e84559 100644 --- a/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf +++ b/infrastructure/terraform/components/dl/module_s3bucket_reporting.tf @@ -1,5 +1,5 @@ module "s3bucket_reporting" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-s3bucket.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-s3bucket.zip" name = "reporting" diff --git a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf index 1977f919..7723aaaf 100644 --- a/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf +++ b/infrastructure/terraform/components/dl/module_sqs_core_notifier.tf @@ -1,5 +1,5 @@ module "sqs_core_notifier" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf b/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf index fbe89c2e..952cac58 100644 --- a/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf +++ b/infrastructure/terraform/components/dl/module_sqs_event_publisher_errors.tf @@ -1,5 +1,5 @@ module "sqs_event_publisher_errors" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf b/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf index f33e7881..4c8ae79e 100644 --- a/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf +++ b/infrastructure/terraform/components/dl/module_sqs_mesh_download.tf @@ -1,5 +1,5 @@ module "sqs_mesh_download" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf b/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf index fd2c267a..cb444d5e 100644 --- a/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf +++ b/infrastructure/terraform/components/dl/module_sqs_move_scanned_files.tf @@ -1,5 +1,5 @@ module "sqs_move_scanned_files" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf index f7cb3842..2f3b22d9 100644 --- a/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_poll.tf @@ -1,5 +1,5 @@ module "sqs_pdm_poll" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf index cb45762a..a9936526 100644 --- a/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf +++ b/infrastructure/terraform/components/dl/module_sqs_pdm_uploader.tf @@ -1,5 +1,5 @@ module "sqs_pdm_uploader" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_scanner.tf b/infrastructure/terraform/components/dl/module_sqs_scanner.tf index 248fe16a..cabecbc6 100644 --- a/infrastructure/terraform/components/dl/module_sqs_scanner.tf +++ b/infrastructure/terraform/components/dl/module_sqs_scanner.tf @@ -1,5 +1,5 @@ module "sqs_scanner" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_ttl.tf b/infrastructure/terraform/components/dl/module_sqs_ttl.tf index 38638a2b..20b0e7ee 100644 --- a/infrastructure/terraform/components/dl/module_sqs_ttl.tf +++ b/infrastructure/terraform/components/dl/module_sqs_ttl.tf @@ -1,5 +1,5 @@ module "sqs_ttl" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component diff --git a/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf b/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf index 5093334c..1aa34864 100644 --- a/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf +++ b/infrastructure/terraform/components/dl/module_sqs_ttl_handle_expiry_errors.tf @@ -1,5 +1,5 @@ module "sqs_ttl_handle_expiry_errors" { - source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-sqs.zip" + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.30/terraform-sqs.zip" aws_account_id = var.aws_account_id component = local.component