diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..d0b5a17 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,16 @@ +name: shellcheck +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] +jobs: + Run-Pre-Commit: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v2 + - name: Install shellcheck + run: sudo apt-get install shellcheck + - name: Run shellcheck + run: shellcheck assets/*.sh diff --git a/lib/build-image-data.ts b/lib/build-image-data.ts index 1c2bb0f..fea7ada 100644 --- a/lib/build-image-data.ts +++ b/lib/build-image-data.ts @@ -40,6 +40,7 @@ export class BuildImageDataStack extends cdk.Stack { versioned: true, removalPolicy: cdk.RemovalPolicy.DESTROY, autoDeleteObjects: true, + enforceSSL: true, }); const dataBucketDeploymentRole = new iam.Role( diff --git a/lib/network.ts b/lib/network.ts index 1e7c0ce..ad09680 100644 --- a/lib/network.ts +++ b/lib/network.ts @@ -1,6 +1,7 @@ import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; /** * The network resources to run the pipeline in. @@ -18,5 +19,14 @@ export class PipelineNetworkStack extends cdk.Stack { this.vpc = new ec2.Vpc(this, 'PipelineVpc', { ipAddresses: ec2.IpAddresses.cidr('10.0.0.0/16'), }); + + new ec2.FlowLog(this, 'VPCFlowLogs', { + resourceType: ec2.FlowLogResourceType.fromVpc(this.vpc), + destination: ec2.FlowLogDestination.toCloudWatchLogs( + new LogGroup(this, 'LogGroup', { + retention: RetentionDays.ONE_YEAR, + }) + ), + }); } } diff --git a/package-lock.json b/package-lock.json index 1374618..eb079e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@typescript-eslint/eslint-plugin": "^5.59.6", "@typescript-eslint/parser": "^5.59.6", "aws-cdk-lib": "2.86.0", + "cdk-nag": "^2.27.131", "eslint": "^8.40.0", "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", @@ -2333,6 +2334,16 @@ } ] }, + "node_modules/cdk-nag": { + "version": "2.27.131", + "resolved": "https://registry.npmjs.org/cdk-nag/-/cdk-nag-2.27.131.tgz", + "integrity": "sha512-ulUJbu2RNsjzYNBVZRgdt0Kr8TA+GMoDqJZgCmgsdk23rEDxyB5i+SD/lWQekjvLtQic8L1KCWFBYDxMSi446A==", + "dev": true, + "peerDependencies": { + "aws-cdk-lib": "^2.78.0", + "constructs": "^10.0.5" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index a710a66..3e927b4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "ts-jest": "^29.1.0", "ts-node": "^10.9.1", "typedoc": "^0.24.8", - "typescript": "~5.0.4" + "typescript": "~5.0.4", + "cdk-nag": "^2.27.131" }, "peerDependencies": { "aws-cdk-lib": "2.86.0", diff --git a/test/__snapshots__/build-image-data.test.ts.snap b/test/__snapshots__/build-image-data.test.ts.snap index 06d0904..2468e67 100644 --- a/test/__snapshots__/build-image-data.test.ts.snap +++ b/test/__snapshots__/build-image-data.test.ts.snap @@ -214,6 +214,40 @@ exports[`Build Image Data Snapshot 1`] = ` }, "PolicyDocument": { "Statement": [ + { + "Action": "s3:*", + "Condition": { + "Bool": { + "aws:SecureTransport": "false", + }, + }, + "Effect": "Deny", + "Principal": { + "AWS": "*", + }, + "Resource": [ + { + "Fn::GetAtt": [ + "BuildImageDataBucketE6A8BC04", + "Arn", + ], + }, + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "BuildImageDataBucketE6A8BC04", + "Arn", + ], + }, + "/*", + ], + ], + }, + ], + }, { "Action": [ "s3:GetBucket*", diff --git a/test/__snapshots__/network.test.ts.snap b/test/__snapshots__/network.test.ts.snap index a4ca466..2a41c0d 100644 --- a/test/__snapshots__/network.test.ts.snap +++ b/test/__snapshots__/network.test.ts.snap @@ -10,6 +10,14 @@ exports[`Pipeline Networking Snapshot 1`] = ` }, }, "Resources": { + "LogGroupF5B46931": { + "DeletionPolicy": "Retain", + "Properties": { + "RetentionInDays": 365, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, "PipelineVpc0543904A": { "Properties": { "CidrBlock": "10.0.0.0/16", @@ -536,6 +544,83 @@ exports[`Pipeline Networking Snapshot 1`] = ` }, "Type": "AWS::EC2::VPCGatewayAttachment", }, + "VPCFlowLogsFlowLogD2BDB2A5": { + "Properties": { + "DeliverLogsPermissionArn": { + "Fn::GetAtt": [ + "VPCFlowLogsIAMRoleFF7B3C14", + "Arn", + ], + }, + "LogDestinationType": "cloud-watch-logs", + "LogGroupName": { + "Ref": "LogGroupF5B46931", + }, + "ResourceId": { + "Ref": "PipelineVpc0543904A", + }, + "ResourceType": "VPC", + "TrafficType": "ALL", + }, + "Type": "AWS::EC2::FlowLog", + }, + "VPCFlowLogsIAMRoleDefaultPolicy0D9292CF": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:DescribeLogStreams", + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "LogGroupF5B46931", + "Arn", + ], + }, + }, + { + "Action": "iam:PassRole", + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "VPCFlowLogsIAMRoleFF7B3C14", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "VPCFlowLogsIAMRoleDefaultPolicy0D9292CF", + "Roles": [ + { + "Ref": "VPCFlowLogsIAMRoleFF7B3C14", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "VPCFlowLogsIAMRoleFF7B3C14": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "vpc-flow-logs.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + }, + "Type": "AWS::IAM::Role", + }, }, "Rules": { "CheckBootstrapVersion": { diff --git a/test/build-image-data-nag.test.ts b/test/build-image-data-nag.test.ts new file mode 100644 index 0000000..1632200 --- /dev/null +++ b/test/build-image-data-nag.test.ts @@ -0,0 +1,76 @@ +import * as cdk from 'aws-cdk-lib'; +import { BuildImageDataStack } from '../lib/build-image-data'; + +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag'; + +describe('BuildImageDataStack cdk-nag AwsSolutions Pack', () => { + let stack: Stack; + let app: App; + + beforeAll(() => { + // GIVEN + app = new App(); + const props = { + bucketName: 'test-bucket', + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + env: { account: '111111111111', region: 'eu-central-1' }, + }; + stack = new BuildImageDataStack(app, 'MyTestStack', props); + + NagSuppressions.addStackSuppressions(stack, [ + { + id: 'AwsSolutions-IAM4', + reason: 'TODO: Re-evaluate managed policies per resources.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'TODO: Re-evaluate "*" per resources.', + }, + ]); + + NagSuppressions.addResourceSuppressionsByPath( + stack, + '/MyTestStack/Custom::CDKBucketDeployment8693BB64968944B69AAFB0CC9EB8756C/Resource', + [ + { + id: 'AwsSolutions-L1', + reason: 'This Lambda function is 3rd Party (from CDK libs)', + }, + ] + ); + + NagSuppressions.addResourceSuppressionsByPath( + stack, + '/MyTestStack/BuildImageDataBucket/Resource', + [ + { + id: 'AwsSolutions-S1', + reason: 'TODO: Add Access Logging', + }, + ] + ); + + // WHEN + Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true })); + }); + + // THEN + test('No unsuppressed Warnings', () => { + const warnings = Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(warnings).toHaveLength(0); + }); + + test('No unsuppressed Errors', () => { + const errors = Annotations.fromStack(stack).findError( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(errors).toHaveLength(0); + }); +}); diff --git a/test/build-image-pipeline-nag.test.ts b/test/build-image-pipeline-nag.test.ts new file mode 100644 index 0000000..97b4a1e --- /dev/null +++ b/test/build-image-pipeline-nag.test.ts @@ -0,0 +1,73 @@ +import * as cdk from 'aws-cdk-lib'; +import { + BuildImagePipelineStack, + ImageKind, +} from '../lib/build-image-pipeline'; +import { Repository } from 'aws-cdk-lib/aws-ecr'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; + +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag'; + +describe('BuildImagePipelineStack cdk-nag AwsSolutions Pack', () => { + let stack: Stack; + let app: App; + + beforeAll(() => { + // GIVEN + const env = { account: '111111111111', region: 'eu-central-1' }; + app = new cdk.App(); + const repoStack = new cdk.Stack(app, 'RepoStack', { env }); + const repository = new Repository(repoStack, 'Repository', {}); + const dataBucket = new Bucket(repoStack, 'Bucket', {}); + + const props = { + env, + imageKind: ImageKind.Ubuntu22_04, + repository, + dataBucket, + }; + + stack = new BuildImagePipelineStack(repoStack, 'MyTestStack', props); + NagSuppressions.addStackSuppressions(stack, [ + { + id: 'AwsSolutions-CB3', + reason: 'Privilege Mode Required To Build Docker Containers.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'TODO: Re-evaluate "*" per resources.', + }, + { + id: 'AwsSolutions-S1', + reason: 'TODO: Re-evaluate bucket access logging.', + }, + { + id: 'AwsSolutions-KMS5', + reason: 'TODO: Re-evaluate key rotation.', + }, + ]); + // WHEN + Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true })); + }); + + // THEN + test('No unsuppressed Warnings', () => { + const warnings = Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + + expect(warnings).toHaveLength(0); + }); + + test('No unsuppressed Errors', () => { + const errors = Annotations.fromStack(stack).findError( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + + expect(errors).toHaveLength(0); + }); +}); diff --git a/test/build-image-repo-nag.test.ts b/test/build-image-repo-nag.test.ts new file mode 100644 index 0000000..0cd9ef8 --- /dev/null +++ b/test/build-image-repo-nag.test.ts @@ -0,0 +1,36 @@ +import { BuildImageRepoStack } from '../lib/build-image-repo'; +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { AwsSolutionsChecks } from 'cdk-nag'; + +describe('Build Image Repository cdk-nag AwsSolutions Pack', () => { + const props = { + env: { account: '111111111111', region: 'eu-central-1' }, + }; + let stack: Stack; + let app: App; + + beforeAll(() => { + // GIVEN + app = new App(); + stack = new BuildImageRepoStack(app, 'MyTestStack', props); + // WHEN + Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true })); + }); + // THEN + test('No unsuppressed Warnings', () => { + const warnings = Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(warnings).toHaveLength(0); + }); + + test('No unsuppressed Errors', () => { + const errors = Annotations.fromStack(stack).findError( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(errors).toHaveLength(0); + }); +}); diff --git a/test/demo-pipeline-nag.test.ts b/test/demo-pipeline-nag.test.ts new file mode 100644 index 0000000..3c3dc75 --- /dev/null +++ b/test/demo-pipeline-nag.test.ts @@ -0,0 +1,78 @@ +import { DemoPipelineStack } from '../lib/demo-pipeline'; +import { Repository } from 'aws-cdk-lib/aws-ecr'; +import { Vpc } from 'aws-cdk-lib/aws-ec2'; + +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag'; +import { ProjectKind } from '../lib'; + +describe('Demo pipeline cdk-nag AwsSolutions Pack', () => { + let stack: Stack; + let app: App; + let vpc: Vpc; + let imageRepo: Repository; + let newStack: Stack; + beforeAll(() => { + // GIVEN + app = new App(); + const env = { account: '12341234', region: 'eu-central-1' }; + newStack = new Stack(app, 'RepoStack', { env }); + imageRepo = new Repository(newStack, 'Repository', {}); + vpc = new Vpc(newStack, 'Bucket', {}); + + stack = new DemoPipelineStack(app, 'MyTestStack', { + env, + imageRepo, + vpc, + projectKind: ProjectKind.PokyAmi, + }); + NagSuppressions.addStackSuppressions(stack, [ + { + id: 'CdkNagValidationFailure', + reason: 'Multiple Validation Failures.', + }, + { + id: 'AwsSolutions-CB3', + reason: 'TODO: Verify CodeBuild Privilege mode is required here.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'TODO: Re-evaluate "*" per resources.', + }, + { + id: 'AwsSolutions-IAM4', + reason: 'TODO: Re-evaluate managed policies per resources.', + }, + { + id: 'AwsSolutions-KMS5', + reason: 'TODO: Re-evaluate key rotation.', + }, + { + id: 'AwsSolutions-S1', + reason: 'TODO: Re-evaluate bucket access logging.', + }, + ]); + // WHEN + Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true })); + }); + + // THEN + test('No unsuppressed Warnings', () => { + const warnings = Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + + expect(warnings).toHaveLength(0); + }); + + test('No unsuppressed Errors', () => { + const errors = Annotations.fromStack(stack).findError( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + + expect(errors).toHaveLength(0); + }); +}); diff --git a/test/network.test-nag.test.ts b/test/network.test-nag.test.ts new file mode 100644 index 0000000..20bda05 --- /dev/null +++ b/test/network.test-nag.test.ts @@ -0,0 +1,39 @@ +import { PipelineNetworkStack } from '../lib/network'; +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { AwsSolutionsChecks } from 'cdk-nag'; + +describe('Pipeline Networking cdk-nag AwsSolutions Pack', () => { + const props = { + env: { account: '111111111111', region: 'eu-central-1' }, + }; + let stack: Stack; + let app: App; + // In this case we can use beforeAll() over beforeEach() since our tests + // do not modify the state of the application + beforeAll(() => { + // GIVEN + app = new App(); + stack = new PipelineNetworkStack(app, props); + + // WHEN + Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true })); + }); + + // THEN + test('No unsuppressed Warnings', () => { + const warnings = Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(warnings).toHaveLength(0); + }); + + test('No unsuppressed Errors', () => { + const errors = Annotations.fromStack(stack).findError( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(errors).toHaveLength(0); + }); +}); diff --git a/test/source-repo-nag.test.ts b/test/source-repo-nag.test.ts new file mode 100644 index 0000000..7e6ecd0 --- /dev/null +++ b/test/source-repo-nag.test.ts @@ -0,0 +1,43 @@ +import { SourceRepo, ProjectKind } from '../lib/constructs/source-repo'; + +import { Annotations, Match } from 'aws-cdk-lib/assertions'; +import { App, Aspects, Stack } from 'aws-cdk-lib'; +import { AwsSolutionsChecks } from 'cdk-nag'; + +describe('Demo Source Repository cdk-nag AwsSolutions Pack', () => { + let stack: Stack; + let app: App; + + beforeAll(() => { + // GIVEN + const props = { + env: { account: '12341234', region: 'eu-central-1' }, + kind: ProjectKind.Poky, + repoName: 'charlie', + }; + + app = new App(); + stack = new Stack(app, 'TestStack', props); + new SourceRepo(stack, 'MyTestStack', props); + + // WHEN + Aspects.of(stack).add(new AwsSolutionsChecks({ verbose: true })); + }); + + // THEN + test('No unsuppressed Warnings', () => { + const warnings = Annotations.fromStack(stack).findWarning( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(warnings).toHaveLength(0); + }); + + test('No unsuppressed Errors', () => { + const errors = Annotations.fromStack(stack).findError( + '*', + Match.stringLikeRegexp('AwsSolutions-.*') + ); + expect(errors).toHaveLength(0); + }); +});