diff --git a/.gitignore b/.gitignore index b12a7ce315..d71fefef18 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ output.json vscode/* .vscode/ .idea +.local +template.packaged.yaml +config.* coverage/ .nyc_output -config.js \ No newline at end of file +config.js 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 fa30b93079..05cd3f3d92 100644 --- a/collectors/aws/collector.js +++ b/collectors/aws/collector.js @@ -954,7 +954,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) { @@ -1058,7 +1058,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 46e5c69c70..32c6644146 100644 --- a/engine.js +++ b/engine.js @@ -2,18 +2,22 @@ var async = require('async'); var exports = require('./exports.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. * @param cloudConfig The configuration for the cloud provider. * @param settings General purpose settings. + * @param [outputHandler] Optional custom handler for result collection. + * @param [callback] callback to be ran upon completion. */ -var engine = function(cloudConfig, settings) { +var engine = function(cloudConfig, settings, outputHandler, callback) { // Initialize any suppression rules based on the the command line arguments var suppressionFilter = suppress.create(settings.suppress); // Initialize the output handler - var outputHandler = output.create(settings); + outputHandler = outputHandler || output.create(process.argv); // Configure Service Provider Collector var collector = require(`./collectors/${settings.cloud}/collector.js`); @@ -26,6 +30,7 @@ var engine = function(cloudConfig, settings) { 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}`); @@ -40,11 +45,14 @@ var engine = function(cloudConfig, settings) { 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 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') { @@ -76,7 +84,6 @@ var engine = function(cloudConfig, settings) { } } } - if (skip) { skippedPlugins.push(pluginId); } else { @@ -100,44 +107,57 @@ var engine = function(cloudConfig, settings) { }, function(err, collection) { if (err || !collection || !Object.keys(collection).length) return console.log(`ERROR: Unable to obtain API metadata: ${err || 'No data returned'}`); outputHandler.writeCollection(collection, settings.cloud); - + console.log('INFO: Metadata collection complete. Analyzing...'); console.log('INFO: Analysis complete. Scan report to follow...'); var maximumStatus = 0; async.mapValuesLimit(plugins, 10, function(plugin, key, pluginDone) { + pluginDone = once(pluginDone); if (skippedPlugins.indexOf(key) > -1) return pluginDone(null, 0); - plugin.run(collection, settings, function(err, results) { - for (var 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; - } - - var complianceMsg = []; - if (settings.compliance && settings.compliance.length) { - settings.compliance.forEach(function(c){ - if (plugin.compliance && plugin.compliance[c]) { - complianceMsg.push(`${c.toUpperCase()}: ${plugin.compliance[c]}`); - } - }); + try { + plugin.run(collection, settings, function(err, results) { + for (var 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; + } + + var complianceMsg = []; + if (settings.compliance && settings.compliance.length) { + settings.compliance.forEach(function(c){ + if (plugin.compliance && plugin.compliance[c]) { + complianceMsg.push(`${c.toUpperCase()}: ${plugin.compliance[c]}`); + } + }); + } + complianceMsg = complianceMsg.join('; '); + if (!complianceMsg.length) complianceMsg = null; + + // Write out the result (to console or elsewhere) + outputHandler.writeResult(results[r], plugin, key, complianceMsg); + + // Add this to our tracking fo the worst status to calculate + // the exit code + maximumStatus = Math.max(maximumStatus, results[r].status); } - complianceMsg = complianceMsg.join('; '); - if (!complianceMsg.length) complianceMsg = null; - - // Write out the result (to console or elsewhere) - outputHandler.writeResult(results[r], plugin, key, complianceMsg); - - // Add this to our tracking fo the worst status to calculate - // the exit code - maximumStatus = Math.max(maximumStatus, results[r].status); - } + setTimeout(function() { pluginDone(err, maximumStatus); }, 0); + }); + } catch (error) { // TODO review this to ensure correctness! + 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) { if (err) return console.log(err); // console.log(JSON.stringify(collection, null, 2)); @@ -149,8 +169,11 @@ var engine = function(cloudConfig, settings) { process.exitCode = maximumStatus; } console.log('INFO: Scan complete'); + if (callback) { + callback(err); + } }); }); }; -module.exports = engine; \ No newline at end of file +module.exports = engine; diff --git a/engine.spec.js b/engine.spec.js index 4faabcf89f..4d63a8ea8c 100644 --- a/engine.spec.js +++ b/engine.spec.js @@ -5,6 +5,7 @@ describe('engine', function () { it('should run with no arguments', function () { // Although we don't pass in anything, this is enough to test // that our dependencies are actually installed. - engine({}, {cloud: 'aws'}); + // We set plugin to something that doesn't exist to prevent any actual api calls from being made + engine({}, {cloud: 'aws', plugin: 'does not exist'}); }) }); 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/index.js b/index.js index 26e612ef56..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,6 +19,10 @@ console.log(` const parser = new ArgumentParser({}); +parser.add_argument('--skip-plugins', { + 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' }); diff --git a/lambda_config.js b/lambda_config.js new file mode 100644 index 0000000000..4e3459ab53 --- /dev/null +++ b/lambda_config.js @@ -0,0 +1,119 @@ +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 getCloudConfig(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 clouds = ['aws', 'azure', 'gcp', 'github', 'oracle']; + + var disallowedKeysByServices = { + 'aws' : [], + 'azure': ['KeyValue'], + 'gcp': ['private_key'], + 'github': [], + 'oracle': ['keyValue', 'keyFingerprint'] + }; + var cloud = parsedEvent.cloud; + var cloudConfig = parsedEvent.cloudConfig; + + if (!clouds.find(c => c === cloud)) { + throw new Error('Invalid cloud specified'); + } + + disallowedKeysByServices[cloud].forEach((config) => { + if (config in cloudConfig) throw (new Error('Configuration passed in through event which must be in Secrets Manager.')); + }); + if (cloud === 'aws') { + // If account_id in aws config, then replace it with roleArn. + if (cloudConfig.account_id) { + cloudConfig.roleArn = ['arn', partition, 'iam', '', cloudConfig.account_id, 'role/' + defaultRoleName].join(':'); + delete cloudConfig.account_id; + } + } else if (cloudConfig.credentialId) { + var secretsManagerKey = [secretPrefix, cloud, cloudConfig.credentialId].join('/'); + var secret = await getSecret(secretsManagerKey); // eslint-disable-line no-await-in-loop + delete cloudConfig.credentialId; + Object.assign(cloudConfig, secret); + } + + return [cloud, cloudConfig]; +} + +/*** + * 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 = {getCloudConfig, parseEvent, getCredentials}; diff --git a/lambda_config.spec.js b/lambda_config.spec.js new file mode 100644 index 0000000000..3ba71d925f --- /dev/null +++ b/lambda_config.spec.js @@ -0,0 +1,128 @@ +var configs = require('./lambda_config.js') +var chai = require('chai'); +var chaiAsPromised = require("chai-as-promised") +var expect = require('chai').expect; +chai.use(chaiAsPromised) + +var embeddedEvent = { + "cloud": "aws", + "cloudConfig": { + "roleArn": "arn:aws:iam::1234567890:role/someRole" + } +} + +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": JSON.stringify(embeddedEvent), + "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": embeddedEvent +} + +var expectedOutcome = [ + 'aws', + { + "roleArn": "arn:aws:iam::1234567890:role/someRole" + } +] + +var configWithAccountId = { + cloud: 'aws', + cloudConfig: { + "account_id": "1234567890" + } +} + +var expectedConfigFromAccountId = [ + 'aws', + { + "roleArn": ("arn:aws:iam::1234567890:role/" + process.env.DEFAULT_ROLE_NAME) + } +] + +var notCredentialedConfiguration = { + cloud: "gcp", + cloudConfig: { + "private_key": "" + } +} + +var invalidCloud = { + cloud: 'fluffyWhiteOne', + cloudConfig: 'mostly water' +} + +describe('configs', function () { + describe('parseEvent', function () { + it('Gets JSON object from SNS event', function () { + expect(configs.parseEvent(snsEvent)).to.deep.equal(embeddedEvent) + }) + it('Gets JSON object from CloudWatch event', function () { + expect(configs.parseEvent(cloudwatchEvent)).to.deep.equal(embeddedEvent) + }) + }) + + describe('getCloudConfig', function () { + var partition = "aws" + it('Gets aws configurations from SNS with RoleArn', async function () { + var input = await configs.getCloudConfig(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.getCloudConfig(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.getCloudConfig(configWithAccountId, partition) + var output = expectedConfigFromAccountId + expect(input).to.deep.equal(output) + }) + it('Should throw an error when given a key that must be credentialed', function () { + var input = configs.getCloudConfig(notCredentialedConfiguration, partition) + return expect(input).to.eventually.be.rejectedWith("Configuration passed in through event which must be in Secrets Manager.") + }) + it('Should throw an error when given a configuration with an valid cloud', function () { + var input = configs.getCloudConfig(invalidCloud, partition) + return expect(input).to.eventually.be.rejectedWith("Invalid cloud specified") + }) + }) +}) 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..9006828253 --- /dev/null +++ b/lambda_index.js @@ -0,0 +1,104 @@ +var AWS = require('aws-sdk'); +var engine = require('./engine.js'); +var output = require('./postprocess/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; + // require('fs').writeFileSync('runresults.json', JSON.stringify(resultsToWrite, null, 2)); + 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}`); + 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 []; +} + +// "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 parsedEvent = configs.parseEvent(event); + var [cloud, cloudConfig] = await configs.getCloudConfig(event, partition); + + var jsonOutput = new MemoryStream(); + var collectionOutput = new MemoryStream(); + var outputHandler = output.multiplexer([output.createJson(jsonOutput)], [output.createCollection(collectionOutput)], false); + //Settings Configuration// + console.log('Configuring Settings'); + var settings = parsedEvent.settings || {}; + settings.china = partition === 'aws-cn'; + settings.govcloud = partition === 'aws-us-gov'; + settings.paginate = settings.paginate || true; + settings.debugTime = settings.debugTime || false; + settings.cloud = cloud; + //Config Gathering// + console.log('Gathering Configurations'); + + if (cloud === 'aws') { + cloudConfig = cloudConfig.roleArn ? await configs.getCredentials(cloudConfig.roleArn, cloudConfig.externalId) : null; + } + + //Run Primary Cloudspoit Engine// + console.log('Begin Calling Main Engine'); + + var enginePromise = promisifiedEngine(cloudConfig, settings, outputHandler); + + await enginePromise; + var results = { + collectionData: { + [cloud]: 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.'); + await writeToS3(process.env.RESULT_BUCKET, results, process.env.RESULT_PREFIX, parsedEvent.s3Prefix); + return 'Ok'; + } catch(err) { + // Just log the error and re-throw so we have a lambda error metric + console.log(err); + throw(err); + } +}; diff --git a/package-lock.json b/package-lock.json index 3a3bfd5893..52b1b28bbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -958,6 +958,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 e206ef6a32..b4867fea4a 100644 --- a/package.json +++ b/package.json @@ -49,12 +49,14 @@ "csv-write-stream": "^2.0.0", "fast-safe-stringify": "^2.0.6", "googleapis": "^40.0.1", + "lodash.once": "^4.1.1", "minimatch": "^3.0.4", "ms-rest-azure": "^2.6.0", "tty-table": "^4.1.3" }, "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/output.js b/postprocess/output.js index 3be8bb7a33..9a1973ecb7 100644 --- a/postprocess/output.js +++ b/postprocess/output.js @@ -18,60 +18,57 @@ function log(msg, settings) { if (!settings.mocha) console.log(msg); } -// For the console output, we don't need any state since we can write -// directly to the console. -var tableHeaders = []; -var tableRows = []; - -var consoleOutputHandler = { - writeResult: function(result, plugin, pluginKey, complianceMsg) { - var toWrite = { - Category: plugin.category, - Plugin: plugin.title, - Description: plugin.description, - Resource: (result.resource || 'N/A'), - Region: (result.region || 'global'), - Status: exchangeStatusWord(result), - Message: result.message || 'N/A' - }; - - if (complianceMsg) { - if (tableHeaders.length !== 8) { - tableHeaders.push({ - value: 'Compliance' - }); +var createConsoleOutputHandler = function(tableHeaders, tableRows, settings) { + return { + writeResult: function(result, plugin, pluginKey, complianceMsg) { + var toWrite = { + Category: plugin.category, + Plugin: plugin.title, + Description: plugin.description, + Resource: (result.resource || 'N/A'), + Region: (result.region || 'global'), + Status: exchangeStatusWord(result), + Message: result.message || 'N/A' + }; + + if (complianceMsg) { + if (tableHeaders.length !== 8) { + tableHeaders.push({ + value: 'Compliance' + }); + } + toWrite.Compliance = complianceMsg; } - toWrite.Compliance = complianceMsg; - } - - tableRows.push(toWrite); - }, - close: function(settings) { - // For console output, print the table - if (settings.console == 'none') { - console.log('INFO: Console output suppressed because "console" setting was "none"'); - } else if (settings.console == 'text') { - tableRows.forEach(function(row){ - Object.entries(row).forEach(function(entry){ - console.log(`${entry[0]}: ${entry[1]}`); + tableRows.push(toWrite); + }, + + close: function() { + // For console output, print the table + if (settings.console == 'none') { + console.log('INFO: Console output suppressed because "console" setting was "none"'); + } else if (settings.console == 'text') { + tableRows.forEach(function(row){ + Object.entries(row).forEach(function(entry){ + console.log(`${entry[0]}: ${entry[1]}`); + }); + console.log('\n'); }); - console.log('\n'); - }); - } else { - const t1 = ttytable(tableHeaders, tableRows, null, { - borderStyle: 'solid', - borderColor: 'white', - paddingBottom: 0, - headerAlign: 'center', - headerColor: 'white', - align: 'left', - color: 'white', - width: '100%' - }).render(); - if (process.argv.join('').indexOf('mocha') === -1) console.log(t1); + } else { + const t1 = ttytable(tableHeaders, tableRows, null, { + borderStyle: 'solid', + borderColor: 'white', + paddingBottom: 0, + headerAlign: 'center', + headerColor: 'white', + align: 'left', + color: 'white', + width: '100%' + }).render(); + if (process.argv.join('').indexOf('mocha') === -1) console.log(t1); + } } - } + }; }; module.exports = { @@ -90,18 +87,18 @@ module.exports = { return { writer: writer, - + writeResult: function(result, plugin, pluginKey, complianceMsg) { var toWrite = [plugin.category, plugin.title, commaSafe(plugin.description), (result.resource || 'N/A'), (result.region || 'Global'), exchangeStatusWord(result), commaSafe(result.message)]; - + if (settings.compliance) toWrite.push(complianceMsg || ''); - + this.writer.write(toWrite); }, - + close: function() { this.writer.end(); log(`INFO: CSV file written to ${settings.csv}`, settings); @@ -118,7 +115,7 @@ module.exports = { var results = []; return { stream: stream, - + writeResult: function(result, plugin, pluginKey, complianceMsg) { var toWrite = { plugin: pluginKey, @@ -128,42 +125,45 @@ module.exports = { resource: result.resource || 'N/A', region: result.region || 'Global', status: exchangeStatusWord(result), + statusNumber: result.status, message: result.message }; if (complianceMsg) toWrite.compliance = complianceMsg; results.push(toWrite); }, - + close: function() { this.stream.write(JSON.stringify(results, null, 2)); this.stream.end(); - log(`INFO: JSON file written to ${settings.json}`, settings); + if (settings) { + log(`INFO: JSON file written to ${settings.json}`, settings); + } } }; }, /*** * 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, settings) { 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: {}, - + /** * Adds the result to be written to the output file. */ @@ -210,7 +210,7 @@ module.exports = { error: error }); }, - + /** * Closes the output handler. For this JUnit output handler, all of * the work happens on close since we need to know information @@ -227,7 +227,7 @@ module.exports = { } this.stream.write('\n'); - + this.stream.end(); log(`INFO: JUnit file written to ${settings.junit}`, settings); }, @@ -269,7 +269,7 @@ module.exports = { } else { this.stream.write('/>\n'); } - + } // Same thing with properties above - this just needs to exist @@ -299,7 +299,46 @@ module.exports = { close: function() { this.stream.write(JSON.stringify(results, null, 2)); this.stream.end(); - log(`INFO: Collection file written to ${settings.collection}`, settings); + if (settings) { + log(`INFO: Collection file written to ${settings.collection}`, settings); + } + } + }; + }, + + // 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 { + writeResult: function(result, plugin, pluginKey, complianceMsg) { + outputs.forEach(function(output) { + if (output.writeResult) { + if (!(ignoreOkStatus && result.status === 0)) { + output.writeResult(result, plugin, pluginKey, complianceMsg); + } + } + }); + }, + // supports collection lists + writeCollection: function(collection, providerName) { + collectionOutputs.forEach(function(collectionOutput) { + if (collectionOutput.write) collectionOutput.write(collection, providerName); + }); + }, + + close: function() { // support collection lists + collectionOutputs.forEach(function(collectionOutput) { + if (collectionOutput.close) { + collectionOutput.close(); + } + }); + outputs.forEach(function(output) { + if (output.close) { + output.close(); + } + }); } }; }, @@ -308,19 +347,19 @@ module.exports = { * 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(settings) { var outputs = []; - var collectionOutput; + var collectionOutputs = []; - tableHeaders = [ + var tableHeaders = [ { value: 'Category', width: '10%', @@ -358,7 +397,7 @@ module.exports = { } ]; - tableRows = []; + var tableRows = []; // Creates the handlers for writing output. if (settings.csv) { @@ -378,7 +417,7 @@ module.exports = { if (settings.collection) { var streamColl = fs.createWriteStream(settings.collection); - collectionOutput = this.createCollection(streamColl, settings); + collectionOutputs.push(this.createCollection(streamColl, settings)); } var addConsoleOutput = settings.console; @@ -386,35 +425,12 @@ module.exports = { // Write to console if specified or by default if there is not // other output handler specified. if (addConsoleOutput || outputs.length == 0) { - outputs.push(consoleOutputHandler); + outputs.push(createConsoleOutputHandler(tableHeaders, tableRows, settings)); } // Ignore any "OK" results - only report issues var ignoreOkStatus = settings.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 { - writeResult: function(result, plugin, pluginKey, complianceMsg) { - outputs.forEach(function(output) { - if (!(ignoreOkStatus && result.status === 0)) { - output.writeResult(result, plugin, pluginKey, complianceMsg); - } - }); - }, - - writeCollection: function(collection, providerName) { - if (collectionOutput) collectionOutput.write(collection, providerName); - }, - - close: function() { - if (collectionOutput) collectionOutput.close(); - outputs.forEach(function(output) { - output.close(settings); - }); - } - }; + return this.multiplexer(outputs, collectionOutputs, ignoreOkStatus); } }; diff --git a/postprocess/output.spec.js b/postprocess/output.spec.js index ccec9c3a02..c6e644279c 100644 --- a/postprocess/output.spec.js +++ b/postprocess/output.spec.js @@ -28,7 +28,7 @@ describe('output', function () { var handler = output.createJunit(buffer, {mocha: true, junit: 'test.junit'}); handler.close(); expect(buffer.cache).to.equal( - '\n' + + '\n' + '\n\n'); }) @@ -98,7 +98,41 @@ describe('output', function () { var handler = output.createJson(buffer, { mocha: true, junit: 'test.json' }); handler.writeResult({ status: 0 }, { title: 'myTitle', description: 'myDescription' }, 'key'); handler.close(); - expect(JSON.stringify(JSON.parse(buffer.cache))).to.equal('[{"plugin":"key","title":"myTitle","description":"myDescription","resource":"N/A","region":"Global","status":"OK"}]'); + expect(JSON.parse(buffer.cache)).to.deep.equal([ + { + "plugin": "key", + "title": "myTitle", + "description": "myDescription", + "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(JSON.parse(buffer1.cache)).to.deep.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'}); + multiplexer.close(); + expect(buffer1.cache).to.equal(buffer2.cache); + expect(JSON.parse(buffer1.cache)).to.deep.equal({"some":"data"}); }) }) @@ -133,4 +167,4 @@ describe('output', function () { // capture the standard output }) }) -}) \ No newline at end of file +})