Enforcing Compliance Rules and Auto-Remediation with Aspects in CDK

cdk-logo.png Working with the AWS CDK allows you to fix missing security rules automatically and allows you to implement your own validation rules. In this article I will explain some of the techniques you can use. Working with aspects is actually something I figured out last year when I started with AWS CDK and went through the documentation, I noticed an article where AWS wrote something about Aspects.

Apps, constructs, nodes, and the construct tree

A little theory with examples, explaining building the app and the constructs tree we need to “visit” our nodes - it’s based on the Typescript language.

The cdk app:

The cdk app represents the entry point for the entire cdk application. The cdk application is composed of one or more Stacks.

CDK app example:

const app = new cdk.App();
new Stack(scope: app, id: 'cross-account', props: {
   stackName: 'cross-account-pipeline-stack',
   description: 'pipeline stack to build a cross account iam role',
   env: envEuWestOne,
});

Defining constructs:

CDK gets compiled down to Cloudformation, so we have to define our constructs within the scope of a Stack. We define a construct by instantiating the class. The constructs take three parameters when they are initialized: scope: the construct's parent, which determines its place in the construct tree. Id: must be unique within this scope and is used to generate unique identifiers. props: set of properties that define the construct's configuration.

Construct example:

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

new aws_iam.Role(scope: this, id: 'cross-account-role', props: {
           roleName: 'CloudWatch-CrossAccountSharingRole',
           path: "/",
           maxSessionDuration: Duration.hours(1)})

Building the construct tree:

A construct represents a cloud component. We define constructs inside other constructs using the scope argument passed to every construct, as seen above. This way, an AWS CDK app defines a hierarchy of constructs known as the construct tree. The construct tree's root is your app; within the app, you instantiate one or more stacks. Within stacks, you instantiate constructs.

Constructs tree example:

{
 "version": "tree-0.1",
 "tree": {
   "id": "App",
   "path": "",
   "children": {
     "Tree": {
       "id": "Tree",
       "path": "Tree",
       "constructInfo": {
         "fqn": "constructs.Construct",
         "version": "10.1.76"
       }
     },
     "cross-account-pipeline-stack": {
       "id": "cross-account-pipeline-stack",
       "path": "cross-account-pipeline-stack",
       "children": {
         "pipeline": {
           "id": "pipeline",
           "path": "cross-account-pipeline-stack/pipeline",
           "children": {
             "Pipeline": {
               "id": "Pipeline",
               "path": "cross-account-pipeline-stack/pipeline/Pipeline",
               "children": {
           "ArtifactsBucket": {
            "id": "ArtifactsBucket",
            "path": "stack/pipeline/Pipeline/ArtifactsBucket",
            "children": {
                  "Resource": {
              "id": "Resource",
              "Path": "stack/pipeline/Pipeline/ArtifactsBucket/Resource",
                "attributes": {
                  "aws:cdk:cloudformation:type": "AWS::S3::Bucket",
                    "aws:cdk:cloudformation:props": {
                      "bucketEncryption": {
                        "serverSideEncryptionConfiguration": [
                         {
                          "serverSideEncryptionByDefault": {
                          "sseAlgorithm": "aws:kms" }}]},

Nodes:

A node is an element in the construct tree, a node attribute is a reference to the node representing that construct in the tree, and each node is a node instance. Through a node’s instance attribute, it is possible to access the construct tree root, to the node's parent scopes and to the node’s children.

Node elements in the construct tree:

  • node.children: the direct children of the construct.
  • node.id: the identifier of the construct within its scope.
  • node.path: the full path of the construct including the IDs of all of its parents.
  • node.root: the root of the construct tree (the app).
  • node.scope: the scope (parent) of the construct, or undefined if the node is the root.
  • node.scopes: all parents of the construct, up to the root.
  • node.uniqueId: the unique alphanumeric identifier for this construct within the tree.

App lifecycle, aspects and the prepare phase

We have to understand the app lifecycle, so that we know the phase where we can change the attributes of our constructs before synthesizing takes place.

The cdk app lifecycle: construct, prepare, validate, synth, deploy.

The final phase for changing the attributes of our constructs is “prepare”. This is where the final modification round of the constructs takes place before they get their final state. When we call the aspects method, constructs will add the custom aspects to the list of internal aspects. When the cdk app goes through the prepare phase, cdk calls the visit method of the object for the constructs and all of its children in top-down order, and the visitor method is free to change anything! in the construct. Compliance and auto-remediation So why should we use this logic, and how can we implement it in our CDK app? When using CDK, you should be aware that resources are deployed by cdk itself that were not compliant, for instance, no versioning on the buckets, no policies on the buckets, no deletion policy on the resources, no tagging on the resources, etc. One of the reasons for that is that when working with CDK constructs, a lot of resources are deployed by the cdk itself, and they are not always compliant. After reading the documentation, I thought that by using Aspects, we might be able to tackle this problem. It might also prevent potential drift status because altering the state of your resources outside the template causes drift, which we want to eliminate.

Based on this theory, there are a few interesting points I want to mention. You can access and alter your nodes before deployment using the visitor pattern. By setting a message priority, you can even prevent the deployment of your resources. Based on your compliance level, you can set rules and apply them to your stack resources We can add attributes to our nodes to make them compliant. Examples with rules, warnings and auto-remediation

If you take a look at the constructs tree you notice an s3 artifacts bucket there, it’s a resource created by the CDK itself, it has SSE applied to it, but not out of the box, we did this by using Aspects:

export class BucketCompliance implements IAspect {

   public visit(node: IConstruct): void {
       if (node instanceof CfnBucket) {const s3BucketId = node.logicalId;
       if (!node.bucketEncryption) {
       Annotations.of(node).addWarning(`
       [The (${s3BucketId}-bucket) has NO encryption enabled!]`);

       Annotations.of(node).addInfo(`
       [For now, we apply the S3_MANAGED/AES256 encryption to your
       (${s3BucketId}-bucket) automatically.]`);

       node.bucketEncryption = {
       serverSideEncryptionConfiguration: [
       {serverSideEncryptionByDefault: {
       sseAlgorithm: 'AES256'}}]};}

I will explain in detail what has been done did here:

Instantiate a compliance class that can visit the constructs. If a node in the constructs tree is an S3 Bucket, get his logical id. If the node does not have encryption, output a warning message to the console. We use the logical id we fetched earlier to define which bucket is affected. We also added an informational message with add.Info. If the node does not have encryption enabled, we add it to the node.

Some more examples:

Check if the node has tags, and if not, add the tags to the node:

if (!node.tags?.hasTags()) {Annotations.of(node).addWarning(`
[The (${s3BucketId}-bucket) has no tagging!]`);
Annotations.of(node).addInfo(`
[We will apply tagging to the (${s3BucketId}-bucket) automatic.]`);

if ([stack.stackName.match, 'prod','A-Z','a-z']) {
node.tags.setTag('stage', 'prod', 100, true);
node.tags.setTag('application', 'app', 100, true);
node.tags.setTag('squad', 'cest', 100, true);
node.tags.setTag('infrastructure', 'cdk', 100, true);
}
return 
node.tags.setTag('stage', 'prod'), 
node.tags.setTag('application', 'app'), 
node.tags.setTag('squad', 'cest'), node.tags.setTag('infrastructure', 'cdk'), 100, true);}

**Another tag check variant, check if the node has required tags, and if not set an error:**

public visit(node: IConstruct): void {
if (!(node instanceof Stack)) return;

this.requiredTags.forEach((tag) => {
if (!Object.keys(node.tags.tagValues()).includes(tag)) {

Annotations.of(node).addError(`
Missing required tag "${tag}" on stack with id "${node.stackName}".`);}});}

Check if the node has a retention period set, apply a retention period of 5 days to the node:

if (node instanceof CfnLogGroup) {
if (!node.retentionInDays) {
const logicalId = node.logicalId;

Annotations.of(node).addInfo(`[applying retention period to the 
(${ logicalId }-loggroup) without a retention period!]`);

node.retentionInDays = RetentionDays.FIVE_DAYS;

Check if the node has a security issue and output a warning message:

if (node instanceof CfnSecurityGroup) {
checkRules(Stack.of(node).
resolve(node.securityGroupIngress));
}
function checkRules (rules: Array<IngressProperty>) {
if (rules) {
for (const rule of rules.values()) {
if (!Tokenization.isResolvable(rule) && (rule.cidrIp == '0.0.0.0/0' || rule.cidrIp == '::/0')) {

Annotations.of(node).addWarning(`
'The Security Group ingress rule allows'
'unrestricted ingress (inbound traffic) from the public internet!'
'change this if the resource has a public ip address!`);

We can define three message levels:

  • add.error: red text, stack/resources will not be deployed; you first have to fix this.
  • add.warning: yellow text, you might want to fix it, so read carefully.
  • add.info: white text, informational messages about the state of your resources.

By creating your own rules you can set the message levels accordingly.

In the next CDK article I will try to explain how to automatically check your package versions for updates and set a feature flag option to auto upgrade your out-of-date package, because after all, we want to be evergreen!

Documentation: