diff --git a/.trivyignore.yaml b/.trivyignore.yaml index 7a699b25..82329a8d 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -50,3 +50,12 @@ vulnerabilities: - id: CVE-2026-26960 statement: tar node module expired_at: 2026-06-01 + - id: CVE-2026-27903 + statement: minimatch node module + expired_at: 2026-06-01 + - id: CVE-2026-27904 + statement: minimatch node module + expired_at: 2026-06-01 + - id: CVE-2026-27606 + statement: rollup node module + expired_at: 2026-06-01 diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway.ts b/packages/cdkConstructs/src/constructs/RestApiGateway.ts new file mode 100644 index 00000000..6870360f --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway.ts @@ -0,0 +1,230 @@ +import {Fn, RemovalPolicy} from "aws-cdk-lib" +import { + CfnStage, + EndpointType, + LogGroupLogDestination, + MethodLoggingLevel, + MTLSConfig, + RestApi, + SecurityPolicy +} from "aws-cdk-lib/aws-apigateway" +import { + IManagedPolicy, + IRole, + ManagedPolicy, + PolicyStatement, + Role, + ServicePrincipal +} from "aws-cdk-lib/aws-iam" +import {Stream} from "aws-cdk-lib/aws-kinesis" +import {Key} from "aws-cdk-lib/aws-kms" +import {CfnSubscriptionFilter, LogGroup} from "aws-cdk-lib/aws-logs" +import {Construct} from "constructs" +import {accessLogFormat} from "./RestApiGateway/accessLogFormat.js" +import {Certificate, CertificateValidation} from "aws-cdk-lib/aws-certificatemanager" +import {Bucket} from "aws-cdk-lib/aws-s3" +import {BucketDeployment, Source} from "aws-cdk-lib/aws-s3-deployment" +import {ARecord, HostedZone, RecordTarget} from "aws-cdk-lib/aws-route53" +import {ApiGateway as ApiGatewayTarget} from "aws-cdk-lib/aws-route53-targets" +import {NagSuppressions} from "cdk-nag" + +export interface RestApiGatewayProps { + readonly stackName: string + readonly logRetentionInDays: number + readonly mutualTlsTrustStoreKey: string | undefined + readonly forwardCsocLogs: boolean + readonly csocApiGatewayDestination: string + readonly executionPolicies: Array +} + +export class RestApiGateway extends Construct { + public readonly api: RestApi + public readonly role: IRole + + public constructor(scope: Construct, id: string, props: RestApiGatewayProps) { + super(scope, id) + + // Imports + const cloudWatchLogsKmsKey = Key.fromKeyArn( + this, "cloudWatchLogsKmsKey", Fn.importValue("account-resources:CloudwatchLogsKmsKeyArn")) + + const splunkDeliveryStream = Stream.fromStreamArn( + this, "SplunkDeliveryStream", Fn.importValue("lambda-resources:SplunkDeliveryStream")) + + const splunkSubscriptionFilterRole = Role.fromRoleArn( + this, "splunkSubscriptionFilterRole", Fn.importValue("lambda-resources:SplunkSubscriptionFilterRole")) + + const trustStoreBucket = Bucket.fromBucketArn( + this, "TrustStoreBucket", Fn.importValue("account-resources:TrustStoreBucket")) + + const trustStoreDeploymentBucket = Bucket.fromBucketArn( + this, "TrustStoreDeploymentBucket", Fn.importValue("account-resources:TrustStoreDeploymentBucket")) + + const trustStoreBucketKmsKey = Key.fromKeyArn( + this, "TrustStoreBucketKmsKey", Fn.importValue("account-resources:TrustStoreBucketKMSKey")) + + const epsDomainName: string = Fn.importValue("eps-route53-resources:EPS-domain") + const hostedZone = HostedZone.fromHostedZoneAttributes(this, "HostedZone", { + hostedZoneId: Fn.importValue("eps-route53-resources:EPS-ZoneID"), + zoneName: epsDomainName + }) + const serviceDomainName = `${props.stackName}.${epsDomainName}` + + // Resources + const logGroup = new LogGroup(this, "ApiGatewayAccessLogGroup", { + encryptionKey: cloudWatchLogsKmsKey, + logGroupName: `/aws/apigateway/${props.stackName}-apigw`, + retention: props.logRetentionInDays, + removalPolicy: RemovalPolicy.DESTROY + }) + + new CfnSubscriptionFilter(this, "ApiGatewayAccessLogsSplunkSubscriptionFilter", { + destinationArn: splunkDeliveryStream.streamArn, + filterPattern: "", + logGroupName: logGroup.logGroupName, + roleArn: splunkSubscriptionFilterRole.roleArn + }) + + if (props.forwardCsocLogs) { + new CfnSubscriptionFilter(this, "ApiGatewayAccessLogsCSOCSubscriptionFilter", { + destinationArn: props.csocApiGatewayDestination, + filterPattern: "", + logGroupName: logGroup.logGroupName, + roleArn: splunkSubscriptionFilterRole.roleArn + }) + } + + const certificate = new Certificate(this, "Certificate", { + domainName: serviceDomainName, + validation: CertificateValidation.fromDns(hostedZone) + }) + + let mtlsConfig: MTLSConfig | undefined + + if (props.mutualTlsTrustStoreKey) { + const trustStoreKeyPrefix = `cpt-api/${props.stackName}-truststore` + const logGroup = new LogGroup(scope, "LambdaLogGroup", { + encryptionKey: cloudWatchLogsKmsKey, + logGroupName: `/aws/lambda/${props.stackName}-truststore-deployment`, + retention: props.logRetentionInDays, + removalPolicy: RemovalPolicy.DESTROY + }) + const trustStoreDeploymentPolicy = new ManagedPolicy(this, "TrustStoreDeploymentPolicy", { + statements: [ + new PolicyStatement({ + actions: [ + "s3:ListBucket" + ], + resources: [ + trustStoreBucket.bucketArn, + trustStoreDeploymentBucket.bucketArn + ] + }), + new PolicyStatement({ + actions: [ + "s3:GetObject" + ], + resources: [trustStoreBucket.arnForObjects(props.mutualTlsTrustStoreKey)] + }), + new PolicyStatement({ + actions: [ + "s3:DeleteObject", + "s3:PutObject" + ], + resources: [ + trustStoreDeploymentBucket.arnForObjects(trustStoreKeyPrefix + "/" + props.mutualTlsTrustStoreKey) + ] + }), + new PolicyStatement({ + actions: [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey" + ], + resources: [trustStoreBucketKmsKey.keyArn] + }), + new PolicyStatement({ + actions: [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + resources: [ + logGroup.logGroupArn, + `${logGroup.logGroupArn}:log-stream:*` + ] + }) + ] + }) + NagSuppressions.addResourceSuppressions(trustStoreDeploymentPolicy, [ + { + id: "AwsSolutions-IAM5", + // eslint-disable-next-line max-len + reason: "Suppress error for not having wildcards in permissions. This is a fine as we need to have permissions on all log streams under path" + } + ]) + const trustStoreDeploymentRole = new Role(this, "TrustStoreDeploymentRole", { + assumedBy: new ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [trustStoreDeploymentPolicy] + }).withoutPolicyUpdates() + const deployment = new BucketDeployment(this, "TrustStoreDeployment", { + sources: [Source.bucket(trustStoreBucket, props.mutualTlsTrustStoreKey)], + destinationBucket: trustStoreDeploymentBucket, + destinationKeyPrefix: trustStoreKeyPrefix, + extract: false, + retainOnDelete: false, + role: trustStoreDeploymentRole, + logGroup: logGroup + }) + mtlsConfig = { + bucket: deployment.deployedBucket, + key: trustStoreKeyPrefix + "/" + props.mutualTlsTrustStoreKey + } + } + + const apiGateway = new RestApi(this, "ApiGateway", { + restApiName: `${props.stackName}-apigw`, + domainName: { + domainName: serviceDomainName, + certificate: certificate, + securityPolicy: SecurityPolicy.TLS_1_2, + endpointType: EndpointType.REGIONAL, + mtls: mtlsConfig + }, + disableExecuteApiEndpoint: mtlsConfig ? true : false, + endpointConfiguration: { + types: [EndpointType.REGIONAL] + }, + deploy: true, + deployOptions: { + accessLogDestination: new LogGroupLogDestination(logGroup), + accessLogFormat: accessLogFormat(), + loggingLevel: MethodLoggingLevel.INFO, + metricsEnabled: true + } + }) + + const role = new Role(this, "ApiGatewayRole", { + assumedBy: new ServicePrincipal("apigateway.amazonaws.com"), + managedPolicies: props.executionPolicies + }).withoutPolicyUpdates() + + new ARecord(this, "ARecord", { + recordName: props.stackName, + target: RecordTarget.fromAlias(new ApiGatewayTarget(apiGateway)), + zone: hostedZone + }) + + const cfnStage = apiGateway.deploymentStage.node.defaultChild as CfnStage + cfnStage.cfnOptions.metadata = { + guard: { + SuppressedRules: [ + "API_GW_CACHE_ENABLED_AND_ENCRYPTED" + ] + } + } + + // Outputs + this.api = apiGateway + this.role = role + } +} diff --git a/packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts b/packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts new file mode 100644 index 00000000..1504faf7 --- /dev/null +++ b/packages/cdkConstructs/src/constructs/RestApiGateway/accessLogFormat.ts @@ -0,0 +1,38 @@ +import {AccessLogFormat} from "aws-cdk-lib/aws-apigateway" + +export const accessLogFormat = () => { + return AccessLogFormat.custom(JSON.stringify({ + requestId: "$context.requestId", + ip: "$context.identity.sourceIp", + caller: "$context.identity.caller", + user: "$context.identity.user", + requestTime: "$context.requestTime", + httpMethod: "$context.httpMethod", + resourcePath: "$context.resourcePath", + status: "$context.status", + protocol: "$context.protocol", + responseLength: "$context.responseLength", + accountId: "$context.accountId", + apiId: "$context.apiId", + stage: "$context.stage", + api_key: "$context.identity.apiKey", + identity: { + sourceIp: "$context.identity.sourceIp", + userAgent: "$context.identity.userAgent", + clientCert: { + subjectDN: "$context.identity.clientCert.subjectDN", + issuerDN: "$context.identity.clientCert.issuerDN", + serialNumber: "$context.identity.clientCert.serialNumber", + validityNotBefore: "$context.identity.clientCert.validity.notBefore", + validityNotAfter: "$context.identity.clientCert.validity.notAfter" + } + }, + integration:{ + error: "$context.integration.error", + integrationStatus: "$context.integration.integrationStatus", + latency: "$context.integration.latency", + requestId: "$context.integration.requestId", + status: "$context.integration.status" + } + })) +} diff --git a/packages/cdkConstructs/src/index.ts b/packages/cdkConstructs/src/index.ts index 0c73b904..83e26a6a 100644 --- a/packages/cdkConstructs/src/index.ts +++ b/packages/cdkConstructs/src/index.ts @@ -1,5 +1,7 @@ // Export all constructs export * from "./constructs/TypescriptLambdaFunction.js" +export * from "./constructs/RestApiGateway.js" +export * from "./constructs/RestApiGateway/accessLogFormat.js" export * from "./constructs/PythonLambdaFunction.js" export * from "./apps/createApp.js" export * from "./config/index.js" diff --git a/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts new file mode 100644 index 00000000..b6bd576e --- /dev/null +++ b/packages/cdkConstructs/tests/constructs/RestApiGateway.test.ts @@ -0,0 +1,329 @@ +import {App, Stack} from "aws-cdk-lib" +import {Template, Match} from "aws-cdk-lib/assertions" +import {ManagedPolicy, PolicyStatement} from "aws-cdk-lib/aws-iam" +import { + describe, + test, + beforeAll, + expect +} from "vitest" + +import {RestApiGateway} from "../../src/constructs/RestApiGateway.js" + +describe("RestApiGateway without mTLS", () => { + let stack: Stack + let app: App + let template: Template + + beforeAll(() => { + app = new App() + stack = new Stack(app, "RestApiGatewayStack") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: undefined, + forwardCsocLogs: false, + csocApiGatewayDestination: "", + executionPolicies: [testPolicy] + }) + + // Add a dummy method to satisfy API Gateway validation + apiGateway.api.root.addMethod("GET") + + template = Template.fromStack(stack) + }) + + test("creates CloudWatch log group with correct properties", () => { + template.hasResourceProperties("AWS::Logs::LogGroup", { + LogGroupName: "/aws/apigateway/test-stack-apigw", + KmsKeyId: {"Fn::ImportValue": "account-resources:CloudwatchLogsKmsKeyArn"}, + RetentionInDays: 30 + }) + }) + + test("creates Splunk subscription filter", () => { + template.hasResourceProperties("AWS::Logs::SubscriptionFilter", { + FilterPattern: "", + RoleArn: {"Fn::ImportValue": "lambda-resources:SplunkSubscriptionFilterRole"}, + DestinationArn: {"Fn::ImportValue": "lambda-resources:SplunkDeliveryStream"} + }) + }) + + test("does not create CSOC subscription filter", () => { + const filters = template.findResources("AWS::Logs::SubscriptionFilter") + const filterCount = Object.keys(filters).length + expect(filterCount).toBe(1) + }) + + test("creates ACM certificate", () => { + template.hasResourceProperties("AWS::CertificateManager::Certificate", { + DomainName: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"} + ]] + }, + DomainValidationOptions: [{ + DomainName: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"} + ]] + }, + HostedZoneId: {"Fn::ImportValue": "eps-route53-resources:EPS-ZoneID"} + }], + ValidationMethod: "DNS" + }) + }) + + test("creates REST API Gateway with correct configuration", () => { + template.hasResourceProperties("AWS::ApiGateway::RestApi", { + Name: "test-stack-apigw", + EndpointConfiguration: { + Types: ["REGIONAL"] + }, + DisableExecuteApiEndpoint: false + }) + }) + + test("creates API Gateway domain name with TLS 1.2", () => { + template.hasResourceProperties("AWS::ApiGateway::DomainName", { + DomainName: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"} + ]] + }, + EndpointConfiguration: { + Types: ["REGIONAL"] + }, + SecurityPolicy: "TLS_1_2" + }) + }) + + test("creates deployment with logging and metrics enabled", () => { + template.hasResourceProperties("AWS::ApiGateway::Stage", { + MethodSettings: [{ + LoggingLevel: "INFO", + MetricsEnabled: true, + DataTraceEnabled: false, + HttpMethod: "*", + ResourcePath: "/*" + }], + AccessLogSetting: Match.objectLike({ + Format: Match.stringLikeRegexp("requestId") + }) + }) + }) + + test("creates IAM role for API Gateway execution", () => { + template.hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: [{ + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "apigateway.amazonaws.com" + } + }], + Version: "2012-10-17" + } + }) + }) + + test("creates Route53 A record", () => { + template.hasResourceProperties("AWS::Route53::RecordSet", { + Name: { + "Fn::Join": ["", [ + "test-stack.", + {"Fn::ImportValue": "eps-route53-resources:EPS-domain"}, + "." + ]] + }, + Type: "A" + }) + }) + + test("sets guard metadata on stage", () => { + const stages = template.findResources("AWS::ApiGateway::Stage") + const stageKeys = Object.keys(stages) + expect(stageKeys.length).toBeGreaterThan(0) + + const stage = stages[stageKeys[0]] + expect(stage.Metadata).toBeDefined() + expect(stage.Metadata.guard).toBeDefined() + expect(stage.Metadata.guard.SuppressedRules).toContain("API_GW_CACHE_ENABLED_AND_ENCRYPTED") + }) +}) + +describe("RestApiGateway with CSOC logs", () => { + let stack: Stack + let app: App + let template: Template + + beforeAll(() => { + app = new App() + stack = new Stack(app, "RestApiGatewayStack") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: undefined, + forwardCsocLogs: true, + csocApiGatewayDestination: "arn:aws:logs:eu-west-2:123456789012:destination:csoc-destination", + executionPolicies: [testPolicy] + }) + + // Add a dummy method to satisfy API Gateway validation + apiGateway.api.root.addMethod("GET") + + template = Template.fromStack(stack) + }) + + test("creates both Splunk and CSOC subscription filters", () => { + const filters = template.findResources("AWS::Logs::SubscriptionFilter") + const filterCount = Object.keys(filters).length + expect(filterCount).toBe(2) + }) + + test("creates CSOC subscription filter with correct destination", () => { + template.hasResourceProperties("AWS::Logs::SubscriptionFilter", { + FilterPattern: "", + DestinationArn: "arn:aws:logs:eu-west-2:123456789012:destination:csoc-destination" + }) + }) +}) + +describe("RestApiGateway with mTLS", () => { + let stack: Stack + let app: App + let template: Template + + beforeAll(() => { + app = new App() + stack = new Stack(app, "RestApiGatewayStack") + + const testPolicy = new ManagedPolicy(stack, "TestPolicy", { + description: "test execution policy", + statements: [ + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: ["arn:aws:lambda:eu-west-2:123456789012:function:test-function"] + }) + ] + }) + + const apiGateway = new RestApiGateway(stack, "TestApiGateway", { + stackName: "test-stack", + logRetentionInDays: 30, + mutualTlsTrustStoreKey: "truststore.pem", + forwardCsocLogs: false, + csocApiGatewayDestination: "", + executionPolicies: [testPolicy] + }) + + // Add a dummy method to satisfy API Gateway validation + apiGateway.api.root.addMethod("GET") + + template = Template.fromStack(stack) + }) + + test("creates trust store deployment log group", () => { + template.hasResourceProperties("AWS::Logs::LogGroup", { + LogGroupName: "/aws/lambda/test-stack-truststore-deployment", + KmsKeyId: {"Fn::ImportValue": "account-resources:CloudwatchLogsKmsKeyArn"}, + RetentionInDays: 30 + }) + }) + + test("creates trust store deployment policy with S3 permissions", () => { + interface PolicyResource { + Properties?: { + PolicyDocument?: { + Statement?: Array<{Action?: Array}> + } + } + } + interface Statement { + Action?: Array + } + + const policies = template.findResources("AWS::IAM::ManagedPolicy") + const trustStorePolicy = Object.values(policies).find((p: PolicyResource) => + p.Properties?.PolicyDocument?.Statement?.some((s: Statement) => + s.Action?.includes("s3:ListBucket") + ) + ) as PolicyResource + expect(trustStorePolicy).toBeDefined() + const statements = trustStorePolicy.Properties?.PolicyDocument?.Statement ?? [] + expect(statements.some((s: Statement) => s.Action?.includes("s3:ListBucket"))).toBe(true) + expect(statements.some((s: Statement) => s.Action?.includes("s3:GetObject"))).toBe(true) + expect(statements.some((s: Statement) => s.Action?.includes("kms:Decrypt"))).toBe(true) + expect(statements.some((s: Statement) => s.Action?.includes("logs:CreateLogStream"))).toBe(true) + }) + + test("creates trust store deployment role", () => { + template.hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "lambda.amazonaws.com" + } + }) + ]), + Version: "2012-10-17" + } + }) + }) + + test("creates bucket deployment custom resource", () => { + const customResources = template.findResources("Custom::CDKBucketDeployment") + expect(Object.keys(customResources).length).toBeGreaterThan(0) + }) + + test("disables execute-api endpoint when mTLS is enabled", () => { + template.hasResourceProperties("AWS::ApiGateway::RestApi", { + Name: "test-stack-apigw", + DisableExecuteApiEndpoint: true + }) + }) + + test("configures mTLS on domain name", () => { + interface DomainNameResource { + Properties: { + MutualTlsAuthentication: { + TruststoreUri: unknown + } + } + } + + const domainNames = template.findResources("AWS::ApiGateway::DomainName") + const domainName = Object.values(domainNames)[0] as DomainNameResource + expect(domainName.Properties.MutualTlsAuthentication).toBeDefined() + expect(domainName.Properties.MutualTlsAuthentication.TruststoreUri).toBeDefined() + }) +}) diff --git a/sonar-project.properties b/sonar-project.properties index 8054cb01..d631f11b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,6 +2,10 @@ sonar.organization=nhsdigital sonar.projectKey=NHSDigital_eps-cdk-utils sonar.host.url=https://sonarcloud.io +sonar.exclusions=\ + packages/serviceSearchClient/vitest.config.ts,\ + packages/enrichPrescriptions/vitest.config.ts + sonar.coverage.exclusions=\ **/*.test.*,\ **/jest.config.ts,scripts/*,\