Skip to main content

AWS Security Hub Notifications for Slack with CDK TypeScript

· 8 min read

In this blog post, I'll be detailing how to set up automated Slack notifications for any new AWS Security Hub findings, through AWS CDK Typescript.

We'll create a single 'securityHubNotificationsStack' that will deploy all the infrastructure we need.

At a high level, here's what we'll build:

  1. An EventBridge rule that triggers for any Security Hub findings
  2. An SQS Queue that consumes the events triggered by the rule
  3. A Lambda Function that processes messages from the queue and sends the notifications to Slack via a Slack incoming webhook
  4. A dead letter queue (DLQ) to store any messages in our queue that fail processing
  5. A CloudWatch alarm to notify us of any failed messages.

Prerequisites:

AWS CDK V2 installed
Security Hub enabled in your AWS account or organization

To start a new CDK project, create a new directory and run the cdk init app --language typescript. This will create a new CDK app for us to use. Next, rename the file in the lib directory to securityHubNotificationsStack.ts. It is in this file that we will define our stack.

lib/securityHubNotificationsStack.ts
import { StackProps, Stack } from "aws-cdk-lib";
import { Construct } from "constructs";

export class SlackNotificationStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
}
}

Firstly, we'll need to create the incoming Slack webhook that will give us an endpoint to send our AWS notifications to. In a browser, log into Slack and create an app. From here you can enable an incoming webhook, and choose a channel as the destination for the notifications. See the full guide on how to do this here: https://api.slack.com/messaging/webhooks. Once you've got your incoming webhook, we can store this in AWS Systems Manager Parameter store as a secure string. This parameter should be created manually through the console because we don't want the parameter value stored in plaintext in the repo, as it allows anyone with access to send data to your Slack workspace.

To access the parameter we've created from within this stack, add the slackIncomingUrlParameterName property to the stack:

lib/securityHubNotificationsStack.ts
export class SlackNotificationStack extends Stack {
private readonly slackWebhookUrlParameterName =
"/slack-notifications/webhook-url";

constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
}
}

Once you've created this parameter, we need to give our Lambda Function permissions to get this parameter from Parameter Store:

lib/securityHubNotificationsStack.ts
const ssmPolicyStatement = new PolicyStatement({
sid: "parameterStoreAccess",
effect: Effect.ALLOW,
actions: ["ssm:GetParameter"],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter${this.ParameterName}`,
],
});

Later, we'll use the AWS Parameter and Secrets Lambda extension in our Lambda function to access this parameter.

Next, we'll need an Eventbridge rule. In AWS, an 'event' indicates a change in an AWS environment. This could be anything from an EC2 instance changing state from 'pending' to 'running', to a Cloudtrail event whenever you make an API call. These events are by default routed through an internal event bus, upon which you can create an Eventbridge rule. All events that flow through this event bus will then be evaluated against each Eventbridge rule that you create. In this example, we'll set our rule to trigger for findings with a severity of 'MEDIUM', 'HIGH', or 'CRITICAL'.

lib/securityHubNotificationsStack.ts
const securityHubRule = new Rule(this, "securityhubRule", {
eventPattern: {
source: ["aws.securityhub"],
detailType: ["Security Hub Findings - Imported"],
detail: {
findings: {
RecordState: ["ACTIVE"],
Severity: {
Label: ["MEDIUM", "HIGH", "CRITICAL"],
},
Workflow: {
Status: ["NEW"],
},
},
},
},
});

We will also need an SNS Topic to send our event to when it triggers the rule. By default, the Eventbridge rule will send a JSON object containing all the details of the event to a destination that you specify. An SNS Topic can be thought of as a fan-out messaging service that acts as a kind of reverse funnel. You can send input to the topic from a source, and multiple different sources can subscribe to the topic. The Topic forwards on the messages you send to it to any of it's subscribers.

lib/securityHubNotificationsStack.ts
const snsTopic = new Topic(this, 'slackbotSNSTopic', {
topicName: 'slackbotSNSTopic'
});

Add the Topic as a destination for the EventBridge Rule we created:

lib/securityHubNotificationsStack.ts
securityHubRule.addTarget(new SnsTopic(snsTopic));

Now, we will need an SQS queue that subscribes to the SNS Topic, and queues any messages events come from the Topic. At this point, we could actually negate the queue and trigger the Lambda function directly from the Topic. However, a queue is a much better option because if for any reason the Lambda function errors or the Slack endpoint is unavailable, the failed messages will become visible again in the queue and either retried, or sent to a dead letter queue. An SNS Topic on the other hand has no memory so any failed messages would be lost, meaning you could potentially not be notified for these Security Hub findings.

lib/securityHubNotificationsStack.ts
const queue = new Queue(this, 'slackSQSQueue', {
visibilityTimeout: Duration.seconds(60)
});

The visibility timeout setting defines how long a message should be invisibile to the message procesors of the queue, after it has first been processed. If the processor successfully completes processing within this time period, the message will be deleted, however if the processor cannot complete processing within this time the message is readded to the queue. AWS documentation states it is best practice for the visibility timeout of the queue should be sent to at least six times as long as the Lambda function timeout, plus the value of 'MaximumBatchingWindowInSeconds'.

Next, add the queue as a subscriber to the SNS Topic. the rawMessageDelivery field configures SNS to send the event as raw text rather than as JSON. The Security Hub findings included in the event will still be a JSON object, but this removes one layer of JSON parsing for us to worry about.

lib/securityHubNotificationsStack.ts
snsTopic.addSubscription(
new SqsSubscription(queue, {
rawMessageDelivery: true,
})
);

Now that we have our queue set up, we can create our Lambda function. We need our Lambda function to perform a few simple tasks; it should assemble text for the notification that it receives from the SQS queue, fetch the value of the Slack webhook parameter that we stored in AWS Parameter Store, and make the POST Axios call that sends the notification text to the webhook. We will use the NodeJSFunction CDK Construct, that by default sets up most of the IAM permissions you need when you configure the function.

Make sure to replace the ARN of the layer version with the correct ARN for your region.

lib/securityHubNotificationsStack.ts
const slackLambda = new NodejsFunction(this, "function", {
entry: path.join(__dirname, "code", "index.ts"),
handler: "handler",
functionName: "Slack-Notifier",
runtime: Runtime.NODEJS_18_X,
timeout: Duration.seconds(10),
retryAttempts: 1,
layers: [
LayerVersion.fromLayerVersionAttributes(this, "LayerVersion", {
layerVersionArn:
"arn:aws:lambda:eu-west-2::layer:AWS-Parameters-and-Secrets-Lambda-Extension:2",
}),
],
environment: {
SLACK_INCOMING_WEBHOOK_URL_PARAMETER_NAME:
this.slackWebhookUrlParameterName,
},
});

We can store the code for our Lambda function itself in an index.js file at the lib/code directory:

lib/code/index.ts
import axios from "axios";
import { SQSEvent } from "aws-lambda";

export async function getWebhookUrl(): Promise<string> {
const parameterName = process.env.SLACK_INCOMING_WEBHOOK_URL_PARAMETER_NAME;
const sessionToken = process.env.AWS_SESSION_TOKEN;
const port = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT || "2773";

const response = await axios({
method: "get",
url: `http://localhost:${port}/systemsmanager/parameters/get`,
headers: {
"X-Aws-Parameters-Secrets-Token": sessionToken,
},
params: {
name: parameterName,
withDecryption: "true",
},
});
return response.data.Parameter.Value;
}

export function composeText(event: SQSEvent) {
const message = JSON.parse(event.Records[0].body);

if (message["detail-type"] === "Security Hub Findings - Imported") {
const resources = JSON.stringify(message.detail.findings[0].Resources);
const text = `*New Security Hub finding:*
*Type:* \`${message.detail.findings[0].Types[0]}\`.
*Severity:* \`${message.detail.findings[0].FindingProviderFields?.Severity?.Label}\`.
*Description:* \`${message.detail.findings[0].Description}\`.
*Resource:* \`${resources}\`.`;
return text;
}

throw new Error(`Unknown detail type: ${message["detail-type"]}`);
}

export async function postAxios(url: string, text: string) {
await axios.post(url, { text: text });
}

export async function handler(event: SQSEvent) {
const text = composeText(event);
const url = await getWebhookUrl();
await postAxios(url, text);
}

This function is using the AWS Parameters and Secrets Lambda Extension I mentioned earlier to access our webhook parameter, in the getSlackIncomingWebhookUrl function.

Now we can add the Policy Statement we created earlier to the role of this Lambda Function, and add the SQS Queue as a trigger for our Lambda function:

lib/securityHubNotificationsStack.ts
slackLambda.addToRolePolicy(ssmPolicyStatement);
slackLambda.addEventSource(new SqsEventSource(queue));

We will also implement a dead letter queue to store any messages from our original SQS queue that the Lambda function failed to process, which could be due to an error on either the producer or consumer side. This is necessary because we need to make sure that we don't lose any notifications if the lambda fails to process them.

lib/securityHubNotificationsStack.ts
const deadLetterQueue = new Queue(this, "dlq", {
queueName: "SecurityHubSlackNotificationsDLQ",
});

Having failed messages sitting in the DLQ isn't much use if we aren't notified about them. Therefore, we will create a CloudWatch alarm that will trigger when any messages are added to the queue, and an SNS topic that we can send the alarm notification to.

lib/securityHubNotificationsStack.ts
const dlqAlarm = new Alarm(this, "dlqAlarm", {
alarmName: "SecurityHubSlackNotificationsDLQ",
metric: deadLetterQueue.metric("ApproximateNumberOfMessagesVisible"),
threshold: 1,
evaluationPeriods: 1,
});
lib/securityHubNotificationsStack.ts
const dlqNotificationTopic = new Topic(this, "dlqNotificationTopic", {
topicName: "dlq-notification-topic",
});
lib/securityHubNotificationsStack.ts
dlqAlarm.addAlarmAction(new SnsAction(dlqNotificationTopic));

You can then subscribe to this DLQ SNS Topic to receive the notifications, for example via email:

lib/securityHubNotificationsStack.ts
dlqNotificationTopic.addSubscription(new aws_sns_subscriptions.EmailSubscription('<YOUR_EMAIL>'));

Finally, let's instantiate our Stack in our app.ts file in the bin directory:

bin/app.ts
import { securityHubNotificationsStack } from '../lib/securityHubNotificationsStack';

new securityHubNotificationsStack(app, 'SlackbotProjectStack', {
env: {account: 'YOUR_ACCOUNT', region: '<YOUR_REGION>'}
});

Now, you should be able to run cdk deploy to deploy the stack to your AWS account, and start receiving Security Hub notifications.

I hope you found this tutorial useful!