From 57431c403dee1ef9961e97f8305bb987a15164f7 Mon Sep 17 00:00:00 2001 From: jhaubold Date: Tue, 7 Apr 2020 17:06:34 -0400 Subject: [PATCH 1/6] rework lambdafication --- .gitignore | 5 +- LambdaInstall.md | 56 ++++++++++++ Makefile | 60 +++++++++++++ cloudformation/deploy.sh | 26 ++++++ cloudformation/template.yaml | 140 ++++++++++++++++++++++++++++++ collectors/aws/collector.js | 4 +- engine.js | 68 +++++++++------ get-default-settings.js | 39 +++++++++ lambda_config.js | 120 ++++++++++++++++++++++++++ lambda_config.spec.js | 129 ++++++++++++++++++++++++++++ lambda_event_details.md | 160 +++++++++++++++++++++++++++++++++++ lambda_index.js | 84 ++++++++++++++++++ package.json | 1 + postprocess/lambda_output.js | 36 ++++++++ 14 files changed, 899 insertions(+), 29 deletions(-) create mode 100644 LambdaInstall.md create mode 100644 Makefile create mode 100755 cloudformation/deploy.sh create mode 100644 cloudformation/template.yaml create mode 100755 get-default-settings.js create mode 100644 lambda_config.js create mode 100644 lambda_config.spec.js create mode 100644 lambda_event_details.md create mode 100644 lambda_index.js create mode 100644 postprocess/lambda_output.js diff --git a/.gitignore b/.gitignore index d348a1d269..417e55f721 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,6 @@ output.json vscode/* .vscode/ .idea -coverage/ -.nyc_output +.local +template.packaged.yaml +config.* diff --git a/LambdaInstall.md b/LambdaInstall.md new file mode 100644 index 0000000000..ab521d4b5b --- /dev/null +++ b/LambdaInstall.md @@ -0,0 +1,56 @@ +# CloudSploit Lambda Deployment Guide + +The `Makefile` has all of the commands required to build, package, and deploy CloudSploit to AWS Lambda. It makes use of environment variables for configuration. + +## Environment Variable COnfiguration + +| Environment Variable | Description +|---------------------------|------------- +| `ARTIFACT_BUCKET` | The name of the bucket to upload the zipped lambda code to +| `STACK_NAME` | The name to give the cloudformation stack +| `DEFAULT_ROLE_NAME` | The default to use for the name of the role for CloudSploit to assume +| `SECRETS_MANAGER_PREFIX` | The prefix to be used for secrets manager secrets +| `BUCKET_NAME` | The name of the bucket to write output to +| `BUCKET_PREFIX` | The prefix to write within the bucket +| `CREATE_BUCKET` | Whether or not to create the bucket, using the given bucket name ("no" or "yes") +| `SNS_TOPIC` | The topic to subscribe the CloudSploit lambda to +| `SCHEDULE` | A schedule expression to run the lambda on +| `SCHEDULED_ACCOUNT_ID` | If schedule provided, the account ID to scan +| `SCHEDULED_ROLE_NAME` | If schedule provided, the role name to assume +| `SCHEDULED_EXTERNAL_ID` | If schedule provided, the external ID to use when assuming the role + +## Sample Config File + +```bash +export ARTIFACT_BUCKET=mybucket +export STACK_NAME=cloudsploit-test +export DEFAULT_ROLE_NAME=cloudsploit-role +export SECRETS_MANAGER_PREFIX="/cloudsploit/secrets/" +export BUCKET_NAME=cloudsploit-output-bucket +export BUCKET_PREFIX="/" +export CREATE_BUCKET=yes +export SNS_TOPIC="" +export SCHEDULE="" +export SCHEDULED_ACCOUNT_ID="" +export SCHEDULED_ROLE_NAME="" +export SCHEDULED_EXTERNAL_ID="" +``` + +## Deploy Process +1. Create a file with the above variables called `config.ENVIRONMENT-DESIGNATOR`, where ENVIRONMENT-DESIGNATOR can be your environment (dev, stage, prod) +2. run `make deploy env=ENVIRONMENT-DESIGNATOR` + * Make deploy will install the npm modules, build the lambda SAM package, and deploy the lambda as a Cloudformation Template. + +## Lambda Trigger +The Lambda can be triggered by a StepFunction or via the AWS CLI using the following event structure: +```json +{ + "aws": { + "roleArn": "arn:aws:iam::ACCOUNTID:role/ROLENAME" + }, + "s3Prefix": "PREFIX_FOR_FINDINGS_FROM_THIS_SPECIFIC_INVOCATION" +} +``` +Substitute the appropriate values for ACCOUNTID, ROLENAME and PREFIX_FOR_FINDINGS_FROM_THIS_SPECIFIC_INVOCATION + +More details on Lambda Invocation options are in the lambda_event_details.md file \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..796cb13fce --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ + +ifndef env +# $(error env is not set) + env ?= dev +endif + +ifdef CONFIG + include $(CONFIG) + export +else + include config.$(env) + export +endif + +ifndef STACK_NAME +$(error STACK_NAME is not set) +endif + +.PHONY: $(FUNCTIONS) + +# # Run all tests +# test: cfn-validate +# cd lambda && $(MAKE) test + +# # Do everything +# clean: +# cd lambda && $(MAKE) clean + +build: + npm install + # because for some reason, the node modules were dated to 1985 + find ./node_modules/* -mtime +10950 -exec touch {} \; + +package: build + aws cloudformation package \ + --s3-bucket ${ARTIFACT_BUCKET} \ + --template-file ./cloudformation/template.yaml \ + --output-template-file ./template.packaged.yaml + +deploy: package + aws cloudformation deploy \ + --template-file ./template.packaged.yaml \ + --capabilities CAPABILITY_IAM \ + --no-fail-on-empty-changeset \ + --stack-name ${STACK_NAME} \ + --parameter-overrides \ + DefaultRoleName=${DEFAULT_ROLE_NAME} \ + SecretsManagerPrefix=${SECRETS_MANAGER_PREFIX} \ + BucketName=${BUCKET_NAME} \ + BucketPrefix=${BUCKET_PREFIX} \ + CreateBucket=${CREATE_BUCKET} \ + SNSTopic=${SNS_TOPIC} \ + Schedule=${SCHEDULE} \ + ScheduledAccountId=${SCHEDULED_ACCOUNT_ID} \ + ScheduledRoleName=${SCHEDULED_ROLE_NAME} \ + ScheduledExternalId=${SCHEDULED_EXTERNAL_ID} + +sync-scorecards: + aws s3 sync s3://$(BUCKET_NAME)/$(BUCKET_PREFIX) Results/$(STACK_NAME) + open Results/$(STACK_NAME) diff --git a/cloudformation/deploy.sh b/cloudformation/deploy.sh new file mode 100755 index 0000000000..b0a0c4f8af --- /dev/null +++ b/cloudformation/deploy.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euxo pipefail + +npm install + +aws cloudformation package \ + --s3-bucket ${ARTIFACT_BUCKET} \ + --template-file ./cloudformation/template.yaml \ + --output-template-file ./template.packaged.yaml + +aws cloudformation deploy \ + --template-file ./template.packaged.yaml \ + --capabilities CAPABILITY_IAM \ + --no-fail-on-empty-changeset \ + --stack-name ${STACK_NAME} \ + --parameter-overrides \ + DefaultRoleName=${DEFAULT_ROLE_NAME} \ + SecretsManagerPrefix=${SECRETS_MANAGER_PREFIX} \ + BucketName=${BUCKET_NAME} \ + BucketPrefix=${BUCKET_PREFIX} \ + CreateBucket=${CREATE_BUCKET} \ + SNSTopic=${SNS_TOPIC} \ + Schedule=${SCHEDULE} \ + ScheduledAccountId=${SCHEDULED_ACCOUNT_ID} \ + ScheduledRoleName=${SCHEDULED_ROLE_NAME} \ + ScheduledExternalId=${SCHEDULED_EXTERNAL_ID} diff --git a/cloudformation/template.yaml b/cloudformation/template.yaml new file mode 100644 index 0000000000..854ce78e46 --- /dev/null +++ b/cloudformation/template.yaml @@ -0,0 +1,140 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: Deploy a lambda version of CloudSploit Scanning Engine, triggered by SNS Message + + +Parameters: + DefaultRoleName: + Description: The default role name to assume when one is not provided + Type: String + + SecretsManagerPrefix: + Description: Prefix of secrets manager secrets for access to provider credentials + Type: String + Default: '' + + BucketName: + Description: Name of the bucket to hold scan reports + Type: String + BucketPrefix: + Description: The prefix to upload the scan reports under + Type: String + Default: '' + CreateBucket: + Description: Create the bucket or use an existing bucket + Type: String + Default: 'no' + AllowedValues: + - 'yes' + - 'no' + + SNSTopic: + Description: ARN from the inventory stack that triggers parallel actions across all accounts + Type: String + Default: '' + + Schedule: + Description: A schedule to be applied to the lambda + Type: String + Default: '' + ScheduledAccountId: + Description: Only used if schedule is specified, scan this account ID when invoked + Type: String + Default: '' + ScheduledRoleName: + Description: Only used if schedule is specified, assume this role name to scan + Type: String + Default: '' + ScheduledExternalId: + Description: Only used if schedule is specified, use this external id to assume the role + Type: String + NoEcho: True + Default: '' + + +Conditions: + SubscribeLambda: !Not [ !Equals [ !Ref SNSTopic, '' ] ] + CreateSchedule: !Not [ !Equals [ !Ref Schedule, ''] ] + ShouldCreateBucket: !Equals [ !Ref CreateBucket, 'yes' ] + + +Resources: + CloudsploitScanner: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub ${AWS::StackName}-scanner + Description: Execute a CloudSploit Scan on the specific AWS account + Timeout: 900 + MemorySize: 3008 + Handler: lambda_index.handler + Runtime: nodejs10.x + CodeUri: .. + Environment: + Variables: + DEFAULT_ROLE_NAME: !Ref DefaultRoleName + RESULT_BUCKET: !Ref BucketName + RESULT_PREFIX: !Ref BucketPrefix + SECRET_PREFIX: !Ref SecretsManagerPrefix + Policies: + - Version: '2012-10-17' + Statement: + - Action: s3:PutObject + Effect: Allow + Resource: + - !Sub arn:aws:s3:::${BucketName}/* + - !Sub arn:aws:s3:::${BucketName} + - Effect: Allow + Action: sts:AssumeRole + Resource: '*' + - Effect: Allow + Action: secretsmanager:GetSecretValue + Resource: !Sub arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${SecretsManagerPrefix}/* + + CloudsploitScannerSchedule: + Condition: CreateSchedule + Type: AWS::Events::Rule + Properties: + ScheduleExpression: !Ref Schedule + State: ENABLED + Targets: + - Arn: !GetAtt CloudsploitScanner.Arn + Id: CloudsploitScannerScheduleLambdaTarget + Input: !Sub | + { + "aws": { + "accountID": "${ScheduledAccountId}", + "roleArn": "${ScheduledRoleName}", + "externalId": "${ScheduledExternalId}" + } + } + + CloudsploitScannerSNSPermission: + Condition: SubscribeLambda + Type: AWS::Lambda::Permission + Properties: + FunctionName: !GetAtt CloudsploitScanner.Arn + Principal: sns.amazonaws.com + SourceArn: !Ref SNSTopic + Action: lambda:invokeFunction + + CloudsploitScannerSubscription: + Condition: SubscribeLambda + Type: AWS::SNS::Subscription + Properties: + Endpoint: !GetAtt CloudsploitScanner.Arn + Protocol: lambda + TopicArn: !Ref SNSTopic + + CloudsploitResultsBucket: + Condition: ShouldCreateBucket + Type: AWS::S3::Bucket + Properties: + BucketName: !Ref BucketName + + +Outputs: + CloudsploutLambdaArn: + Value: !GetAtt CloudsploitScanner.Arn + + CloudsploutLambdaName: + Value: !Ref CloudsploitScanner diff --git a/collectors/aws/collector.js b/collectors/aws/collector.js index d188f70857..f0d9d02b88 100644 --- a/collectors/aws/collector.js +++ b/collectors/aws/collector.js @@ -877,7 +877,7 @@ var collect = function (AWSConfig, settings, callback) { globalServices.indexOf(service) === -1) return regionCb(); if (!collection[serviceLower][callKey][region]) collection[serviceLower][callKey][region] = {}; - var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); + var LocalAWSConfig = Object.assign({}, AWSConfig); LocalAWSConfig.region = region; if (callObj.override) { @@ -982,7 +982,7 @@ var collect = function (AWSConfig, settings, callback) { !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data || !collection[callObj.reliesOnService][callObj.reliesOnCall][region].data.length)) return regionCb(); - var LocalAWSConfig = JSON.parse(JSON.stringify(AWSConfig)); + var LocalAWSConfig = Object.assign({}, AWSConfig); if (callObj.deleteRegion) { //delete LocalAWSConfig.region; LocalAWSConfig.region = settings.govcloud ? 'us-gov-west-1' : settings.china ? 'cn-north-1' : 'us-east-1'; diff --git a/engine.js b/engine.js index 52d36bad08..f0547bce4d 100644 --- a/engine.js +++ b/engine.js @@ -1,9 +1,10 @@ - var async = require('async'); var plugins = require('./exports.js'); var complianceControls = require('./compliance/controls.js') var suppress = require('./postprocess/suppress.js') var output = require('./postprocess/output.js') +var helpers = require('./helpers/shared') +var once = require('lodash.once'); /** * The main function to execute CloudSploit scans. @@ -12,10 +13,12 @@ var output = require('./postprocess/output.js') * @param GitHubConfig The configuration for Github. If undefined, then don't run. * @param OracleConfig The configuration for Oracle. If undefined, then don't run. * @param GoogleConfig The configuration for Google. If undefined, then don't run. - + * * @param settings General purpose settings. + * @param [outputHandler] Optional custom handler for result collection. + * @param [callback] callback to be ran upon completion. */ -var engine = function (AWSConfig, AzureConfig, GitHubConfig, OracleConfig, GoogleConfig, settings) { +var engine = function (AWSConfig, AzureConfig, GitHubConfig, OracleConfig, GoogleConfig, settings, outputHandler, callback) { // Determine if scan is a compliance scan var complianceArgs = process.argv .filter(function (arg) { @@ -39,7 +42,7 @@ var engine = function (AWSConfig, AzureConfig, GitHubConfig, OracleConfig, Googl var suppressionFilter = suppress.create(process.argv) // Initialize the output handler - var outputHandler = output.create(process.argv) + outputHandler = outputHandler || output.create(process.argv) // The original cloudsploit always has a 0 exit code. With this option, we can have // the exit code depend on the results (useful for integration with CI systems) @@ -108,7 +111,7 @@ var engine = function (AWSConfig, AzureConfig, GitHubConfig, OracleConfig, Googl if (sp == 'github' && !serviceProviderConfig.organization && plugin.types.indexOf('user') === -1) continue; - + // Skip if our compliance set says don't run the rule if (!compliance.includes(spp, plugin)) continue; @@ -161,28 +164,40 @@ var engine = function (AWSConfig, AzureConfig, GitHubConfig, OracleConfig, Googl plugin.types.indexOf('user') === -1) return pluginDone(null, 0); var maximumStatus = 0 - plugin.run(collection, settings, function(err, results) { - outputHandler.startCompliance(plugin, key, compliance) - - for (r in results) { - // If we have suppressed this result, then don't process it - // so that it doesn't affect the return code. - if (suppressionFilter([key, results[r].region || 'any', results[r].resource || 'any'].join(':'))) { - continue; + // ensure callback is only called once, since plugin.run could throw after triggering the callback + pluginDone = once(pluginDone); + try { + plugin.run(collection, settings, function(err, results) { + outputHandler.startCompliance(plugin, key, compliance) + + for (r in results) { + // If we have suppressed this result, then don't process it + // so that it doesn't affect the return code. + if (suppressionFilter([key, results[r].region || 'any', results[r].resource || 'any'].join(':'))) { + continue; + } + + // Write out the result (to console or elsewhere) + outputHandler.writeResult(results[r], plugin, key) + + // Add this to our tracking fo the worst status to calculate + // the exit code + maximumStatus = Math.max(maximumStatus, results[r].status) } - - // Write out the result (to console or elsewhere) - outputHandler.writeResult(results[r], plugin, key) - - // Add this to our tracking fo the worst status to calculate - // the exit code - maximumStatus = Math.max(maximumStatus, results[r].status) - } - - outputHandler.endCompliance(plugin, key, compliance) - + outputHandler.endCompliance(plugin, key, compliance) + setTimeout(function() { pluginDone(err, maximumStatus); }, 0); + }); + } catch (error) { + outputHandler.writeResult({ + status: 3, + region: 'unknown', + resource: null, + custom: false, + message: helpers.addError(error) + }, plugin, key) + maximumStatus = 3; setTimeout(function() { pluginDone(err, maximumStatus); }, 0); - }); + } }, function(err, results){ if (err) return console.log(err); var summaryStatus = Math.max(...Object.values(results)) @@ -196,6 +211,9 @@ var engine = function (AWSConfig, AzureConfig, GitHubConfig, OracleConfig, Googl process.exitCode = Math.max(results) } console.log('Done'); + if(callback) { + callback(err) + } }); }; diff --git a/get-default-settings.js b/get-default-settings.js new file mode 100755 index 0000000000..3a0d9765ac --- /dev/null +++ b/get-default-settings.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +const providers = require('./exports'); + +// Settings in plugins have this structure +// "settings": { +// "service_limit_percentage_fail": { +// "name": "Service Limit Percentage Fail", +// "description": "Return a failing result when utilized services equals or exceeds this percentage", +// "regex": "^(100|[1-9][0-9]?)$", +// "default": 90 +// } +// } +// Then the plugins get the value from the settings object by referencing the same key +// e.g. settings['service_limit_percentage_fail'] +// +// thus there are two keys in the output from this file for each settings. One is used by the plugin on execution, the +// second for infomational purposes only +// +// "service_limit_percentage_warn_description": { // this is for information only +// "name": "Service Limit Percentage Warn", +// "description": "Return a warning result when utilized services equals or exceeds this percentage", +// "regex": "^(100|[1-9][0-9]?)$", +// "default": 75 +// }, +// "service_limit_percentage_warn": 75 // plugin reads this value + +const settings = {}; + +for (let providerName in providers) { + for (let pluginName in providers[providerName]) { + const plugin = providers[providerName][pluginName]; + for (let settingName in plugin.settings || {}) { + settings[settingName + '_description'] = plugin.settings[settingName]; + settings[settingName] = plugin.settings[settingName].default; + } + } +} + +console.log(JSON.stringify(settings, null, 2)); \ No newline at end of file diff --git a/lambda_config.js b/lambda_config.js new file mode 100644 index 0000000000..5cb869e55a --- /dev/null +++ b/lambda_config.js @@ -0,0 +1,120 @@ +var AWS = require('aws-sdk'); +var secretManager = new AWS.SecretsManager(); + +/*** + * Finds a secret from Secrets Manager given a key and a region. + * Expected that the value in Secrets Manager is a JSON. + * + * @param {String} secretManagerKey A key for where to find the secrets in secret manager. + * + * @returns A JSON object with the secret(s) found in secret manager. + */ +async function getSecret(secretManagerKey) { + var data = await secretManager.getSecretValue({SecretId: secretManagerKey}).promise(); + return data.SecretString ? JSON.parse(data.SecretString) : {}; +} + +/** + * Parses AWS events, currently expects either an SNS event or Cloudwatch event. + * + * @param event AWS Event to be parsed. + * @returns Json object containing the configuration detail from the event. + */ +function parseEvent(event) { + var allConfigurations; + + //Expected events are SNS and Cloudwatch, could add other events here if needed. + if(event.Records && event.Records[0].Sns) { + console.log("SNS Event Trigger"); + allConfigurations = JSON.parse(event.Records[0].Sns.Message); + } else if(event.detail) { + console.log("CloudWatch Event Trigger"); + allConfigurations = event.detail; + } else { + allConfigurations = event; + } + // console.assert(allConfigurations, "Configurations not found from incoming Event."); + + return allConfigurations +} + +/*** + * Parses the incoming event to create configurations used for the engine. + * Enforces that exactly 1 expected service is found in the event. + * Any other data will be passed through untouched. + * + * @param {String} parsedEvent A parsed event sources from an AWS initiating event. + * @param {String} partition The AWS partition (at current, aws, aws-cn, or aws-us-govt) + * @returns The parsed configurations with secrets in place. + * + * @throws Any misconfiguration will result in an error being thrown. + */ +async function getConfigurations(parsedEvent, partition) { + console.log("Begin Parsing of Incoming Event"); + var secretPrefix = process.env.SECRET_PREFIX; + var defaultRoleName = process.env.DEFAULT_ROLE_NAME; + + //Anything in these arrays will be required to be found in the CredentialID Secret Manager. + var expectedServices = { + 'aws' : [], + 'azure': ["KeyValue"], + 'gcp': ["private_key"], + 'github': [], + 'oracle': ["keyValue","keyFingerprint"] + }; + + var serviceCount = 0; + for (service in parsedEvent) { + if(service in expectedServices) { + console.log("Found Service ",service.toUpperCase()); + serviceCount++; + if(serviceCount > 1) throw (new Error("Multiple Services in Incoming Event.")); + expectedServices[service].forEach((config) => { + if (config in parsedEvent[service]) throw (new Error("Configuration passed in through event which must be in Secrets Manager.")); + }) + if(service === 'aws') { + //If account_id in aws config, then replace it with roleArn. + if (parsedEvent.aws.account_id) { + parsedEvent.aws.roleArn = ["arn", partition, "iam", "", parsedEvent.aws.account_id, "role/" + defaultRoleName].join(':'); + delete parsedEvent.aws.account_id; + } + } else if(parsedEvent[service].credentialId) { + var secretsManagerKey = [secretPrefix, service, parsedEvent[service].credentialId].join('/'); + secret = await getSecret(secretsManagerKey); // eslint-disable-line no-await-in-loop + delete parsedEvent[service].credentialId; + Object.assign(parsedEvent[service], secret); + } + } + } + + if(serviceCount === 0) throw (new Error("No services provided or provided services are malformed in Incoming Event.")); + return parsedEvent; +} + +/*** + * Uses STS to obtain credentials for AWS Config. + * It is expected that AWSConfig is only obtainable via assuming a role. + * + * @param {String} roleArn The ARN for the role to get credentials for. + * @param {String} [externalID] The externalID used for role assumption. + * @returns AWS Configuration for cloudsploit engine. + * + */ + +async function getCredentials(roleArn, externalId) { + console.log("Getting Credentials for AWS Configuration"); + if(!roleArn) { + throw new Error("roleArn is not defined from incoming event."); + } + var STSParams = { + RoleArn: roleArn, + ExternalId: externalId + }; + let credentials = new AWS.ChainableTemporaryCredentials({ params: STSParams }); + + return credentials.getPromise().then(() => { + return { credentials } + }); +} + +module.exports = {getConfigurations, parseEvent, getCredentials} diff --git a/lambda_config.spec.js b/lambda_config.spec.js new file mode 100644 index 0000000000..7d69ad80db --- /dev/null +++ b/lambda_config.spec.js @@ -0,0 +1,129 @@ +var configs = require('./lambda_config.js') +var chai = require('chai'); +var chaiAsPromised = require("chai-as-promised") +var expect = require('chai').expect; +chai.use(chaiAsPromised) + +//Put events into JSON files... +var snsEvent = { + "Records": [ + { + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-east-1:{{{accountId}}}:ExampleTopic", + "Sns": { + "Type": "Notification", + "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:ExampleTopic", + "Subject": "example subject", + "Message": "{\"aws\" : {\"roleArn\": \"arn:aws:iam::1234567890:role/someRole\"}}", + "Timestamp": "1970-01-01T00:00:00.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": { + "Test": { + "Type": "String", + "Value": "TestString" + }, + "TestBinary": { + "Type": "Binary", + "Value": "TestBinary" + } + } + } + } + ] +} + +var cloudwatchEvent = { + "id": "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c", + "detail-type": "Scheduled Event", + "source": "aws.events", + "account": "{{{account-id}}}", + "time": "1970-01-01T00:00:00Z", + "region": "us-east-1", + "resources": [ + "arn:aws:events:us-east-1:123456789012:rule/ExampleRule" + ], + "detail": { + "aws": { + "roleArn": "arn:aws:iam::1234567890:role/someRole" + } + } +} + +var expectedOutcome = { + 'aws': { + "roleArn": "arn:aws:iam::1234567890:role/someRole" + } +} + +var configWithAccountId = { + 'aws': { + "account_id": "1234567890" + } +} + +var expectedConfigFromAccountId = { + 'aws': { + "roleArn": ("arn:aws:iam::1234567890:role/" + process.env.DEFAULT_ROLE_NAME) + } +} + +var notCredentialedConfiguration = { + "gcp" : { + "private_key": "" + } +} + +var noServiceProvided = {} + +var multipleServicesProvided = { + 'aws' : {}, + 'gcp' : {} +} + +describe('configs', function () { + describe('parseEvent', function () { + it('Gets JSON object from SNS event', function () { + expect(configs.parseEvent(snsEvent)).to.deep.equal(expectedOutcome) + }) + it('Gets JSON object from CloudWatch event', function () { + expect(configs.parseEvent(cloudwatchEvent)).to.deep.equal(expectedOutcome) + }) + }) + + describe('getConfigurations', function () { + var partition = "aws" + it('Gets aws configurations from SNS with RoleArn', async function () { + var input = await configs.getConfigurations(configs.parseEvent(snsEvent), partition) + var output = expectedOutcome + expect(input).to.deep.equal(output) + }) + it('Gets aws configurations from Cloudwatch with RoleArn', async function () { + var input = await configs.getConfigurations(configs.parseEvent(cloudwatchEvent), partition) + var output = expectedOutcome + expect(input).to.deep.equal(output) + }) + it('Gets aws configurations containing Account ID', async function () { + var input = await configs.getConfigurations(configWithAccountId, partition) + var output = expectedConfigFromAccountId + expect(input).to.deep.equal(output) + }) + it('Should throw an error when given a key that must be credentialed', async function () { + var input = configs.getConfigurations(notCredentialedConfiguration, partition) + expect(input).to.be.rejectedWith("Configuration passed in through event which must be in Secrets Manager.") + }) + it('Should throw an error when given a configuration without a valid service', async function () { + var input = configs.getConfigurations(noServiceProvided, partition) + expect(input).to.be.rejectedWith("No services provided or provided services are malformed in Incoming Event.") + }) + it('Should throw an error when given a configuration with multiple services', async function () { + var input = configs.getConfigurations(multipleServicesProvided, partition) + expect(input).to.be.rejectedWith("Multiple Services in Incoming Event.") + }) + //need to find a way to test credentialedId. + }) +}) \ No newline at end of file diff --git a/lambda_event_details.md b/lambda_event_details.md new file mode 100644 index 0000000000..a9f37d2d92 --- /dev/null +++ b/lambda_event_details.md @@ -0,0 +1,160 @@ +# Expected Event Information: +There are three ways to invoke the lambda. +1. Publish event to SNS topic that the lambda is subscribed to. +2. Setup a CloudWatch Event. The Event must be in the details section. +3. Directly invoke the lambda with the event as the payload + +### Important Notes +* If neither SNS nor Cloudwatch events are identified, it is assumed to be a direct invocation. +* The accepted format for a payload is that it must contain exactly one of a supported service and additionally any other information in an additional settings object. +* The supprted services are: `aws`, `gcp`, `github`, `oracle`, `azure` +* Any other service other than `aws` requires that secrets be passed in through `AWS secretmanager`, however you can put as much of the configuration for the service as you would like into `secretsmanager`. +* If a service contains a key for something that is required to be a secret, the scan will fail. +* Any information found in `secretsmanager` will be added directly to the configuration object and the` credentialID` value will be deleted. Examples of minimum expected values in secrets Manager can be found in the examples below. + +## AWS +The aws event accepts either a full role arn or an account ID for which it can find the provided default_role set in the environment variables. + +NOTE: If both `accountId` and `roleArn` are present, it will use the `accountId` and the role name from the cloudformation parameter and overwrite the `roleArn` value. + +Example events: + +``` +{ + aws: { + accountId: 1234567890, + externalId: '' // optional + }, + settings: {...}, + "s3Prefix": "PREFIX_FOR_FINDINGS_FROM_THIS_SPECIFIC_INVOCATION" +} +``` +or +``` +{ + aws: { + roleArn: "arn:aws:iam::1234567890:role/someRole", + externalId: '' // optional + }, + settings: {...} +} +``` +## GCP +Required credential information cannot be passed in through the payload and must be passed in through SecretesManager. The last part of the key provided in a field 'credentialId' + +Example event: +``` +{ + gcp: { + credentialId: 'some_key_string', + project: '', + serviceId:'', + region:'' + }, + settings: {...} +} +``` + +Minimum Required information in SecretsManager: +``` +{ + "KeyValue": "value" +} +``` + +## Oracle +Required credential information cannot be passed in through the payload and must be passed in through SecretesManager. The last part of the key provided in a field 'credentialId' + +Example event: +``` +{ + oracle: { + credentialId: 'some_key_string' + RESTversion: '', + tenancyId: '', + compartmentId: '', + userId: '', + region: '' + }, + settings: {...} +} +``` + +Minimum Required information in SecretsManager: +``` +{ + "private_key": "value" +} +``` + +## Github +Required credential information cannot be passed in through the payload and must be passed in through SecretesManager. The last part of the key provided in a field 'credentialId' + +Example event: +``` +{ + github: { + credentialId: 'some_key_string' + url: '', + organization: '', + login: '' + }, + settings: {...} +} +``` + +Minimum Required information in SecretsManager: +``` +{ + "token": "value" +} +``` + +## Azure +Required credential information cannot be passed in through the payload and must be passed in through SecretesManager. The last part of the key provided in a field 'credentialId' + +Example event: +``` +{ + azure: { + credentialId: 'some_key_string' + DirectoryID: '', + SubscriptionID: '', + location: '' + }, + settings: {...} +} +``` + +Minimum Required information in SecretsManager: +``` +{ + "keyValue": "value", + "keyFingerprint" : "value" +} +``` + +# S3 Output +Two objects are created the S3 bucket specified in the template parameters. The format for the object keys are: +``` +s3://///.json +s3://///latest.json +``` + +Where: +* BucketName: Required parameter passed in through Cloudformation via the Lambda environment variables. +* BucketPrefix: Optional parameter passed in through Cloudformation via the Lambda environment variables. +* s3Prefix: Optional value passed in through the event invokation at root (an example provided below.) +* date: Date is generated based on the day of the run. + +Example Event with s3Prefix: +``` +{ + aws: { + accountId: 1234567890, + externalId: '' // optional + }, + settings: {...}, + s3Prefix: "My/File/Prefix" +} +``` diff --git a/lambda_index.js b/lambda_index.js new file mode 100644 index 0000000000..2b7a126fc5 --- /dev/null +++ b/lambda_index.js @@ -0,0 +1,84 @@ +var AWS = require('aws-sdk'); +var engine = require('./engine.js'); +var lambdaOutput = require('./postprocess/lambda_output.js'); +var configs = require('./lambda_config.js') +var util = require('util') +var promisifiedEngine = util.promisify(engine) + +/*** + * Writes the output to S3, it writes two files. + * First file is a file with the current date the second file is 'latest'. Both json files. + * The full path looks like this where two files are created, one with latest and one with the date: + * s3://bucket/[templateprefix/][s3Prefix/][date && latest].json + * + * @param {String} bucket The bucket where files will be written to. + * @param {JSON} resultsToWrite The results to be persisted in S3. + * @param {String} [templatePrefix] The prefix for the file in the associated bucket passed in through environment information. + * @param {String} [s3Prefix] The prefix for the file in the associated bucket passed in through the event. + * + * @returns a list or promises for write to S3. + */ +async function writeToS3(bucket, resultsToWrite, templatePrefix, s3Prefix) { + var s3 = new AWS.S3({apiVersion: 'latest'}); + var bucketPrefix = templatePrefix ? `${templatePrefix}/` : ""; + bucketPrefix = s3Prefix ? `${bucketPrefix}${s3Prefix}/` : bucketPrefix; + if (bucket && resultsToWrite) { + console.log("Writing Output to S3"); + var dt = new Date(); + var objectName = [dt.getFullYear(), dt.getMonth() + 1, dt.getDate() + '.json'].join( '-' ); + var key = bucketPrefix + objectName; + var latestKey = bucketPrefix + "latest.json"; + var results = JSON.stringify(resultsToWrite, null, 2); + console.log(`Writing results to s3://${bucket}/${key}`) + console.log(`Writing results to s3://${bucket}/${latestKey}`) + // require('fs').writeFileSync('runresults.json', results); + var promises = [ + s3.putObject({Bucket: bucket, Key: latestKey, Body: results}).promise(), + s3.putObject({Bucket: bucket, Key: key, Body: results}).promise() + ]; + + return Promise.all(promises); + } + return [] +} + +exports.handler = async function(event, context) { + console.log("EVENT:", JSON.stringify(event)); + try { + //Object Initialization// + var partition = context.invokedFunctionArn.split(':')[1]; + var configurations = await configs.getConfigurations(configs.parseEvent(event), partition); + var outputHandler = lambdaOutput.create(); + //Settings Configuration// + console.log("Configuring Settings"); + var settings = configurations.settings || {}; + settings.china = partition === 'aws-cn'; + settings.govcloud = partition === 'aws-us-gov'; + settings.paginate = settings.paginate || true; + settings.debugTime = settings.debugTime || false; + + //Config Gathering// + console.log("Gathering Configurations"); + var AWSConfig = configurations.aws.roleArn ? await configs.getCredentials(configurations.aws.roleArn, configurations.aws.externalId) : null; + var AzureConfig = configurations.azure || null; + var GoogleConfig = configurations.gcp || null; + var GitHubConfig = configurations.github || null; + var OracleConfig = configurations.oracle || null; + + //Run Primary Cloudspoit Engine// + console.log("Begin Calling Main Engine") + + var enginePromise = promisifiedEngine(AWSConfig, AzureConfig, GitHubConfig, OracleConfig, GoogleConfig, settings, outputHandler); + + const collectionData = await enginePromise; + var results = outputHandler.getResuls(); + console.assert(results.collectionData, "No Collection Data found."); + console.assert(results.ResultsData, "No Results Data found."); + await writeToS3(process.env.RESULT_BUCKET, results, process.env.RESULT_PREFIX, configurations.s3Prefix); + return 'Ok'; + } catch(err) { + // Just log the error and re-throw so we have a lambda error metric + console.log(err); + throw(err); + } +} \ No newline at end of file diff --git a/package.json b/package.json index f4e5afc4d9..80f8e4ea9f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "csv-write-stream": "^2.0.0", "fast-safe-stringify": "^2.0.6", "googleapis": "^40.0.1", + "lodash.once": "^4.1.1", "ms-rest-azure": "^2.5.9" }, "devDependencies": { diff --git a/postprocess/lambda_output.js b/postprocess/lambda_output.js new file mode 100644 index 0000000000..6e03a558cb --- /dev/null +++ b/postprocess/lambda_output.js @@ -0,0 +1,36 @@ +module.exports = { + create: function () { + //Could add other collectors here if memory becomes an issue. + //Refer to output.js for what that would look like. + return { + collectionData: {}, + ResultsData: {}, + startCompliance: function(plugin, pluginKey, compliance) { + }, + + endCompliance: function(plugin, pluginKey, compliance) { + }, + + writeResult: function (result, plugin, pluginKey) { + if(!this.ResultsData[plugin.title]) { + this.ResultsData[plugin.title] = [] + } + this.ResultsData[plugin.title].push(result) + }, + + writeCollection(collection, serviceProviderName) { + this.collectionData[serviceProviderName] = {collection} + }, + + close: function () { + }, + + getResuls: function() { + return { + collectionData: this.collectionData, + ResultsData: this.ResultsData, + } + } + } + } +} From 2e25932f7f0a3fc7ac6268ae6f638f5e7a3e2465 Mon Sep 17 00:00:00 2001 From: jhaubold Date: Thu, 9 Apr 2020 16:19:00 -0400 Subject: [PATCH 2/6] revise output handlers --- lambda_index.js | 29 +- package-lock.json | 9 + package.json | 1 + .../apigateway/apiGatewayRestApiWafEnabled.js | 67 +++ .../apiGatewayRestApiWafEnabled.spec.js | 88 ++++ postprocess/lambda_output.js | 36 -- postprocess/output.js | 396 ------------------ postprocess/output.spec.js | 142 ------- 8 files changed, 189 insertions(+), 579 deletions(-) create mode 100644 plugins/aws/apigateway/apiGatewayRestApiWafEnabled.js create mode 100644 plugins/aws/apigateway/apiGatewayRestApiWafEnabled.spec.js delete mode 100644 postprocess/lambda_output.js delete mode 100644 postprocess/output.js delete mode 100644 postprocess/output.spec.js diff --git a/lambda_index.js b/lambda_index.js index 2b7a126fc5..0dd25a756d 100644 --- a/lambda_index.js +++ b/lambda_index.js @@ -1,6 +1,6 @@ var AWS = require('aws-sdk'); var engine = require('./engine.js'); -var lambdaOutput = require('./postprocess/lambda_output.js'); +var output = require('./postprocess/output.js'); var configs = require('./lambda_config.js') var util = require('util') var promisifiedEngine = util.promisify(engine) @@ -42,13 +42,27 @@ async function writeToS3(bucket, resultsToWrite, templatePrefix, s3Prefix) { return [] } +// "memoryStream" to get outputs instead of using a file stream +class MemoryStream { + constructor() { + this.data = '' + } + write(chunk) { + this.data += chunk; + } + end() {} +} + exports.handler = async function(event, context) { console.log("EVENT:", JSON.stringify(event)); try { //Object Initialization// var partition = context.invokedFunctionArn.split(':')[1]; var configurations = await configs.getConfigurations(configs.parseEvent(event), partition); - var outputHandler = lambdaOutput.create(); + + jsonOutput = new MemoryStream(); + collectionOutput = new MemoryStream(); + var outputHandler = output.multiplexer([output.createJson(jsonOutput)], [output.createCollection(collectionOutput)], false); //Settings Configuration// console.log("Configuring Settings"); var settings = configurations.settings || {}; @@ -70,10 +84,15 @@ exports.handler = async function(event, context) { var enginePromise = promisifiedEngine(AWSConfig, AzureConfig, GitHubConfig, OracleConfig, GoogleConfig, settings, outputHandler); - const collectionData = await enginePromise; - var results = outputHandler.getResuls(); + + await enginePromise; + var results = { + collectionData: JSON.parse(collectionOutput.data), + resultsData: JSON.parse(jsonOutput.data) + } + console.assert(results.collectionData, "No Collection Data found."); - console.assert(results.ResultsData, "No Results Data found."); + console.assert(results.resultsData, "No Results Data found."); await writeToS3(process.env.RESULT_BUCKET, results, process.env.RESULT_PREFIX, configurations.s3Prefix); return 'Ok'; } catch(err) { diff --git a/package-lock.json b/package-lock.json index e40b8b87f4..6f0b9851f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1054,6 +1054,15 @@ "type-detect": "^4.0.5" } }, + "chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", + "dev": true, + "requires": { + "check-error": "^1.0.2" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", diff --git a/package.json b/package.json index 80f8e4ea9f..b034a8b06c 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ }, "devDependencies": { "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", "mocha": "^6.1.4", "nodemon": "^1.19.4", "nyc": "^14.1.1" diff --git a/plugins/aws/apigateway/apiGatewayRestApiWafEnabled.js b/plugins/aws/apigateway/apiGatewayRestApiWafEnabled.js new file mode 100644 index 0000000000..6e9d655c94 --- /dev/null +++ b/plugins/aws/apigateway/apiGatewayRestApiWafEnabled.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'API Gateway REST API WAF Enabled', + category: 'API Gateway', + description: 'Ensure that all API Gateway REST APIs have WAF enabled.', + more_info: 'Enabling WAF allows control over requests to the API Gateway, allowing or denying traffic based off rules in the Web ACL', + link: 'https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-control-access-aws-waf.html', + recommended_action: '1. Enter the WAF service. 2. Enter Web ACLs and filter by the region the API Gateway is in. 3. If no Web ACL is found, Create a new Web ACL in the region the Gateway resides and in Resource type to associate with web ACL, select the API Gateway. ', + apis: ['APIGateway:getRestApis', 'APIGateway:getStages'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.apigateway, function(region, lcb){ + + var restApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (!restApis) return lcb(); + + if (restApis.err || !restApis.data) { + helpers.addResult(results, 3, 'Unable to query for API Gateways: ' + helpers.addError(restApis), region); + return lcb(); + } + + if (!restApis.data.length) { + helpers.addResult(results, 0, 'No API Gateways found', region); + return lcb(); + } + + async.each(restApis.data, (api, cb) => { + var stages = helpers.addSource(cache, source, ['apigateway', 'getStages', region, api.id]); + if (!stages) { + helpers.addResult(results, 3, 'Unable to query for REST API Stage: ' + helpers.addError(api.name), region, api.name); + return cb(); + } + + if (stages.err || !stages.data) { + helpers.addResult(results, 3, 'Unable to query for REST API Gateways: ' + helpers.addError(api.name), region, api.name); + return cb(); + } + + if (stages.data.item.length < 1) { + helpers.addResult(results, 0, 'REST API does not have any stages', region, api.name); + return cb(); + } + + stages.data.item.forEach(stage => { + if (!stage.webAclArn || stage.webAclArn.length < 1) { + helpers.addResult(results, 2, 'The REST API/stage does not have WAF enabled', region, api.name + '/' + stage.stageName); + } else { + helpers.addResult(results, 0, 'The REST API/stage has WAF enabled', region, api.name + '/' + stage.stageName); + } + }); + cb(); + }, function() { + lcb(); + }); + }, function() { + callback(null, results, source) + }); + } +}; \ No newline at end of file diff --git a/plugins/aws/apigateway/apiGatewayRestApiWafEnabled.spec.js b/plugins/aws/apigateway/apiGatewayRestApiWafEnabled.spec.js new file mode 100644 index 0000000000..4620df8212 --- /dev/null +++ b/plugins/aws/apigateway/apiGatewayRestApiWafEnabled.spec.js @@ -0,0 +1,88 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var apiGatewayWafEnabled = require('./apiGatewayRestApiWafEnabled.js') + +const createCache = (gateways, stages) => { + return { + apigateway: { + getRestApis: { + 'us-east-1': { + data: gateways + } + }, + getStages: { + 'us-east-1': stages + } + }, + } +}; + +describe('apiGatewayWafEnabled', function () { + describe('run', function () { + it('should FAIL if passed Gateways without WAF attached', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(2) + done() + }; + + const cache = createCache( + [ + { + id: "testId", + name: "stage" + } + ], + { + testId: { + data: { + item: [{ + deploymentId: 'wfy9ux', + stageName: 'Prod', + }] + } + } + }); + + apiGatewayWafEnabled.run(cache, {}, callback); + }) + + it('should PASS if given Gateways with WAF attached', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(0) + done() + }; + + const cache = createCache( + [ + { + id: "testId", + name: "stage" + } + ], + { + testId: { + data: { + item: [{ + deploymentId: 'wfy9ux', + stageName: 'Prod', + webAclArn: 'test' + }] + } + } + }); + + apiGatewayWafEnabled.run(cache, {}, callback); + }) + + it('should PASS if no Gateways passed at all', function (done) { + const callback = (err, results) => { + expect(results[0].status).to.equal(0) + done() + }; + + const cache = createCache([], []); + + apiGatewayWafEnabled.run(cache, {}, callback); + }) + }) +}) diff --git a/postprocess/lambda_output.js b/postprocess/lambda_output.js deleted file mode 100644 index 6e03a558cb..0000000000 --- a/postprocess/lambda_output.js +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = { - create: function () { - //Could add other collectors here if memory becomes an issue. - //Refer to output.js for what that would look like. - return { - collectionData: {}, - ResultsData: {}, - startCompliance: function(plugin, pluginKey, compliance) { - }, - - endCompliance: function(plugin, pluginKey, compliance) { - }, - - writeResult: function (result, plugin, pluginKey) { - if(!this.ResultsData[plugin.title]) { - this.ResultsData[plugin.title] = [] - } - this.ResultsData[plugin.title].push(result) - }, - - writeCollection(collection, serviceProviderName) { - this.collectionData[serviceProviderName] = {collection} - }, - - close: function () { - }, - - getResuls: function() { - return { - collectionData: this.collectionData, - ResultsData: this.ResultsData, - } - } - } - } -} diff --git a/postprocess/output.js b/postprocess/output.js deleted file mode 100644 index 400c56125e..0000000000 --- a/postprocess/output.js +++ /dev/null @@ -1,396 +0,0 @@ -var csvWriter = require('csv-write-stream'); -var fs = require('fs'); - -// For the console output, we don't need any state since we can write -// directly to the console. -var consoleOutputHandler = { - startCompliance: function(plugin, pluginKey, compliance) { - var complianceDesc = compliance.describe(pluginKey, plugin); - if (complianceDesc) { - console.log(''); - console.log('-----------------------'); - console.log(plugin.title); - console.log('-----------------------'); - console.log(complianceDesc); - console.log(''); - } - }, - - endCompliance: function(plugin, pluginKey, compliance) { - // For console output, we don't do anything - }, - - writeResult: function (result, plugin, pluginKey) { - var statusWord; - if (result.status === 0) { - statusWord = 'OK'; - } else if (result.status === 1) { - statusWord = 'WARN'; - } else if (result.status === 2) { - statusWord = 'FAIL'; - } else { - statusWord = 'UNKNOWN'; - } - - console.log(plugin.category + '\t' + plugin.title + '\t' + - (result.resource || 'N/A') + '\t' + - (result.region || 'Global') + '\t\t' + - statusWord + '\t' + result.message); - }, - - close: function() {} -} - -module.exports = { - /** - * Creates an output handler that writes output in the CSV format. - * @param {fs.WriteSteam} stream The stream to write to or an object that - * obeys the writeable stream contract. - */ - createCsv: function (stream) { - var writer = csvWriter({headers: ['category', 'title', 'resource', - 'region', 'statusWord', 'message']}); - writer.pipe(stream); - - return { - writer: writer, - - startCompliance: function(plugin, pluginKey, compliance) { - }, - - endCompliance: function(plugin, pluginKey, compliance) { - }, - - writeResult: function (result, plugin, pluginKey) { - var statusWord; - if (result.status === 0) { - statusWord = 'OK'; - } else if (result.status === 1) { - statusWord = 'WARN'; - } else if (result.status === 2) { - statusWord = 'FAIL'; - } else { - statusWord = 'UNKNOWN'; - } - - this.writer.write([plugin.category, plugin.title, - (result.resource || 'N/A'), - (result.region || 'Global'), - statusWord, result.message]); - }, - - close: function () { - this.writer.end(); - } - } - }, - - /** - * Creates an output handler that writes output in the JSON format. - * @param {fs.WriteSteam} stream The stream to write to or an object that - * obeys the writeable stream contract. - */ - createJson: function (stream) { - var results = []; - return { - stream: stream, - - startCompliance: function(plugin, pluginKey, compliance) { - }, - - endCompliance: function(plugin, pluginKey, compliance) { - }, - - writeResult: function (result, plugin, pluginKey) { - var statusWord; - if (result.status === 0) { - statusWord = 'OK'; - } else if (result.status === 1) { - statusWord = 'WARN'; - } else if (result.status === 2) { - statusWord = 'FAIL'; - } else { - statusWord = 'UNKNOWN'; - } - - results.push({ - plugin: pluginKey, - category: plugin.category, - title: plugin.title, - resource: result.resource || 'N/A', - region: result.region || 'Global', - status: statusWord, - message: result.message - }) - }, - - close: function () { - this.stream.write(JSON.stringify(results)); - this.stream.end(); - } - } - }, - - /*** - * Creates an output handler that writes output in the JUnit XML format. - * - * This constructs the XML directly, rather than through a library so that - * we don't need to pull in another NPM dependency. This keeps things - * simple. - * - * @param {fs.WriteStream} stream The stream to write to or an object that - * obeys the writeable stream contract. - */ - createJunit: function (stream) { - return { - stream: stream, - - /** - * The test suites are how we represent result - each test suite - * maps to one plugin (more specifically the plugin key) so that - * we group tests based on the plugin key. - */ - testSuites: {}, - - startCompliance: function(plugin, pluginKey, compliance) { - }, - - endCompliance: function(plugin, pluginKey, compliance) { - }, - - /** - * Adds the result to be written to the output file. - */ - writeResult: function (result, plugin, pluginKey) { - var suiteName = pluginKey; - if (!this.testSuites.hasOwnProperty(suiteName)) { - // The time to report for the tests (since we don't have - // time for any of them.) The expected JUnit format doesn't - // allow for time or MS, so omit those - var time = (new Date()).toISOString(); - time = time.substr(0, time.indexOf('.')); - - this.testSuites[suiteName] = { - name: plugin.title + ': ' + (plugin.description || ''), - package: pluginKey, - failures: 0, - errors: 0, - testCases: [], - time: time - }; - } - - // Get the test suite that we want to add to - var testSuite = this.testSuites[pluginKey]; - - // Was this test an error or failure? - var failure = result.status === 2 ? (result.message || 'Unexpected failure') : undefined; - testSuite.failures += failure ? 1 : 0; - var error = result.status > 2 ? (result.message || 'Unexpected error') : undefined; - testSuite.errors += error ? 1 : 0; - - // Each plugin can generate multiple results, which we map as - // one plugin to one test suite. Each result in that suite needs - // to have enough context to be useful (even for passes), so - // we add all of that that information at the name of the test - var name = result.region + '; ' + (result.resource || 'N/A') + '; ' + result.message; - - testSuite.testCases.push({ - name: name, - classname: pluginKey, - file: '', - line: 0, - failure: failure, - error: error - }); - }, - - /** - * Closes the output handler. For this JUnit output handler, all of - * the work happens on close since we need to know information - * about results upfront. - */ - close: function () { - this.stream.write('\n'); - this.stream.write('\n'); - - var index = 0; - for (var key in this.testSuites) { - this._writeSuite(this.testSuites[key], index); - index += 1; - } - - this.stream.write('\n'); - - this.stream.end(); - }, - - /** - * Writes the test suite to the output stream. This should really - * only be called internally by this class. - * @param testSuite The test suite to write to the stream - */ - _writeSuite: function (testSuite, index) { - var numTests = testSuite.testCases.length; - - this.stream.write('\t\n'); - - // The schema says we must have the properties element, but it can be empty - this.stream.write('\t\t\n'); - for (var testCase of testSuite.testCases) { - this.stream.write('\t\t\n\t\t\t\n' + - '\t\t\n'); - } else if (testCase.error) { - this.stream.write('>\n\t\t\t\n' + - '\t\t\n'); - } else { - this.stream.write('/>\n'); - } - - } - - // Same thing with properties above - this just needs to exist - // even if we don't have data (according to the schema) - this.stream.write('\t\t\n'); - this.stream.write('\t\t\n'); - - this.stream.write('\t\n'); - } - } - }, - - /** - * Creates an output handler that writes collection in the JSON format. - * @param {fs.WriteSteam} stream The stream to write to or an object that - * obeys the writeable stream contract. - */ - createCollection: function(stream) { - var results = {}; - return { - stream: stream, - - write: function (collection, providerName) { - results[providerName] = collection; - }, - - close: function () { - this.stream.write(JSON.stringify(results)); - this.stream.end(); - } - } - }, - /** - * Creates an output handler depending on the arguments list as expected - * in the command line format. If multiple output handlers are specified - * in the arguments, then constructs a unified view so that it appears that - * there is only one output handler. - * - * @param {string[]} argv Array of command line arguments (may contain - * arguments that are not relevant to constructing output handlers). - * - * @return A object that obeys the output handler contract. This may be - * one output handler or one that forwards function calls to a group of - * output handlers. - */ - create: function (argv) { - var outputs = []; - var collectionOutput; - - // Creates the handlers for writing output. - var addCsvOutput = argv.find(function (arg) { - return arg.startsWith('--csv='); - }) - if (addCsvOutput) { - var stream = fs.createWriteStream(addCsvOutput.substr(6)); - outputs.push(this.createCsv(stream)); - } - - var addJunitOutput = argv.find(function (arg) { - return arg.startsWith('--junit='); - }) - if (addJunitOutput) { - var stream = fs.createWriteStream(addJunitOutput.substr(8)); - outputs.push(this.createJunit(stream)); - } - - var addConsoleOutput = argv.find(function (arg) { - return arg.startsWith('--console'); - }) - - var addCollectionOutput = argv.find(function(arg) { - return arg.startsWith('--collection=') - }) - if(addCollectionOutput) { - var stream = fs.createWriteStream(addCollectionOutput.substr(13)) - collectionOutput = this.createCollection(stream) - } - - // Write to console if specified or by default if there is not - // other output handler specified. - if (addConsoleOutput || outputs.length == 0) { - outputs.push(consoleOutputHandler); - } - - // Ignore any "OK" results - only report issues - var ignoreOkStatus = argv.find(function (arg) { - return arg.startsWith('--ignore-ok'); - }) - - // This creates a multiplexer-like object that forwards the - // call onto any output handler that has been defined. This - // allows us to simply send the output to multiple handlers - // and the caller doesn't need to worry about that part. - return { - startCompliance: function(plugin, pluginKey, compliance) { - for (var output of outputs) { - output.startCompliance(plugin, pluginKey, compliance); - } - }, - - endCompliance: function(plugin, pluginKey, compliance) { - for (var output of outputs) { - output.endCompliance(plugin, pluginKey, compliance); - } - }, - - writeResult: function (result, plugin, pluginKey) { - for (var output of outputs) { - if (!(ignoreOkStatus && result.status === 0)) { - output.writeResult(result, plugin, pluginKey); - } - } - }, - - writeCollection: function(collection, providerName) { - if(collectionOutput) { - collectionOutput.write(collection, providerName) - } - }, - - close: function () { - if(collectionOutput) { - collectionOutput.close() - } - for (var output of outputs) { - output.close(); - } - } - } - } -} diff --git a/postprocess/output.spec.js b/postprocess/output.spec.js deleted file mode 100644 index 7046fb8230..0000000000 --- a/postprocess/output.spec.js +++ /dev/null @@ -1,142 +0,0 @@ -var assert = require('assert'); -var expect = require('chai').expect; -var output = require('./output') - -/** - * Creates an object that looks like an output stream that we can write - * to (but is actually just a buffer caching the data) - */ -var createOutputBuffer = function () { - return { - cache: '', - - write: function (data) { - this.cache += data; - }, - - end: function () {}, - on: function (event, fn) {}, - once: function(event, fn) {}, - emit: function(even, fn) {} - } -} - -describe('output', function () { - describe('junit', function () { - it('should generate empty junit when no results', function () { - var buffer = createOutputBuffer(); - var handler = output.createJunit(buffer); - handler.close(); - expect(buffer.cache).to.equal( - '\n' + - '\n\n'); - }) - - it('should indicate one pass there is one passing result', function () { - var buffer = createOutputBuffer(); - var handler = output.createJunit(buffer); - handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); - handler.close(); - - expect(buffer.cache).to.include(' tests="1" '); - expect(buffer.cache).to.include(' failures="0" '); - expect(buffer.cache).to.include(' errors="0" '); - }) - - it('should indicate one failure there is one failing result', function () { - var buffer = createOutputBuffer(); - var handler = output.createJunit(buffer); - handler.writeResult({status: 2, message: 'fail message'}, {title:'myTitle'}, 'key'); - handler.close(); - - expect(buffer.cache).to.include(' tests="1" '); - expect(buffer.cache).to.include(' failures="1" '); - expect(buffer.cache).to.include(' errors="0" '); - expect(buffer.cache).to.include('fail message'); - }) - - it('should indicate one error there is one failing error', function () { - var buffer = createOutputBuffer(); - var handler = output.createJunit(buffer); - handler.writeResult({status: 3, message: 'error message'}, {title:'myTitle'}, 'key'); - handler.close(); - - expect(buffer.cache).to.include(' tests="1" '); - expect(buffer.cache).to.include(' failures="0" '); - expect(buffer.cache).to.include(' errors="1" '); - expect(buffer.cache).to.include('error message'); - }) - }) - - describe('csv', function () { - it('should generate only header if no results', function () { - var buffer = createOutputBuffer(); - var handler = output.createCsv(buffer); - handler.close(); - expect(buffer.cache).to.equal(''); - }) - - it('should indicate one pass there is one passing result', function () { - var buffer = createOutputBuffer(); - var handler = output.createCsv(buffer); - handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); - handler.close(); - expect(buffer.cache).to.equal('category,title,resource,region,statusWord,message\n,myTitle,N/A,Global,OK,\n'); - }) - }) - - describe('json', function () { - it('should generate empty array if no results', function () { - var buffer = createOutputBuffer(); - var handler = output.createJson(buffer); - handler.close(); - expect(buffer.cache).to.equal('[]'); - }) - - it('should indicate one pass there is one passing result', function () { - var buffer = createOutputBuffer(); - var handler = output.createJson(buffer); - handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); - handler.close(); - expect(buffer.cache).to.equal('[{"plugin":"key","title":"myTitle","resource":"N/A","region":"Global","status":"OK"}]'); - }) - }) - - describe('create', function() { - it('should write to console without errors', function () { - // Create with no arguments is valid and just says create the - // default, which is console output. - var handler = output.create([]) - - handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); - handler.close(); - // No expect here because in the current structure, we cannot - // capture the standard output - }) - - it('should handle compliance sections without errors', function () { - // Create with no arguments is valid and just says create the - // default, which is console output. - var handler = output.create([]); - - // Create the information about the compliance rule - for this - // test, it doesn't have to be anything fancy - var complianceRule = { - describe: function (pluginKey, plugin) { - return 'desc'; - } - }; - var plugin = { - title: 'title' - }; - var pluginKey = 'someIdentifier'; - - handler.startCompliance(plugin, pluginKey, complianceRule); - handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); - handler.endCompliance(plugin, pluginKey, complianceRule); - handler.close(); - // No expect here because in the current structure, we cannot - // capture the standard output - }) - }) -}) \ No newline at end of file From 1710fb90ecf69705d6886be6f168beb4df41d26f Mon Sep 17 00:00:00 2001 From: jhaubold Date: Mon, 13 Apr 2020 15:10:32 -0400 Subject: [PATCH 3/6] update output handlers --- postprocess/output.js | 411 +++++++++++++++++++++++++++++++++++++ postprocess/output.spec.js | 167 +++++++++++++++ 2 files changed, 578 insertions(+) create mode 100644 postprocess/output.js create mode 100644 postprocess/output.spec.js diff --git a/postprocess/output.js b/postprocess/output.js new file mode 100644 index 0000000000..c330a42670 --- /dev/null +++ b/postprocess/output.js @@ -0,0 +1,411 @@ +var csvWriter = require('csv-write-stream'); +var fs = require('fs'); + +// For the console output, we don't need any state since we can write +// directly to the console. +var consoleOutputHandler = { + startCompliance: function(plugin, pluginKey, compliance) { + var complianceDesc = compliance.describe(pluginKey, plugin); + if (complianceDesc) { + console.log(''); + console.log('-----------------------'); + console.log(plugin.title); + console.log('-----------------------'); + console.log(complianceDesc); + console.log(''); + } + }, + + endCompliance: function(plugin, pluginKey, compliance) { + // For console output, we don't do anything + }, + + writeResult: function (result, plugin, pluginKey) { + var statusWord; + if (result.status === 0) { + statusWord = 'OK'; + } else if (result.status === 1) { + statusWord = 'WARN'; + } else if (result.status === 2) { + statusWord = 'FAIL'; + } else { + statusWord = 'UNKNOWN'; + } + + console.log(plugin.category + '\t' + plugin.title + '\t' + + (result.resource || 'N/A') + '\t' + + (result.region || 'Global') + '\t\t' + + statusWord + '\t' + result.message); + }, + + close: function() {} +} + +module.exports = { + /** + * Creates an output handler that writes output in the CSV format. + * @param {fs.WriteSteam} stream The stream to write to or an object that + * obeys the writeable stream contract. + */ + createCsv: function (stream) { + var writer = csvWriter({headers: ['category', 'title', 'resource', + 'region', 'statusWord', 'message']}); + writer.pipe(stream); + + return { + writer: writer, + + startCompliance: function(plugin, pluginKey, compliance) { + }, + + endCompliance: function(plugin, pluginKey, compliance) { + }, + + writeResult: function (result, plugin, pluginKey) { + var statusWord; + if (result.status === 0) { + statusWord = 'OK'; + } else if (result.status === 1) { + statusWord = 'WARN'; + } else if (result.status === 2) { + statusWord = 'FAIL'; + } else { + statusWord = 'UNKNOWN'; + } + + this.writer.write([plugin.category, plugin.title, + (result.resource || 'N/A'), + (result.region || 'Global'), + statusWord, result.message]); + }, + + close: function () { + this.writer.end(); + } + } + }, + + /** + * Creates an output handler that writes output in the JSON format. + * @param {fs.WriteSteam} stream The stream to write to or an object that + * obeys the writeable stream contract. + */ + createJson: function (stream) { + var results = []; + return { + stream: stream, + + startCompliance: function(plugin, pluginKey, compliance) { + }, + + endCompliance: function(plugin, pluginKey, compliance) { + }, + + writeResult: function (result, plugin, pluginKey) { + var statusWord; + if (result.status === 0) { + statusWord = 'OK'; + } else if (result.status === 1) { + statusWord = 'WARN'; + } else if (result.status === 2) { + statusWord = 'FAIL'; + } else { + statusWord = 'UNKNOWN'; + } + + results.push({ + plugin: pluginKey, + category: plugin.category, + title: plugin.title, + resource: result.resource || 'N/A', + region: result.region || 'Global', + status: statusWord, + statusNumber: result.status, + message: result.message + }) + }, + + close: function () { + this.stream.write(JSON.stringify(results)); + this.stream.end(); + } + } + }, + + /*** + * Creates an output handler that writes output in the JUnit XML format. + * + * This constructs the XML directly, rather than through a library so that + * we don't need to pull in another NPM dependency. This keeps things + * simple. + * + * @param {fs.WriteStream} stream The stream to write to or an object that + * obeys the writeable stream contract. + */ + createJunit: function (stream) { + return { + stream: stream, + + /** + * The test suites are how we represent result - each test suite + * maps to one plugin (more specifically the plugin key) so that + * we group tests based on the plugin key. + */ + testSuites: {}, + + startCompliance: function(plugin, pluginKey, compliance) { + }, + + endCompliance: function(plugin, pluginKey, compliance) { + }, + + /** + * Adds the result to be written to the output file. + */ + writeResult: function (result, plugin, pluginKey) { + var suiteName = pluginKey; + if (!this.testSuites.hasOwnProperty(suiteName)) { + // The time to report for the tests (since we don't have + // time for any of them.) The expected JUnit format doesn't + // allow for time or MS, so omit those + var time = (new Date()).toISOString(); + time = time.substr(0, time.indexOf('.')); + + this.testSuites[suiteName] = { + name: plugin.title + ': ' + (plugin.description || ''), + package: pluginKey, + failures: 0, + errors: 0, + testCases: [], + time: time + }; + } + + // Get the test suite that we want to add to + var testSuite = this.testSuites[pluginKey]; + + // Was this test an error or failure? + var failure = result.status === 2 ? (result.message || 'Unexpected failure') : undefined; + testSuite.failures += failure ? 1 : 0; + var error = result.status > 2 ? (result.message || 'Unexpected error') : undefined; + testSuite.errors += error ? 1 : 0; + + // Each plugin can generate multiple results, which we map as + // one plugin to one test suite. Each result in that suite needs + // to have enough context to be useful (even for passes), so + // we add all of that that information at the name of the test + var name = result.region + '; ' + (result.resource || 'N/A') + '; ' + result.message; + + testSuite.testCases.push({ + name: name, + classname: pluginKey, + file: '', + line: 0, + failure: failure, + error: error + }); + }, + + /** + * Closes the output handler. For this JUnit output handler, all of + * the work happens on close since we need to know information + * about results upfront. + */ + close: function () { + this.stream.write('\n'); + this.stream.write('\n'); + + var index = 0; + for (var key in this.testSuites) { + this._writeSuite(this.testSuites[key], index); + index += 1; + } + + this.stream.write('\n'); + + this.stream.end(); + }, + + /** + * Writes the test suite to the output stream. This should really + * only be called internally by this class. + * @param testSuite The test suite to write to the stream + */ + _writeSuite: function (testSuite, index) { + var numTests = testSuite.testCases.length; + + this.stream.write('\t\n'); + + // The schema says we must have the properties element, but it can be empty + this.stream.write('\t\t\n'); + for (var testCase of testSuite.testCases) { + this.stream.write('\t\t\n\t\t\t\n' + + '\t\t\n'); + } else if (testCase.error) { + this.stream.write('>\n\t\t\t\n' + + '\t\t\n'); + } else { + this.stream.write('/>\n'); + } + + } + + // Same thing with properties above - this just needs to exist + // even if we don't have data (according to the schema) + this.stream.write('\t\t\n'); + this.stream.write('\t\t\n'); + + this.stream.write('\t\n'); + } + } + }, + + /** + * Creates an output handler that writes collection in the JSON format. + * @param {fs.WriteSteam} stream The stream to write to or an object that + * obeys the writeable stream contract. + */ + createCollection: function(stream) { + var results = {}; + return { + stream: stream, + + write: function (collection, providerName) { + results[providerName] = collection; + }, + + close: function () { + this.stream.write(JSON.stringify(results)); + this.stream.end(); + } + } + }, + + // This creates a multiplexer-like object that forwards the + // call onto any output handler that has been defined. This + // allows us to simply send the output to multiple handlers + // and the caller doesn't need to worry about that part. + multiplexer(outputs, collectionOutputs, ignoreOkStatus) { + return { + startCompliance: function(plugin, pluginKey, compliance) { + for (var output of outputs) { + if (output.startCompliance) { + output.startCompliance(plugin, pluginKey, compliance); + } + } + }, + + endCompliance: function(plugin, pluginKey, compliance) { + for (var output of outputs) { + if (output.endCompliance) { + output.endCompliance(plugin, pluginKey, compliance); + } + } + }, + + writeResult: function (result, plugin, pluginKey) { + for (var output of outputs) { + if (output.writeResult) { + if (!(ignoreOkStatus && result.status === 0)) { + output.writeResult(result, plugin, pluginKey); + } + } + } + }, + + writeCollection: function(collection, providerName) { + for (var collectionOutput of collectionOutputs) + if(collectionOutput.write) { + collectionOutput.write(collection, providerName) + } + }, + + close: function () { + for (var collectionOutput of collectionOutputs) + if(collectionOutput.close) { + collectionOutput.close() + } + for (var output of outputs) { + if (output.close) { + output.close(); + } + } + } + } + }, + /** + * Creates an output handler depending on the arguments list as expected + * in the command line format. If multiple output handlers are specified + * in the arguments, then constructs a unified view so that it appears that + * there is only one output handler. + * + * @param {string[]} argv Array of command line arguments (may contain + * arguments that are not relevant to constructing output handlers). + * + * @return A object that obeys the output handler contract. This may be + * one output handler or one that forwards function calls to a group of + * output handlers. + */ + create: function (argv) { + var outputs = []; + var collectionOutputs = []; + + // Creates the handlers for writing output. + var addCsvOutput = argv.find(function (arg) { + return arg.startsWith('--csv='); + }) + if (addCsvOutput) { + var stream = fs.createWriteStream(addCsvOutput.substr(6)); + outputs.push(this.createCsv(stream)); + } + + var addJunitOutput = argv.find(function (arg) { + return arg.startsWith('--junit='); + }) + if (addJunitOutput) { + var stream = fs.createWriteStream(addJunitOutput.substr(8)); + outputs.push(this.createJunit(stream)); + } + + var addConsoleOutput = argv.find(function (arg) { + return arg.startsWith('--console'); + }) + + var addCollectionOutput = argv.find(function(arg) { + return arg.startsWith('--collection=') + }) + if(addCollectionOutput) { + var stream = fs.createWriteStream(addCollectionOutput.substr(13)) + collectionOutputs.append(this.createCollection(stream)) + } + + // Write to console if specified or by default if there is not + // other output handler specified. + if (addConsoleOutput || outputs.length == 0) { + outputs.push(consoleOutputHandler); + } + + // Ignore any "OK" results - only report issues + var ignoreOkStatus = argv.find(function (arg) { + return arg.startsWith('--ignore-ok'); + }) + + return this.multiplexer(outputs, collectionOutputs, ignoreOkStatus); + } +} diff --git a/postprocess/output.spec.js b/postprocess/output.spec.js new file mode 100644 index 0000000000..11055e68ca --- /dev/null +++ b/postprocess/output.spec.js @@ -0,0 +1,167 @@ +var assert = require('assert'); +var expect = require('chai').expect; +var output = require('./output') + +/** + * Creates an object that looks like an output stream that we can write + * to (but is actually just a buffer caching the data) + */ +var createOutputBuffer = function () { + return { + cache: '', + + write: function (data) { + this.cache += data; + }, + + end: function () {}, + on: function (event, fn) {}, + once: function(event, fn) {}, + emit: function(even, fn) {} + } +} + +describe('output', function () { + describe('junit', function () { + it('should generate empty junit when no results', function () { + var buffer = createOutputBuffer(); + var handler = output.createJunit(buffer); + handler.close(); + expect(buffer.cache).to.equal( + '\n' + + '\n\n'); + }) + + it('should indicate one pass there is one passing result', function () { + var buffer = createOutputBuffer(); + var handler = output.createJunit(buffer); + handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); + handler.close(); + + expect(buffer.cache).to.include(' tests="1" '); + expect(buffer.cache).to.include(' failures="0" '); + expect(buffer.cache).to.include(' errors="0" '); + }) + + it('should indicate one failure there is one failing result', function () { + var buffer = createOutputBuffer(); + var handler = output.createJunit(buffer); + handler.writeResult({status: 2, message: 'fail message'}, {title:'myTitle'}, 'key'); + handler.close(); + + expect(buffer.cache).to.include(' tests="1" '); + expect(buffer.cache).to.include(' failures="1" '); + expect(buffer.cache).to.include(' errors="0" '); + expect(buffer.cache).to.include('fail message'); + }) + + it('should indicate one error there is one failing error', function () { + var buffer = createOutputBuffer(); + var handler = output.createJunit(buffer); + handler.writeResult({status: 3, message: 'error message'}, {title:'myTitle'}, 'key'); + handler.close(); + + expect(buffer.cache).to.include(' tests="1" '); + expect(buffer.cache).to.include(' failures="0" '); + expect(buffer.cache).to.include(' errors="1" '); + expect(buffer.cache).to.include('error message'); + }) + }) + + describe('csv', function () { + it('should generate only header if no results', function () { + var buffer = createOutputBuffer(); + var handler = output.createCsv(buffer); + handler.close(); + expect(buffer.cache).to.equal(''); + }) + + it('should indicate one pass there is one passing result', function () { + var buffer = createOutputBuffer(); + var handler = output.createCsv(buffer); + handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); + handler.close(); + expect(buffer.cache).to.equal('category,title,resource,region,statusWord,message\n,myTitle,N/A,Global,OK,\n'); + }) + }) + + describe('json', function () { + it('should generate empty array if no results', function () { + var buffer = createOutputBuffer(); + var handler = output.createJson(buffer); + handler.close(); + expect(buffer.cache).to.equal('[]'); + }) + + it('should indicate one pass there is one passing result', function () { + var buffer = createOutputBuffer(); + var handler = output.createJson(buffer); + handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); + handler.close(); + expect(buffer.cache).to.equal('[{"plugin":"key","title":"myTitle","resource":"N/A","region":"Global","status":"OK","statusNumber":0}]'); + }) + }) + + describe('multiplexer', function () { + it('should write to all outputs', function () { + var buffer1 = createOutputBuffer(); + var buffer2 = createOutputBuffer(); + var jsonOutput1 = output.createJson(buffer1); + var jsonOutput2 = output.createJson(buffer2); + var multiplexer = output.multiplexer([jsonOutput1, jsonOutput2], [], false); + multiplexer.writeResult({status: 0}, {title:'myTitle'}, 'key'); + multiplexer.close(); + expect(buffer1.cache).to.equal(buffer2.cache); + expect(buffer1.cache).to.equal('[{"plugin":"key","title":"myTitle","resource":"N/A","region":"Global","status":"OK","statusNumber":0}]'); + }) + it('should write to all collectionOutputs', function () { + var buffer1 = createOutputBuffer(); + var buffer2 = createOutputBuffer(); + var collectionOutput1 = output.createCollection(buffer1); + var collectionOutput2 = output.createCollection(buffer2); + var multiplexer = output.multiplexer([], [collectionOutput1, collectionOutput2], false); + multiplexer.writeCollection({some: 'data'}, 'AWS'); + multiplexer.close(); + expect(buffer1.cache).to.equal(buffer2.cache); + expect(buffer1.cache).to.equal('{"AWS":{"some":"data"}}'); + }) + }) + + describe('create', function() { + it('should write to console without errors', function () { + // Create with no arguments is valid and just says create the + // default, which is console output. + var handler = output.create([]) + + handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); + handler.close(); + // No expect here because in the current structure, we cannot + // capture the standard output + }) + + it('should handle compliance sections without errors', function () { + // Create with no arguments is valid and just says create the + // default, which is console output. + var handler = output.create([]); + + // Create the information about the compliance rule - for this + // test, it doesn't have to be anything fancy + var complianceRule = { + describe: function (pluginKey, plugin) { + return 'desc'; + } + }; + var plugin = { + title: 'title' + }; + var pluginKey = 'someIdentifier'; + + handler.startCompliance(plugin, pluginKey, complianceRule); + handler.writeResult({status: 0}, {title:'myTitle'}, 'key'); + handler.endCompliance(plugin, pluginKey, complianceRule); + handler.close(); + // No expect here because in the current structure, we cannot + // capture the standard output + }) + }) +}) \ No newline at end of file From 5b9b109b7b4090d7d4b327a227d4a2a3cfda3da8 Mon Sep 17 00:00:00 2001 From: jhaubold Date: Tue, 8 Sep 2020 14:33:28 -0400 Subject: [PATCH 4/6] fix debugging statements --- lambda_index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lambda_index.js b/lambda_index.js index 371fb210ad..9006828253 100644 --- a/lambda_index.js +++ b/lambda_index.js @@ -22,7 +22,7 @@ async function writeToS3(bucket, resultsToWrite, templatePrefix, s3Prefix) { var s3 = new AWS.S3({apiVersion: 'latest'}); var bucketPrefix = templatePrefix ? `${templatePrefix}/` : ''; bucketPrefix = s3Prefix ? `${bucketPrefix}${s3Prefix}/` : bucketPrefix; - require('fs').writeFileSync('runresults.json', JSON.stringify(resultsToWrite, null, 2)); + // require('fs').writeFileSync('runresults.json', JSON.stringify(resultsToWrite, null, 2)); if (bucket && resultsToWrite) { console.log('Writing Output to S3'); var dt = new Date(); @@ -33,8 +33,8 @@ async function writeToS3(bucket, resultsToWrite, templatePrefix, s3Prefix) { console.log(`Writing results to s3://${bucket}/${key}`); console.log(`Writing results to s3://${bucket}/${latestKey}`); var promises = [ - // s3.putObject({Bucket: bucket, Key: latestKey, Body: results}).promise(), - // s3.putObject({Bucket: bucket, Key: key, Body: results}).promise() + s3.putObject({Bucket: bucket, Key: latestKey, Body: results}).promise(), + s3.putObject({Bucket: bucket, Key: key, Body: results}).promise() ]; return Promise.all(promises); From 771b249437c0556969a0f0f7da65f3c39a6b1ac0 Mon Sep 17 00:00:00 2001 From: Matthew Skillman Date: Tue, 6 Oct 2020 15:54:43 -0400 Subject: [PATCH 5/6] ability to skip plugins from skipPlugins list --- engine.js | 20 ++++++++++++++++++-- index.js | 4 ++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/engine.js b/engine.js index bd1b762252..b2ccff1557 100644 --- a/engine.js +++ b/engine.js @@ -44,12 +44,28 @@ var engine = function(cloudConfig, settings, outputHandler, callback) { Object.entries(plugins).forEach(function(p){ var pluginId = p[0]; var plugin = p[1]; - // Skip plugins that don't match the ID flag var skip = false; if (settings.plugin && settings.plugin !== pluginId) { skip = true; - } else { + } + else if (settings.skipPlugins) { + if (Array.isArray(settings.skipPlugins)) { + if (settings.skipPlugins.includes(pluginId)) { + skip = true; + console.debug(`DEBUG: Skipping plugin ${plugin.title} because it is within skipPlugins`); + } + } + else { + // assumes type is String + let asArray = settings.skipPlugins.split(','); + if (asArray.includes(pluginId)) { + skip = true; + console.debug(`DEBUG: Skipping plugin ${plugin.title} because it is within skipPlugins`); + } + } + } + else { // Skip GitHub plugins that do not match the run type if (settings.cloud == 'github') { if (cloudConfig.organization && diff --git a/index.js b/index.js index 26e612ef56..50a81a97eb 100755 --- a/index.js +++ b/index.js @@ -19,6 +19,10 @@ console.log(` const parser = new ArgumentParser({}); +parser.add_argument('--skipPlugins', { + help: 'Plugin ids (corresponds to keys in exports.js) to skip' +}); + parser.add_argument('--config', { help: 'The path to a CloudSploit config file containing cloud credentials. See config_example.js' }); From e8d982b1842ac795663d0a468c2c0c415e3266c3 Mon Sep 17 00:00:00 2001 From: jhaubold Date: Thu, 8 Oct 2020 12:52:56 -0400 Subject: [PATCH 6/6] update logic --- engine.js | 24 ++++++------------------ index.js | 14 +++++++------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/engine.js b/engine.js index b2ccff1557..32c6644146 100644 --- a/engine.js +++ b/engine.js @@ -30,6 +30,7 @@ var engine = function(cloudConfig, settings, outputHandler, callback) { if (settings.china) console.log('INFO: Using AWS China mode'); if (settings.ignore_ok) console.log('INFO: Ignoring passing results'); if (settings.skip_paginate) console.log('INFO: Skipping AWS pagination mode'); + if (settings.skip_plugins) console.log('INFO: Skipping plugins:', settings.skip_plugins); if (settings.suppress && settings.suppress.length) console.log('INFO: Suppressing results based on suppress flags'); if (settings.plugin) { if (!plugins[settings.plugin]) return console.log(`ERROR: Invalid plugin: ${settings.plugin}`); @@ -48,24 +49,11 @@ var engine = function(cloudConfig, settings, outputHandler, callback) { var skip = false; if (settings.plugin && settings.plugin !== pluginId) { skip = true; - } - else if (settings.skipPlugins) { - if (Array.isArray(settings.skipPlugins)) { - if (settings.skipPlugins.includes(pluginId)) { - skip = true; - console.debug(`DEBUG: Skipping plugin ${plugin.title} because it is within skipPlugins`); - } - } - else { - // assumes type is String - let asArray = settings.skipPlugins.split(','); - if (asArray.includes(pluginId)) { - skip = true; - console.debug(`DEBUG: Skipping plugin ${plugin.title} because it is within skipPlugins`); - } - } - } - else { + } else if (settings.skip_plugins && settings.skip_plugins.includes && settings.skip_plugins.includes(pluginId)) { + // includes method present on strings and arrays + skip = true; + console.debug(`DEBUG: Skipping plugin ${plugin.title} because it is within skip plugins list`); + } else { // Skip GitHub plugins that do not match the run type if (settings.cloud == 'github') { if (cloudConfig.organization && diff --git a/index.js b/index.js index 50a81a97eb..e6f5d7f8d1 100755 --- a/index.js +++ b/index.js @@ -4,14 +4,14 @@ const { ArgumentParser } = require('argparse'); const engine = require('./engine'); console.log(` - _____ _ _ _____ _ _ _ - / ____| | | |/ ____| | | (_) | - | | | | ___ _ _ __| | (___ _ __ | | ___ _| |_ + _____ _ _ _____ _ _ _ + / ____| | | |/ ____| | | (_) | + | | | | ___ _ _ __| | (___ _ __ | | ___ _| |_ | | | |/ _ \\| | | |/ _\` |\\___ \\| '_ \\| |/ _ \\| | __| - | |____| | (_) | |_| | (_| |____) | |_) | | (_) | | |_ + | |____| | (_) | |_| | (_| |____) | |_) | | (_) | | |_ \\_____|_|\\___/ \\__,_|\\__,_|_____/| .__/|_|\\___/|_|\\__| - | | - |_| + | | + |_| CloudSploit by Aqua Security, Ltd. Cloud security auditing for AWS, Azure, GCP, Oracle, and GitHub @@ -19,7 +19,7 @@ console.log(` const parser = new ArgumentParser({}); -parser.add_argument('--skipPlugins', { +parser.add_argument('--skip-plugins', { help: 'Plugin ids (corresponds to keys in exports.js) to skip' });