cdk/lib/security-hq.ts (230 lines of code) (raw):

import { AccessScope } from "@guardian/cdk/lib/constants"; import { GuAlarm } from "@guardian/cdk/lib/constructs/cloudwatch"; import type { GuStackProps } from "@guardian/cdk/lib/constructs/core"; import { GuDistributionBucketParameter, GuParameter, GuStack, GuStringParameter, } from "@guardian/cdk/lib/constructs/core"; import type { AppIdentity } from "@guardian/cdk/lib/constructs/core/identity"; import { GuCname } from "@guardian/cdk/lib/constructs/dns"; import { GuDynamoTable } from "@guardian/cdk/lib/constructs/dynamodb"; import { GuHttpsEgressSecurityGroup } from "@guardian/cdk/lib/constructs/ec2"; import { GuAllowPolicy, GuDynamoDBReadPolicy, GuDynamoDBWritePolicy, GuGetS3ObjectsPolicy, GuPutCloudwatchMetricsPolicy, } from "@guardian/cdk/lib/constructs/iam"; import { GuAnghammaradSenderPolicy } from "@guardian/cdk/lib/constructs/iam/policies/anghammarad"; import { GuEc2AppExperimental } from "@guardian/cdk/lib/experimental/patterns/ec2-app"; import { Duration, RemovalPolicy, SecretValue } from "aws-cdk-lib"; import type { App } from "aws-cdk-lib"; import { ComparisonOperator, Metric, TreatMissingData, } from "aws-cdk-lib/aws-cloudwatch"; import { AttributeType } from "aws-cdk-lib/aws-dynamodb"; import { InstanceClass, InstanceSize, InstanceType, UserData } from "aws-cdk-lib/aws-ec2"; import { ListenerAction, UnauthenticatedAction, } from "aws-cdk-lib/aws-elasticloadbalancingv2"; import { Topic } from "aws-cdk-lib/aws-sns"; import { EmailSubscription } from "aws-cdk-lib/aws-sns-subscriptions"; import { ParameterDataType, ParameterTier, StringParameter, } from "aws-cdk-lib/aws-ssm"; interface SecurityHQProps extends GuStackProps { /** * Which application build to run. * This will typically match the build number provided by CI. */ buildIdentifier: string; } export class SecurityHQ extends GuStack { private static app: AppIdentity = { app: "security-hq", }; constructor(scope: App, id: string, props: SecurityHQProps) { super(scope, id, props); const { buildIdentifier } = props; const table = new GuDynamoTable(this, "DynamoTable", { tableName: `security-hq-iam`, removalPolicy: RemovalPolicy.RETAIN, readCapacity: 5, writeCapacity: 5, partitionKey: { name: "id", type: AttributeType.STRING, }, sortKey: { name: "dateNotificationSent", type: AttributeType.NUMBER, }, devXBackups: { enabled: true }, }); this.overrideLogicalId(table, { logicalId: "SecurityHqIamDynamoTable", reason: "Migrated from a YAML template", }); const distBucket = GuDistributionBucketParameter.getInstance(this); const auditDataS3BucketName = new GuStringParameter( this, "AuditDataS3BucketName", { description: "Name of the S3 bucket to fetch auditable data from (e.g. Janus data)", default: `/${this.stack}/${SecurityHQ.app.app}/audit-data-s3-bucket/name`, fromSSM: true, } ); const auditDataS3BucketPath = `${this.stack}/${this.stage}/*`; const domainName = "security-hq.gutools.co.uk"; const userData = UserData.forLinux(); userData.addCommands(`# setup security-hq mkdir -p /etc/gu aws --region eu-west-1 s3 cp s3://${distBucket.valueAsString}/security/${this.stage}/security-hq/security-hq.conf /etc/gu aws --region eu-west-1 s3 cp s3://${distBucket.valueAsString}/security/${this.stage}/security-hq/security-hq-service-account-cert.json /etc/gu aws --region eu-west-1 s3 cp s3://${distBucket.valueAsString}/security/${this.stage}/security-hq/security-hq-${buildIdentifier}.deb /tmp/installer.deb dpkg -i /tmp/installer.deb`); const ec2App = new GuEc2AppExperimental(this, { buildIdentifier, applicationLogging: { enabled: true }, access: { scope: AccessScope.PUBLIC, }, app: "security-hq", applicationPort: 9000, instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.LARGE), certificateProps: { domainName, }, monitoringConfiguration: { noMonitoring: true }, scaling: { minimumInstances: 1, }, userData, roleConfiguration: { additionalPolicies: [ GuAnghammaradSenderPolicy.getInstance(this), new GuPutCloudwatchMetricsPolicy(this), new GuGetS3ObjectsPolicy(this, "S3AuditRead", { bucketName: auditDataS3BucketName.valueAsString, paths: [auditDataS3BucketPath], }), new GuDynamoDBReadPolicy(this, "DynamoRead", { tableName: table.tableName, }), new GuDynamoDBWritePolicy(this, "DynamoWrite", { tableName: table.tableName, }), // Allow security HQ to assume roles in watched accounts. new GuAllowPolicy(this, "AssumeRole", { resources: ["*"], actions: ["sts:AssumeRole"], }), // Get the list of regions. new GuAllowPolicy(this, "DescribeRegions", { resources: ["*"], actions: ["ec2:DescribeRegions"], }), ], }, }); new GuCname(this, "DnsRecord", { app: SecurityHQ.app.app, domainName, ttl: Duration.hours(1), resourceRecord: ec2App.loadBalancer.loadBalancerDnsName, }); // Need to give the ALB outbound access on 443 for the IdP endpoints (to support Google Auth). const outboundHttpsSecurityGroup = new GuHttpsEgressSecurityGroup( this, "idp-access", { app: SecurityHQ.app.app, vpc: ec2App.vpc, } ); ec2App.loadBalancer.addSecurityGroup(outboundHttpsSecurityGroup); // This parameter is used by https://github.com/guardian/waf new StringParameter(this, "AlbSsmParam", { parameterName: `/infosec/waf/services/${this.stage}/security-hq-alb-arn`, description: `The arn of the ALB for security-hq-${this.stage}. N.B. this parameter is created via cdk`, simpleName: false, stringValue: ec2App.loadBalancer.loadBalancerArn, tier: ParameterTier.STANDARD, dataType: ParameterDataType.TEXT, }); const clientId = new GuStringParameter(this, "ClientId", { description: "Google OAuth client ID", }); ec2App.listener.addAction("DefaultAction", { action: ListenerAction.authenticateOidc({ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", issuer: "https://accounts.google.com", scope: "openid", authenticationRequestExtraParams: { hd: "guardian.co.uk" }, onUnauthenticatedRequest: UnauthenticatedAction.AUTHENTICATE, tokenEndpoint: "https://oauth2.googleapis.com/token", userInfoEndpoint: "https://openidconnect.googleapis.com/v1/userinfo", clientId: clientId.valueAsString, clientSecret: SecretValue.secretsManager( `/${this.stage}/deploy/security-hq/client-secret` ), next: ListenerAction.forward([ec2App.targetGroup]), }), }); const notificationTopic = new Topic(this, "NotificationTopic", { displayName: "Security HQ notifications", }); const emailDest = new GuParameter(this, "CloudwatchAlarmEmailDestination", { description: "Send Security HQ cloudwatch alarms to this email address", }); notificationTopic.addSubscription( new EmailSubscription(emailDest.valueAsString) ); new GuAlarm(this, "RemovePasswordFailureAlarm", { app: SecurityHQ.app.app, alarmName: "Security HQ failed to remove a vulnerable password (new stack)", alarmDescription: "The credentials reaper feature of Security HQ logs either success or failure to cloudwatch, and this alarm lets us know when it logs a failure. Check the application logs for more details https://logs.gutools.co.uk/s/devx/goto/f9915a6e4e94a000732d67026cea91be.", snsTopicName: notificationTopic.topicName, threshold: 1, evaluationPeriods: 1, metric: new Metric({ metricName: "IamRemovePassword", namespace: "SecurityHQ", period: Duration.seconds(60), statistic: "sum", dimensionsMap: { ReaperExecutionStatus: "Failure", }, }), treatMissingData: TreatMissingData.NOT_BREACHING, comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, }); new GuAlarm(this, "DisableAccessKeyFailureAlarm", { app: SecurityHQ.app.app, alarmName: "Security HQ failed to disable a vulnerable access key (new stack)", alarmDescription: "The credentials reaper feature of Security HQ logs either success or failure to cloudwatch, and this alarm lets us know when it logs a failure. Check the application logs for more details https://logs.gutools.co.uk/s/devx/goto/f9915a6e4e94a000732d67026cea91be.", snsTopicName: notificationTopic.topicName, threshold: 1, evaluationPeriods: 1, metric: new Metric({ metricName: "IamDisableAccessKey", namespace: "SecurityHQ", period: Duration.seconds(60), statistic: "sum", dimensionsMap: { ReaperExecutionStatus: "Failure", }, }), treatMissingData: TreatMissingData.NOT_BREACHING, comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, }); } }