├── .gitignore ├── spec ├── spec_helper.rb ├── test_templates │ └── json_templates.zip ├── .rubocop.yml ├── plain_text_results_spec.rb ├── e2e │ ├── pipeline_invoked_nag_spec.rb │ ├── code_pipeline_using_nag.yml │ └── e2e_role.yml ├── zip_util_spec.rb ├── plain_text_summary_spec.rb ├── code_pipeline_invoker_spec.rb ├── factory.rb ├── s3_util_spec.rb └── code_pipeline_util_spec.rb ├── Gemfile ├── lib ├── clients.rb ├── handler.rb ├── plain_text_summary.rb ├── zip_util.rb ├── s3_util.rb ├── plain_text_results.rb ├── code_pipeline_util.rb └── code_pipeline_invoker.rb ├── LICENSE.md ├── infra └── sar_deploy_role.yml ├── lambda.yml ├── README.md ├── .github └── workflows │ └── pipeline.yml └── Rakefile /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .bundle/ 3 | target 4 | vendor 5 | .idea 6 | Gemfile.lock 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start do 3 | add_filter 'spec/' 4 | end 5 | require 'rspec' -------------------------------------------------------------------------------- /spec/test_templates/json_templates.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stelligent/cfn-nag-pipeline/HEAD/spec/test_templates/json_templates.zip -------------------------------------------------------------------------------- /spec/.rubocop.yml: -------------------------------------------------------------------------------- 1 | # specs tend to have long blocks 2 | Metrics/BlockLength: 3 | Max: 250 4 | Naming/FileName: 5 | Enabled: false 6 | Layout/IndentHeredoc: 7 | Enabled: false 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'cfn-nag' 4 | gem 'rubyzip' 5 | 6 | group :test do 7 | gem 'rspec' 8 | gem 'simplecov' 9 | gem 'aws-sdk-codepipeline' 10 | gem 'aws-sdk-s3' 11 | end 12 | -------------------------------------------------------------------------------- /lib/clients.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-s3' 2 | require 'aws-sdk-codepipeline' 3 | 4 | # CodePipeline Client container 5 | module Clients 6 | def codepipeline 7 | Aws::CodePipeline::Client.new 8 | end 9 | 10 | def s3(region: nil) 11 | if region.nil? || region.empty? 12 | Aws::S3::Client.new 13 | else 14 | Aws::S3::Client.new(region: region) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/handler.rb: -------------------------------------------------------------------------------- 1 | require_relative 'code_pipeline_invoker' 2 | 3 | def handler(event:, context:) 4 | code_pipeline_job = event['CodePipeline.job'] 5 | 6 | if code_pipeline_job.nil? 7 | raise "CodePipeline.job not found in #{event}" 8 | end 9 | 10 | CodePipelineInvoker.new( 11 | code_pipeline_job, 12 | context.aws_request_id, 13 | ENV['RULE_BUCKET_NAME'], 14 | ENV['RULE_BUCKET_PREFIX'] 15 | ).audit 16 | end -------------------------------------------------------------------------------- /lib/plain_text_summary.rb: -------------------------------------------------------------------------------- 1 | require 'cfn-nag/violation' 2 | 3 | # Plaintext summary of failures/warnings 4 | class PlainTextSummary 5 | def render(audit_results) 6 | @results = '' 7 | warning_count = fail_count = 0 8 | audit_results.each do |audit_result| 9 | violations = audit_result[:audit_result][:violations] 10 | fail_count += Violation.count_failures violations 11 | warning_count += Violation.count_warnings violations 12 | end 13 | @results += "Failures count: #{fail_count}#{nl}" 14 | @results += "Warnings count: #{warning_count}#{nl}" 15 | @results 16 | end 17 | 18 | private 19 | 20 | def nl 21 | "\n" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/zip_util.rb: -------------------------------------------------------------------------------- 1 | require 'zip' 2 | 3 | # Utility methods for dealing with zipfiles 4 | class ZipUtil 5 | def self.read_files_from_zip(zip_file_path, file_path_within_zip) 6 | file_contents = [] 7 | 8 | raise "#{zip_file_path} not found" unless File.exist? zip_file_path 9 | 10 | Zip::File.open(zip_file_path) do |zip_file| 11 | entries = check_zip_entries zip_file, file_path_within_zip 12 | 13 | entries.each do |entry| 14 | file_contents << { name: entry.name, 15 | contents: entry.get_input_stream.read } 16 | end 17 | end 18 | file_contents 19 | end 20 | 21 | private 22 | 23 | def self.check_zip_entries(zip_file, file_path_within_zip) 24 | entries = zip_file.glob(file_path_within_zip) 25 | if entries.empty? 26 | raise "#{file_path_within_zip} not found in #{zip_file.entries}" 27 | end 28 | entries 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-2021 Stelligent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /spec/plain_text_results_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'json' 3 | require 'plain_text_results' 4 | 5 | describe PlainTextResults do 6 | context 'some results' do 7 | it 'emits a string' do 8 | audit_results = [ 9 | { 10 | name: 'templates/fred1.json', 11 | audit_result: { 12 | failure_count: 1, 13 | violations: [ 14 | Violation.new( 15 | id: 'F1', type: Violation::FAILING_VIOLATION, 16 | message: 17 | 'EBS volume should have server-side encryption enabled', 18 | logical_resource_ids: %w[NewVolume1 NewVolume2] 19 | ) 20 | ] 21 | } 22 | }, 23 | { 24 | name: 'templates/fred2.json', 25 | audit_result: { 26 | failure_count: 0, 27 | violations: [] 28 | } 29 | } 30 | ] 31 | 32 | plain_text_audit_results = PlainTextResults.new.render audit_results 33 | puts plain_text_audit_results 34 | expect(plain_text_audit_results.class).to eq String 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/e2e/pipeline_invoked_nag_spec.rb: -------------------------------------------------------------------------------- 1 | describe 'Pipeline Invocation', :e2e do 2 | context 'Pipeline running with execution id' do 3 | it 'returns a failure count' do 4 | pipeline_name = ENV['pipeline_name'] 5 | execution_id = ENV['execution_id'] 6 | puts "Execution id: #{execution_id}" 7 | 8 | execution_id = execution_id.chomp if execution_id 9 | begin 10 | status = `aws codepipeline get-pipeline-execution --pipeline-name #{pipeline_name} --output text --pipeline-execution-id #{execution_id} --query pipelineExecution.status`.chomp 11 | puts "Status: #{status}" 12 | sleep 15 13 | end until status != 'InProgress' 14 | 15 | jmespath_to_cfn_nag_action = 'stageStates[?stageName == `Scan`]|[0].actionStates|[0].latestExecution.errorDetails.message' 16 | actual_failure_message = `aws codepipeline get-pipeline-state --name #{pipeline_name} --output text --query '#{jmespath_to_cfn_nag_action}'`.chomp 17 | 18 | expected_failure_message = "Failures count: 5Warnings count: 5" 19 | 20 | expect(actual_failure_message.gsub("\n",'')).to eq(expected_failure_message) 21 | end 22 | end 23 | end -------------------------------------------------------------------------------- /lib/s3_util.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | require_relative 'clients' 3 | 4 | # S3 utility methods 5 | class S3Util 6 | include Clients 7 | 8 | ## 9 | # Stream the given S3 object to a file in /tmp/s3utilXXXXX 10 | # 11 | # @return the absolute path of temp file name (e.g. /tmp/s3utilXXXX) 12 | ## 13 | def retrieve_s3_object_content_to_tmp_file(bucket_name:, object_key:) 14 | bucket_region = bucket_region bucket_name 15 | 16 | tempfile = "/tmp/s3util#{Time.now.to_i}#{Time.now.to_i}" 17 | File.open(tempfile, 'wb') do |file| 18 | s3(region: bucket_region).get_object(bucket: bucket_name, 19 | key: object_key) do |chunk| 20 | file.write(chunk) 21 | end 22 | end 23 | 24 | tempfile 25 | end 26 | 27 | ## 28 | # Given an s3 bucket name, return the region/location for the bucket's storage 29 | ## 30 | def bucket_region(bucket_name) 31 | get_bucket_location_response = s3.get_bucket_location bucket: bucket_name 32 | bucket_region = get_bucket_location_response.location_constraint 33 | bucket_region = bucket_region == '' ? 'us-east-1' : bucket_region 34 | bucket_region == 'EU' ? 'eu-west-1' : bucket_region 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/zip_util_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'json' 3 | require 'zip_util' 4 | 5 | describe ZipUtil, :zip do 6 | context 'zip doesnt exist' do 7 | it 'raises an error' do 8 | expect do 9 | _ = ZipUtil.read_files_from_zip \ 10 | '/tmp/somethingiwillneverfind', 11 | 'spec/test_templates/json/ec2_volume/*.json' 12 | end.to raise_error '/tmp/somethingiwillneverfind not found' 13 | end 14 | end 15 | 16 | context 'file within zip doesnt exist' do 17 | it 'raises an error' do 18 | expect do 19 | _ = ZipUtil.read_files_from_zip \ 20 | 'spec/test_templates/json_templates.zip', 21 | '/wrong/*.json' 22 | end.to raise_error(%r{/wrong/\*\.json not found in .*}) 23 | end 24 | end 25 | 26 | context 'three files in zip that match glob' do 27 | it 'returns string content of files' do 28 | contents = ZipUtil.read_files_from_zip \ 29 | 'spec/test_templates/json_templates.zip', 30 | 'spec/test_templates/json/ec2_volume/*.json' 31 | 32 | expect(contents.size).to eq 4 33 | 34 | contents.each do |content| 35 | puts content[:name] 36 | # puts content[:contents] 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /infra/sar_deploy_role.yml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | TargetEnv: 3 | Type: String 4 | Default: prod 5 | ServerlessApplicationName: 6 | Type: String 7 | Default: cfn-nag-pipeline 8 | 9 | Resources: 10 | CfnNagSarServiceUser: 11 | Type: AWS::IAM::User 12 | 13 | SarDeployPolicy: 14 | Type: AWS::IAM::ManagedPolicy 15 | Properties: 16 | Users: 17 | - !Ref CfnNagSarServiceUser 18 | PolicyDocument: 19 | Version: 2012-10-17 20 | Statement: 21 | - 22 | Effect: Allow 23 | Action: 24 | - s3:CreateBucket 25 | - s3:HeadBucket 26 | Resource: '*' 27 | - 28 | Effect: Allow 29 | Action: 30 | - s3:PutBucketPolicy 31 | - s3:PutObject 32 | - s3:PutBucketVersioning 33 | Resource: 34 | - !Sub arn:aws:s3:::cfn-nag-pipeline-${TargetEnv}-${AWS::Region} 35 | - !Sub 'arn:aws:s3:::cfn-nag-pipeline-${TargetEnv}-${AWS::Region}/*' 36 | 37 | - 38 | Effect: Allow 39 | Action: 40 | - serverlessrepo:CreateApplication 41 | - serverlessrepo:GetApplication* 42 | Resource: '*' 43 | - 44 | Effect: Allow 45 | Action: 46 | - serverlessrepo:CreateApplicationVersion 47 | - serverlessrepo:PutApplicationPolicy 48 | Resource: 49 | - !Sub arn:aws:serverlessrepo:${AWS::Region}:${AWS::AccountId}:applications/${ServerlessApplicationName} 50 | -------------------------------------------------------------------------------- /lambda.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: Lambda function to run cfn_nag as a step in a pipeline 4 | Parameters: 5 | PipelineBucketName: 6 | Description: 'The bucket name to allow access to read artifacts from - the CodePipeline Artifact Store' 7 | Type: String 8 | RuleBucketName: 9 | Description: 'The bucket to load custom nag rules from' 10 | Type: String 11 | Default: '' 12 | RuleBucketPrefix: 13 | Description: 'The prefix in the bucket to load custom nag rules from' 14 | Type: String 15 | Default: '' 16 | 17 | Metadata: 18 | AWS::CloudFormation::Interface: 19 | ParameterLabels: 20 | PipelineBucketName: 21 | default: 'CodePipeline Artifact Bucket Name' 22 | RuleBucketName: 23 | default: 'Custom Rules Bucket Name' 24 | RuleBucketPrefix: 25 | default: 'Custom Rules Bucket Prefix' 26 | 27 | Conditions: 28 | NoCustomRules: !Equals [ !Ref RuleBucketName, '' ] 29 | 30 | Resources: 31 | CfnNagFunction: 32 | Type: AWS::Serverless::Function 33 | Properties: 34 | FunctionName: cfn-nag-pipeline 35 | Runtime: ruby2.7 36 | MemorySize: 1024 37 | Timeout: 300 38 | CodeUri: lib 39 | Handler: handler.handler 40 | Environment: 41 | Variables: 42 | RULE_BUCKET_NAME: !Ref RuleBucketName 43 | RULE_BUCKET_PREFIX: !Ref RuleBucketPrefix 44 | 45 | Policies: 46 | - CodePipelineLambdaExecutionPolicy: {} 47 | - S3ReadPolicy: 48 | BucketName: !Ref PipelineBucketName 49 | - S3ReadPolicy: 50 | BucketName: !Ref RuleBucketName -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![cfn_nag](https://github.com/stelligent/cfn_nag/raw/master/logo.png?raw=true "cfn_nag") 2 | 3 | ## Overview 4 | A lambda function to run [cfn_nag](https://github.com/stelligent/cfn_nag) as an action in CodePipeline. 5 | 6 | ## Installation 7 | To install, navigate to the [cfn-nag-pipeline](https://serverlessrepo.aws.amazon.com/applications/us-east-1/275155842945/cfn-nag-pipeline) application in the AWS Serverless Repo (SAR) console and click deploy. 8 | 9 | ### Custom Rules 10 | The "application" deployed in SAR always reflects the latest version of cfn_nag published to [rubygems.org](https://rubygems.org/gems/cfn-nag). This means the "core" rules should always be up to date. That said, if you have developed custom rules, as of [0.5.5](https://github.com/stelligent/cfn_nag/releases/tag/v0.5.5) you can load those rules from an S3 bucket of your choosing. At the point of deploying the "application" from SAR, you can select a rule bucket name and a prefix within that bucket. Any objects with a key of the form: `prefix/\*Rule.rb` will be loaded as a cfn_nag rule. 11 | 12 | ## Reference the Lambda from AWS CodePipeline 13 | 14 | * Add a source step for a repository with CloudFormation templates 15 | * Add a downstream build step with provider `AWS Lambda` 16 | * Select the function name `cfn-nag-pipeline` 17 | * Select the glob for CloudFormation templates in the user parameters section for the step: e.g. `spec/test_templates/json/ec2_volume/*.json` 18 | * Select the name of the Input Artifact from the repository 19 | * For an example of such a pipeline, in this repository see: `spec/e2e/code_pipeline_using_nag.yml` 20 | 21 | ## Development 22 | 23 | * Ensure **awscli** is installed. The credentials will need permission to create an S3 bucket, lambda functions, and an IAM role for the functions (at least) 24 | * To run tests and build the lambda function, run: `rake` 25 | * To deploy the function, run: `rake deploy` 26 | * [e2e_role.yml](./spec/e2e/e2e_role.yml) is necessary to run the release pipeline 27 | -------------------------------------------------------------------------------- /spec/plain_text_summary_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'json' 3 | require 'plain_text_summary' 4 | 5 | describe PlainTextSummary do 6 | context 'some results' do 7 | it 'emits a string' do 8 | audit_results = [ 9 | { 10 | name: 'templates/fred1.json', 11 | audit_result: { 12 | failure_count: 2, 13 | violations: [ 14 | Violation.new( 15 | id: 'F1', 16 | type: Violation::FAILING_VIOLATION, 17 | message: 18 | 'EBS volume should have server-side encryption enabled', 19 | logical_resource_ids: %w[NewVolume1 NewVolume2] 20 | ) 21 | ] 22 | } 23 | }, 24 | { 25 | name: 'templates/fred2.json', 26 | audit_result: { 27 | failure_count: 0, 28 | violations: [] 29 | } 30 | }, 31 | { 32 | name: 'templates/fred3.json', 33 | audit_result: { 34 | failure_count: 0, 35 | violations: [ 36 | Violation.new( 37 | id: 'F1', 38 | type: Violation::WARNING, 39 | message: 40 | 'EBS volume should have server-side encryption enabled', 41 | logical_resource_ids: %w[NewVolume1] 42 | ) 43 | ] 44 | } 45 | }, 46 | { 47 | name: 'templates/fred4.json', 48 | audit_result: { 49 | failure_count: 1, 50 | violations: [ 51 | Violation.new( 52 | id: 'F1', 53 | type: Violation::FAILING_VIOLATION, 54 | message: 55 | 'EBS volume should have server-side encryption enabled', 56 | logical_resource_ids: %w[NewVolume1] 57 | ) 58 | ] 59 | } 60 | } 61 | ] 62 | 63 | actual_summary = PlainTextSummary.new.render audit_results 64 | expected_summary = < '1234' 10 | } 11 | aws_request_id = '1234' 12 | 13 | @stubbed_codepipeline = Aws::CodePipeline::Client.new(stub_responses: true) 14 | @stubbed_codepipeline.stub_responses(:put_job_failure_result, {}) 15 | @stubbed_codepipeline.stub_responses(:put_job_success_result, {}) 16 | 17 | @code_pipeline_invoker = CodePipelineInvoker.new( 18 | code_pipeline_job, 19 | aws_request_id, 20 | nil, 21 | nil 22 | ) 23 | 24 | allow(@code_pipeline_invoker).to receive(:codepipeline) 25 | .and_return(@stubbed_codepipeline) 26 | end 27 | 28 | context 'when the audit raises an error' do 29 | it 'marks the job result as a failure' do 30 | expect(@code_pipeline_invoker).to receive(:audit_impl) 31 | .and_raise('cause a failure') 32 | 33 | # this expectation is what we care most about 34 | expect(@stubbed_codepipeline).to receive(:put_job_failure_result) 35 | .exactly(1) 36 | .times 37 | 38 | # expectations are above us 39 | @code_pipeline_invoker.audit 40 | end 41 | end 42 | 43 | context 'when there are failing violations' do 44 | it 'marks the job result as a success' do 45 | expect(CodePipelineUtil).to receive(:retrieve_files_within_input_artifact) 46 | .with(any_args) 47 | .and_return(json_templates_zip_file_contents) 48 | 49 | # this expectatoin is what we care most about 50 | expect(@stubbed_codepipeline).to receive(:put_job_failure_result) 51 | .exactly(1) 52 | .times 53 | 54 | # expectations are above us 55 | @code_pipeline_invoker.audit 56 | end 57 | end 58 | 59 | context 'when there are no failing violations' do 60 | it 'marks the job result as a failure' do 61 | expect(CodePipelineUtil).to receive(:retrieve_files_within_input_artifact) 62 | .with(any_args) 63 | .and_return(no_violations_cfn_templates) 64 | 65 | # this expectation is what we care most about 66 | expect(@stubbed_codepipeline).to receive(:put_job_success_result) 67 | .exactly(1) 68 | .times 69 | 70 | # expectations are above us 71 | @code_pipeline_invoker.audit 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/code_pipeline_util.rb: -------------------------------------------------------------------------------- 1 | require 'zip_util' 2 | require 's3_util' 3 | require 'fileutils' 4 | 5 | 6 | class CodePipelineUtil 7 | class << self 8 | ## 9 | # return a string with the contents of the file within the artifact zip 10 | # 11 | def retrieve_files_within_input_artifact( 12 | codepipeline_event:, 13 | path_within_input_artifact: user_parameters(codepipeline_event) 14 | ) 15 | begin 16 | zip_path = retrieve_input_artifact codepipeline_event 17 | files_from_zip = ZipUtil.read_files_from_zip zip_path, 18 | path_within_input_artifact 19 | ensure 20 | cleanup zip_path 21 | end 22 | files_from_zip 23 | end 24 | 25 | ## 26 | # $lambdaLogger.log \ 27 | # "audit_result2: #{lambda_inputs['CodePipeline.job'] 28 | # ['data']['inputArtifacts']}" 29 | # 30 | # audit_result2: [ 31 | # { 32 | # location={ 33 | # s3Location={ 34 | # bucketName=codepipeline-us-east-1-324320755747, 35 | # objectKey=cfn-nag-example/MyApp/Dkh7wwX.zip 36 | # }, 37 | # type=S3 38 | # }, 39 | # revision=98dbfc107ef1221eb6c32f4f7015564456d670ec, 40 | # name=MyApp 41 | # } 42 | # ] 43 | def retrieve_input_artifact(codepipeline_event) 44 | input_artifact = input_artifact(codepipeline_event) 45 | 46 | s3util.retrieve_s3_object_content_to_tmp_file( 47 | bucket_name: input_artifact['location']['s3Location']['bucketName'], 48 | object_key: input_artifact['location']['s3Location']['objectKey'] 49 | ) 50 | end 51 | 52 | private 53 | 54 | def cleanup(zip_path) 55 | FileUtils.rm zip_path unless zip_path.nil? 56 | end 57 | 58 | def s3util 59 | S3Util.new 60 | end 61 | 62 | def user_parameters(codepipeline_event) 63 | codepipeline_event['data']['actionConfiguration']['configuration']['UserParameters'] 64 | end 65 | 66 | def input_artifact(codepipeline_event) 67 | unless codepipeline_event['data']['inputArtifacts'].size == 1 68 | raise 'Must have 1 and only 1 input artifact' 69 | end 70 | 71 | input_artifact = codepipeline_event['data']['inputArtifacts'].first 72 | 73 | if input_artifact['location']['type'] != 'S3' 74 | raise 'S3 is only type supported' 75 | end 76 | 77 | input_artifact 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/factory.rb: -------------------------------------------------------------------------------- 1 | def ebs_volume_with_encryption 2 | < { 11 | 'actionConfiguration' => { 12 | 'configuration' => { 13 | 'UserParameters' => 'spec/test_templates/json/ec2_volume/*.json' 14 | } 15 | } 16 | } 17 | } 18 | 19 | expect(CodePipelineUtil).to \ 20 | receive(:retrieve_input_artifact).and_return 'spec/test_templates/' \ 21 | 'json_templates.zip' 22 | expect(CodePipelineUtil).to receive(:cleanup) {} 23 | 24 | actual_file_contents = \ 25 | CodePipelineUtil.retrieve_files_within_input_artifact \ 26 | codepipeline_event: code_pipeline_event 27 | 28 | #expect(actual_file_contents).to eq json_templates_zip_file_contents 29 | end 30 | end 31 | end 32 | 33 | describe '#retrieve_input_artifact' do 34 | context 'when there is one s3 artifact' do 35 | it 'retrieves the one artifact' do 36 | expected_zip_path = '/tmp/s3util1234' 37 | 38 | s3util_double = double('s3util') 39 | expect(s3util_double).to( 40 | receive(:retrieve_s3_object_content_to_tmp_file) 41 | .with(bucket_name: 'somebucket', object_key: 'somekey') 42 | .and_return(expected_zip_path) 43 | ) 44 | 45 | expect(CodePipelineUtil).to \ 46 | receive(:s3util).and_return(s3util_double) 47 | 48 | actual_zip_path = \ 49 | CodePipelineUtil.retrieve_input_artifact( 50 | 'data' => { 'inputArtifacts' => 51 | [{ 'location' => 52 | { 'type' => 'S3', 53 | 's3Location' => { 'bucketName' => 'somebucket', 54 | 'objectKey' => 'somekey' } } }] } 55 | ) 56 | expect(actual_zip_path).to eq expected_zip_path 57 | end 58 | end 59 | 60 | def self.retrieve_input_artifact(codepipeline_event) 61 | input_artifact = input_artifact(codepipeline_event) 62 | 63 | s3util.retrieve_s3_object_content_to_tmp_file \ 64 | bucket_name: input_artifact['location']['s3Location']['bucketName'], 65 | object_key: input_artifact['location']['s3Location']['objectKey'] 66 | end 67 | 68 | context 'when is there is zero, or > 1 artifact' do 69 | it 'raises an error' do 70 | expect do 71 | CodePipelineUtil.retrieve_input_artifact( 72 | 'data' => { 73 | 'inputArtifacts' => %(artifact1 artifact2) 74 | } 75 | ) 76 | end.to raise_error 'Must have 1 and only 1 input artifact' 77 | end 78 | end 79 | 80 | context 'when is one non-s3 artifact' do 81 | it 'raises an error' do 82 | expect do 83 | CodePipelineUtil.retrieve_input_artifact( 84 | 'data' => { 85 | 'inputArtifacts' => [ 86 | { 87 | 'location' => { 'type' => 'MadeupGit' } 88 | } 89 | ] 90 | } 91 | ) 92 | end.to raise_error 'S3 is only type supported' 93 | end 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: cfn_nag_sar_for_code_pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | repository_dispatch: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | commit: 12 | name: Commit 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@master 17 | - name: Set up Ruby 18 | uses: actions/setup-ruby@v1 19 | with: 20 | ruby-version: '2.7' 21 | 22 | - name: Configure AWS Credentials for cfn validate 23 | uses: aws-actions/configure-aws-credentials@v1 24 | with: 25 | aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} 26 | aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} 27 | aws-region: us-east-1 28 | - name: Rspec tests 29 | run: rake test 30 | 31 | acceptance: 32 | name: Acceptance 33 | runs-on: ubuntu-latest 34 | needs: [commit] 35 | steps: 36 | - uses: actions/checkout@master 37 | - name: Set up Ruby 38 | uses: actions/setup-ruby@v1 39 | with: 40 | ruby-version: '2.7' 41 | 42 | - name: Configure AWS Credentials 43 | uses: aws-actions/configure-aws-credentials@v1 44 | with: 45 | aws-access-key-id: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }} 46 | aws-secret-access-key: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} 47 | aws-region: us-east-1 48 | - name: Deploy the SAR app with nag 49 | run: rake sar[dev] 50 | 51 | - name: Deploy the nag Lambda from SAR 52 | run: rake deploy 53 | - name: Deploy a CodePipeline to use cfn-nag lambda 54 | run: aws cloudformation deploy --stack-name nag-sar-e2e --template-file spec/e2e/code_pipeline_using_nag.yml --capabilities CAPABILITY_IAM --no-fail-on-empty-changeset 55 | - name: Discover the source bucket 56 | id: source_bucket 57 | run: | 58 | BUCKET_NAME=$(aws cloudformation describe-stacks --stack-name nag-sar-e2e --query 'Stacks|[0].Outputs[?OutputKey==`CfnNagSarPipelineSourceBucket`]|[0].OutputValue' --output text) 59 | echo "::set-output name=source_artifacts_bucket_name::$BUCKET_NAME" 60 | - name: Put source into right place in s3 61 | run: aws s3 cp spec/test_templates/json_templates.zip s3://${{ steps.source_bucket.outputs.source_artifacts_bucket_name }}/ 62 | - name: Kick off the pipeline 63 | id: kickoff 64 | run: | 65 | EXECUTION_ID=$(aws codepipeline start-pipeline-execution --name CfnNagSarTestPipeline --output text --query pipelineExecutionId) 66 | sleep 15 67 | echo "::set-output name=execution_id::$EXECUTION_ID" 68 | - name: Inspect status 69 | run: rspec spec/e2e/pipeline_invoked_nag_spec.rb 70 | env: 71 | pipeline_name: CfnNagSarTestPipeline 72 | execution_id: ${{ steps.kickoff.outputs.execution_id }} 73 | - name: Undeploy the pipeline 74 | run: aws cloudformation delete-stack --stack-name nag-sar-e2e 75 | - name: Undeploy the nag Lambda from SAR 76 | run: rake undeploy 77 | 78 | release: 79 | name: Release 80 | runs-on: ubuntu-latest 81 | needs: [commit, acceptance] 82 | steps: 83 | - uses: actions/checkout@master 84 | - name: Set up Ruby 85 | uses: actions/setup-ruby@v1 86 | with: 87 | ruby-version: '2.7' 88 | 89 | - name: Configure AWS Credentials 90 | uses: aws-actions/configure-aws-credentials@v1 91 | with: 92 | aws-access-key-id: ${{ secrets.PROD_AWS_ACCESS_KEY_ID }} 93 | aws-secret-access-key: ${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} 94 | aws-region: us-east-1 95 | - name: Deploy the SAR app 96 | run: rake sar[prod] 97 | 98 | -------------------------------------------------------------------------------- /lib/code_pipeline_invoker.rb: -------------------------------------------------------------------------------- 1 | require 'cfn-nag' 2 | require_relative 'code_pipeline_util' 3 | require_relative 'clients' 4 | require_relative 'plain_text_results' 5 | require_relative 'plain_text_summary' 6 | 7 | 8 | class CodePipelineInvoker 9 | include Clients 10 | 11 | def initialize(code_pipeline_job, aws_request_id, s3_rule_bucket_name, s3_rule_prefix) 12 | @aws_request_id = aws_request_id 13 | @code_pipeline_job = code_pipeline_job 14 | @s3_rule_bucket_name = s3_rule_bucket_name 15 | @s3_rule_prefix = s3_rule_prefix 16 | end 17 | 18 | def audit 19 | job_id = @code_pipeline_job['id'] 20 | log "job_id: #{job_id}" 21 | 22 | audit_impl job_id 23 | rescue Exception => e 24 | log exception_message(e) 25 | codepipeline.put_job_failure_result failure_details: { 26 | type: 'JobFailed', 27 | message: 'Error executing cfn-nag: ' + "#{e.class}", 28 | **external_execution_id 29 | }, job_id: job_id 30 | end 31 | 32 | def log(message) 33 | puts message 34 | end 35 | 36 | private 37 | 38 | def exception_message(e) 39 | "Error:\n\t#{e.to_s}\nBacktrace:\n\t#{e.backtrace.join("\n\t")}" 40 | end 41 | 42 | def retrieve_cloudformation_entries 43 | cloudformation_entries = CodePipelineUtil.retrieve_files_within_input_artifact( 44 | codepipeline_event: @code_pipeline_job 45 | ) 46 | log "cloudformation_entries: #{cloudformation_entries}" 47 | cloudformation_entries 48 | end 49 | 50 | def audit_impl(job_id) 51 | cloudformation_entries = retrieve_cloudformation_entries 52 | 53 | cfn_nag = CfnNag.new config: cfn_nag_config 54 | 55 | audit_results = cloudformation_entries.map do |cloudformation_entry| 56 | { 57 | name: cloudformation_entry[:name], 58 | audit_result: cfn_nag.audit(cloudformation_string: cloudformation_entry[:contents]) 59 | } 60 | end 61 | 62 | put_job_result job_id, audit_results 63 | end 64 | 65 | def cfn_nag_config 66 | if @s3_rule_bucket_name 67 | s3_rule_bucket_definition = < 0 94 | end 95 | end 96 | 97 | def external_execution_id 98 | { 99 | external_execution_id: @aws_request_id 100 | } 101 | end 102 | 103 | def put_job_result(job_id, 104 | audit_results) 105 | log PlainTextResults.new.render audit_results 106 | 107 | audit_results_summary = PlainTextSummary.new.render audit_results 108 | 109 | if any_audit_failures?(audit_results) 110 | codepipeline.put_job_failure_result failure_details: { 111 | type: 'JobFailed', 112 | message: audit_results_summary, 113 | **external_execution_id 114 | }, job_id: job_id 115 | else 116 | codepipeline.put_job_success_result execution_details: { 117 | summary: audit_results_summary, 118 | **external_execution_id 119 | }, job_id: job_id 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/e2e/code_pipeline_using_nag.yml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | SourceObjectKey: 3 | Description: 'S3 source artifact' 4 | Type: String 5 | Default: json_templates.zip 6 | CfnNagLambdaFunctionName: 7 | Type: String 8 | Default: cfn-nag-pipeline 9 | CfnTemplatePattern: 10 | Type: String 11 | Default: 'spec/test_templates/json/ec2_volume/*.json' 12 | 13 | Resources: 14 | CfnNagSarPipelineSourceBucket: 15 | Type: AWS::S3::Bucket 16 | DeletionPolicy: Retain 17 | Properties: 18 | VersioningConfiguration: 19 | Status: Enabled 20 | 21 | CodePipelineArtifactStoreBucket: 22 | Type: AWS::S3::Bucket 23 | DeletionPolicy: Retain 24 | 25 | CodePipelineServiceRole: 26 | Type: AWS::IAM::Role 27 | Properties: 28 | AssumeRolePolicyDocument: 29 | Version: 2012-10-17 30 | Statement: 31 | - 32 | Effect: Allow 33 | Principal: 34 | Service: 35 | - codepipeline.amazonaws.com 36 | Action: sts:AssumeRole 37 | Path: / 38 | Policies: 39 | - 40 | PolicyName: AWS-CodePipeline-Service 41 | PolicyDocument: 42 | Version: 2012-10-17 43 | Statement: 44 | - 45 | Effect: Allow 46 | Action: 47 | - lambda:ListFunctions 48 | Resource: '*' 49 | - 50 | Effect: Allow 51 | Action: 52 | - lambda:InvokeFunction 53 | Resource: 54 | - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${CfnNagLambdaFunctionName} 55 | - 56 | Effect: Allow 57 | Action: 58 | - iam:PassRole 59 | Resource: '*' 60 | - 61 | Effect: Allow 62 | Action: 63 | - cloudformation:* 64 | Resource: '*' 65 | - 66 | Effect: Allow 67 | Action: 68 | - s3:* 69 | Resource: 70 | - !Sub "${CodePipelineArtifactStoreBucket.Arn}/*" 71 | - !Sub "${CodePipelineArtifactStoreBucket.Arn}" 72 | - !Sub "${CfnNagSarPipelineSourceBucket.Arn}/*" 73 | - !Sub "${CfnNagSarPipelineSourceBucket.Arn}" 74 | 75 | CfnNagSarTestPipeline: 76 | Type: AWS::CodePipeline::Pipeline 77 | Properties: 78 | Name: CfnNagSarTestPipeline 79 | RoleArn: 80 | !GetAtt CodePipelineServiceRole.Arn 81 | Stages: 82 | - 83 | Name: Source 84 | Actions: 85 | - 86 | Name: SourceAction 87 | ActionTypeId: 88 | Category: Source 89 | Owner: AWS 90 | Version: 1 91 | Provider: S3 92 | OutputArtifacts: 93 | - Name: SourceOutput 94 | Configuration: 95 | S3Bucket: !Ref CfnNagSarPipelineSourceBucket 96 | S3ObjectKey: !Ref SourceObjectKey 97 | PollForSourceChanges: false 98 | RunOrder: 1 99 | - 100 | Name: Scan 101 | Actions: 102 | - 103 | Name: CfnNagAction 104 | InputArtifacts: 105 | - Name: SourceOutput 106 | ActionTypeId: 107 | Category: Invoke 108 | Owner: AWS 109 | Version: 1 110 | Provider: Lambda 111 | Configuration: 112 | FunctionName: !Ref CfnNagLambdaFunctionName 113 | UserParameters: !Ref CfnTemplatePattern 114 | RunOrder: 1 115 | 116 | ArtifactStore: 117 | Type: S3 118 | Location: !Ref CodePipelineArtifactStoreBucket 119 | 120 | Outputs: 121 | CfnNagSarTestPipeline: 122 | Value: !Ref CfnNagSarTestPipeline 123 | CfnNagSarPipelineSourceBucket: 124 | Value: !Ref CfnNagSarPipelineSourceBucket -------------------------------------------------------------------------------- /spec/e2e/e2e_role.yml: -------------------------------------------------------------------------------- 1 | Parameters: 2 | TargetEnv: 3 | Type: String 4 | Default: dev 5 | ServerlessApplicationName: 6 | Type: String 7 | Default: cfn-nag-pipeline 8 | PipelineName: 9 | Type: String 10 | Default: CfnNagSarTestPipeline 11 | 12 | ## 13 | # This is the sar_deploy_role plus can stand up a pipeline, can deploy a lambda from SAR 14 | # run the pipeline and check the results and tear it all down 15 | ## 16 | Resources: 17 | CfnNagSarServiceUser: 18 | Type: AWS::IAM::User 19 | 20 | E2EPolicy: 21 | Type: AWS::IAM::ManagedPolicy 22 | Properties: 23 | Users: 24 | - !Ref CfnNagSarServiceUser 25 | PolicyDocument: 26 | Version: 2012-10-17 27 | Statement: 28 | - 29 | Effect: Allow 30 | Action: 31 | - s3:CreateBucket 32 | - s3:HeadBucket 33 | Resource: '*' 34 | - 35 | Effect: Allow 36 | Action: 37 | - s3:PutBucketPolicy 38 | - s3:PutObject 39 | - s3:GetObject 40 | - s3:PutBucketVersioning 41 | Resource: 42 | - !Sub arn:aws:s3:::cfn-nag-pipeline-${TargetEnv}-${AWS::Region} 43 | - !Sub 'arn:aws:s3:::cfn-nag-pipeline-${TargetEnv}-${AWS::Region}/*' 44 | - !Sub 'arn:aws:s3:::*cfnnagsarpipelinesourcebucket*/*' 45 | - !Sub 'arn:aws:s3:::*cfnnagsarpipelinesourcebucket*' 46 | - 47 | Effect: Allow 48 | Action: 49 | - serverlessrepo:CreateApplication 50 | - serverlessrepo:GetApplication* 51 | Resource: '*' 52 | - 53 | Effect: Allow 54 | Action: 55 | - serverlessrepo:CreateApplicationVersion 56 | - serverlessrepo:PutApplicationPolicy 57 | Resource: 58 | - !Sub arn:aws:serverlessrepo:${AWS::Region}:${AWS::AccountId}:applications/${ServerlessApplicationName} 59 | - 60 | Effect: Allow 61 | Action: 62 | - cloudformation:* 63 | - lambda:CreateFunction 64 | - lambda:DeleteFunction 65 | # we have to create roles for the codepipeline to do its thing 66 | - iam:CreateRole 67 | - iam:GetRole 68 | - iam:PutRolePolicy 69 | - iam:GetRolePolicy 70 | - iam:AttachRolePolicy 71 | - iam:PassRole 72 | Resource: '*' 73 | - 74 | Effect: Allow 75 | Action: 76 | - lambda:GetFunction 77 | - lambda:UpdateFunctionCode 78 | Resource: 79 | - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${ServerlessApplicationName} 80 | - 81 | Effect: Allow 82 | Action: 83 | - codepipeline:CreatePipeline 84 | - codepipeline:UpdatePipeline 85 | - codepipeline:GetPipeline* 86 | - codepipeline:StartPipelineExecution 87 | - codepipeline:TagResource 88 | - codepipeline:UntagResource 89 | - codepipeline:ListTagsForResource 90 | Resource: 91 | - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${PipelineName} 92 | - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${PipelineName}/* 93 | 94 | ############################# DELETE PERMISSIONS ######################################### 95 | - 96 | Effect: Allow 97 | Action: 98 | - iam:DeleteRole 99 | - iam:DetachRolePolicy 100 | - iam:DeleteRolePolicy 101 | Resource: '*' 102 | - 103 | Effect: Allow 104 | Action: 105 | - codepipeline:DeletePipeline 106 | Resource: 107 | - !Sub arn:aws:codepipeline:${AWS::Region}:${AWS::AccountId}:${PipelineName} 108 | - 109 | Effect: Allow 110 | Action: 111 | - lambda:DeleteFunction 112 | Resource: 113 | - !Sub arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${ServerlessApplicationName} 114 | - 115 | Effect: Allow 116 | Action: 117 | - s3:DeleteBucket 118 | Resource: 119 | - !Sub arn:aws:s3:::cfn-nag-pipeline-${TargetEnv}-${AWS::Region} 120 | - !Sub 'arn:aws:s3:::cfn-nag-pipeline-${TargetEnv}-${AWS::Region}/*' 121 | - !Sub 'arn:aws:s3:::*cfnnagsarpipelinesourcebucket*/*' 122 | - !Sub 'arn:aws:s3:::*cfnnagsarpipelinesourcebucket*' -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'rspec/core/rake_task' 3 | 4 | #AWS_DEFAULT_REGION = `aws configure get region`.chomp 5 | AWS_DEFAULT_REGION = 'us-east-1' 6 | AWS_ACCOUNT_ID = `aws sts get-caller-identity --output text --query 'Account'`.chomp 7 | LAMBDA_DEPLOYMENT_CFN_STACK_NAME = 'aws-serverless-repository-cfn-nag-pipeline' 8 | 9 | build_properties = { 10 | 'dev' => { 11 | 'public_visibility' => false 12 | }, 13 | 'prod' => { 14 | 'public_visibility' => true 15 | } 16 | } 17 | 18 | RSpec::Core::RakeTask.new(:spec) do |task| 19 | task.exclude_pattern = 'spec/e2e/*.rb' 20 | end 21 | 22 | task default: [:test] 23 | 24 | task test: [:spec] do 25 | puts '[INFO] validating cfn template' 26 | cfn_templates = %w[ 27 | lambda.yml 28 | spec/e2e/e2e_role.yml 29 | spec/e2e/code_pipeline_using_nag.yml 30 | ] 31 | cfn_templates.each do |cfn_template| 32 | sh "aws cloudformation validate-template --template-body file://#{cfn_template} --region #{AWS_DEFAULT_REGION}" 33 | end 34 | end 35 | 36 | task :bucket, :bucket_name do |task, args| 37 | bucket_name = args[:bucket_name] 38 | bucket_policy = < /dev/null 2>&1 || (aws s3 mb s3://#{bucket_name} && sleep 10)" 62 | sh "aws s3api put-bucket-policy --bucket #{bucket_name} --policy '#{bucket_policy}'" 63 | end 64 | 65 | task :stage, :target_env do |task, args| 66 | puts "[INFO] staging lambda zip" 67 | bucket_name = "cfn-nag-pipeline-#{args[:target_env]}-#{AWS_DEFAULT_REGION}" 68 | 69 | Rake::Task['bucket'].invoke(bucket_name) 70 | 71 | sh 'bundle install --path lib/vendor/bundle --without test' 72 | sh 'mkdir target || true' 73 | sh "aws cloudformation package --template-file lambda.yml --s3-bucket #{bucket_name} --output-template-file target/lambda.yml" 74 | end 75 | 76 | task :sar, :target_env do |task, args| 77 | Rake::Task['stage'].invoke(args[:target_env]) 78 | public_visibility = build_properties[args[:target_env]]['public_visibility'] 79 | 80 | application_id = "arn:aws:serverlessrepo:#{AWS_DEFAULT_REGION}:#{AWS_ACCOUNT_ID}:applications/cfn-nag-pipeline" 81 | readme_url = 'https://raw.githubusercontent.com/stelligent/cfn-nag-pipeline/master/README.md' 82 | license_url = 'https://raw.githubusercontent.com/stelligent/cfn-nag-pipeline/master/LICENSE.md' 83 | source_code_url = 'https://github.com/stelligent/cfn-nag-pipeline' 84 | create_application_if_necessary_command = < /dev/null 2>&1 || \ 86 | aws serverlessrepo create-application --author Stelligent \ 87 | --description "A lambda function to run cfn_nag as an action in CodePipeline" \ 88 | --labels codepipeline cloudformation cfn_nag \ 89 | --readme-body #{readme_url} \ 90 | --spdx-license-id MIT \ 91 | --license-body #{license_url} \ 92 | --source-code-url #{source_code_url} \ 93 | --name "cfn-nag-pipeline" 94 | END 95 | sh create_application_if_necessary_command 96 | 97 | gem_listing_of_cfn_nag = `gem list -q cfn-nag`.chomp 98 | cfn_nag_version = gem_listing_of_cfn_nag.split('(')[1].split(')')[0] 99 | 100 | puts "[INFO] creating new application version #{cfn_nag_version}" 101 | #### IF VERSION ALREADY THERE!!!!!????? UPDATE OR REPLACE???? 102 | #### lazy to ignore failure but need to list-application-versions 103 | #--application-id 104 | create_application_version_command = <